diff --git a/CHANGELOG.md b/CHANGELOG.md index 844ffec..48113a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,43 @@ 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: 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 + 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 +115,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/Controller/Payment/Setup.php b/Controller/Payment/Setup.php index 93a800a..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->getCurrency(), + 'currency' => $currency, 'callback_url' => $this->storeManager->getStore()->getBaseUrl() . "paystack/payment/callback", 'metadata' => array('custom_fields' => array( array( 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/Test/Unit/Controller/Payment/SetupTest.php b/Test/Unit/Controller/Payment/SetupTest.php index a4e71a9..88df6e6 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'; @@ -159,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(); @@ -178,7 +271,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 +279,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); 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. 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 @@ - + 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: {