Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,18 @@ composer.phar
# Environment
.env
dev/.env

# Build artifact (run build-adobe-zip.sh to create)
*.zip
build-adobe-zip.log
docs
graphify-out
# Local dev harnesses (not committed; also excluded from the Marketplace zip by build-adobe-zip.sh)
dev-ee
dev-ee/*
dev-repro
dev-repro/*

# macOS
.DS_Store
**/.DS_Store
30 changes: 8 additions & 22 deletions Block/System/Config/Form/Field/Webhook.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,13 @@
namespace Pstk\Paystack\Block\System\Config\Form\Field;

use Magento\Framework\Data\Form\Element\AbstractElement;
use Magento\Store\Model\Store as Store;

/**
* Backend system config datetime field renderer
*
* @api
* @since 100.0.2
* Backend system config field renderer for displaying the Paystack webhook URL.
*/
class Webhook extends \Magento\Config\Block\System\Config\Form\Field
{

/**
* @param \Magento\Backend\Block\Template\Context $context
* @param Store $store
* @param array $data
*/
public function __construct(
\Magento\Backend\Block\Template\Context $context,
Store $store,
array $data = []
) {
$this->store = $store;

parent::__construct($context, $data);
}

/**
* Returns element html
*
Expand All @@ -57,10 +38,15 @@ public function __construct(
*/
protected function _getElementHtml(AbstractElement $element)
{
$webhookUrl = $this->store->getBaseUrl() . 'paystack/payment/webhook';
try {
$webhookUrl = $this->_storeManager->getStore()->getBaseUrl() . 'paystack/payment/webhook';
} catch (\Exception $e) {
$webhookUrl = '{{store_base_url}}paystack/payment/webhook';
}

$value = "You may login to <a target=\"_blank\" href=\"https://dashboard.paystack.co/#/settings/developer\">Paystack Developer Settings</a> to update your Webhook URL to:<br><br>"
. "<strong style='color:red;'>$webhookUrl</strong>";

$element->setValue($webhookUrl);

return $value;
Expand Down
83 changes: 83 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Changelog

All notable changes to the Paystack Magento 2 module are documented here.
This project adheres to [Semantic Versioning](https://semver.org/).

The entries below cover every release since the last tag, **v3.0.4**.

## [3.0.10] - 2026-07-17

Consolidated release for Magento 2.4.9 / PHP 8.5, verified end-to-end with
Content-Security-Policy enforced.

### Fixed
- **Payment verification failed on PHP 8.5 even when the payment succeeded.**
`Gateway/PaystackApiClient.php` called `curl_close()`, which is deprecated in
PHP 8.5 (a no-op since PHP 8.0). Magento escalates the deprecation to an
exception, and it fired *after* the charge was confirmed — so customers saw
"Payment verification failed" despite a successful payment. Removed all
`curl_close()` calls (the handle is freed automatically).
- **Admin order creation on Adobe Commerce (EE).** The payment method now
implements `MethodInterface` directly instead of extending `AbstractMethod`,
and `getInfoInstance()` matches the expected contract, preventing EE-only
interceptors from crashing admin pages. Adds a defence-in-depth admin-area guard.

### Added
- PHPUnit unit-test suite (`Test/Unit/**`, `phpunit.xml`) covering the payment
model, controllers, gateway client, observers, config provider, and plugins.
- End-to-end test coverage and a dedicated admin-config MFTF page object/section.

## [3.0.9] - 2026-07-17

Superseded by 3.0.10 (its fixes are included there).

### Fixed
- **Checkout page hung on the loading spinner under enforced CSP.** The module's
PHP CSP `PolicyCollector` replaced Magento's entire Content-Security-Policy,
dropping `'self'` from `script-src` and blocking Magento's own JavaScript on the
checkout page (CSP is enforced by default on checkout/payment pages since 2.4.7).
Replaced with the standard, additive `etc/csp_whitelist.xml` mechanism.
- **MFTF `PaystackPaymentConfigAvailableTest` 404'd in the Adobe pipeline.** The
test navigated with a raw `amOnPage url="admin/..."` that resolved to
`/admin/admin/...`. Switched to an `area="admin"` page object (emits a correct
base-relative URL) and core `AdminLoginActionGroup`/`AdminLogoutActionGroup`.

## [3.0.8] - 2026-06-22

### Added
- Storefront guest-checkout MFTF coverage (`StorefrontPaystackCheckoutRendersTest`)
guarding against the "checkout does not load" class of failure.

### Changed
- Hardened the Adobe Marketplace build script so internal artifacts are excluded
from the published zip.

## [3.0.7] - 2026-04-07

### Changed
- Version bump (no functional changes).

## [3.0.6] - 2026-04-07

### Changed
- Reworked the checkout method-renderer JavaScript to lazy-load the Paystack Inline
SDK, so a slow/blocked SDK no longer stalls checkout rendering.
- Updated `ConfigProvider` and the payment model.

### Added
- First vendor MFTF test (`PaystackPaymentConfigAvailableTest`) verifying the
payment method appears in admin configuration.
- Empty `etc/adminhtml/di.xml` to keep the module out of the admin DI scope.

## [3.0.5] - 2026-03-06

### Fixed
- **Admin order-create crash on Adobe Commerce (EE).** Scoped the
`PaymentManagementInterface` preference to `frontend`/`webapi_rest` (removed from
the global/admin scope) and lazy-loaded the payment method, so admin order
creation and MFTF tests are no longer affected by frontend-only dependencies.

---

_Note: 3.0.5–3.0.10 were not individually tagged; see the commit history since
[`v3.0.4`](https://github.com/PaystackHQ/plugin-magento-2/releases/tag/v3.0.4) for details._
120 changes: 120 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Module Identity

- **Module name**: `Pstk_Paystack`
- **PHP namespace**: `Pstk\Paystack`
- **Composer package**: `pstk/paystack-magento2-module`
- **Magento payment method code**: `pstk_paystack` (constant `Pstk\Paystack\Model\Payment\Paystack::CODE`)
- **Requires**: Magento 2.4.x, PHP 8.2+ (this is the supported target, but note `composer.json` has an empty `require: {}` — these constraints are **not** enforced by Composer)

## Build

```bash
# Build zip for Adobe Commerce Marketplace
./build-adobe-zip.sh
# Output: pstk-paystack-magento2-module-<version>.zip
```

`build-adobe-zip.sh` reads the version from `composer.json` and always rebuilds from scratch (removes any stale zip first). Its exclusion list is `.git*`, `.DS_Store`, `dev/`, `vendor/`, `.env`, `auth.json`, `CLAUDE.md`, `docs/`, `graphify-out/`, `node_modules/`, the build script itself, and prior `*.zip` builds — so `CLAUDE.md`, internal QA artifacts, and tooling caches never ship to Marketplace.

> ⚠️ **The exclusion list does NOT include `dev-ee/`** (added after the script was written). A fresh build will bundle the entire EE test harness into the Marketplace zip. Add `-x "dev-ee/*"` before building a release, or the harness will ship. (The committed `pstk-paystack-magento2-module-3.0.8.zip` predates `dev-ee/`, so it is clean.)

### Versioning

The version string lives in **three** places that must be kept in sync on a version bump:
- `composer.json` (`version`) — source of truth used by the build script
- `etc/module.xml` (`setup_version` on `<module>`)
- `README.md` (the **Version:** line)

## Development Environment

```bash
cd dev
cp .env.example .env
docker compose up -d
bash setup.sh
```

The `dev/` directory contains a full Docker-based Magento 2 environment (Magento 2.4.8 via the Mage-OS mirror — no Adobe Marketplace auth needed). `docker compose up -d` builds the image and installs Magento on first run (~8 min total); `setup.sh` enables the module, disables 2FA, sets developer mode, and seeds test data (`dev/seed-products.php`). Containers: `paystack-magento`, `paystack-db`, `paystack-search`.

- Storefront: `http://localhost:8080` · Admin: `http://localhost:8080/admin` (`admin` / `Admin12345!`)
- Paystack test card: `4084 0840 8408 4081`, exp `12/30`, CVV `408`, PIN `0000`, OTP `123456`
- `docker compose down -v` resets all data.

The local `dev/` env uses the Mage-OS **Community Edition** mirror. It **cannot** reproduce Adobe Commerce (Enterprise) issues — those depend on EE-only layers (Varnish FPC, Content Staging). For EE-specific reproduction there is a separate `dev-ee/` harness (see below).

### `dev-ee/` — Enterprise baseline harness

`dev-ee/` is a standalone EE + Varnish + Selenium + MFTF harness (`run-ee-baseline.sh`) built to determine whether two EE-only MFTF failures reported in Adobe's QA (`MC-84` AdminConfigurableProductCreateTest, `MC-26602` AdminCreateGroupedProductTest) are caused by this module or are pre-existing EE core-test flakiness. It runs each test N times in two arms (no-module vs with-module) and compares failure rates. It is the empirical counterpart to `docs/EE-NO-MODULE-BASELINE.md`.

**Status:** written without valid Adobe Commerce keys and **never executed end-to-end** — the local `auth.json` keys return HTTP 401, and `check-ee-keys.sh` preflight refuses to run until they work. Treat `run-ee-baseline.sh` as a runbook, not a one-shot; fragile spots are marked `SEAM:` with manual fallbacks in its README.

## Testing

Tests use the Magento Functional Testing Framework (MFTF), located in `Test/Mftf/`. MFTF tests run against a live Magento instance:

```bash
# From Magento root (not this repo root)
vendor/bin/mftf run:test PaystackPaymentConfigAvailableTest
vendor/bin/mftf run:test StorefrontPaystackCheckoutRendersTest
```

Current tests (`Test/Mftf/Test/`): `PaystackPaymentConfigAvailableTest.xml` and `StorefrontPaystackCheckoutRendersTest.xml`, backed by the page object `Test/Mftf/Page/AdminPaymentConfigPage.xml`. `Test/Mftf/Suite/` exists but is **empty** — there are no suites, so `vendor/bin/mftf run:suite` has nothing to run.

There are no unit tests — the test suite is entirely MFTF (browser-level functional tests). There is no configured linter or static-analysis tooling (no PHPCS/PHPStan config, no composer `scripts`); match the surrounding code style by hand. The only CI is `.github/workflows/codeql-analysis.yml` (CodeQL security scanning).

The `docs/` directory (gitignored, never shipped) holds local MFTF Allure report artifacts (`mftfmagento/`, `mftfvendor/`) plus the `EE-NO-MODULE-BASELINE.md` analysis — it is not module code.

## Architecture

### Payment Flows

There are two integration types, selectable in admin config:

**Inline (default)**: Paystack popup opens in the browser after order is placed.
1. JS calls `afterPlaceOrder()` → Paystack popup opens
2. On success, JS calls `GET /V1/paystack/verify/{reference}_{quoteId}` (REST API, anonymous)
3. `PaymentManagement::verifyPayment()` verifies with Paystack API, dispatches `paystack_payment_verify_after`
4. `ObserverAfterPaymentVerify` sets order to Processing and sends confirmation email

**Standard (redirect)**: Customer is redirected to Paystack's hosted page.
1. `/paystack/payment/setup` — initializes transaction, redirects to Paystack
2. `/paystack/payment/callback` — Paystack returns here; verifies transaction, dispatches `paystack_payment_verify_after`
3. `/paystack/payment/recreate` — retry path: cancels the failed/abandoned order, restores the quote, and redirects back to the checkout payment step

**Webhook** (independent, server-to-server):
- `/paystack/payment/webhook` — receives `charge.success` events from Paystack
- Validates HMAC-SHA512 signature, verifies transaction, dispatches `paystack_payment_verify_after`
- CSRF validation skipped via `Plugin/CsrfValidatorSkip.php`

The custom event `paystack_payment_verify_after` is the single point where order status is updated to Processing and confirmation email is sent (`Observer/ObserverAfterPaymentVerify.php`). Initial order confirmation email is suppressed by `ObserverBeforeSalesOrderPlace` until payment is verified.

### Key Classes

| Class | Responsibility |
|---|---|
| `Gateway/PaystackApiClient.php` | All Paystack API calls: initialize transaction, verify, validate webhook signature |
| `Model/PaymentManagement.php` | REST API endpoint for inline payment verification |
| `Model/Ui/ConfigProvider.php` | Injects public key, integration type, and URLs into checkout JS config |
| `Controller/Payment/AbstractPaystackStandard.php` | Base controller with shared utilities (quote loading, message handling) |
| `etc/csp_whitelist.xml` | Whitelists Paystack domains in Magento's Content Security Policy (additive; the Magento-standard mechanism) |

### DI Configuration Scoping

This is critical — the payment method is intentionally unavailable in admin order creation:

- `etc/frontend/di.xml` — registers `ConfigProvider`, `PaymentManagementInterface` preference, CSRF-skip plugin (CSP is handled by `etc/csp_whitelist.xml`, not here)
- `etc/adminhtml/di.xml` — intentionally empty (prevents EE admin crash on order create)
- `etc/webapi_rest/di.xml` — `PaymentManagementInterface` preference for REST API calls
- `etc/di.xml` — root scope (minimal)

### Configuration Path

All settings live under `payment/pstk_paystack/` in Magento config. Secret keys use the `Encrypted` backend model. Test mode toggles between test/live key pairs in `PaystackApiClient`.

### Quote ID as Transaction Anchor

For inline payments, Paystack generates the transaction reference on the client side. The `quoteId` is passed as metadata in the Paystack transaction so the webhook/verification can locate the correct order when no Magento-generated reference is available.
23 changes: 22 additions & 1 deletion Controller/Payment/AbstractPaystackStandard.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
namespace Pstk\Paystack\Controller\Payment;

use Magento\Payment\Helper\Data as PaymentHelper;
use Magento\Store\Model\StoreManagerInterface;
use Pstk\Paystack\Gateway\PaystackApiClient;


Expand All @@ -42,6 +43,7 @@ abstract class AbstractPaystackStandard extends \Magento\Framework\App\Action\Ac
*/
protected $orderInterface;
protected $checkoutSession;
protected $paymentHelper;
protected $method;
protected $messageManager;

Expand All @@ -51,6 +53,12 @@ abstract class AbstractPaystackStandard extends \Magento\Framework\App\Action\Ac
*/
protected $configProvider;

/**
*
* @var StoreManagerInterface
*/
protected $storeManager;

/**
*
* @var PaystackApiClient
Expand Down Expand Up @@ -89,6 +97,7 @@ public function __construct(
PaymentHelper $paymentHelper,
\Magento\Framework\Message\ManagerInterface $messageManager,
\Pstk\Paystack\Model\Ui\ConfigProvider $configProvider,
StoreManagerInterface $storeManager,
\Magento\Framework\Event\Manager $eventManager,
\Magento\Framework\App\Request\Http $request,
\Psr\Log\LoggerInterface $logger,
Expand All @@ -98,16 +107,28 @@ public function __construct(
$this->orderRepository = $orderRepository;
$this->orderInterface = $orderInterface;
$this->checkoutSession = $checkoutSession;
$this->method = $paymentHelper->getMethodInstance(\Pstk\Paystack\Model\Payment\Paystack::CODE);
$this->paymentHelper = $paymentHelper;
$this->messageManager = $messageManager;
$this->configProvider = $configProvider;
$this->storeManager = $storeManager;
$this->eventManager = $eventManager;
$this->request = $request;
$this->logger = $logger;
$this->paystackClient = $paystackClient;

parent::__construct($context);
}

/**
* @return \Magento\Payment\Model\MethodInterface
*/
protected function getMethod()
{
if ($this->method === null) {
$this->method = $this->paymentHelper->getMethodInstance(\Pstk\Paystack\Model\Payment\Paystack::CODE);
}
return $this->method;
}

protected function redirectToFinal($successFul = true, $message="") {
if($successFul){
Expand Down
4 changes: 2 additions & 2 deletions Controller/Payment/Setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function execute() {

$message = '';
$order = $this->orderInterface->loadByIncrementId($this->checkoutSession->getLastRealOrder()->getIncrementId());
if ($order && $this->method->getCode() == $order->getPayment()->getMethod()) {
if ($order && $this->getMethod()->getCode() == $order->getPayment()->getMethod()) {

try {
return $this->processAuthorization($order);
Expand All @@ -55,7 +55,7 @@ protected function processAuthorization(\Magento\Sales\Model\Order $order) {
'email' => $order->getCustomerEmail(), // unique to customers
'reference' => $order->getIncrementId(), // unique to transactions
'currency' => $order->getCurrency(),
'callback_url' => $this->configProvider->getStore()->getBaseUrl() . "paystack/payment/callback",
'callback_url' => $this->storeManager->getStore()->getBaseUrl() . "paystack/payment/callback",
'metadata' => array('custom_fields' => array(
array(
"display_name"=>"Plugin",
Expand Down
Loading
Loading