Payment Gateway 6.1.9

11 August 2026

This release has upgrade notes. Read them before updating — they describe behaviour changes that need your attention.

PAYMENTSMERCHANTSREFUNDSSETTLEMENTSECURITYRELIABILITYBUG-FIXES

Payment Gateway v6.1.9

Release Date: August 11, 2026 Tags: PAYMENTS, MERCHANTS, REFUNDS, SETTLEMENT, SECURITY, RELIABILITY, BUG-FIXES

Overview

This release corrects the arithmetic that splits every payment into your fee and the merchant's share. The fee was rounded to eight decimal places while the wallet that receives it settles at the currency's own precision, so the two halves did not add up to what the customer paid, and your profit ledger and the fee wallet disagreed by the same difference. Read Upgrade Notes before updating: records already written carry it, and it does not correct itself.

Other defects moved or misreported money: the merchant refund API could pay the same refund twice, a cancel could stamp CANCELLED over a payment that had just completed and been paid for, the last leg of a refund could be refused outright, and the checkout told solvent customers they were short. Merchant API keys with an IP allowlist are now matched against an address the caller cannot forge; if your proxy runs on another machine there is a setting to add first.

Update Core first — this add-on is built on the address resolution and transaction helpers that ship with it, and will not load its routes without them. Requires Core v6.6.3.

Update Instructions

pnpm updator

No settings change is required on a single-machine install, and no migration runs. Payments, refunds and payouts already in your database are not rewritten — see Upgrade Notes for what to inspect.


Upgrade Notes

  • Payments taken before this update credited a fee that had been rounded to the currency's precision on its way into the fee wallet, beside a merchant balance that kept the unrounded remainder. For each currency you take payments in, compare the fee total in your profit ledger against what the fee wallet actually received: they differ by the sum of those roundings, and the merchant balances differ from the customers' payments by the same amount. The gap is at most half of the currency's smallest unit per payment — half a cent on USD — and falls either side of the true figure depending on the amount, so the two totals drift apart rather than cancelling to nothing. Correct the balances you carry forward before you reconcile a payout period against them.

  • If two refund calls for one payment ever arrived through the merchant API at the same moment, both could pay in full. Look for payments whose completed refunds total more than the payment itself, and for customers credited twice for one order. Each duplicate carries its own refund reference, so nothing in the wallet ledger flagged them.

  • A cancel arriving while a buyer was confirming could write CANCELLED over a payment that had already completed, and send a cancellation webhook after the completion webhook. Look for cancelled payments that nevertheless have wallet debits, a merchant balance credit and a collected fee against them: the merchant was told to void an order they had in fact been paid for. Reconcile those with the merchant directly.

  • A payment refunded in two or more legs could be left reading PARTIALLY_REFUNDED with a vanishing fraction outstanding. New refunds now settle the status correctly, but rows already in that state stay as they are and any further refund against them is now refused as already refunded in full. Correct their status yourself if your reporting keys on it.

  • Merchant API keys that restrict access by IP are now matched against the address the request actually arrived from, resolved through the trusted-proxy chain rather than read out of a header the caller wrote. If your reverse proxy runs on a different machine from the backend, declare its network in TRUST_PROXY_CIDRS before updating, or the address seen for every merchant call becomes the proxy's and IP-restricted keys will start refusing legitimate traffic. Loopback proxies on the same host need nothing set.


Changed

  • Changed the originating address recorded against a completed payment is now the single resolved client address rather than the forwarding header exactly as it arrived, so the field holds one address instead of a caller-authored list of hops.

Fixed

The fee and the merchant's share did not add up to what the customer paid

A payment is split in two. The fee is credited to a wallet, and the wallet rounds every credit to that currency's own precision — two decimal places on USD. The merchant's share was written to their gateway balance at full precision. The fee itself, however, was rounded to eight decimal places before the split, so the two settled figures were rounded in two different places to two different precisions and stopped summing to the whole. A 3% + 1 fee on a 10.20 USD allocation produced a fee of 1.326 credited as 1.33, beside a merchant share of 8.874 — 10.204 against a customer who paid 10.20. Nothing could see it: each leg was individually correct, and the profit ledger recorded the unrounded fee while the wallet held the rounded one, so the ledger and the wallet disagreed by exactly the amount the split had gained or lost.

  • Fixed the fee is now rounded where it will actually be settled — at the precision of the currency that leg settles in — and the merchant's share is taken as the exact remainder by subtraction, so fee plus merchant share equals the amount paid, on the quote written when the payment is created and on each allocation when it is confirmed.

A payment that had already taken the customer's money could answer as a failure

Building the redirect back to the merchant was the first thing that could fail after the transaction committed. A stored return URL that does not parse therefore debited the buyer, credited the merchant, marked the payment COMPLETED — and then answered with a server error, telling the caller the payment had failed. A merchant acting on that answer treats a settled order as unpaid. The confirmation itself cannot be replayed — a second attempt is refused as already completed — but a fresh payment for the same order charges the customer a second time. It survived unnoticed because the write path refuses such a value, so only a row created by an import, a hand edit or a migration could carry one, and nothing looked again on the way out.

  • Fixed an unparseable merchant return URL is now refused before any money moves, with nothing debited and the payment still payable, and the redirect built after settlement can no longer fail the request — a payment that has already moved money answers without a redirect and the checkout page shows its success state.

