From 920cef3513fcda13660f7a58f1225d072ac90719 Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Mon, 17 Aug 2026 10:28:39 +0200 Subject: [PATCH 1/4] fix: round the inline transaction amount instead of rounding it up Math.ceil(grand_total * 100) overcharges by one subunit whenever the float product lands just above the integer, which is common: Math.ceil(8.21 * 100) === 822 // 821.0000000000001 -> 822 Math.ceil(1.10 * 100) === 111 // 110.00000000000001 -> 111 Math.round gives the correct subunit amount for every total. Verified that PHP round($v * 100) and JS Math.round(v * 100) agree on every value across two exhaustive sweeps (0-2000 at 0.001 steps, 100000-500000 at 0.07 steps, zero divergences), including exact .5 cases such as 45.675 -> 4568 -- PHP round() is half-away-from-zero, matching JS for positive values. So the server-side amount check added later can compare against this without drift. This is the inline/popup mirror of the redirect-flow fix in #70. Co-Authored-By: Claude Opus 5 (1M context) --- .../web/js/view/payment/method-renderer/pstk_paystack-method.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5722c76..895ee20 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 @@ -112,7 +112,7 @@ define( popup.newTransaction({ key: paystackConfiguration.public_key, email: paymentData.email, - amount: Math.ceil(quote.totals().grand_total * 100), + amount: Math.round(quote.totals().grand_total * 100), phone: paymentData.telephone, currency: checkoutConfig.totalsData.quote_currency_code, metadata: { From 2445683d4b77ea0a17930a5c944543738eafd22a Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Mon, 17 Aug 2026 10:30:27 +0200 Subject: [PATCH 2/4] fix: send the order currency to Paystack instead of null Magento\Sales\Model\Order has no getCurrency() method. Verified by reflection: getCurrency declared: NO (magic __call -> getData) getOrderCurrencyCode declared: YES in Magento\Sales\Model\Order So the call fell through AbstractModel::__call to getData('currency'), and sales_order has no `currency` column -- the payload carried currency: null on every redirect-flow transaction. Paystack accepts a null currency silently and substitutes the integration's own default: {"amount":150000,"currency":null} -> {"status":true,"message":"Authorization URL created"} On a store whose order currency differs from the Paystack integration default that means the right number is charged in the wrong currency -- a base-NGN / display-USD store sent a 12.50 USD order as amount 1250 with no currency and was charged N12.50 instead of ~N19,000. getOrderCurrencyCode() is the correct accessor, not getBaseCurrencyCode(): the customer is charged in the display/quote currency, and the inline flow already sends quote_currency_code, which is the same value. BEHAVIOUR CHANGE: a merchant whose Paystack integration does not have their store currency enabled will now see initialize fail with unsupported_currency where it previously completed (in the wrong currency). That is the correct outcome, but it is visible -- such merchants must enable the currency on their Paystack integration. The test stubbed getCurrency() on a mock of Order, which is how this hid: the stub made a non-existent method look real. Under PHPUnit 10.5 that stub errors outright once the suite is actually run, so both cases were dead. Retargeted to getOrderCurrencyCode(), added a currency assertion to the initialize payload, and swapped StoreInterface for the concrete Store model since StoreInterface does not declare getBaseUrl(). Confirmed the test now fails if either the currency fix or #70's integer-amount fix is reverted. Co-Authored-By: Claude Opus 5 (1M context) --- Controller/Payment/Setup.php | 2 +- Test/Unit/Controller/Payment/SetupTest.php | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Controller/Payment/Setup.php b/Controller/Payment/Setup.php index 93a800a..d4b1ed0 100644 --- a/Controller/Payment/Setup.php +++ b/Controller/Payment/Setup.php @@ -54,7 +54,7 @@ protected function processAuthorization(\Magento\Sales\Model\Order $order) { 'amount' => (int) round($order->getGrandTotal() * 100), // in kobo (integer, subunit) 'email' => $order->getCustomerEmail(), // unique to customers 'reference' => $order->getIncrementId(), // unique to transactions - 'currency' => $order->getCurrency(), + 'currency' => $order->getOrderCurrencyCode(), 'callback_url' => $this->storeManager->getStore()->getBaseUrl() . "paystack/payment/callback", 'metadata' => array('custom_fields' => array( array( diff --git a/Test/Unit/Controller/Payment/SetupTest.php b/Test/Unit/Controller/Payment/SetupTest.php index a4e71a9..617bf01 100644 --- a/Test/Unit/Controller/Payment/SetupTest.php +++ b/Test/Unit/Controller/Payment/SetupTest.php @@ -24,7 +24,7 @@ use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Payment; use Magento\Checkout\Model\Session as CheckoutSession; -use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; use Psr\Log\LoggerInterface; @@ -121,7 +121,7 @@ public function testSuccessfulSetupRedirectsToPaystack(): void $order->method('getGrandTotal')->willReturn(5000.00); $order->method('getCustomerEmail')->willReturn('john@example.com'); $order->method('getIncrementId')->willReturn('000000001'); - $order->method('getCurrency')->willReturn('NGN'); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); $this->orderInterface->method('loadByIncrementId') ->with('000000001') @@ -133,7 +133,7 @@ public function testSuccessfulSetupRedirectsToPaystack(): void ->with(Paystack::CODE) ->willReturn($methodInstance); - $store = $this->createMock(StoreInterface::class); + $store = $this->createMock(Store::class); $store->method('getBaseUrl')->willReturn('https://example.com/'); $this->storeManager->method('getStore')->willReturn($store); @@ -145,7 +145,8 @@ public function testSuccessfulSetupRedirectsToPaystack(): void $this->paystackClient->expects($this->once()) ->method('initializeTransaction') ->with($this->callback(function ($params) { - return $params['amount'] === 500000 // kobo + return $params['amount'] === 500000 // kobo, integer subunit + && $params['currency'] === 'NGN' && $params['email'] === 'john@example.com' && $params['reference'] === '000000001' && $params['callback_url'] === 'https://example.com/paystack/payment/callback'; @@ -178,7 +179,7 @@ public function testApiExceptionSavesStatusHistory(): void $order->method('getGrandTotal')->willReturn(100.00); $order->method('getCustomerEmail')->willReturn('john@test.com'); $order->method('getIncrementId')->willReturn('000000001'); - $order->method('getCurrency')->willReturn('NGN'); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); $this->orderInterface->method('loadByIncrementId')->willReturn($order); @@ -186,7 +187,7 @@ public function testApiExceptionSavesStatusHistory(): void $methodInstance->method('getCode')->willReturn(Paystack::CODE); $this->paymentHelper->method('getMethodInstance')->willReturn($methodInstance); - $store = $this->createMock(StoreInterface::class); + $store = $this->createMock(Store::class); $store->method('getBaseUrl')->willReturn('https://example.com/'); $this->storeManager->method('getStore')->willReturn($store); From 503d6a615f9beb07f88ba5550f2ec0c60b9debef Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Mon, 17 Aug 2026 10:32:18 +0200 Subject: [PATCH 3/4] release: 3.0.11 Version bumped in all three places CLAUDE.md requires kept in sync: composer.json, etc/module.xml, README.md. CHANGELOG documents the three outbound-payload fixes and the one behaviour change merchants need to know about (a store currency not enabled on the Paystack integration now fails visibly rather than being charged in the default currency). Also corrected two stale CHANGELOG statements: the "since the last tag" preamble said v3.0.4, and the closing note said 3.0.5-3.0.10 were untagged when v3.0.10 was in fact tagged. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++-- README.md | 2 +- composer.json | 2 +- etc/module.xml | 2 +- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 844ffec..5e7c028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,44 @@ 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**. +The entries below cover every release since the last tag, **v3.0.10**. + +## [3.0.11] - 2026-08-17 + +Corrects the transaction payload sent to Paystack. All three fixes are in what +the module *sends*; none changes how a payment is verified or how an order +advances. + +### Fixed +- **Redirect-mode checkout failed outright on many order totals.** The amount + was sent as `grandTotal * 100`, a floating-point product — so a 19.99 order + became `1998.9999999999998`. Paystack rejects a non-integer amount + (`"amount" must be an integer`, `invalid_amount`), which surfaced to the + customer as a failed checkout they could not complete. Totals such as 19.99, + 1.10, 0.29 and 8.21 were affected; totals whose product is exactly + representable, such as 5000.00, were not — which is why this was + intermittent. The amount is now an integer number of subunits. + Thanks to @iammcoding (#70). +- **Inline (popup) mode overcharged by one subunit on some totals.** The amount + used `Math.ceil`, so `Math.ceil(8.21 * 100)` produced 822 instead of 821 + whenever the float product landed just above the integer. Now uses + `Math.round`. +- **Redirect mode sent no currency at all.** The code called + `$order->getCurrency()`, which is not a method on `Magento\Sales\Model\Order` + — it resolved through Magento's magic getter to a non-existent `currency` + column and returned `null`. Paystack silently substitutes the integration's + default currency for a null value, so orders were charged the correct number + in the merchant's default currency rather than the order's. On a store whose + display currency differs from the Paystack default this mischarged + significantly: a 12.50 USD order was charged as 12.50 in the default + currency. Now sends `getOrderCurrencyCode()`. + +### Upgrade note +If your Paystack integration does not have your store's currency enabled, the +redirect flow will now fail with `unsupported_currency` where it previously +completed (in the wrong currency). Enable your store's currency on your Paystack +integration. This is a deliberate change: a visible failure is better than a +silent mischarge. ## [3.0.10] - 2026-07-17 @@ -79,5 +116,7 @@ Superseded by 3.0.10 (its fixes are included there). --- -_Note: 3.0.5–3.0.10 were not individually tagged; see the commit history since +_Note: 3.0.5–3.0.9 were not individually tagged — they were released together as +[`v3.0.10`](https://github.com/PaystackHQ/plugin-magento-2/releases/tag/v3.0.10). +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/README.md b/README.md index f2596ed..0c75f65 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Paystack payment gateway extension for Magento 2 -**Version:** 3.0.10 (Paystack v2 Inline.js API) +**Version:** 3.0.11 (Paystack v2 Inline.js API) ## Requirements diff --git a/composer.json b/composer.json index 8b87301..bb47f62 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.10", + "version": "3.0.11", "require": {}, "type": "magento2-module", "license": [ diff --git a/etc/module.xml b/etc/module.xml index 167612a..d239082 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,6 +1,6 @@ - + From 6d18ef6ff4ed0e443b6f87cd6462cb275eb4dcf5 Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Mon, 17 Aug 2026 10:40:33 +0200 Subject: [PATCH 4/4] fix: fail closed on a missing order currency, and cover the fractional totals Gate-2 diff review findings on the R1 changes. Setup.php: getOrderCurrencyCode() fixed the value but not the failure mode. Paystack accepts a null currency silently and substitutes the integration's own default, so an order with an empty currency code would still be charged in the wrong currency -- with a success response and no trace. Now throws ApiException before the API call, which the existing catch turns into order history plus the failure page. Reachable for order rows created outside normal checkout (data import, headless/third-party creation, partially migrated stores). SetupTest: the amount assertion used a grand total of 5000.00, whose *100 product is exactly representable, so it did not regression-test the defect #70 fixed -- (int)(19.99*100) truncates to 1998 and would have passed. Added a data provider covering the inexact totals (19.99, 8.21, 1.10, 0.29) and a case asserting initializeTransaction is never called when the order currency is missing. Confirmed both new cases fail when the respective fix is reverted. ConfigProviderTest: the same StoreInterface stub defect fixed in SetupTest was present here too (StoreInterface does not declare getBaseUrl(), so PHPUnit 10 refuses to configure it). Fixing only one of the two would have left the suite unable to run while claiming coverage. build-adobe-zip.sh: `rm -f "$ZIP"` only removed the zip for the version being built, leaving prior-version zips at the repo root to be uploaded to Adobe by mistake. Now removes every "${NAME}"-*.zip. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +- Controller/Payment/Setup.php | 14 +++- Test/Unit/Controller/Payment/SetupTest.php | 92 ++++++++++++++++++++++ Test/Unit/Model/Ui/ConfigProviderTest.php | 4 +- build-adobe-zip.sh | 6 +- 5 files changed, 113 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e7c028..48113a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,8 @@ The entries below cover every release since the last tag, **v3.0.10**. ## [3.0.11] - 2026-08-17 -Corrects the transaction payload sent to Paystack. All three fixes are in what -the module *sends*; none changes how a payment is verified or how an order -advances. +Corrects the transaction payload sent to Paystack: the amount is now always an +integer number of currency subunits, and the order's own currency is sent. ### Fixed - **Redirect-mode checkout failed outright on many order totals.** The amount diff --git a/Controller/Payment/Setup.php b/Controller/Payment/Setup.php index d4b1ed0..c86cd8a 100644 --- a/Controller/Payment/Setup.php +++ b/Controller/Payment/Setup.php @@ -48,13 +48,25 @@ public function execute() { } protected function processAuthorization(\Magento\Sales\Model\Order $order) { + + // Fail closed on a missing order currency. Paystack accepts a null currency + // silently and substitutes the integration's own default, so an empty value + // here would charge in the wrong currency with a success response and no + // trace. The caller turns this into order history plus the failure page. + $currency = $order->getOrderCurrencyCode(); + if (!$currency) { + throw new \Pstk\Paystack\Gateway\Exception\ApiException( + 'Cannot start a Paystack transaction: the order has no currency code.' + ); + } + $tranx = $this->paystackClient->initializeTransaction([ 'first_name' => $order->getCustomerFirstname(), 'last_name' => $order->getCustomerLastname(), 'amount' => (int) round($order->getGrandTotal() * 100), // in kobo (integer, subunit) 'email' => $order->getCustomerEmail(), // unique to customers 'reference' => $order->getIncrementId(), // unique to transactions - 'currency' => $order->getOrderCurrencyCode(), + 'currency' => $currency, 'callback_url' => $this->storeManager->getStore()->getBaseUrl() . "paystack/payment/callback", 'metadata' => array('custom_fields' => array( array( diff --git a/Test/Unit/Controller/Payment/SetupTest.php b/Test/Unit/Controller/Payment/SetupTest.php index 617bf01..88df6e6 100644 --- a/Test/Unit/Controller/Payment/SetupTest.php +++ b/Test/Unit/Controller/Payment/SetupTest.php @@ -160,6 +160,98 @@ public function testSuccessfulSetupRedirectsToPaystack(): void $controller->execute(); } + /** + * Paystack rejects a non-integer amount outright ("amount" must be an integer, + * invalid_amount), so the customer cannot pay at all. Only totals whose + * grandTotal*100 is NOT exactly representable as a float exercise that: 5000.00 + * gives a clean 500000.0, but 19.99 gives 1998.9999999999998. These cases are + * what distinguishes round() from truncation -- (int)(19.99*100) is 1998, which + * would silently undercharge and pass a 5000.00-only assertion. + * + * @dataProvider inexactTotalProvider + */ + public function testFractionalTotalsAreSentAsIntegerSubunits(float $grandTotal, int $expectedSubunits): void + { + $controller = $this->createController(); + $order = $this->primeOrder($grandTotal, 'NGN'); + + $this->paystackClient->expects($this->once()) + ->method('initializeTransaction') + ->with($this->callback(function ($params) use ($expectedSubunits) { + return $params['amount'] === $expectedSubunits + && is_int($params['amount']); + })) + ->willReturn((object) ['data' => (object) ['authorization_url' => 'https://checkout.paystack.com/abc123']]); + + $controller->execute(); + } + + public static function inexactTotalProvider(): array + { + return [ + '19.99 -> 1999' => [19.99, 1999], + '8.21 -> 821' => [8.21, 821], + '1.10 -> 110' => [1.10, 110], + '0.29 -> 29' => [0.29, 29], + ]; + } + + /** + * Paystack accepts a null currency silently and substitutes the integration's + * own default, so an empty order currency must fail closed rather than charge + * in whatever currency the merchant happens to have configured. + */ + public function testMissingOrderCurrencyIsRejectedBeforeCallingPaystack(): void + { + $controller = $this->createController(); + $order = $this->primeOrder(100.00, null); + $order->method('getStatus')->willReturn('pending'); + + $this->paystackClient->expects($this->never())->method('initializeTransaction'); + + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with('pending', $this->stringContains('no currency code')); + + $controller->execute(); + } + + /** + * Shared happy-path scaffolding for the cases above. + * + * @return MockObject|Order + */ + private function primeOrder(float $grandTotal, ?string $currencyCode) + { + $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($grandTotal); + $order->method('getCustomerEmail')->willReturn('john@example.com'); + $order->method('getIncrementId')->willReturn('000000001'); + $order->method('getOrderCurrencyCode')->willReturn($currencyCode); + + $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(Store::class); + $store->method('getBaseUrl')->willReturn('https://example.com/'); + $this->storeManager->method('getStore')->willReturn($store); + + return $order; + } + public function testApiExceptionSavesStatusHistory(): void { $controller = $this->createController(); diff --git a/Test/Unit/Model/Ui/ConfigProviderTest.php b/Test/Unit/Model/Ui/ConfigProviderTest.php index 3ba7c03..8ce0bed 100644 --- a/Test/Unit/Model/Ui/ConfigProviderTest.php +++ b/Test/Unit/Model/Ui/ConfigProviderTest.php @@ -8,7 +8,7 @@ use Pstk\Paystack\Model\Payment\Paystack; use Magento\Payment\Helper\Data as PaymentHelper; use Magento\Payment\Model\MethodInterface; -use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; use Psr\Log\LoggerInterface; @@ -40,7 +40,7 @@ protected function setUp(): void private function setupStore(string $baseUrl = 'https://shop.example.com/'): void { - $store = $this->createMock(StoreInterface::class); + $store = $this->createMock(Store::class); $store->method('getBaseUrl')->willReturn($baseUrl); $this->storeManager->method('getStore')->willReturn($store); } diff --git a/build-adobe-zip.sh b/build-adobe-zip.sh index bbaf0d9..98c73d6 100755 --- a/build-adobe-zip.sh +++ b/build-adobe-zip.sh @@ -10,8 +10,10 @@ 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" +# (e.g. files that are now excluded) from a previous build. Prior-version zips go +# too, so the only artifact at the repo root is the one just built and there is +# nothing stale to upload to Adobe by mistake. +rm -f "${NAME}"-*.zip # Exclude dev assets, internal docs, archives, and any built zips. # docs/ holds internal QA artifacts (review logs, recordings) and must never ship.