Most bugs are discovered by developers. Checkout bugs are discovered by customers, and usually by the customer who was trying hardest to give you money. That difference is not just embarrassing — it changes what "correct" has to mean before you ship.
Three things make this code unlike the rest of the application. The failure has a currency amount attached. The evidence lives in someone else's system as well as yours. And you frequently cannot fix it by deploying: a double charge stays charged until a human refunds it, and an order that shipped to a stale address has already left the building.
I spend a lot of my time in payment and shipping flows on a marketplace, and this is the set of things I've come to treat as non-negotiable rather than nice-to-have.
Assume every request happens twice
The customer's connection drops halfway through checkout and they hit the button again. Your job queue retries a task whose success response was lost. A webhook is redelivered because your endpoint took too long to acknowledge it. Someone double-clicks. None of these are exotic — over enough orders, all of them happen.
The fix is idempotency, and it has to be real rather than aspirational. Every operation that moves money carries a key derived from the intent, not from the attempt: this cart, this customer, this checkout session — not a fresh UUID generated on each click, which is a unique key for something that isn't unique. Store the key with the result. When a request arrives carrying a key you've already completed, return the original result instead of doing the work again.
Payment providers support this directly, and it's worth using theirs as well as your own. But the provider's idempotency doesn't protect your side of the boundary. If you charge once and then write two order rows because the retry re-ran your handler, you have a different bug with the same shape.
The redirect is not the confirmation
The most common structural mistake I see: treating the customer's return to your success page as the moment the payment happened. It isn't. It's the moment the browser came back — which may never happen. People close the tab. Their phone dies. The bank's 3-D Secure step bounces them somewhere unexpected. Mobile networks drop the redirect entirely.
The authoritative signal is the provider's server-to-server notification. The redirect is a UX affordance for the human; the webhook is the record. That means your order fulfilment has to be driven from the webhook, and your success page has to be able to say "we're confirming this" for the case where the customer arrives before the webhook does — a race that will absolutely happen, because the two are unrelated network paths.
Which also means: verify webhook signatures, and reject anything that fails. An unverified endpoint that marks orders paid is an endpoint that lets anyone mark orders paid.
Make the illegal transitions impossible, not merely unlikely
Orders are state machines whether or not you've written one. Left implicit, the states live as a scatter of booleans — is_paid, is_shipped, is_cancelled, is_refunded — and nothing in the schema prevents a row from being paid, cancelled and shipped simultaneously. Then a support agent cancels an order at the same moment the warehouse marks it shipped, and now you get to explain what the system thinks happened.
Write the states down as one field with a fixed set of values, and write down which transitions are permitted. Then enforce it where it can't be bypassed: a conditional update that only moves an order to shipped if it is currently paid will lose the race safely, whereas read-then-write will happily lose it dangerously. The database is the only place two concurrent requests genuinely agree.
Never store money in a float
This is old advice and it is still routinely ignored. Binary floating point cannot represent most decimal fractions exactly, so amounts drift, and the drift shows up as a one-cent discrepancy in a total that nobody can reproduce.
Store integer minor units — cents, sen, whatever the currency's smallest unit is — and carry the currency code alongside every amount, because an integer on its own is a number, not a price. Currencies also don't agree on how many decimal places they have, so hardcoding two is a bug waiting for its first order in a zero-decimal currency.
And decide, explicitly, where rounding happens. Per line item or on the order total is a real choice with different results; tax on a discounted price is different from a discount on a taxed price. Pick one, write it down where the next developer will find it, and make the tests assert the exact figures — not "approximately".
Shipping is where the assumptions hide
Payments get the attention; shipping quietly accumulates more edge cases. A few worth deciding on deliberately rather than discovering:
- A quote is a point-in-time fact. The rate you showed at checkout and the rate the carrier charges at dispatch can differ, and if your margin depends on them matching, you need to know which one you're honouring — and store the quoted figure, not just recompute it later and hope.
- Addresses are not a solved problem. Every format assumption you make will be wrong somewhere. Postcodes aren't universally numeric, some countries don't use them at all, and a required "state" field is a wall for anyone in a country without states. Validate structure loosely and let the carrier's validation be the authority.
- Snapshot the address onto the order. If shipping reads from the customer's current profile, then a customer updating their address after ordering silently rewrites the destination of an order already in a box. Copy it at purchase time; the order is a record of what was agreed, not a live view of the customer.
- Split shipments break the one-order-one-parcel assumption that most schemas start with, and multi-vendor marketplaces hit this on day one, because items from different vendors were never going to travel together.
Reconcile, because you will drift
Given enough volume, your database and your payment provider's records will disagree. A webhook was dropped and never retried, a refund was issued in the provider's dashboard rather than through your application, a chargeback landed, a job died between charging and writing.
The useful posture is not "prevent all drift" — you won't — but "detect it quickly and without a human noticing first". A scheduled job that pulls the provider's transactions for a window and compares them against your orders, then reports anything that appears on one side only, is a small amount of code that buys a lot of sleep. The point isn't to auto-correct; it's that a discrepancy becomes something you find on Tuesday morning rather than something an accountant finds in March.
Test the paths that hurt
The happy path gets tested because it's the one you build. The paths worth writing tests for are the other ones: the duplicate submit, the webhook that arrives twice, the webhook that arrives before the redirect, the payment that succeeds while your database write fails, the refund that exceeds the captured amount, the cancel that races the ship.
Providers give you sandbox cards for exactly these — declines, insufficient funds, 3-D Secure challenges, disputes. They're worth going through deliberately rather than testing the one card number that always works.
None of this is clever. It's mostly the discipline of assuming the network will fail mid-transaction and writing code that's still correct when it does. But it's the part of a commerce build where being unglamorous and thorough pays for itself, because it's the part where the bugs cost money and take a phone call to unwind.