The merchant API could pay the same refund twice

The refund API decided how much of a payment was still refundable from reads taken before its transaction opened and without a lock. Two calls for the same payment arriving together — a double submit, an at-least-once queue redelivery, two workers draining one job — both saw nothing yet refunded, both passed the check, and both paid the full amount. A 100 USD payment refunded the customer 200. Nothing downstream could catch it, because each refund mints its own reference, so the wallet's duplicate protection and the gateway's own uniqueness checks saw two unrelated payouts.

  • Fixed the API now locks the payment, re-reads its status and its completed refunds inside that lock, and decides the amount there, so a second simultaneous call sees the first one's refund and is refused. Both refund doors — the merchant dashboard and the API — now share one implementation of that decision instead of two transcriptions that had already drifted apart.

The last part of a refund was refused, and a fully refunded payment stayed partial

Both refund doors compared money with bare arithmetic on binary floating point. Refunding the last 0.2 of a 0.3 payment was refused as exceeding a remaining refundable amount that prints as the very same 0.2 the merchant asked for — a refusal no one can act on. In the other direction, a 0.8 payment refunded in two legs never quite reached its own total, so it stayed PARTIALLY_REFUNDED forever with a fraction of a trillionth of a cent outstanding and the merchant's dashboard reporting a fully refunded payment as partial.

  • Fixed both doors now compare refundable amounts with the tolerance the gateway's own money helpers were written with, so the final leg of a refund is accepted and a payment refunded in full is marked REFUNDED.

Cancelling a payment could erase one that had just been paid

Both cancel doors read the payment, checked its status, and then wrote CANCELLED with no condition attached to the write. A buyer who clicks Pay and then Back, or a merchant integration sweeping what look like abandoned intents, sends a cancel whose snapshot still says the payment is pending. It waits on the row lock the confirmation holds; the confirmation commits — wallets debited, the merchant's balance credited, the fee booked, the completion webhook delivered — and the cancel then stamps CANCELLED over COMPLETED and sends a cancellation webhook behind it. The merchant is told to void an order they have already been paid for, and the buyer is out the money holding a cancelled receipt.

  • Fixed both the checkout cancel and the merchant API cancel now re-check the status as part of the write itself, so a payment that completed while the cancel was waiting is left exactly as it is and the caller is told it can no longer be cancelled. No cancellation webhook is sent in that case.

A merchant API key's IP allowlist could be satisfied by a header the caller wrote

Every merchant API door built the caller's address from the leftmost entry of the forwarding header — the one position a caller fully controls — with no check that the request had come through a proxy of yours at all, and fell back to two more single-value headers that Apache and nginx pass straight through from the client. Anyone holding a leaked secret key could therefore satisfy that key's IP allowlist by naming an allowed address in a header, which is precisely the control meant to make a leaked key useless off the merchant's own servers.

  • Fixed all six merchant API doors — payment creation, payment lookup, payment cancellation, refund creation, refund lookup and key validation — now resolve the caller's address only from forwarding headers written by a trusted peer, reading the hop list from the right so an entry the caller prepended is ignored.

The checkout told solvent customers they were short

When no single wallet covered the payment, the checkout fell back to showing the customer's first wallet and worked out the shortfall by subtracting that wallet's own balance from the payment amount — two figures in two different currencies. That block renders precisely when the two differ, so it was wrong almost every time it appeared. A 100 USD payment against a wallet holding 0.5 BTC read "You need $99.50 more" to someone holding roughly thirty-two thousand dollars, and a wallet holding 50,000 NGN produced a negative figure. The server had been sending a properly converted shortfall all along, and the wallet selector on the same page was already using it.

  • Fixed all five checkout designs now show the converted shortfall the server sends, so the figure is a real amount in the payment's currency and a customer who can afford the payment is no longer told to top up. Where the server has not sent one the old subtraction still stands in, but it can no longer print a negative amount.

The public landing page advertised the wrong currency, and totals that were not ones

The live activity feed printed a dollar sign directly in front of each payment's own currency code, so a 0.05 BTC payment rendered as "$0.05 BTC". Two totals on that page carried the same false mark: the strip's recent-volume figure, and the all-time volume headline shown to every visitor. Each is a plain sum of payments taken in whatever currency each was taken in — naira plus bitcoin plus tether — so neither is a quantity of dollars, or of anything else.

  • Fixed each payment now shows only its own currency, and the recent-volume total is labelled with that currency when every recent payment shares one. When they do not, there is no single total to publish and the figure reads zero rather than a made-up dollar amount. The all-time volume headline keeps its magnitude and loses the currency mark, since that sum spans every currency the gateway has ever taken.

A merchant's payout attempt could vanish from the payout run

The scheduled job that creates merchant payouts takes one merchant at a time inside a single transaction, and several of its own checks — a payout already exists for this period, the payable amount is below the merchant's threshold — roll that transaction back themselves before reporting why. Its error handler then opened by rolling back again, and rolling back a transaction that has already finished fails on its own, from inside the handler and before it reached its own logging. So if anything went wrong in the moment after one of those checks, or after a payout had already been committed, the reason was never written down and that merchant was counted neither as created nor as failed in the run's summary — the attempt simply disappeared.

  • Fixed the job now rolls back only a transaction that is still open, so its error handler always reaches its log line and the run's created-and-failed summary accounts for every merchant it tried.