diff --git a/.gitignore b/.gitignore
index aba8ab0..5c85352 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/Block/System/Config/Form/Field/Webhook.php b/Block/System/Config/Form/Field/Webhook.php
index 0ba0010..f230fb2 100644
--- a/Block/System/Config/Form/Field/Webhook.php
+++ b/Block/System/Config/Form/Field/Webhook.php
@@ -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
*
@@ -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 Paystack Developer Settings to update your Webhook URL to:
"
. "$webhookUrl";
-
+
$element->setValue($webhookUrl);
return $value;
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..844ffec
--- /dev/null
+++ b/CHANGELOG.md
@@ -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._
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..bf28747
--- /dev/null
+++ b/CLAUDE.md
@@ -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-.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 ``)
+- `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.
diff --git a/Controller/Payment/AbstractPaystackStandard.php b/Controller/Payment/AbstractPaystackStandard.php
index 26d3cee..43a267f 100644
--- a/Controller/Payment/AbstractPaystackStandard.php
+++ b/Controller/Payment/AbstractPaystackStandard.php
@@ -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;
@@ -42,6 +43,7 @@ abstract class AbstractPaystackStandard extends \Magento\Framework\App\Action\Ac
*/
protected $orderInterface;
protected $checkoutSession;
+ protected $paymentHelper;
protected $method;
protected $messageManager;
@@ -51,6 +53,12 @@ abstract class AbstractPaystackStandard extends \Magento\Framework\App\Action\Ac
*/
protected $configProvider;
+ /**
+ *
+ * @var StoreManagerInterface
+ */
+ protected $storeManager;
+
/**
*
* @var PaystackApiClient
@@ -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,
@@ -98,9 +107,10 @@ 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;
@@ -108,6 +118,17 @@ public function __construct(
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){
diff --git a/Controller/Payment/Setup.php b/Controller/Payment/Setup.php
index 128c6b7..f7ac7fa 100644
--- a/Controller/Payment/Setup.php
+++ b/Controller/Payment/Setup.php
@@ -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);
@@ -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",
diff --git a/Gateway/PaystackApiClient.php b/Gateway/PaystackApiClient.php
index 9991848..6a43e9f 100644
--- a/Gateway/PaystackApiClient.php
+++ b/Gateway/PaystackApiClient.php
@@ -10,16 +10,31 @@ class PaystackApiClient
{
private const BASE_URL = 'https://api.paystack.co';
- /** @var string */
+ /** @var PaymentHelper */
+ private $paymentHelper;
+
+ /** @var string|null */
private $secretKey;
public function __construct(PaymentHelper $paymentHelper)
{
- $method = $paymentHelper->getMethodInstance(PaystackModel::CODE);
- $this->secretKey = $method->getConfigData('live_secret_key');
- if ($method->getConfigData('test_mode')) {
- $this->secretKey = $method->getConfigData('test_secret_key');
+ $this->paymentHelper = $paymentHelper;
+ }
+
+ /**
+ * @return string
+ */
+ private function getSecretKey(): string
+ {
+ if ($this->secretKey === null) {
+ $method = $this->paymentHelper->getMethodInstance(PaystackModel::CODE);
+ $this->secretKey = $method->getConfigData('live_secret_key');
+ if ($method->getConfigData('test_mode')) {
+ $this->secretKey = $method->getConfigData('test_secret_key');
+ }
+ $this->secretKey = (string) $this->secretKey;
}
+ return $this->secretKey;
}
/**
@@ -55,7 +70,7 @@ public function verifyTransaction(string $reference): object
*/
public function validateWebhookSignature(string $rawBody, string $signature): bool
{
- $computed = hash_hmac('sha512', $rawBody, $this->secretKey);
+ $computed = hash_hmac('sha512', $rawBody, $this->getSecretKey());
return hash_equals($computed, $signature);
}
@@ -85,7 +100,6 @@ public function logTransactionSuccess(string $transactionReference, string $publ
CURLOPT_TIMEOUT => 5,
]);
curl_exec($ch);
- curl_close($ch);
}
/**
@@ -104,7 +118,7 @@ private function request(string $method, string $endpoint, ?array $data = null):
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
- 'Authorization: Bearer ' . $this->secretKey,
+ 'Authorization: Bearer ' . $this->getSecretKey(),
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
@@ -119,12 +133,15 @@ private function request(string $method, string $endpoint, ?array $data = null):
if (curl_errno($ch)) {
$error = curl_error($ch);
- curl_close($ch);
throw new ApiException('Paystack API request failed: ' . $error);
}
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
+ // Note: curl_close() is intentionally omitted. Since PHP 8.0 the curl handle is a
+ // \CurlHandle object that is freed automatically, and calling curl_close() emits a
+ // deprecation notice on PHP 8.5 which Magento escalates to an exception — that was
+ // aborting verifyPayment() after a successful charge ("payment verification failed"
+ // while the payment went through).
$body = json_decode($response);
diff --git a/Model/CspPolicyCollector.php b/Model/CspPolicyCollector.php
deleted file mode 100644
index 8a9c271..0000000
--- a/Model/CspPolicyCollector.php
+++ /dev/null
@@ -1,46 +0,0 @@
-.
*/
namespace Pstk\Paystack\Model\Payment;
+use Magento\Framework\App\Config\ScopeConfigInterface;
+use Magento\Framework\App\State;
+use Magento\Framework\DataObject;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Payment\Model\InfoInterface;
+use Magento\Payment\Model\MethodInterface;
+use Magento\Quote\Api\Data\CartInterface;
+use Magento\Store\Model\ScopeInterface;
+
/**
- * Paystack main payment method model
- *
- * @author Olayode Ezekiel
+ * Paystack payment method model.
+ *
+ * Implements MethodInterface directly (does not extend AbstractMethod) so that
+ * Adobe Commerce EE plugins on AbstractMethod cannot be inherited by this class.
+ * This prevents EE-specific interceptors with frontend-only dependencies from
+ * crashing admin pages such as the order-creation customer-selection grid.
+ *
+ * IMPORTANT: getInfoInstance() must throw LocalizedException when no instance
+ * is set, matching AbstractMethod's contract. EE plugins on MethodInterface
+ * rely on this exception (not a null return) to gracefully skip methods that
+ * have no associated payment info during admin page rendering.
*/
-class Paystack extends \Magento\Payment\Model\Method\AbstractMethod
+class Paystack extends DataObject implements MethodInterface
{
-
const CODE = 'pstk_paystack';
-
+
+ /** @var string */
protected $_code = self::CODE;
- protected $_isOffline = true;
- public function isAvailable(
- ?\Magento\Quote\Api\Data\CartInterface $quote = null
+ /** @var string */
+ protected $_infoBlockType = \Magento\Payment\Block\Info::class;
+
+ /** @var InfoInterface|null */
+ private $infoInstance;
+
+ /** @var int|string|null */
+ private $storeId;
+
+ /** @var ScopeConfigInterface */
+ private $scopeConfig;
+
+ /** @var State */
+ private $appState;
+
+ public function __construct(
+ ScopeConfigInterface $scopeConfig,
+ State $appState,
+ array $data = []
) {
- return parent::isAvailable($quote);
+ $this->scopeConfig = $scopeConfig;
+ $this->appState = $appState;
+ parent::__construct($data);
+ }
+
+ // -------------------------------------------------------------------------
+ // Identity
+ // -------------------------------------------------------------------------
+
+ public function getCode()
+ {
+ return $this->_code;
+ }
+
+ // -------------------------------------------------------------------------
+ // Store scope
+ // -------------------------------------------------------------------------
+
+ public function setStore($storeId)
+ {
+ $this->storeId = $storeId;
+ return $this;
+ }
+
+ public function getStore()
+ {
+ return $this->storeId;
+ }
+
+ // -------------------------------------------------------------------------
+ // Configuration helpers
+ // -------------------------------------------------------------------------
+
+ public function getConfigData($field, $storeId = null)
+ {
+ if ($storeId === null) {
+ $storeId = $this->getStore();
+ }
+ $path = 'payment/' . $this->getCode() . '/' . $field;
+ return $this->scopeConfig->getValue($path, ScopeInterface::SCOPE_STORE, $storeId);
+ }
+
+ public function getTitle()
+ {
+ return $this->getConfigData('title');
+ }
+
+ // -------------------------------------------------------------------------
+ // Availability checks
+ // -------------------------------------------------------------------------
+
+ public function isActive($storeId = null)
+ {
+ return (bool)(int)$this->getConfigData('active', $storeId);
+ }
+
+ public function isAvailable(?CartInterface $quote = null)
+ {
+ if ($this->isAdminArea()) {
+ return false;
+ }
+ $storeId = $quote ? $quote->getStoreId() : null;
+ return $this->isActive($storeId) && $this->canUseCheckout();
+ }
+
+ public function canUseInternal()
+ {
+ return false;
+ }
+
+ public function canUseCheckout()
+ {
+ $value = $this->getConfigData('can_use_checkout');
+ return $value === null ? true : (bool)(int)$value;
+ }
+
+ public function canUseForCountry($country)
+ {
+ if (!$country) {
+ return true;
+ }
+ if ((int)$this->getConfigData('allowspecific') === 1) {
+ $specificCountries = explode(',', (string)$this->getConfigData('specificcountry'));
+ if (!in_array($country, $specificCountries, true)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public function canUseForCurrency($currencyCode)
+ {
+ return true;
+ }
+
+ // -------------------------------------------------------------------------
+ // Capability flags — Paystack is an external redirect gateway
+ // -------------------------------------------------------------------------
+
+ public function isGateway()
+ {
+ return true;
+ }
+
+ public function isOffline()
+ {
+ return false;
+ }
+
+ public function isInitializeNeeded()
+ {
+ return false;
+ }
+
+ public function canOrder()
+ {
+ return false;
+ }
+
+ public function canAuthorize()
+ {
+ return false;
+ }
+
+ public function canCapture()
+ {
+ return false;
+ }
+
+ public function canCapturePartial()
+ {
+ return false;
+ }
+
+ public function canCaptureOnce()
+ {
+ return false;
+ }
+
+ public function canRefund()
+ {
+ return false;
+ }
+
+ public function canRefundPartialPerInvoice()
+ {
+ return false;
+ }
+
+ public function canVoid()
+ {
+ return false;
+ }
+
+ public function canEdit()
+ {
+ return true;
+ }
+
+ public function canFetchTransactionInfo()
+ {
+ return false;
+ }
+
+ public function canReviewPayment()
+ {
+ return false;
+ }
+
+ // -------------------------------------------------------------------------
+ // Block types
+ // -------------------------------------------------------------------------
+
+ public function getFormBlockType()
+ {
+ return \Magento\Payment\Block\Form::class;
+ }
+
+ public function getInfoBlockType()
+ {
+ return $this->_infoBlockType;
+ }
+
+ // -------------------------------------------------------------------------
+ // Info instance (used by Magento to store payment data on quotes/orders)
+ // -------------------------------------------------------------------------
+
+ public function getInfoInstance()
+ {
+ if (!$this->infoInstance instanceof InfoInterface) {
+ throw new LocalizedException(
+ __('We cannot retrieve the payment information object instance.')
+ );
+ }
+ return $this->infoInstance;
+ }
+
+ public function setInfoInstance(InfoInterface $info)
+ {
+ $this->infoInstance = $info;
+ return $this;
+ }
+
+ // -------------------------------------------------------------------------
+ // Payment actions — Paystack processes payments externally
+ // -------------------------------------------------------------------------
+
+ public function validate()
+ {
+ return $this;
+ }
+
+ public function assignData(DataObject $data)
+ {
+ return $this;
+ }
+
+ public function initialize($paymentAction, $stateObject)
+ {
+ return $this;
+ }
+
+ public function getConfigPaymentAction()
+ {
+ return $this->getConfigData('payment_action');
+ }
+
+ public function fetchTransactionInfo(InfoInterface $payment, $transactionId)
+ {
+ return [];
+ }
+
+ public function order(InfoInterface $payment, $amount)
+ {
+ throw new \Magento\Framework\Exception\LocalizedException(
+ __('Order action is not supported by Paystack.')
+ );
+ }
+
+ public function authorize(InfoInterface $payment, $amount)
+ {
+ throw new \Magento\Framework\Exception\LocalizedException(
+ __('Authorize action is not supported by Paystack.')
+ );
+ }
+
+ public function capture(InfoInterface $payment, $amount)
+ {
+ throw new \Magento\Framework\Exception\LocalizedException(
+ __('Capture action is not supported by Paystack.')
+ );
+ }
+
+ public function refund(InfoInterface $payment, $creditMemo)
+ {
+ throw new \Magento\Framework\Exception\LocalizedException(
+ __('Refund action is not supported by Paystack.')
+ );
+ }
+
+ public function cancel(InfoInterface $payment)
+ {
+ return $this;
+ }
+
+ public function void(InfoInterface $payment)
+ {
+ throw new \Magento\Framework\Exception\LocalizedException(
+ __('Void action is not supported by Paystack.')
+ );
+ }
+
+ public function acceptPayment(InfoInterface $payment)
+ {
+ throw new \Magento\Framework\Exception\LocalizedException(
+ __('Accept payment action is not supported by Paystack.')
+ );
+ }
+
+ public function denyPayment(InfoInterface $payment)
+ {
+ throw new \Magento\Framework\Exception\LocalizedException(
+ __('Deny payment action is not supported by Paystack.')
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // Internal helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Detect whether we are running in the adminhtml area.
+ *
+ * Used as a defence-in-depth guard so that even if canUseInternal() is
+ * somehow bypassed, Paystack never surfaces as available in admin context.
+ */
+ private function isAdminArea(): bool
+ {
+ try {
+ return $this->appState->getAreaCode() === \Magento\Framework\App\Area::AREA_ADMINHTML;
+ } catch (\Magento\Framework\Exception\LocalizedException $e) {
+ // Area code not set yet (e.g. during setup:di:compile) — safe default.
+ return false;
+ }
}
}
diff --git a/Model/PaymentManagement.php b/Model/PaymentManagement.php
index 1210d62..2faedc6 100644
--- a/Model/PaymentManagement.php
+++ b/Model/PaymentManagement.php
@@ -103,7 +103,7 @@ public function verifyPayment($reference)
]);
}
$this->logger->warning('Paystack: quoteId mismatch — order not updated');
- } catch (Exception $e) {
+ } catch (\Throwable $e) {
$this->logger->error('Paystack: verifyPayment exception', ['error' => $e->getMessage()]);
return json_encode([
'status' => false,
diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php
index bdb0681..7de2650 100644
--- a/Model/Ui/ConfigProvider.php
+++ b/Model/Ui/ConfigProvider.php
@@ -3,21 +3,36 @@
use Magento\Checkout\Model\ConfigProviderInterface;
use Magento\Payment\Helper\Data as PaymentHelper;
-use Magento\Store\Model\Store as Store;
+use Magento\Store\Model\StoreManagerInterface;
+use Psr\Log\LoggerInterface;
-/**
- * Class ConfigProvider
- */
class ConfigProvider implements ConfigProviderInterface
{
- protected $method;
- protected $store;
+ protected $paymentHelper;
+ protected $storeManager;
+ protected $logger;
+ private $method;
- public function __construct(PaymentHelper $paymentHelper, Store $store)
+ public function __construct(
+ PaymentHelper $paymentHelper,
+ StoreManagerInterface $storeManager,
+ LoggerInterface $logger
+ ) {
+ $this->paymentHelper = $paymentHelper;
+ $this->storeManager = $storeManager;
+ $this->logger = $logger;
+ }
+
+ /**
+ * @return \Magento\Payment\Model\MethodInterface
+ */
+ private function getMethod()
{
- $this->method = $paymentHelper->getMethodInstance(\Pstk\Paystack\Model\Payment\Paystack::CODE);
- $this->store = $store;
+ if ($this->method === null) {
+ $this->method = $this->paymentHelper->getMethodInstance(\Pstk\Paystack\Model\Payment\Paystack::CODE);
+ }
+ return $this->method;
}
/**
@@ -27,51 +42,57 @@ public function __construct(PaymentHelper $paymentHelper, Store $store)
*/
public function getConfig()
{
- $publicKey = $this->method->getConfigData('live_public_key');
- if ($this->method->getConfigData('test_mode')) {
- $publicKey = $this->method->getConfigData('test_public_key');
- }
-
- $integrationType = $this->method->getConfigData('integration_type')?: 'inline';
+ try {
+ $method = $this->getMethod();
+
+ $publicKey = $method->getConfigData('live_public_key');
+ if ($method->getConfigData('test_mode')) {
+ $publicKey = $method->getConfigData('test_public_key');
+ }
+
+ $integrationType = $method->getConfigData('integration_type') ?: 'inline';
+ $baseUrl = $this->storeManager->getStore()->getBaseUrl();
- return [
- 'payment' => [
- \Pstk\Paystack\Model\Payment\Paystack::CODE => [
- 'public_key' => $publicKey,
- 'integration_type' => $integrationType,
- 'api_url' => $this->store->getBaseUrl() . 'rest/',
- 'integration_type_standard_url' => $this->store->getBaseUrl() . 'paystack/payment/setup',
- 'recreate_quote_url' => $this->store->getBaseUrl() . 'paystack/payment/recreate',
+ return [
+ 'payment' => [
+ \Pstk\Paystack\Model\Payment\Paystack::CODE => [
+ 'public_key' => $publicKey,
+ 'integration_type' => $integrationType,
+ 'api_url' => $baseUrl . 'rest/',
+ 'integration_type_standard_url' => $baseUrl . 'paystack/payment/setup',
+ 'recreate_quote_url' => $baseUrl . 'paystack/payment/recreate',
+ ]
]
- ]
- ];
- }
-
- public function getStore() {
- return $this->store;
+ ];
+ } catch (\Throwable $e) {
+ $this->logger->error('Paystack ConfigProvider: ' . $e->getMessage());
+ return [];
+ }
}
-
+
/**
* Get secret key for webhook process
- *
+ *
* @return array
*/
- public function getSecretKeyArray(){
- $data = ["live" => $this->method->getConfigData('live_secret_key')];
- if ($this->method->getConfigData('test_mode')) {
- $data = ["test" => $this->method->getConfigData('test_secret_key')];
+ public function getSecretKeyArray()
+ {
+ $method = $this->getMethod();
+ $data = ["live" => $method->getConfigData('live_secret_key')];
+ if ($method->getConfigData('test_mode')) {
+ $data = ["test" => $method->getConfigData('test_secret_key')];
}
-
+
return $data;
}
- public function getPublicKey(){
- $publicKey = $this->method->getConfigData('live_public_key');
- if ($this->method->getConfigData('test_mode')) {
- $publicKey = $this->method->getConfigData('test_public_key');
+ public function getPublicKey()
+ {
+ $method = $this->getMethod();
+ $publicKey = $method->getConfigData('live_public_key');
+ if ($method->getConfigData('test_mode')) {
+ $publicKey = $method->getConfigData('test_public_key');
}
return $publicKey;
}
-
-
}
diff --git a/README.md b/README.md
index 6e04f04..bbd5673 100644
--- a/README.md
+++ b/README.md
@@ -1,138 +1,138 @@
-[![Latest Version on Packagist][ico-version]][link-packagist]
-[![Software License][ico-license]](LICENSE)
-[![Total Downloads][ico-downloads]][link-downloads]
-
-## Paystack Magento 2 Module
-
-Paystack payment gateway Magento2 extension
-
-**Version:** 3.0.4 (Paystack v2 Inline.js API)
-
-## Requirements
-
-- Magento 2.4.x
-- PHP 8.2+
-
-## Installation
-
-### Composer (Recommended)
-
-Go to your Magento2 root folder and run:
-
-```bash
-composer require pstk/paystack-magento2-module
-php bin/magento module:enable Pstk_Paystack
-php bin/magento setup:upgrade
-php bin/magento setup:di:compile
-php bin/magento cache:flush
-```
-
-### Manual Installation
-
-Copy all files to `app/code/Pstk/Paystack/` in your Magento installation, then run:
-
-```bash
-php bin/magento module:enable Pstk_Paystack
-php bin/magento setup:upgrade
-php bin/magento setup:di:compile
-php bin/magento cache:flush
-```
-
-## Configuration
-
-To configure the plugin in *Magento Admin*:
-1. Go to **Stores > Configuration > Sales > Payment Methods**.
-2. Find **Paystack** and configure:
- - **Enabled**: Yes/No
- - **Title**: What customers see at checkout
- - **Integration Type**: Inline (Popup) or Redirect
- - **Test Mode**: Enable for sandbox testing
- - **Test/Live Secret Key**: Get from your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer)
- - **Test/Live Public Key**: Get from your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer)
-3. Click **Save Config**.
-
-### Webhook Setup
-
-For reliable payment confirmation (especially for the redirect flow), set up a webhook in your Paystack dashboard:
-
-1. Go to **Settings > API Keys & Webhooks** on your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer)
-2. Set the Webhook URL to: `https://yourdomain.com/paystack/payment/webhook`
-3. The module handles `charge.success` events and automatically updates order status
-
-## Development Environment
-
-A Docker-based development environment is included in the `dev/` directory for contributors and testing.
-
-### Prerequisites
-
-- [Docker](https://www.docker.com/) (or [Rancher Desktop](https://rancherdesktop.io/) with `dockerd` runtime)
-- A [Paystack test account](https://dashboard.paystack.com/#/signup)
-
-### Quick Start
-
-```bash
-cd dev
-cp .env.example .env # Add your Paystack test keys
-docker compose up -d # First run builds the image (~5 min) and installs Magento (~3 min)
-bash setup.sh # Enables module, creates test products, configures everything
-```
-
-Once complete you'll see:
-
-```
-============================================
- Setup complete!
-
- Storefront: http://localhost:8080
- Admin panel: http://localhost:8080/admin
- Admin login: admin / Admin12345!
-
- Test card: 4084 0840 8408 4081
- Expiry: 12/30
- CVV: 408
- PIN: 0000
- OTP: 123456
-============================================
-```
-
-### What's Included
-
-- **Magento 2.4.8-p3** via [Mage-OS](https://mage-os.org/) public mirror (no Adobe marketplace auth needed)
-- **OpenSearch 2.19.1** + **MariaDB 10.6**
-- **5 test products** with images and a configured homepage
-- **Paystack payment** pre-configured in test mode (inline popup)
-- Container names: `paystack-magento`, `paystack-db`, `paystack-search`
-
-### Tear Down
-
-```bash
-cd dev
-docker compose down # Stop containers (preserves data)
-docker compose down -v # Stop containers and delete all data
-```
-
-## Documentation
-
-* [Paystack Documentation](https://developers.paystack.co/v2.0/docs/)
-* [Paystack Helpdesk](https://paystack.com/help)
-
-## Support
-
-For bug reports and feature requests directly related to this plugin, please use the [issue tracker](https://github.com/PaystackHQ/plugin-magento-2/issues).
-
-For general support or questions about your Paystack account, you can reach out by sending a message from [our website](https://paystack.com/contact).
-
-## Community
-
-If you are a developer, please join our Developer Community on [Slack](https://slack.paystack.com).
-
-## Contributing to the Magento 2 plugin
-
-If you have a patch or have stumbled upon an issue with the Magento 2 plugin, you can contribute this back to the code. Please read our [contributor guidelines](https://github.com/PaystackHQ/plugin-magento-2/blob/master/CONTRIBUTING.md) for more information how you can do this.
-
-[ico-version]: https://img.shields.io/packagist/v/pstk/paystack-magento2-module.svg?style=flat-square
-[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square
-[ico-downloads]: https://img.shields.io/packagist/dt/pstk/paystack-magento2-module.svg?style=flat-square
-
-[link-packagist]: https://packagist.org/packages/pstk/paystack-magento2-module
-[link-downloads]: https://packagist.org/packages/pstk/paystack-magento2-module
+[![Latest Version on Packagist][ico-version]][link-packagist]
+[![Software License][ico-license]](LICENSE)
+[![Total Downloads][ico-downloads]][link-downloads]
+
+## Paystack Magento 2 Module
+
+Paystack payment gateway Magento2 extension
+
+**Version:** 3.0.10 (Paystack v2 Inline.js API)
+
+## Requirements
+
+- Magento 2.4.x
+- PHP 8.2+
+
+## Installation
+
+### Composer (Recommended)
+
+Go to your Magento2 root folder and run:
+
+```bash
+composer require pstk/paystack-magento2-module
+php bin/magento module:enable Pstk_Paystack
+php bin/magento setup:upgrade
+php bin/magento setup:di:compile
+php bin/magento cache:flush
+```
+
+### Manual Installation
+
+Copy all files to `app/code/Pstk/Paystack/` in your Magento installation, then run:
+
+```bash
+php bin/magento module:enable Pstk_Paystack
+php bin/magento setup:upgrade
+php bin/magento setup:di:compile
+php bin/magento cache:flush
+```
+
+## Configuration
+
+To configure the plugin in *Magento Admin*:
+1. Go to **Stores > Configuration > Sales > Payment Methods**.
+2. Find **Paystack** and configure:
+ - **Enabled**: Yes/No
+ - **Title**: What customers see at checkout
+ - **Integration Type**: Inline (Popup) or Redirect
+ - **Test Mode**: Enable for sandbox testing
+ - **Test/Live Secret Key**: Get from your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer)
+ - **Test/Live Public Key**: Get from your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer)
+3. Click **Save Config**.
+
+### Webhook Setup
+
+For reliable payment confirmation (especially for the redirect flow), set up a webhook in your Paystack dashboard:
+
+1. Go to **Settings > API Keys & Webhooks** on your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer)
+2. Set the Webhook URL to: `https://yourdomain.com/paystack/payment/webhook`
+3. The module handles `charge.success` events and automatically updates order status
+
+## Development Environment
+
+A Docker-based development environment is included in the `dev/` directory for contributors and testing.
+
+### Prerequisites
+
+- [Docker](https://www.docker.com/) (or [Rancher Desktop](https://rancherdesktop.io/) with `dockerd` runtime)
+- A [Paystack test account](https://dashboard.paystack.com/#/signup)
+
+### Quick Start
+
+```bash
+cd dev
+cp .env.example .env # Add your Paystack test keys
+docker compose up -d # First run builds the image (~5 min) and installs Magento (~3 min)
+bash setup.sh # Enables module, creates test products, configures everything
+```
+
+Once complete you'll see:
+
+```
+============================================
+ Setup complete!
+
+ Storefront: http://localhost:8080
+ Admin panel: http://localhost:8080/admin
+ Admin login: admin / Admin12345!
+
+ Test card: 4084 0840 8408 4081
+ Expiry: 12/30
+ CVV: 408
+ PIN: 0000
+ OTP: 123456
+============================================
+```
+
+### What's Included
+
+- **Magento 2.4.8-p3** via [Mage-OS](https://mage-os.org/) public mirror (no Adobe marketplace auth needed)
+- **OpenSearch 2.19.1** + **MariaDB 10.6**
+- **5 test products** with images and a configured homepage
+- **Paystack payment** pre-configured in test mode (inline popup)
+- Container names: `paystack-magento`, `paystack-db`, `paystack-search`
+
+### Tear Down
+
+```bash
+cd dev
+docker compose down # Stop containers (preserves data)
+docker compose down -v # Stop containers and delete all data
+```
+
+## Documentation
+
+* [Paystack Documentation](https://developers.paystack.co/v2.0/docs/)
+* [Paystack Helpdesk](https://paystack.com/help)
+
+## Support
+
+For bug reports and feature requests directly related to this plugin, please use the [issue tracker](https://github.com/PaystackHQ/plugin-magento-2/issues).
+
+For general support or questions about your Paystack account, you can reach out by sending a message from [our website](https://paystack.com/contact).
+
+## Community
+
+If you are a developer, please join our Developer Community on [Slack](https://slack.paystack.com).
+
+## Contributing to the Magento 2 plugin
+
+If you have a patch or have stumbled upon an issue with the Magento 2 plugin, you can contribute this back to the code. Please read our [contributor guidelines](https://github.com/PaystackHQ/plugin-magento-2/blob/master/CONTRIBUTING.md) for more information how you can do this.
+
+[ico-version]: https://img.shields.io/packagist/v/pstk/paystack-magento2-module.svg?style=flat-square
+[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square
+[ico-downloads]: https://img.shields.io/packagist/dt/pstk/paystack-magento2-module.svg?style=flat-square
+
+[link-packagist]: https://packagist.org/packages/pstk/paystack-magento2-module
+[link-downloads]: https://packagist.org/packages/pstk/paystack-magento2-module
diff --git a/Test/Mftf/Page/PaystackPaymentConfigPage.xml b/Test/Mftf/Page/PaystackPaymentConfigPage.xml
new file mode 100644
index 0000000..9e9ef3c
--- /dev/null
+++ b/Test/Mftf/Page/PaystackPaymentConfigPage.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/Test/Mftf/README.md b/Test/Mftf/README.md
new file mode 100644
index 0000000..61f88d9
--- /dev/null
+++ b/Test/Mftf/README.md
@@ -0,0 +1,33 @@
+# MFTF tests for Pstk_Paystack
+
+This module provides **vendor-supplied** MFTF tests for the Paystack payment extension. They use Magento's standard action groups and page objects, mirroring how the Adobe Commerce Marketplace pipeline's own baseline suite navigates.
+
+## Running
+
+From the Magento root (with the extension installed), ensure `MAGENTO_ADMIN_PASSWORD` is set (e.g. in `.env` or `.credentials`), then:
+
+```bash
+vendor/bin/mftf generate:tests
+vendor/bin/mftf run:test PaystackPaymentConfigAvailableTest
+```
+
+Or run by group:
+
+```bash
+vendor/bin/mftf run:group Paystack
+```
+
+Run a single storefront test by name:
+
+```bash
+vendor/bin/mftf run:test StorefrontPaystackCheckoutRendersTest
+```
+
+## Tests
+
+- **PaystackPaymentConfigAvailableTest** – Logs in to admin (`AdminLoginActionGroup`), opens Stores → Configuration → Sales → Payment Methods via the `PaystackPaymentConfigPage` page object, and asserts that “Paystack” is visible.
+- **StorefrontPaystackCheckoutRendersTest** – Guest **storefront checkout** coverage. Creates a simple product, enables Paystack (inline), then drives add-to-cart → checkout → shipping → payment and asserts the checkout actually renders (no infinite loading mask) and the Paystack method appears on the payment step. This is the regression guard for the “checkout does not load” class of failure; the prior suite only covered the admin config screen.
+
+## Pages
+
+- **PaystackPaymentConfigPage** – `area="admin"` page object for the Payment Methods config section. Using a page object (rather than a raw `admin/...` `amOnPage` string) makes MFTF emit a leading-slash, base-relative URL, avoiding the `/admin/admin/...` noRoute 404 that failed the original submission.
diff --git a/Test/Mftf/Section/PaystackPaymentConfigSection.xml b/Test/Mftf/Section/PaystackPaymentConfigSection.xml
new file mode 100644
index 0000000..3832cf9
--- /dev/null
+++ b/Test/Mftf/Section/PaystackPaymentConfigSection.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml b/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml
new file mode 100644
index 0000000..140f7a5
--- /dev/null
+++ b/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml b/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml
new file mode 100644
index 0000000..7be5d86
--- /dev/null
+++ b/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 100.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test/Unit/Controller/Payment/CallbackTest.php b/Test/Unit/Controller/Payment/CallbackTest.php
new file mode 100644
index 0000000..0e41620
--- /dev/null
+++ b/Test/Unit/Controller/Payment/CallbackTest.php
@@ -0,0 +1,178 @@
+paystackClient = $this->createMock(PaystackApiClient::class);
+ $this->eventManager = $this->createMock(EventManager::class);
+ $this->request = $this->createMock(HttpRequest::class);
+ $this->orderInterface = $this->createMock(\Magento\Sales\Model\Order::class);
+ $this->messageManager = $this->createMock(MessageManager::class);
+
+ $redirect = $this->createMock(Redirect::class);
+ $redirect->method('setUrl')->willReturnSelf();
+
+ $redirectFactory = $this->createMock(RedirectFactory::class);
+ $redirectFactory->method('create')->willReturn($redirect);
+
+ $context = $this->createMock(Context::class);
+ $context->method('getRequest')->willReturn($this->request);
+ $context->method('getResponse')->willReturn($this->createMock(\Magento\Framework\App\ResponseInterface::class));
+ $context->method('getObjectManager')->willReturn($this->createMock(\Magento\Framework\ObjectManagerInterface::class));
+ $context->method('getEventManager')->willReturn($this->eventManager);
+ $context->method('getMessageManager')->willReturn($this->messageManager);
+ $context->method('getRedirect')->willReturn($this->createMock(RedirectInterface::class));
+ $context->method('getActionFlag')->willReturn($this->createMock(\Magento\Framework\App\ActionFlag::class));
+ $context->method('getView')->willReturn($this->createMock(\Magento\Framework\App\ViewInterface::class));
+ $context->method('getUrl')->willReturn($this->createMock(\Magento\Framework\UrlInterface::class));
+ $context->method('getResultRedirectFactory')->willReturn($redirectFactory);
+ $context->method('getResultFactory')->willReturn(
+ $this->createMock(\Magento\Framework\Controller\ResultFactory::class)
+ );
+
+ return new Callback(
+ $context,
+ $this->createMock(PageFactory::class),
+ $this->createMock(OrderRepositoryInterface::class),
+ $this->orderInterface,
+ $this->createMock(CheckoutSession::class),
+ $this->createMock(PaymentHelper::class),
+ $this->messageManager,
+ $this->createMock(ConfigProvider::class),
+ $this->createMock(StoreManagerInterface::class),
+ $this->eventManager,
+ $this->request,
+ $this->createMock(LoggerInterface::class),
+ $this->paystackClient
+ );
+ }
+
+ public function testSuccessfulCallbackDispatchesEvent(): void
+ {
+ $controller = $this->createController();
+
+ $this->request->method('get')
+ ->with('reference')
+ ->willReturn('000000001_suffix');
+
+ $verifyResponse = (object) [
+ 'data' => (object) [
+ 'reference' => '000000001_suffix',
+ 'status' => 'success',
+ ],
+ ];
+ $this->paystackClient->method('verifyTransaction')
+ ->with('000000001_suffix')
+ ->willReturn($verifyResponse);
+
+ $order = $this->createMock(\Magento\Sales\Model\Order::class);
+ $order->method('getIncrementId')->willReturn('000000001');
+ $this->orderInterface->method('loadByIncrementId')
+ ->with('000000001')
+ ->willReturn($order);
+
+ $this->eventManager->expects($this->once())
+ ->method('dispatch')
+ ->with('paystack_payment_verify_after', ['paystack_order' => $order]);
+
+ $controller->execute();
+ }
+
+ public function testMissingReferenceRedirectsToFailure(): void
+ {
+ $controller = $this->createController();
+
+ $this->request->method('get')
+ ->with('reference')
+ ->willReturn(null);
+
+ $this->messageManager->expects($this->once())
+ ->method('addErrorMessage');
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $controller->execute();
+ }
+
+ public function testApiExceptionRedirectsToFailure(): void
+ {
+ $controller = $this->createController();
+
+ $this->request->method('get')->willReturn('bad_ref');
+
+ $this->paystackClient->method('verifyTransaction')
+ ->willThrowException(new ApiException('Transaction failed'));
+
+ $this->messageManager->expects($this->once())
+ ->method('addErrorMessage');
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $controller->execute();
+ }
+
+ public function testOrderNotFoundRedirectsToFailure(): void
+ {
+ $controller = $this->createController();
+
+ $this->request->method('get')->willReturn('000000099');
+
+ $verifyResponse = (object) [
+ 'data' => (object) [
+ 'reference' => '000000099',
+ 'status' => 'success',
+ ],
+ ];
+ $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse);
+
+ $order = $this->createMock(\Magento\Sales\Model\Order::class);
+ $order->method('getIncrementId')->willReturn(null);
+ $this->orderInterface->method('loadByIncrementId')->willReturn($order);
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $controller->execute();
+ }
+}
diff --git a/Test/Unit/Controller/Payment/RecreateTest.php b/Test/Unit/Controller/Payment/RecreateTest.php
new file mode 100644
index 0000000..7f06c09
--- /dev/null
+++ b/Test/Unit/Controller/Payment/RecreateTest.php
@@ -0,0 +1,123 @@
+checkoutSession = $this->createMock(CheckoutSession::class);
+
+ $redirect = $this->createMock(Redirect::class);
+ $redirect->method('setUrl')->willReturnSelf();
+
+ $redirectFactory = $this->createMock(RedirectFactory::class);
+ $redirectFactory->method('create')->willReturn($redirect);
+
+ $context = $this->createMock(Context::class);
+ $request = $this->createMock(HttpRequest::class);
+ $context->method('getRequest')->willReturn($request);
+ $context->method('getResponse')->willReturn($this->createMock(\Magento\Framework\App\ResponseInterface::class));
+ $context->method('getObjectManager')->willReturn($this->createMock(\Magento\Framework\ObjectManagerInterface::class));
+ $context->method('getEventManager')->willReturn($this->createMock(EventManager::class));
+ $context->method('getMessageManager')->willReturn($this->createMock(MessageManager::class));
+ $context->method('getRedirect')->willReturn($this->createMock(RedirectInterface::class));
+ $context->method('getActionFlag')->willReturn($this->createMock(\Magento\Framework\App\ActionFlag::class));
+ $context->method('getView')->willReturn($this->createMock(\Magento\Framework\App\ViewInterface::class));
+ $context->method('getUrl')->willReturn($this->createMock(\Magento\Framework\UrlInterface::class));
+ $context->method('getResultRedirectFactory')->willReturn($redirectFactory);
+ $context->method('getResultFactory')->willReturn(
+ $this->createMock(\Magento\Framework\Controller\ResultFactory::class)
+ );
+
+ return new Recreate(
+ $context,
+ $this->createMock(PageFactory::class),
+ $this->createMock(OrderRepositoryInterface::class),
+ $this->createMock(Order::class),
+ $this->checkoutSession,
+ $this->createMock(PaymentHelper::class),
+ $this->createMock(MessageManager::class),
+ $this->createMock(ConfigProvider::class),
+ $this->createMock(StoreManagerInterface::class),
+ $this->createMock(EventManager::class),
+ $request,
+ $this->createMock(LoggerInterface::class),
+ $this->createMock(PaystackApiClient::class)
+ );
+ }
+
+ public function testCancelsActiveOrderAndRestoresQuote(): void
+ {
+ $controller = $this->createController();
+
+ $order = $this->createMock(Order::class);
+ $order->method('getId')->willReturn(1);
+ $order->method('getState')->willReturn(Order::STATE_NEW);
+
+ $order->expects($this->once())
+ ->method('registerCancellation')
+ ->with('Payment failed or cancelled')
+ ->willReturn($order);
+ $order->expects($this->once())->method('save');
+
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($order);
+ $this->checkoutSession->expects($this->once())->method('restoreQuote');
+
+ $controller->execute();
+ }
+
+ public function testAlreadyCancelledOrderIsNotCancelledAgain(): void
+ {
+ $controller = $this->createController();
+
+ $order = $this->createMock(Order::class);
+ $order->method('getId')->willReturn(1);
+ $order->method('getState')->willReturn(Order::STATE_CANCELED);
+
+ $order->expects($this->never())->method('registerCancellation');
+
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($order);
+ $this->checkoutSession->expects($this->once())->method('restoreQuote');
+
+ $controller->execute();
+ }
+
+ public function testNoOrderStillRestoresQuote(): void
+ {
+ $controller = $this->createController();
+
+ $order = $this->createMock(Order::class);
+ $order->method('getId')->willReturn(null);
+
+ $order->expects($this->never())->method('registerCancellation');
+
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($order);
+ $this->checkoutSession->expects($this->once())->method('restoreQuote');
+
+ $controller->execute();
+ }
+}
diff --git a/Test/Unit/Controller/Payment/SetupTest.php b/Test/Unit/Controller/Payment/SetupTest.php
new file mode 100644
index 0000000..a4e71a9
--- /dev/null
+++ b/Test/Unit/Controller/Payment/SetupTest.php
@@ -0,0 +1,206 @@
+paystackClient = $this->createMock(PaystackApiClient::class);
+ $this->checkoutSession = $this->createMock(CheckoutSession::class);
+ $this->orderInterface = $this->createMock(Order::class);
+ $this->paymentHelper = $this->createMock(PaymentHelper::class);
+ $this->storeManager = $this->createMock(StoreManagerInterface::class);
+ $this->orderRepository = $this->createMock(OrderRepositoryInterface::class);
+ $this->messageManager = $this->createMock(MessageManager::class);
+
+ $this->redirect = $this->createMock(Redirect::class);
+ $this->redirect->method('setUrl')->willReturnSelf();
+
+ $redirectFactory = $this->createMock(RedirectFactory::class);
+ $redirectFactory->method('create')->willReturn($this->redirect);
+
+ $context = $this->createMock(Context::class);
+ $request = $this->createMock(HttpRequest::class);
+ $context->method('getRequest')->willReturn($request);
+ $context->method('getResponse')->willReturn($this->createMock(\Magento\Framework\App\ResponseInterface::class));
+ $context->method('getObjectManager')->willReturn($this->createMock(\Magento\Framework\ObjectManagerInterface::class));
+ $context->method('getEventManager')->willReturn($this->createMock(EventManager::class));
+ $context->method('getMessageManager')->willReturn($this->messageManager);
+ $context->method('getRedirect')->willReturn($this->createMock(RedirectInterface::class));
+ $context->method('getActionFlag')->willReturn($this->createMock(\Magento\Framework\App\ActionFlag::class));
+ $context->method('getView')->willReturn($this->createMock(\Magento\Framework\App\ViewInterface::class));
+ $context->method('getUrl')->willReturn($this->createMock(\Magento\Framework\UrlInterface::class));
+ $context->method('getResultRedirectFactory')->willReturn($redirectFactory);
+ $context->method('getResultFactory')->willReturn(
+ $this->createMock(\Magento\Framework\Controller\ResultFactory::class)
+ );
+
+ return new Setup(
+ $context,
+ $this->createMock(PageFactory::class),
+ $this->orderRepository,
+ $this->orderInterface,
+ $this->checkoutSession,
+ $this->paymentHelper,
+ $this->messageManager,
+ $this->createMock(ConfigProvider::class),
+ $this->storeManager,
+ $this->createMock(EventManager::class),
+ $request,
+ $this->createMock(LoggerInterface::class),
+ $this->paystackClient
+ );
+ }
+
+ public function testSuccessfulSetupRedirectsToPaystack(): void
+ {
+ $controller = $this->createController();
+
+ $lastOrder = $this->createMock(Order::class);
+ $lastOrder->method('getIncrementId')->willReturn('000000001');
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder);
+
+ $payment = $this->createMock(Payment::class);
+ $payment->method('getMethod')->willReturn(Paystack::CODE);
+
+ $order = $this->createMock(Order::class);
+ $order->method('getPayment')->willReturn($payment);
+ $order->method('getCustomerFirstname')->willReturn('John');
+ $order->method('getCustomerLastname')->willReturn('Doe');
+ $order->method('getGrandTotal')->willReturn(5000.00);
+ $order->method('getCustomerEmail')->willReturn('john@example.com');
+ $order->method('getIncrementId')->willReturn('000000001');
+ $order->method('getCurrency')->willReturn('NGN');
+
+ $this->orderInterface->method('loadByIncrementId')
+ ->with('000000001')
+ ->willReturn($order);
+
+ $methodInstance = $this->createMock(MethodInterface::class);
+ $methodInstance->method('getCode')->willReturn(Paystack::CODE);
+ $this->paymentHelper->method('getMethodInstance')
+ ->with(Paystack::CODE)
+ ->willReturn($methodInstance);
+
+ $store = $this->createMock(StoreInterface::class);
+ $store->method('getBaseUrl')->willReturn('https://example.com/');
+ $this->storeManager->method('getStore')->willReturn($store);
+
+ $txResponse = (object) [
+ 'data' => (object) [
+ 'authorization_url' => 'https://checkout.paystack.com/abc123',
+ ],
+ ];
+ $this->paystackClient->expects($this->once())
+ ->method('initializeTransaction')
+ ->with($this->callback(function ($params) {
+ return $params['amount'] === 500000 // kobo
+ && $params['email'] === 'john@example.com'
+ && $params['reference'] === '000000001'
+ && $params['callback_url'] === 'https://example.com/paystack/payment/callback';
+ }))
+ ->willReturn($txResponse);
+
+ $this->redirect->expects($this->once())
+ ->method('setUrl')
+ ->with('https://checkout.paystack.com/abc123');
+
+ $controller->execute();
+ }
+
+ public function testApiExceptionSavesStatusHistory(): void
+ {
+ $controller = $this->createController();
+
+ $lastOrder = $this->createMock(Order::class);
+ $lastOrder->method('getIncrementId')->willReturn('000000001');
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder);
+
+ $payment = $this->createMock(Payment::class);
+ $payment->method('getMethod')->willReturn(Paystack::CODE);
+
+ $order = $this->createMock(Order::class);
+ $order->method('getPayment')->willReturn($payment);
+ $order->method('getStatus')->willReturn('pending');
+ $order->method('getCustomerFirstname')->willReturn('John');
+ $order->method('getCustomerLastname')->willReturn('Doe');
+ $order->method('getGrandTotal')->willReturn(100.00);
+ $order->method('getCustomerEmail')->willReturn('john@test.com');
+ $order->method('getIncrementId')->willReturn('000000001');
+ $order->method('getCurrency')->willReturn('NGN');
+
+ $this->orderInterface->method('loadByIncrementId')->willReturn($order);
+
+ $methodInstance = $this->createMock(MethodInterface::class);
+ $methodInstance->method('getCode')->willReturn(Paystack::CODE);
+ $this->paymentHelper->method('getMethodInstance')->willReturn($methodInstance);
+
+ $store = $this->createMock(StoreInterface::class);
+ $store->method('getBaseUrl')->willReturn('https://example.com/');
+ $this->storeManager->method('getStore')->willReturn($store);
+
+ $this->paystackClient->method('initializeTransaction')
+ ->willThrowException(new ApiException('Invalid key'));
+
+ $order->expects($this->once())
+ ->method('addStatusToHistory')
+ ->with('pending', 'Invalid key');
+
+ $this->orderRepository->expects($this->once())
+ ->method('save')
+ ->with($order);
+
+ $controller->execute();
+ }
+}
diff --git a/Test/Unit/Controller/Payment/WebhookTest.php b/Test/Unit/Controller/Payment/WebhookTest.php
new file mode 100644
index 0000000..53821f6
--- /dev/null
+++ b/Test/Unit/Controller/Payment/WebhookTest.php
@@ -0,0 +1,308 @@
+paystackClient = $this->createMock(PaystackApiClient::class);
+ $this->eventManager = $this->createMock(EventManager::class);
+ $this->request = $this->createMock(HttpRequest::class);
+ $this->orderInterface = $this->createMock(\Magento\Sales\Model\Order::class);
+ $this->orderRepository = $this->createMock(OrderRepositoryInterface::class);
+ $this->configProvider = $this->createMock(ConfigProvider::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->rawResult = $this->createMock(Raw::class);
+ $this->rawResult->method('setContents')->willReturnSelf();
+
+ $resultFactory = $this->createMock(ResultFactory::class);
+ $resultFactory->method('create')
+ ->with(ResultFactory::TYPE_RAW)
+ ->willReturn($this->rawResult);
+
+ $context = $this->createMock(Context::class);
+ $context->method('getResultFactory')->willReturn($resultFactory);
+ // getRequest is called by parent constructor
+ $context->method('getRequest')->willReturn($this->request);
+ $context->method('getResponse')->willReturn($this->createMock(\Magento\Framework\App\ResponseInterface::class));
+ $context->method('getObjectManager')->willReturn($this->createMock(\Magento\Framework\ObjectManagerInterface::class));
+ $context->method('getEventManager')->willReturn($this->eventManager);
+ $context->method('getMessageManager')->willReturn($this->createMock(\Magento\Framework\Message\ManagerInterface::class));
+ $context->method('getRedirect')->willReturn($this->createMock(\Magento\Framework\App\Response\RedirectInterface::class));
+ $context->method('getActionFlag')->willReturn($this->createMock(\Magento\Framework\App\ActionFlag::class));
+ $context->method('getView')->willReturn($this->createMock(\Magento\Framework\App\ViewInterface::class));
+ $context->method('getUrl')->willReturn($this->createMock(\Magento\Framework\UrlInterface::class));
+ $context->method('getResultRedirectFactory')->willReturn(
+ $this->createMock(\Magento\Framework\Controller\Result\RedirectFactory::class)
+ );
+
+ $pageFactory = $this->createMock(PageFactory::class);
+ $checkoutSession = $this->createMock(CheckoutSession::class);
+ $paymentHelper = $this->createMock(PaymentHelper::class);
+ $messageManager = $this->createMock(\Magento\Framework\Message\ManagerInterface::class);
+ $storeManager = $this->createMock(StoreManagerInterface::class);
+
+ $this->controller = new Webhook(
+ $context,
+ $pageFactory,
+ $this->orderRepository,
+ $this->orderInterface,
+ $checkoutSession,
+ $paymentHelper,
+ $messageManager,
+ $this->configProvider,
+ $storeManager,
+ $this->eventManager,
+ $this->request,
+ $this->logger,
+ $this->paystackClient
+ );
+ }
+
+ public function testInvalidSignatureReturnsAuthFailed(): void
+ {
+ $this->request->method('getContent')->willReturn('{"event":"charge.success"}');
+ $this->request->method('getHeader')->with('X-Paystack-Signature')->willReturn('bad_sig');
+
+ $this->paystackClient->method('validateWebhookSignature')->willReturn(false);
+
+ $this->rawResult->expects($this->atLeastOnce())
+ ->method('setContents')
+ ->with($this->callback(function ($val) {
+ return $val === 'auth failed';
+ }));
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $this->controller->execute();
+ }
+
+ public function testMissingSignatureReturnsAuthFailed(): void
+ {
+ $this->request->method('getContent')->willReturn('{"event":"charge.success"}');
+ $this->request->method('getHeader')->with('X-Paystack-Signature')->willReturn('');
+
+ $this->rawResult->expects($this->atLeastOnce())
+ ->method('setContents')
+ ->with($this->callback(function ($val) {
+ return $val === 'auth failed';
+ }));
+
+ $this->controller->execute();
+ }
+
+ public function testChargeSuccessWithValidOrder(): void
+ {
+ $rawBody = json_encode([
+ 'event' => 'charge.success',
+ 'data' => [
+ 'status' => 'success',
+ 'reference' => 'ORDER_001',
+ ],
+ ]);
+
+ $this->request->method('getContent')->willReturn($rawBody);
+ $this->request->method('getHeader')->with('X-Paystack-Signature')->willReturn('valid_sig');
+
+ $this->paystackClient->method('validateWebhookSignature')->willReturn(true);
+
+ $verifyResponse = (object) [
+ 'data' => (object) [
+ 'reference' => 'ORDER_001',
+ 'status' => 'success',
+ ],
+ ];
+ $this->paystackClient->method('verifyTransaction')
+ ->with('ORDER_001')
+ ->willReturn($verifyResponse);
+
+ $this->configProvider->method('getPublicKey')->willReturn('pk_test_123');
+ $this->paystackClient->expects($this->once())
+ ->method('logTransactionSuccess')
+ ->with('ORDER_001', 'pk_test_123');
+
+ $order = $this->createMock(\Magento\Sales\Model\Order::class);
+ $order->method('getId')->willReturn(1);
+ $order->method('getIncrementId')->willReturn('ORDER_001');
+ $order->method('getStatus')->willReturn('pending');
+
+ $this->orderInterface->method('loadByIncrementId')
+ ->with('ORDER_001')
+ ->willReturn($order);
+
+ $this->eventManager->expects($this->once())
+ ->method('dispatch')
+ ->with('paystack_payment_verify_after', ['paystack_order' => $order]);
+
+ $this->rawResult->expects($this->atLeastOnce())
+ ->method('setContents')
+ ->with('success');
+
+ $this->controller->execute();
+ }
+
+ public function testChargeSuccessWithQuoteIdFallback(): void
+ {
+ $rawBody = json_encode([
+ 'event' => 'charge.success',
+ 'data' => [
+ 'status' => 'success',
+ 'reference' => 'PSK_ref123',
+ 'metadata' => ['quoteId' => '55'],
+ ],
+ ]);
+
+ $this->request->method('getContent')->willReturn($rawBody);
+ $this->request->method('getHeader')->willReturn('valid_sig');
+ $this->paystackClient->method('validateWebhookSignature')->willReturn(true);
+
+ $verifyResponse = (object) [
+ 'data' => (object) [
+ 'reference' => 'PSK_ref123',
+ 'status' => 'success',
+ 'metadata' => (object) ['quoteId' => '55'],
+ ],
+ ];
+ $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse);
+ $this->configProvider->method('getPublicKey')->willReturn('pk_test');
+
+ // loadByIncrementId returns empty order (not found by reference)
+ $emptyOrder = $this->createMock(\Magento\Sales\Model\Order::class);
+ $emptyOrder->method('getId')->willReturn(null);
+ $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder);
+
+ // The webhook uses ObjectManager::getInstance() for the fallback path,
+ // which cannot be easily unit-tested. Verify the loadByIncrementId was attempted.
+ $this->orderInterface->expects($this->once())->method('loadByIncrementId');
+
+ $this->controller->execute();
+ }
+
+ public function testInvalidJsonPayloadReturnsInvalidPayload(): void
+ {
+ $this->request->method('getContent')->willReturn('not-json');
+ $this->request->method('getHeader')->willReturn('valid_sig');
+ $this->paystackClient->method('validateWebhookSignature')->willReturn(true);
+
+ $this->rawResult->expects($this->atLeastOnce())
+ ->method('setContents')
+ ->with($this->callback(function ($val) {
+ return $val === 'invalid payload';
+ }));
+
+ $this->controller->execute();
+ }
+
+ public function testNonChargeSuccessEventIsIgnored(): void
+ {
+ $rawBody = json_encode([
+ 'event' => 'transfer.success',
+ 'data' => ['reference' => 'TRF_001'],
+ ]);
+
+ $this->request->method('getContent')->willReturn($rawBody);
+ $this->request->method('getHeader')->willReturn('valid_sig');
+ $this->paystackClient->method('validateWebhookSignature')->willReturn(true);
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+ $this->paystackClient->expects($this->never())->method('verifyTransaction');
+
+ $this->controller->execute();
+ }
+
+ public function testChargeSuccessWithNonSuccessStatusIsIgnored(): void
+ {
+ $rawBody = json_encode([
+ 'event' => 'charge.success',
+ 'data' => [
+ 'status' => 'failed',
+ 'reference' => 'ORDER_002',
+ ],
+ ]);
+
+ $this->request->method('getContent')->willReturn($rawBody);
+ $this->request->method('getHeader')->willReturn('valid_sig');
+ $this->paystackClient->method('validateWebhookSignature')->willReturn(true);
+
+ $this->paystackClient->expects($this->never())->method('verifyTransaction');
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $this->controller->execute();
+ }
+
+ public function testApiExceptionDuringVerifyReturnsErrorMessage(): void
+ {
+ $rawBody = json_encode([
+ 'event' => 'charge.success',
+ 'data' => [
+ 'status' => 'success',
+ 'reference' => 'ORDER_003',
+ ],
+ ]);
+
+ $this->request->method('getContent')->willReturn($rawBody);
+ $this->request->method('getHeader')->willReturn('valid_sig');
+ $this->paystackClient->method('validateWebhookSignature')->willReturn(true);
+ $this->paystackClient->method('verifyTransaction')
+ ->willThrowException(new ApiException('API down'));
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $this->rawResult->expects($this->atLeastOnce())
+ ->method('setContents')
+ ->with($this->callback(function ($val) {
+ return $val === 'API down';
+ }));
+
+ $this->controller->execute();
+ }
+}
diff --git a/Test/Unit/Gateway/PaystackApiClientTest.php b/Test/Unit/Gateway/PaystackApiClientTest.php
new file mode 100644
index 0000000..348b658
--- /dev/null
+++ b/Test/Unit/Gateway/PaystackApiClientTest.php
@@ -0,0 +1,108 @@
+paymentHelper = $this->createMock(PaymentHelper::class);
+ $this->paymentMethod = $this->createMock(MethodInterface::class);
+
+ $this->paymentHelper->method('getMethodInstance')
+ ->with(Paystack::CODE)
+ ->willReturn($this->paymentMethod);
+
+ $this->client = new PaystackApiClient($this->paymentHelper);
+ }
+
+ public function testValidateWebhookSignatureValid(): void
+ {
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ if ($field === 'test_mode') return true;
+ if ($field === 'test_secret_key') return 'sk_test_secret123';
+ return null;
+ });
+
+ $rawBody = '{"event":"charge.success","data":{"reference":"abc123"}}';
+ $signature = hash_hmac('sha512', $rawBody, 'sk_test_secret123');
+
+ $this->assertTrue($this->client->validateWebhookSignature($rawBody, $signature));
+ }
+
+ public function testValidateWebhookSignatureInvalid(): void
+ {
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ if ($field === 'test_mode') return true;
+ if ($field === 'test_secret_key') return 'sk_test_secret123';
+ return null;
+ });
+
+ $rawBody = '{"event":"charge.success"}';
+ $forgedSignature = hash_hmac('sha512', $rawBody, 'wrong_key');
+
+ $this->assertFalse($this->client->validateWebhookSignature($rawBody, $forgedSignature));
+ }
+
+ public function testValidateWebhookSignatureUsesLiveKeyWhenNotTestMode(): void
+ {
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ if ($field === 'test_mode') return false;
+ if ($field === 'live_secret_key') return 'sk_live_real456';
+ return null;
+ });
+
+ $rawBody = '{"event":"charge.success"}';
+ $signature = hash_hmac('sha512', $rawBody, 'sk_live_real456');
+
+ $this->assertTrue($this->client->validateWebhookSignature($rawBody, $signature));
+ }
+
+ public function testValidateWebhookSignatureEmptySignatureFails(): void
+ {
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ if ($field === 'test_mode') return true;
+ if ($field === 'test_secret_key') return 'sk_test_secret123';
+ return null;
+ });
+
+ $this->assertFalse($this->client->validateWebhookSignature('body', ''));
+ }
+
+ public function testValidateWebhookSignatureTampered(): void
+ {
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ if ($field === 'test_mode') return true;
+ if ($field === 'test_secret_key') return 'sk_test_secret123';
+ return null;
+ });
+
+ $originalBody = '{"event":"charge.success","data":{"amount":10000}}';
+ $signature = hash_hmac('sha512', $originalBody, 'sk_test_secret123');
+
+ $tamperedBody = '{"event":"charge.success","data":{"amount":1}}';
+
+ $this->assertFalse($this->client->validateWebhookSignature($tamperedBody, $signature));
+ }
+}
diff --git a/Test/Unit/Model/Payment/PaystackTest.php b/Test/Unit/Model/Payment/PaystackTest.php
new file mode 100644
index 0000000..e6b649f
--- /dev/null
+++ b/Test/Unit/Model/Payment/PaystackTest.php
@@ -0,0 +1,380 @@
+scopeConfig = $this->createMock(ScopeConfigInterface::class);
+ $this->appState = $this->createMock(State::class);
+ // Default to frontend area for most tests
+ $this->appState->method('getAreaCode')->willReturn(Area::AREA_FRONTEND);
+ $this->model = new Paystack($this->scopeConfig, $this->appState);
+ }
+
+ /**
+ * Helper: create a Paystack instance in admin area context.
+ */
+ private function createAdminModel(): Paystack
+ {
+ $adminState = $this->createMock(State::class);
+ $adminState->method('getAreaCode')->willReturn(Area::AREA_ADMINHTML);
+ return new Paystack($this->scopeConfig, $adminState);
+ }
+
+ // ---- Identity ----
+
+ public function testGetCodeReturnsPstkPaystack(): void
+ {
+ $this->assertEquals('pstk_paystack', $this->model->getCode());
+ }
+
+ // ---- Store scope ----
+
+ public function testSetAndGetStore(): void
+ {
+ $this->model->setStore(5);
+ $this->assertEquals(5, $this->model->getStore());
+ }
+
+ // ---- Configuration ----
+
+ public function testGetConfigDataReadsFromScopeConfig(): void
+ {
+ $this->scopeConfig->expects($this->once())
+ ->method('getValue')
+ ->with('payment/pstk_paystack/title', 'store', null)
+ ->willReturn('Pay with Paystack');
+
+ $this->assertEquals('Pay with Paystack', $this->model->getConfigData('title'));
+ }
+
+ public function testGetConfigDataUsesStoreId(): void
+ {
+ $this->model->setStore(3);
+
+ $this->scopeConfig->expects($this->once())
+ ->method('getValue')
+ ->with('payment/pstk_paystack/active', 'store', 3)
+ ->willReturn('1');
+
+ $this->assertEquals('1', $this->model->getConfigData('active'));
+ }
+
+ public function testGetConfigDataStoreIdParamOverridesSetStore(): void
+ {
+ $this->model->setStore(3);
+
+ $this->scopeConfig->expects($this->once())
+ ->method('getValue')
+ ->with('payment/pstk_paystack/active', 'store', 7)
+ ->willReturn('0');
+
+ $this->assertEquals('0', $this->model->getConfigData('active', 7));
+ }
+
+ public function testGetTitle(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('Paystack');
+ $this->assertEquals('Paystack', $this->model->getTitle());
+ }
+
+ // ---- Availability ----
+
+ public function testIsActiveWhenConfigEnabled(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('1');
+ $this->assertTrue($this->model->isActive());
+ }
+
+ public function testIsActiveWhenConfigDisabled(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('0');
+ $this->assertFalse($this->model->isActive());
+ }
+
+ public function testIsAvailableWhenActiveAndCheckoutAllowed(): void
+ {
+ $this->scopeConfig->method('getValue')
+ ->willReturnCallback(function ($path) {
+ if (str_ends_with($path, '/active')) return '1';
+ if (str_ends_with($path, '/can_use_checkout')) return '1';
+ return null;
+ });
+
+ $quote = $this->createMock(CartInterface::class);
+ $quote->method('getStoreId')->willReturn(1);
+
+ $this->assertTrue($this->model->isAvailable($quote));
+ }
+
+ public function testIsAvailableReturnsFalseWhenInactive(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('0');
+
+ $this->assertFalse($this->model->isAvailable());
+ }
+
+ public function testIsAvailableReturnsFalseInAdminArea(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('1');
+
+ $adminModel = $this->createAdminModel();
+ $this->assertFalse($adminModel->isAvailable());
+ }
+
+ public function testCanUseInternalReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canUseInternal());
+ }
+
+ public function testCanUseCheckoutDefaultsToTrue(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn(null);
+ $this->assertTrue($this->model->canUseCheckout());
+ }
+
+ // ---- Country restrictions ----
+
+ public function testCanUseForCountryAllowsAllWhenNotRestricted(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('0');
+ $this->assertTrue($this->model->canUseForCountry('NG'));
+ }
+
+ public function testCanUseForCountryAllowsSpecificCountry(): void
+ {
+ $this->scopeConfig->method('getValue')
+ ->willReturnCallback(function ($path) {
+ if (str_ends_with($path, '/allowspecific')) return '1';
+ if (str_ends_with($path, '/specificcountry')) return 'NG,GH,ZA';
+ return null;
+ });
+
+ $this->assertTrue($this->model->canUseForCountry('NG'));
+ }
+
+ public function testCanUseForCountryRejectsUnlistedCountry(): void
+ {
+ $this->scopeConfig->method('getValue')
+ ->willReturnCallback(function ($path) {
+ if (str_ends_with($path, '/allowspecific')) return '1';
+ if (str_ends_with($path, '/specificcountry')) return 'NG,GH';
+ return null;
+ });
+
+ $this->assertFalse($this->model->canUseForCountry('US'));
+ }
+
+ public function testCanUseForCountryAllowsEmptyCountry(): void
+ {
+ $this->assertTrue($this->model->canUseForCountry(''));
+ }
+
+ // ---- Currency ----
+
+ public function testCanUseForCurrencyAcceptsAnyCurrency(): void
+ {
+ $this->assertTrue($this->model->canUseForCurrency('NGN'));
+ $this->assertTrue($this->model->canUseForCurrency('USD'));
+ $this->assertTrue($this->model->canUseForCurrency('GHS'));
+ $this->assertTrue($this->model->canUseForCurrency('ZAR'));
+ $this->assertTrue($this->model->canUseForCurrency('KES'));
+ }
+
+ // ---- Capability flags ----
+
+ public function testIsGatewayReturnsTrue(): void
+ {
+ $this->assertTrue($this->model->isGateway());
+ }
+
+ public function testIsOfflineReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->isOffline());
+ }
+
+ public function testCanOrderReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canOrder());
+ }
+
+ public function testCanAuthorizeReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canAuthorize());
+ }
+
+ public function testCanCaptureReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canCapture());
+ }
+
+ public function testCanCapturePartialReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canCapturePartial());
+ }
+
+ public function testCanCaptureOnceReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canCaptureOnce());
+ }
+
+ public function testCanRefundReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canRefund());
+ }
+
+ public function testCanRefundPartialPerInvoiceReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canRefundPartialPerInvoice());
+ }
+
+ public function testCanVoidReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canVoid());
+ }
+
+ public function testCanEditReturnsTrue(): void
+ {
+ $this->assertTrue($this->model->canEdit());
+ }
+
+ public function testCanFetchTransactionInfoReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canFetchTransactionInfo());
+ }
+
+ public function testCanReviewPaymentReturnsFalse(): void
+ {
+ $this->assertFalse($this->model->canReviewPayment());
+ }
+
+ // ---- Payment actions throw exceptions ----
+
+ public function testOrderThrowsException(): void
+ {
+ $this->expectException(LocalizedException::class);
+ $payment = $this->createMock(InfoInterface::class);
+ $this->model->order($payment, 100);
+ }
+
+ public function testAuthorizeThrowsException(): void
+ {
+ $this->expectException(LocalizedException::class);
+ $payment = $this->createMock(InfoInterface::class);
+ $this->model->authorize($payment, 100);
+ }
+
+ public function testCaptureThrowsException(): void
+ {
+ $this->expectException(LocalizedException::class);
+ $payment = $this->createMock(InfoInterface::class);
+ $this->model->capture($payment, 100);
+ }
+
+ public function testRefundThrowsException(): void
+ {
+ $this->expectException(LocalizedException::class);
+ $payment = $this->createMock(InfoInterface::class);
+ $this->model->refund($payment, 50);
+ }
+
+ public function testVoidThrowsException(): void
+ {
+ $this->expectException(LocalizedException::class);
+ $payment = $this->createMock(InfoInterface::class);
+ $this->model->void($payment);
+ }
+
+ public function testAcceptPaymentThrowsException(): void
+ {
+ $this->expectException(LocalizedException::class);
+ $payment = $this->createMock(InfoInterface::class);
+ $this->model->acceptPayment($payment);
+ }
+
+ public function testDenyPaymentThrowsException(): void
+ {
+ $this->expectException(LocalizedException::class);
+ $payment = $this->createMock(InfoInterface::class);
+ $this->model->denyPayment($payment);
+ }
+
+ // ---- Non-throwing methods ----
+
+ public function testCancelReturnsThis(): void
+ {
+ $payment = $this->createMock(InfoInterface::class);
+ $this->assertSame($this->model, $this->model->cancel($payment));
+ }
+
+ public function testValidateReturnsThis(): void
+ {
+ $this->assertSame($this->model, $this->model->validate());
+ }
+
+ public function testAssignDataReturnsThis(): void
+ {
+ $data = new \Magento\Framework\DataObject();
+ $this->assertSame($this->model, $this->model->assignData($data));
+ }
+
+ public function testInitializeReturnsThis(): void
+ {
+ $stateObject = new \stdClass();
+ $this->assertSame($this->model, $this->model->initialize('authorize', $stateObject));
+ }
+
+ public function testFetchTransactionInfoReturnsEmptyArray(): void
+ {
+ $payment = $this->createMock(InfoInterface::class);
+ $this->assertEquals([], $this->model->fetchTransactionInfo($payment, 'txn_123'));
+ }
+
+ // ---- Block types ----
+
+ public function testGetFormBlockType(): void
+ {
+ $this->assertEquals(\Magento\Payment\Block\Form::class, $this->model->getFormBlockType());
+ }
+
+ public function testGetInfoBlockType(): void
+ {
+ $this->assertEquals(\Magento\Payment\Block\Info::class, $this->model->getInfoBlockType());
+ }
+
+ // ---- Info instance ----
+
+ public function testSetAndGetInfoInstance(): void
+ {
+ $info = $this->createMock(InfoInterface::class);
+ $this->model->setInfoInstance($info);
+ $this->assertSame($info, $this->model->getInfoInstance());
+ }
+
+ public function testGetInfoInstanceThrowsWhenNotSet(): void
+ {
+ $this->expectException(LocalizedException::class);
+ $this->model->getInfoInstance();
+ }
+}
diff --git a/Test/Unit/Model/PaymentManagementTest.php b/Test/Unit/Model/PaymentManagementTest.php
new file mode 100644
index 0000000..c8a77cb
--- /dev/null
+++ b/Test/Unit/Model/PaymentManagementTest.php
@@ -0,0 +1,204 @@
+paystackClient = $this->createMock(PaystackApiClient::class);
+ $this->eventManager = $this->createMock(EventManager::class);
+ $this->orderInterface = $this->createMock(\Magento\Sales\Model\Order::class);
+ $this->checkoutSession = $this->createMock(CheckoutSession::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->paymentManagement = new PaymentManagement(
+ $this->paystackClient,
+ $this->eventManager,
+ $this->orderInterface,
+ $this->checkoutSession,
+ $this->logger
+ );
+ }
+
+ public function testVerifyPaymentSuccessful(): void
+ {
+ $quoteId = '42';
+ $reference = 'PSK_abc123_-~-_' . $quoteId;
+
+ $txData = (object) [
+ 'status' => 'success',
+ 'reference' => 'PSK_abc123',
+ 'metadata' => (object) ['quoteId' => $quoteId],
+ ];
+ $apiResponse = (object) ['data' => $txData];
+
+ $this->paystackClient->expects($this->once())
+ ->method('verifyTransaction')
+ ->with('PSK_abc123')
+ ->willReturn($apiResponse);
+
+ $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class);
+ $lastOrder->method('getIncrementId')->willReturn('000000001');
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder);
+
+ $order = $this->createMock(\Magento\Sales\Model\Order::class);
+ $order->method('getQuoteId')->willReturn($quoteId);
+ $this->orderInterface->method('loadByIncrementId')
+ ->with('000000001')
+ ->willReturn($order);
+
+ $this->eventManager->expects($this->once())
+ ->method('dispatch')
+ ->with('paystack_payment_verify_after', ['paystack_order' => $order]);
+
+ $result = json_decode($this->paymentManagement->verifyPayment($reference), true);
+
+ $this->assertTrue($result['status']);
+ $this->assertEquals('Verification successful', $result['message']);
+ }
+
+ public function testVerifyPaymentQuoteIdMismatch(): void
+ {
+ $reference = 'PSK_abc123_-~-_42';
+
+ $txData = (object) [
+ 'status' => 'success',
+ 'reference' => 'PSK_abc123',
+ 'metadata' => (object) ['quoteId' => '99'],
+ ];
+ $apiResponse = (object) ['data' => $txData];
+
+ $this->paystackClient->method('verifyTransaction')->willReturn($apiResponse);
+
+ $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class);
+ $lastOrder->method('getIncrementId')->willReturn('000000001');
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder);
+
+ $order = $this->createMock(\Magento\Sales\Model\Order::class);
+ $order->method('getQuoteId')->willReturn('42');
+ $this->orderInterface->method('loadByIncrementId')->willReturn($order);
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $result = json_decode($this->paymentManagement->verifyPayment($reference), true);
+
+ $this->assertFalse($result['status']);
+ $this->assertStringContainsString("quoteId doesn't match", $result['message']);
+ }
+
+ public function testVerifyPaymentOrderQuoteIdMismatch(): void
+ {
+ $reference = 'PSK_abc123_-~-_42';
+
+ $txData = (object) [
+ 'status' => 'success',
+ 'reference' => 'PSK_abc123',
+ 'metadata' => (object) ['quoteId' => '42'],
+ ];
+ $apiResponse = (object) ['data' => $txData];
+
+ $this->paystackClient->method('verifyTransaction')->willReturn($apiResponse);
+
+ $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class);
+ $lastOrder->method('getIncrementId')->willReturn('000000001');
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder);
+
+ $order = $this->createMock(\Magento\Sales\Model\Order::class);
+ $order->method('getQuoteId')->willReturn('99');
+ $this->orderInterface->method('loadByIncrementId')->willReturn($order);
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $result = json_decode($this->paymentManagement->verifyPayment($reference), true);
+
+ $this->assertFalse($result['status']);
+ }
+
+ public function testVerifyPaymentApiExceptionReturnsError(): void
+ {
+ $reference = 'bad_ref_-~-_42';
+
+ $this->paystackClient->method('verifyTransaction')
+ ->willThrowException(new ApiException('Transaction not found'));
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $result = json_decode($this->paymentManagement->verifyPayment($reference), true);
+
+ $this->assertFalse($result['status']);
+ $this->assertEquals('Transaction not found', $result['message']);
+ }
+
+ public function testVerifyPaymentNoLastOrderReturnsError(): void
+ {
+ $reference = 'PSK_abc123_-~-_42';
+
+ $txData = (object) [
+ 'status' => 'success',
+ 'reference' => 'PSK_abc123',
+ 'metadata' => (object) ['quoteId' => '42'],
+ ];
+ $this->paystackClient->method('verifyTransaction')
+ ->willReturn((object) ['data' => $txData]);
+
+ $this->checkoutSession->method('getLastRealOrder')->willReturn(null);
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $result = json_decode($this->paymentManagement->verifyPayment($reference), true);
+
+ $this->assertFalse($result['status']);
+ }
+
+ public function testVerifyPaymentNoIncrementIdReturnsError(): void
+ {
+ $reference = 'PSK_abc123_-~-_42';
+
+ $txData = (object) [
+ 'status' => 'success',
+ 'reference' => 'PSK_abc123',
+ 'metadata' => (object) ['quoteId' => '42'],
+ ];
+ $this->paystackClient->method('verifyTransaction')
+ ->willReturn((object) ['data' => $txData]);
+
+ $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class);
+ $lastOrder->method('getIncrementId')->willReturn(null);
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder);
+
+ $this->eventManager->expects($this->never())->method('dispatch');
+
+ $result = json_decode($this->paymentManagement->verifyPayment($reference), true);
+
+ $this->assertFalse($result['status']);
+ }
+}
diff --git a/Test/Unit/Model/Ui/ConfigProviderTest.php b/Test/Unit/Model/Ui/ConfigProviderTest.php
new file mode 100644
index 0000000..3ba7c03
--- /dev/null
+++ b/Test/Unit/Model/Ui/ConfigProviderTest.php
@@ -0,0 +1,185 @@
+paymentMethod = $this->createMock(MethodInterface::class);
+
+ $paymentHelper = $this->createMock(PaymentHelper::class);
+ $paymentHelper->method('getMethodInstance')
+ ->with(Paystack::CODE)
+ ->willReturn($this->paymentMethod);
+
+ $this->storeManager = $this->createMock(StoreManagerInterface::class);
+ $logger = $this->createMock(LoggerInterface::class);
+
+ $this->configProvider = new ConfigProvider($paymentHelper, $this->storeManager, $logger);
+ }
+
+ private function setupStore(string $baseUrl = 'https://shop.example.com/'): void
+ {
+ $store = $this->createMock(StoreInterface::class);
+ $store->method('getBaseUrl')->willReturn($baseUrl);
+ $this->storeManager->method('getStore')->willReturn($store);
+ }
+
+ public function testGetConfigReturnsCorrectStructureTestMode(): void
+ {
+ $this->setupStore();
+
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ return match ($field) {
+ 'test_mode' => true,
+ 'test_public_key' => 'pk_test_abc123',
+ 'live_public_key' => 'pk_live_xyz',
+ 'integration_type' => 'inline',
+ default => null,
+ };
+ });
+
+ $config = $this->configProvider->getConfig();
+
+ $this->assertArrayHasKey('payment', $config);
+ $this->assertArrayHasKey(Paystack::CODE, $config['payment']);
+
+ $pstk = $config['payment'][Paystack::CODE];
+ $this->assertEquals('pk_test_abc123', $pstk['public_key']);
+ $this->assertEquals('inline', $pstk['integration_type']);
+ $this->assertEquals('https://shop.example.com/rest/', $pstk['api_url']);
+ $this->assertEquals('https://shop.example.com/paystack/payment/setup', $pstk['integration_type_standard_url']);
+ $this->assertEquals('https://shop.example.com/paystack/payment/recreate', $pstk['recreate_quote_url']);
+ }
+
+ public function testGetConfigUsesLiveKeyWhenNotTestMode(): void
+ {
+ $this->setupStore();
+
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ return match ($field) {
+ 'test_mode' => false,
+ 'live_public_key' => 'pk_live_real',
+ 'integration_type' => 'redirect',
+ default => null,
+ };
+ });
+
+ $config = $this->configProvider->getConfig();
+
+ $this->assertEquals('pk_live_real', $config['payment'][Paystack::CODE]['public_key']);
+ $this->assertEquals('redirect', $config['payment'][Paystack::CODE]['integration_type']);
+ }
+
+ public function testGetConfigDefaultsToInlineIntegrationType(): void
+ {
+ $this->setupStore();
+
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ return match ($field) {
+ 'test_mode' => true,
+ 'test_public_key' => 'pk_test_123',
+ 'integration_type' => null,
+ default => null,
+ };
+ });
+
+ $config = $this->configProvider->getConfig();
+
+ $this->assertEquals('inline', $config['payment'][Paystack::CODE]['integration_type']);
+ }
+
+ public function testGetConfigReturnsEmptyOnException(): void
+ {
+ $paymentHelper = $this->createMock(PaymentHelper::class);
+ $paymentHelper->method('getMethodInstance')
+ ->willThrowException(new \Exception('Method not found'));
+
+ $configProvider = new ConfigProvider(
+ $paymentHelper,
+ $this->createMock(StoreManagerInterface::class),
+ $this->createMock(LoggerInterface::class)
+ );
+
+ $this->assertEquals([], $configProvider->getConfig());
+ }
+
+ public function testGetPublicKeyTestMode(): void
+ {
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ return match ($field) {
+ 'test_mode' => true,
+ 'test_public_key' => 'pk_test_xyz',
+ default => null,
+ };
+ });
+
+ $this->assertEquals('pk_test_xyz', $this->configProvider->getPublicKey());
+ }
+
+ public function testGetPublicKeyLiveMode(): void
+ {
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ return match ($field) {
+ 'test_mode' => false,
+ 'live_public_key' => 'pk_live_prod',
+ default => null,
+ };
+ });
+
+ $this->assertEquals('pk_live_prod', $this->configProvider->getPublicKey());
+ }
+
+ public function testGetSecretKeyArrayTestMode(): void
+ {
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ return match ($field) {
+ 'test_mode' => true,
+ 'test_secret_key' => 'sk_test_secret',
+ default => null,
+ };
+ });
+
+ $this->assertEquals(['test' => 'sk_test_secret'], $this->configProvider->getSecretKeyArray());
+ }
+
+ public function testGetSecretKeyArrayLiveMode(): void
+ {
+ $this->paymentMethod->method('getConfigData')
+ ->willReturnCallback(function ($field) {
+ return match ($field) {
+ 'test_mode' => false,
+ 'live_secret_key' => 'sk_live_secret',
+ default => null,
+ };
+ });
+
+ $this->assertEquals(['live' => 'sk_live_secret'], $this->configProvider->getSecretKeyArray());
+ }
+}
diff --git a/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php b/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php
new file mode 100644
index 0000000..e1009bb
--- /dev/null
+++ b/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php
@@ -0,0 +1,106 @@
+orderSender = $this->createMock(OrderSender::class);
+ $this->observer = new ObserverAfterPaymentVerify($this->orderSender);
+ }
+
+ public function testPendingOrderTransitionsToProcessing(): void
+ {
+ $order = $this->createMock(Order::class);
+ $order->method('getStatus')->willReturn('pending');
+
+ $order->expects($this->once())
+ ->method('setState')
+ ->with(Order::STATE_PROCESSING)
+ ->willReturn($order);
+ $order->expects($this->once())
+ ->method('addStatusToHistory')
+ ->with(
+ Order::STATE_PROCESSING,
+ $this->callback(fn($msg) => str_contains((string)$msg, 'Paystack Payment Verified')),
+ true
+ )
+ ->willReturn($order);
+ $order->expects($this->once())
+ ->method('setCanSendNewEmailFlag')
+ ->with(true)
+ ->willReturn($order);
+ $order->expects($this->once())
+ ->method('setCustomerNoteNotify')
+ ->with(true)
+ ->willReturn($order);
+ $order->expects($this->once())->method('save');
+
+ $this->orderSender->expects($this->once())
+ ->method('send')
+ ->with($order, true);
+
+ $eventObserver = $this->createMock(Observer::class);
+ $eventObserver->method('getPaystackOrder')->willReturn($order);
+
+ $this->observer->execute($eventObserver);
+ }
+
+ public function testNonPendingOrderIsNotUpdated(): void
+ {
+ $order = $this->createMock(Order::class);
+ $order->method('getStatus')->willReturn('processing');
+
+ $order->expects($this->never())->method('setState');
+ $order->expects($this->never())->method('save');
+
+ $eventObserver = $this->createMock(Observer::class);
+ $eventObserver->method('getPaystackOrder')->willReturn($order);
+
+ $this->observer->execute($eventObserver);
+ }
+
+ public function testEmailSendingFailureDoesNotAffectOrderStatus(): void
+ {
+ $order = $this->createMock(Order::class);
+ $order->method('getStatus')->willReturn('pending');
+ $order->method('setState')->willReturn($order);
+ $order->method('addStatusToHistory')->willReturn($order);
+ $order->method('setCanSendNewEmailFlag')->willReturn($order);
+ $order->method('setCustomerNoteNotify')->willReturn($order);
+
+ $order->expects($this->once())->method('save');
+
+ $this->orderSender->method('send')
+ ->willThrowException(new \Exception('SMTP failure'));
+
+ $eventObserver = $this->createMock(Observer::class);
+ $eventObserver->method('getPaystackOrder')->willReturn($order);
+
+ // Should not throw
+ $this->observer->execute($eventObserver);
+ }
+
+ public function testNullOrderDoesNotCrash(): void
+ {
+ $eventObserver = $this->createMock(Observer::class);
+ $eventObserver->method('getPaystackOrder')->willReturn(null);
+
+ // Should not throw
+ $this->observer->execute($eventObserver);
+ }
+}
diff --git a/Test/Unit/Observer/ObserverBeforeSalesOrderPlaceTest.php b/Test/Unit/Observer/ObserverBeforeSalesOrderPlaceTest.php
new file mode 100644
index 0000000..2d13c99
--- /dev/null
+++ b/Test/Unit/Observer/ObserverBeforeSalesOrderPlaceTest.php
@@ -0,0 +1,79 @@
+observer = new ObserverBeforeSalesOrderPlace();
+ }
+
+ public function testPaystackOrderSuppressesEmail(): void
+ {
+ $payment = $this->createMock(Payment::class);
+ $payment->method('getMethod')->willReturn(Paystack::CODE);
+
+ $order = $this->createMock(Order::class);
+ $order->method('getPayment')->willReturn($payment);
+ $order->expects($this->once())
+ ->method('setCanSendNewEmailFlag')
+ ->with(false)
+ ->willReturn($order);
+ $order->expects($this->once())
+ ->method('setCustomerNoteNotify')
+ ->with(false);
+
+ $event = $this->createMock(Event::class);
+ $event->method('getOrder')->willReturn($order);
+
+ $eventObserver = $this->createMock(Observer::class);
+ $eventObserver->method('getEvent')->willReturn($event);
+
+ $this->observer->execute($eventObserver);
+ }
+
+ public function testNonPaystackOrderDoesNotSuppressEmail(): void
+ {
+ $payment = $this->createMock(Payment::class);
+ $payment->method('getMethod')->willReturn('checkmo');
+
+ $order = $this->createMock(Order::class);
+ $order->method('getPayment')->willReturn($payment);
+ $order->expects($this->never())->method('setCanSendNewEmailFlag');
+
+ $event = $this->createMock(Event::class);
+ $event->method('getOrder')->willReturn($order);
+
+ $eventObserver = $this->createMock(Observer::class);
+ $eventObserver->method('getEvent')->willReturn($event);
+
+ $this->observer->execute($eventObserver);
+ }
+
+ public function testNullPaymentDoesNotCrash(): void
+ {
+ $order = $this->createMock(Order::class);
+ $order->method('getPayment')->willReturn(null);
+
+ $event = $this->createMock(Event::class);
+ $event->method('getOrder')->willReturn($order);
+
+ $eventObserver = $this->createMock(Observer::class);
+ $eventObserver->method('getEvent')->willReturn($event);
+
+ $this->observer->execute($eventObserver);
+ }
+}
diff --git a/Test/Unit/Plugin/CsrfValidatorSkipTest.php b/Test/Unit/Plugin/CsrfValidatorSkipTest.php
new file mode 100644
index 0000000..2bca51b
--- /dev/null
+++ b/Test/Unit/Plugin/CsrfValidatorSkipTest.php
@@ -0,0 +1,76 @@
+plugin = new CsrfValidatorSkip();
+ }
+
+ public function testWebhookActionSkipsCsrfValidation(): void
+ {
+ $request = $this->createMock(RequestInterface::class);
+ $request->method('getModuleName')->willReturn('paystack');
+ $request->method('getActionName')->willReturn('webhook');
+
+ $action = $this->createMock(ActionInterface::class);
+ $subject = new \stdClass();
+
+ $proceedCalled = false;
+ $proceed = function () use (&$proceedCalled) {
+ $proceedCalled = true;
+ };
+
+ $this->plugin->aroundValidate($subject, $proceed, $request, $action);
+
+ $this->assertFalse($proceedCalled, 'CSRF validation should be skipped for webhook');
+ }
+
+ public function testNonWebhookActionProceedsWithCsrfValidation(): void
+ {
+ $request = $this->createMock(RequestInterface::class);
+ $request->method('getModuleName')->willReturn('checkout');
+ $request->method('getActionName')->willReturn('index');
+
+ $action = $this->createMock(ActionInterface::class);
+ $subject = new \stdClass();
+
+ $proceedCalled = false;
+ $proceed = function () use (&$proceedCalled) {
+ $proceedCalled = true;
+ };
+
+ $this->plugin->aroundValidate($subject, $proceed, $request, $action);
+
+ $this->assertTrue($proceedCalled, 'CSRF validation should proceed for non-webhook actions');
+ }
+
+ public function testPaystackNonWebhookActionProceedsNormally(): void
+ {
+ $request = $this->createMock(RequestInterface::class);
+ $request->method('getModuleName')->willReturn('paystack');
+ $request->method('getActionName')->willReturn('callback');
+
+ $action = $this->createMock(ActionInterface::class);
+ $subject = new \stdClass();
+
+ $proceedCalled = false;
+ $proceed = function () use (&$proceedCalled) {
+ $proceedCalled = true;
+ };
+
+ $this->plugin->aroundValidate($subject, $proceed, $request, $action);
+
+ $this->assertTrue($proceedCalled, 'CSRF validation should proceed for paystack/callback');
+ }
+}
diff --git a/build-adobe-zip.sh b/build-adobe-zip.sh
new file mode 100755
index 0000000..2515a23
--- /dev/null
+++ b/build-adobe-zip.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+#
+# Build a zip of the plugin for Adobe Commerce Marketplace upload.
+# Run from the repository root: ./build-adobe-zip.sh
+#
+set -e
+
+NAME="pstk-paystack-magento2-module"
+VERSION=$(grep -E '"version"' composer.json | sed 's/.*: *"\(.*\)".*/\1/')
+ZIP="${NAME}-${VERSION}.zip"
+
+# Always build fresh — `zip -r` updates in place and would keep stale entries
+# (e.g. files that are now excluded) from a previous build.
+rm -f "$ZIP"
+
+# Exclude dev assets, internal docs, archives, and any built zips.
+# docs/ holds internal QA artifacts (review logs, recordings) and must never ship.
+# graphify-out/ is gitignored internal knowledge-graph tooling cache — must never ship.
+zip -r "$ZIP" . \
+ -x "*.git*" \
+ -x "*.DS_Store" \
+ -x "dev/*" \
+ -x "dev-ee/*" \
+ -x "dev-repro/*" \
+ -x "vendor/*" \
+ -x ".env" \
+ -x "auth.json" \
+ -x "CLAUDE.md" \
+ -x "docs/*" \
+ -x "graphify-out/*" \
+ -x "node_modules/*" \
+ -x "build-adobe-zip.sh" \
+ -x "${NAME}-*.zip"
+
+echo "Created: $ZIP"
+echo "Size: $(du -h "$ZIP" | cut -f1)"
+echo ""
+echo "Upload $ZIP to Adobe Commerce Marketplace."
+unzip -l "$ZIP" | head -30
diff --git a/composer.json b/composer.json
index c834533..8b87301 100644
--- a/composer.json
+++ b/composer.json
@@ -1,7 +1,7 @@
{
"name": "pstk/paystack-magento2-module",
"description": "Paystack Magento2 Module using \\Magento\\Payment\\Model\\Method\\AbstractMethod",
- "version": "3.0.4",
+ "version": "3.0.10",
"require": {},
"type": "magento2-module",
"license": [
diff --git a/dev/Dockerfile b/dev/Dockerfile
index c7280f5..2c76eec 100644
--- a/dev/Dockerfile
+++ b/dev/Dockerfile
@@ -1,4 +1,4 @@
-FROM php:8.3-apache
+FROM php:8.4-apache
# Install system dependencies
RUN apt-get update && apt-get install -y \
diff --git a/dev/entrypoint.sh b/dev/entrypoint.sh
index 71f57c6..88ca037 100644
--- a/dev/entrypoint.sh
+++ b/dev/entrypoint.sh
@@ -26,7 +26,6 @@ if [ ! -f /var/www/html/app/etc/env.php ]; then
--backend-frontname=admin \
--language=en_US \
--currency=NGN \
- --allowed-currencies=NGN \
--timezone=Africa/Lagos \
--use-rewrites=1 \
--cleanup-database
diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml
new file mode 100644
index 0000000..898d75e
--- /dev/null
+++ b/etc/adminhtml/di.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/etc/csp_whitelist.xml b/etc/csp_whitelist.xml
new file mode 100644
index 0000000..030c09b
--- /dev/null
+++ b/etc/csp_whitelist.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ js.paystack.co
+
+
+
+
+ api.paystack.co
+ js.paystack.co
+ plugin-tracker.paystackintegrations.com
+
+
+
+
+ checkout.paystack.com
+ standard.paystack.co
+
+
+
+
diff --git a/etc/di.xml b/etc/di.xml
index 0b62035..38c1841 100644
--- a/etc/di.xml
+++ b/etc/di.xml
@@ -1,8 +1,4 @@
-
-
-
-
diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml
index 888e464..c9737a9 100644
--- a/etc/frontend/di.xml
+++ b/etc/frontend/di.xml
@@ -1,5 +1,13 @@
+
+
+
+
+ Magento\Checkout\Model\Session\Proxy
+
+
+
@@ -11,12 +19,6 @@
-
-
-
-
- - Pstk\Paystack\Model\CspPolicyCollector
-
-
-
+
diff --git a/etc/module.xml b/etc/module.xml
index b016bf6..167612a 100644
--- a/etc/module.xml
+++ b/etc/module.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/etc/webapi_rest/di.xml b/etc/webapi_rest/di.xml
new file mode 100644
index 0000000..2766c27
--- /dev/null
+++ b/etc/webapi_rest/di.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Magento\Checkout\Model\Session\Proxy
+
+
+
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..d5e5c35
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ Test/Unit
+
+
+
+
+ .
+
+
+ Test
+ dev
+ docs
+ vendor
+
+
+
diff --git a/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js b/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js
index c02ac67..5722c76 100644
--- a/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js
+++ b/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js
@@ -45,123 +45,166 @@ define(
*/
afterPlaceOrder: function () {
var checkoutConfig = window.checkoutConfig;
- var paymentData = quote.billingAddress();
- var paystackConfiguration = checkoutConfig.payment.pstk_paystack;
+ var paystackConfiguration = checkoutConfig.payment && checkoutConfig.payment.pstk_paystack;
+
+ if (!paystackConfiguration) {
+ this.messageContainer.addErrorMessage({
+ message: "Paystack configuration is missing. Please contact support."
+ });
+ this.isPlaceOrderActionAllowed(true);
+ return;
+ }
if (paystackConfiguration.integration_type == 'standard') {
this.redirectToCustomAction(paystackConfiguration.integration_type_standard_url);
+ return;
+ }
+
+ var paymentData = quote.billingAddress();
+
+ if (!paymentData) {
+ this.messageContainer.addErrorMessage({
+ message: "Billing address is required to process payment."
+ });
+ this.isPlaceOrderActionAllowed(true);
+ return;
+ }
+
+ if (checkoutConfig.isCustomerLoggedIn) {
+ var customerData = checkoutConfig.customerData;
+ paymentData.email = customerData.email;
} else {
- if (checkoutConfig.isCustomerLoggedIn) {
- var customerData = checkoutConfig.customerData;
- paymentData.email = customerData.email;
- } else {
- paymentData.email = quote.guestEmail;
- }
+ paymentData.email = quote.guestEmail;
+ }
- var quoteId = checkoutConfig.quoteItemData[0].quote_id;
-
- var _this = this;
- _this.isPlaceOrderActionAllowed(false);
-
- require(['paystack'], function (PaystackPop) {
- var popup = new PaystackPop();
- popup.newTransaction({
- key: paystackConfiguration.public_key,
- email: paymentData.email,
- amount: Math.ceil(quote.totals().grand_total * 100),
- phone: paymentData.telephone,
- currency: checkoutConfig.totalsData.quote_currency_code,
- metadata: {
- quoteId: quoteId,
- custom_fields: [
- {
- display_name: "QuoteId",
- variable_name: "quote id",
- value: quoteId
- },
- {
- display_name: "Address",
- variable_name: "address",
- value: paymentData.street[0] + ", " + paymentData.street[1]
- },
- {
- display_name: "Postal Code",
- variable_name: "postal_code",
- value: paymentData.postcode
- },
- {
- display_name: "City",
- variable_name: "city",
- value: paymentData.city + ", " + paymentData.countryId
- },
- {
- display_name: "Plugin",
- variable_name: "plugin",
- value: "magento-2"
- }
- ]
- },
- onSuccess: function (response) {
- fullScreenLoader.startLoader();
- $.ajax({
- method: "GET",
- url: paystackConfiguration.api_url + "V1/paystack/verify/" + response.reference + "_-~-_" + quoteId,
- dataType: 'text'
- }).done(function (data) {
- try {
- data = JSON.parse(data);
- if (typeof data === 'string') {
- data = JSON.parse(data);
- }
- } catch (e) {
- console.error('Payment verification JSON parse error:', e);
- fullScreenLoader.stopLoader();
- _this.isPlaceOrderActionAllowed(true);
- _this.messageContainer.addErrorMessage({
- message: "Payment verification error."
- });
- return;
- }
+ var quoteItems = checkoutConfig.quoteItemData;
+ var quoteId = quoteItems && quoteItems.length > 0 ? quoteItems[0].quote_id : null;
- $.ajax({
- method: 'POST',
- url: "https://plugin-tracker.paystackintegrations.com/log/charge_success",
- data: {
- plugin_name: 'magento-2',
- transaction_reference: response.reference,
- public_key: paystackConfiguration.public_key
- }
- });
+ if (!quoteId) {
+ this.messageContainer.addErrorMessage({
+ message: "Unable to identify your cart. Please refresh and try again."
+ });
+ this.isPlaceOrderActionAllowed(true);
+ return;
+ }
+
+ var streetLines = paymentData.street || [];
+ var streetAddress = [streetLines[0] || '', streetLines[1] || '']
+ .filter(Boolean).join(', ');
- if (data.status && data.data && data.data.status === "success") {
- redirectOnSuccessAction.execute();
- return;
+ var _this = this;
+ _this.isPlaceOrderActionAllowed(false);
+
+ // Stop any loader left active by placeOrderAction before opening the popup.
+ fullScreenLoader.stopLoader();
+
+ require(['paystack'], function () {
+ var PaystackPop = window.PaystackPop;
+ if (typeof PaystackPop !== 'function') {
+ fullScreenLoader.stopLoader();
+ _this.isPlaceOrderActionAllowed(true);
+ _this.messageContainer.addErrorMessage({
+ message: "Unable to load Paystack. Please check your internet connection and try again."
+ });
+ return;
+ }
+ var popup = new PaystackPop();
+ popup.newTransaction({
+ key: paystackConfiguration.public_key,
+ email: paymentData.email,
+ amount: Math.ceil(quote.totals().grand_total * 100),
+ phone: paymentData.telephone,
+ currency: checkoutConfig.totalsData.quote_currency_code,
+ metadata: {
+ quoteId: quoteId,
+ custom_fields: [
+ {
+ display_name: "QuoteId",
+ variable_name: "quote id",
+ value: quoteId
+ },
+ {
+ display_name: "Address",
+ variable_name: "address",
+ value: streetAddress
+ },
+ {
+ display_name: "Postal Code",
+ variable_name: "postal_code",
+ value: paymentData.postcode
+ },
+ {
+ display_name: "City",
+ variable_name: "city",
+ value: paymentData.city + ", " + paymentData.countryId
+ },
+ {
+ display_name: "Plugin",
+ variable_name: "plugin",
+ value: "magento-2"
+ }
+ ]
+ },
+ onSuccess: function (response) {
+ fullScreenLoader.startLoader();
+ $.ajax({
+ method: "GET",
+ url: paystackConfiguration.api_url + "V1/paystack/verify/" + response.reference + "_-~-_" + quoteId,
+ dataType: 'text'
+ }).done(function (data) {
+ try {
+ data = JSON.parse(data);
+ if (typeof data === 'string') {
+ data = JSON.parse(data);
}
+ } catch (e) {
+ console.error('Payment verification JSON parse error:', e);
fullScreenLoader.stopLoader();
_this.isPlaceOrderActionAllowed(true);
_this.messageContainer.addErrorMessage({
- message: "Payment verification failed. Status: " + (data.data ? data.data.status : 'unknown')
- });
- }).fail(function () {
- fullScreenLoader.stopLoader();
- _this.isPlaceOrderActionAllowed(true);
- _this.messageContainer.addErrorMessage({
- message: "Payment verification failed."
+ message: "Payment verification error."
});
+ return;
+ }
+
+ $.ajax({
+ method: 'POST',
+ url: "https://plugin-tracker.paystackintegrations.com/log/charge_success",
+ data: {
+ plugin_name: 'magento-2',
+ transaction_reference: response.reference,
+ public_key: paystackConfiguration.public_key
+ }
});
- },
- onCancel: function () {
- _this.redirectToCustomAction(paystackConfiguration.recreate_quote_url);
- }
- });
- }, function () {
- fullScreenLoader.stopLoader();
- _this.isPlaceOrderActionAllowed(true);
- _this.messageContainer.addErrorMessage({
- message: "Unable to load Paystack. Please check your internet connection and try again."
- });
+
+ if (data.status && data.data && data.data.status === "success") {
+ redirectOnSuccessAction.execute();
+ return;
+ }
+ fullScreenLoader.stopLoader();
+ _this.isPlaceOrderActionAllowed(true);
+ _this.messageContainer.addErrorMessage({
+ message: "Payment verification failed. Status: " + (data.data ? data.data.status : 'unknown')
+ });
+ }).fail(function () {
+ fullScreenLoader.stopLoader();
+ _this.isPlaceOrderActionAllowed(true);
+ _this.messageContainer.addErrorMessage({
+ message: "Payment verification failed."
+ });
+ });
+ },
+ onCancel: function () {
+ _this.redirectToCustomAction(paystackConfiguration.recreate_quote_url);
+ }
});
- }
+ }, function () {
+ fullScreenLoader.stopLoader();
+ _this.isPlaceOrderActionAllowed(true);
+ _this.messageContainer.addErrorMessage({
+ message: "Unable to load Paystack. Please check your internet connection and try again."
+ });
+ });
},
});