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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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._
14 changes: 13 additions & 1 deletion Controller/Payment/Setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
105 changes: 99 additions & 6 deletions Test/Unit/Controller/Payment/SetupTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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')
Expand All @@ -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);

Expand All @@ -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';
Expand All @@ -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();
Expand All @@ -178,15 +271,15 @@ 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);

$methodInstance = $this->createMock(MethodInterface::class);
$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);

Expand Down
4 changes: 2 additions & 2 deletions Test/Unit/Model/Ui/ConfigProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Expand Down
6 changes: 4 additions & 2 deletions build-adobe-zip.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion etc/module.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Pstk_Paystack" setup_version="3.0.10">
<module name="Pstk_Paystack" setup_version="3.0.11">
<sequence>
<module name="Magento_Sales"/>
<module name="Magento_Payment"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down