checklistpaymentscheckoutsecurityidempotencyqa

Payment & checkout testing checklist — the cases where money breaks

Checkout is the most expensive screen in the product. This is where money and trust meet: one mistake isn’t “looks off” — it’s real money charged (or not charged), refunds, chargebacks and support tickets with a screenshot of the bank app attached. And yet payments usually get tested the same way: a test card, “Pay”, a green screen, done. But everything interesting lives outside the happy path — in declines, double charges, an abandoned 3-D Secure, and a network drop between the charge and the response.

Here’s what I check in payments and why.

Money is integer minor units, not a float

First rule: money is not stored or computed as a float. 0.1 + 0.2 in binary arithmetic is 0.30000000000000004, and at scale those tails turn into penny mismatches between the order, the receipt and the bank statement. The right way is integer minor units (cents, kopecks) in int, formatting to dollars/rubles only for display.

What to check: rounding after discounts and taxes (banker’s vs mathematical), line items summing to the total down to the cent, locale display ($1,234.56 vs 1 234,56 ₽), three decimals for currencies like the dinar and zero for the yen. And separately — that the displayed amount exactly equals the charged amount. “Screen says 9.90, charged 10.00” isn’t cosmetic; it’s a refund and lost trust.

Double charges: idempotency at every step

The most expensive class of bugs. The user taps “Pay” twice, the network drops between request and response, the client retries on timeout, the user hits “Back” and resubmits, the provider’s webhook arrives twice. None of these may lead to a second charge.

The defense is an idempotency key on the payment operation: the client generates an Idempotency-Key, and the server returns the result of the first attempt instead of creating a second one (that’s how Stripe does it). It’s the same class of checks as in the post on idempotency and retry storms: double tap, killing the app between request and response, retry from another device, form double-submit. Don’t check “is there a key” — check the behavior: two identical requests → one charge, one order, one receipt.

Bank declines are normal, not a server error

There are more declines in production than a test bench suggests: insufficient funds, do not honor, expired card, wrong CVC, limit, the bank’s own fraud block. Each is a separate scenario, and payment providers ship test cards for every decline code. Run those — not only the “successful” 4242 4242 4242 4242.

What to check on a decline: the user sees a clear message (“your bank declined it, try another card”), not “Error 500” and not a raw provider code; whether they can retry without re-entering everything; and above all — the order does not flip to “paid”. A decline that still creates a paid order in your system is money from thin air and a talk with accounting.

3-D Secure: test the abandoned challenge

Card payments in the EU, and increasingly everywhere, go through 3-D Secure / SCA: a bank redirect or modal with a code. The happy path — enter the code, come back, payment succeeds. What you must test are the failures: the user closed the 3DS window, hit “Cancel”, timed out on the code, hit “Back” mid-challenge, or got a frictionless flow instead of a challenge (the bank didn’t ask). In every case one question: what state are the order and the money in if the user never finished? A stuck pending with no charge is acceptable; a charge without a 3DS confirmation, or “paid” on an abandoned challenge, is not.

The webhook is the source of truth, not the redirect

A key architectural thing that often breaks: confirm the payment by the provider’s server webhook, not by the fact the user got redirected to /success. The user may close the tab right after the charge and never see /success — the order must still be created. And the reverse: opening /success by hand without paying anything must not create an order.

What to check: the order is created on the webhook even if the user never returned; duplicate webhooks are deduped by event_id; events can arrive out of order (payment_succeeded before payment_created); the webhook signature is verified and a forged request is rejected; the provider retries the webhook if your server answered non-200. Test with the provider’s CLI/webhook tester and traffic interception (Proxyman/Charles) so you see what actually arrives.

The server computes totals and promo codes, not the client

Anything that affects the total is recomputed on the server and never trusted from the client — otherwise it’s tampering from the OWASP API Top 10: change the amount in the request, buy for a dollar. Check: total = line items − discount + tax + shipping, recomputed on the backend; tax/VAT by region; shipping by the rules.

Promo codes are their own minefield: invalid, expired, usage limit reached, applied twice, stacking two codes (allowed?), a code below the order minimum, a code in another currency, a negative or 100%-off total (paying 0 — what does the payment layer do?). And brute-forcing promo codes is a rate limiting concern: with no limit, someone scripts their way to other people’s codes.

PCI and card data: what must never be in your logs

The easiest way to hold the PCI DSS boundary: card data never touches your server. Card fields live in the provider’s iframe/SDK, you get a token, not the PAN. What I check: the full card number and CVV do not land in logs, analytics, sentry, the DB; CVV is never stored (not even for saved cards); a saved card is a provider token, not a number; checkout is HTTPS-only; card fields don’t autofill with foreign data and aren’t logged on the front end. This isn’t a “security feature for later” — a leaked PAN in your logs is an incident and a fine (PCI SSC).

Network and states: a drop at every step

Payment is a chain of network calls, and it breaks at every link. I test drops: before submit, during the charge (the most dangerous — double charge), on the 3DS step, between the charge and the webhook. Plus states: an expired session/cart at the payment moment; the item sold out while the user was paying; the price changed during payment (charge the old one or show the new one?); two tabs on one order; a slow network and a spinner you can’t bypass with a double tap.

And the order as a state machine: pending → paid → failed / refunded. Never show “paid” until confirmed by the webhook; reconcile your status with the provider’s (a mismatch = an alert); support refunds and partial refunds; support cancellation before payment. Same principle as the forms checklist — double submit and server-side validation, just with a higher cost of error.

Checklist

  • Money in integer minor units, not float; rounding after discounts/taxes verified
  • Displayed amount exactly equals the charged amount; locale format and decimals per currency
  • Double tap / retry / drop / “Back” → one charge (idempotency key)
  • Test cards run for EVERY decline code, not just the successful one
  • On a decline — clear message, retry without re-entry, order NOT “paid”
  • 3DS: cancel, close, timeout, “Back”, frictionless — order and money in a valid state
  • Confirmation by webhook, not redirect; closed the tab → order still created
  • Webhooks: dedup by event_id, out-of-order, signature check, retry on non-200
  • Total, taxes, discounts, shipping recomputed on the SERVER; amount not trusted from client
  • Promo code: invalid, expired, limit, stacking, min total, wrong currency, 0/negative
  • Promo code brute force is rate-limited
  • PAN/CVV not in logs/analytics/DB; CVV never stored; saved card = token
  • Checkout over HTTPS; card fields in the provider iframe/SDK
  • Network drop at every link of the chain; expired session/cart; item sold out; price changed
  • Order = state machine pending→paid→failed/refunded; “paid” only after the webhook
  • Your status reconciled with the provider’s; refunds and partial refunds supported

Checkout is treacherous because on a demo card everything is green, and it breaks where people don’t look: in a bank decline, a dropped network, an abandoned 3DS, a webhook that arrived twice or never arrived at all. Here the cost of a missed bug is measured in money and trust, not tickets — so the happy path goes first, and everything else is what’s left.