Retrying QR payments without double charging.
Making QR payment retries safe: one pending order per cart, no silent switch to cash, keyed settlement and a careful cleanup job.
At Laayo an order is created before it is paid. When a customer picks Fonepay QR at checkout, the server writes the order as unpaid and pending, mints a dynamic QR for it, and waits. The customer scans with their bank app, the payment network tells us, and a success hook marks the order paid and lets the restaurant see it.
Place first, pay second is convenient: every payment has an order to point at. But the interesting cases are all the ones where the money does not arrive. The customer cancels, the QR expires, the bank app hangs, or the customer taps Retry.
I got the retry path wrong in two different ways before it was right.
The order that turned into cash on delivery
On staging I placed an order with QR, cancelled the payment, tapped Retry, and ended up with a cash on delivery order. Nobody asked me to pay cash. It just happened.
The cause was a customer setting along the lines of "pay on delivery if online payment fails". It defaulted to on. The fallback code only cancelled an unpaid order when that setting was off, so with it on, the unpaid order stayed alive and was treated as cash. A setting meant to be helpful had become a silent change of payment method.
I removed the silent switch and put the rule on the server, not in the app: an unpaid online order may not leave pending unless the same write marks it paid, or the customer explicitly switches it to cash. Every path that moves an order forward goes through that check, including the admin panel, the restaurant app, the rider's accept button and the newer Go endpoints. Laayo runs a Go service in front of an older Laravel app and routes move over one at a time, so a guard that exists in only one of them is not a guard.
// An unpaid online order may only leave "pending" when this same write
// pays it, or when the customer has chosen to pay cash instead.
func checkLeavePending(cur, next Order) error {
if cur.Status != "pending" || next.Status == "pending" || next.Status == "canceled" {
return nil
}
if cur.PaymentMethod != "digital" || cur.PaymentStatus == "paid" {
return nil
}
if next.PaymentStatus == "paid" || next.PaymentMethod == "cash_on_delivery" {
return nil
}
return ErrUnpaidOnline // the caller gets a 409
}// An unpaid online order may only leave "pending" when this same write
// pays it, or when the customer has chosen to pay cash instead.
func checkLeavePending(cur, next Order) error {
if cur.Status != "pending" || next.Status == "pending" || next.Status == "canceled" {
return nil
}
if cur.PaymentMethod != "digital" || cur.PaymentStatus == "paid" {
return nil
}
if next.PaymentStatus == "paid" || next.PaymentMethod == "cash_on_delivery" {
return nil
}
return ErrUnpaidOnline // the caller gets a 409
}There is a cost. Staff can no longer move an unpaid online order to cash from the panel, and they can't mark one delivered. I decided that was correct. If the customer wants to pay cash, the customer says so.
Three orders from one cart
Then I tested again on staging and found three unpaid orders from one cart. Each tap on Place order created a fresh order, and each cancelled QR left one behind in pending.
The fix was in the app, and it was small. When place order returns an unpaid online order, the app remembers it together with a signature of the cart (store, items, options, quantities). On the next Place order:
- same cart, online payment: pay the same order again
- same cart, cash: switch the payment method of the existing order on the server
- changed cart, or a different kind of payment: cancel the old order first, then place a new one
The app forgets the pending order after thirty minutes, which is also when the server gives up on an abandoned one. The two clocks have to agree, otherwise the app tries to reuse an order the server has already cancelled.
The cart needed protecting too. The old flow deleted the cart as soon as the order was placed, so a cancelled QR left the customer with an empty cart and an unpaid order. Now the server keeps the cart for QR orders and clears it only in the payment success hook. A cancelled payment brings the customer back to the cart they left.
Making the money side idempotent
Retries only feel safe to customers if they are safe in the ledger. A few rules do most of the work.
One QR per attempt, reused for a while. If the app asks for a QR for the same order within ten minutes, the server returns the same one instead of minting another. Only an explicit Retry passes a flag that mints a fresh QR. The readable reference in the QR remark is stored per payment, so retries don't create a new reference each time.
Settlement is keyed, not counted. The payment requests table has a unique key on the payment method plus the provider's transaction id. If the same confirmation arrives twice, say from the socket and from a reconcile job, the second insert fails and nothing is credited twice. Wallet top-ups and cash collection settle in the same transaction as that insert.
Proxies must not replay writes. When I moved QR minting to Go, nginx could fall back to the old PHP handler if Go failed, and a replayed mint would write a second payment row. So the fallback only happens on a bad gateway, when Go could not be reached. Go reports its own errors with a status nginx never retries.
Cleanup is conservative. A Go cron cancels abandoned unpaid online orders after thirty minutes, and it skips any order whose QR could still be paid. Even so, a customer can pay at the very last moment on an order the job has just cancelled. That case needs a manual refund. I accepted it, because the alternative is unpaid orders hanging around forever where a restaurant might see them.
Polling without lying
While the customer pays, the app shows a waiting screen. Two details mattered.
iOS suspends the app's timers while the customer is in their bank app on the same phone. My first version polled every three seconds and still showed nothing for half a minute after the payment, because nothing ran until the customer came back and the next poll was slow. The app now checks the moment it returns to the foreground.
The slow part was the status endpoint itself. Once it saw that a QR had been scanned, it ran a live reconcile with the provider. I added a second endpoint that only reads our own database (two flags: scanned and paid) and the app polls that every second. The reconcile still runs, just not on the path the customer is waiting on.
Get new posts by email
One email when something new goes up. No tracking pixels, unsubscribe in one click.