Write down each allowed move

Start with created, awaiting authorization, authorized, captured, failed, refunded, and reversed. Use the states your provider actually sends. For each move, record who may cause it, what evidence is required, and whether it can happen twice. Do not let a browser request set a final state directly.

Tie approval to frozen details

After a customer approves an amount and recipient, keep those values fixed for that payment. If either value changes, require a new approval. The OWASP transaction authorization guide calls for steps to run in order and for transaction data to stay protected through approval.

State and effect contract

For each allowed transition, write the prior state, next state, required actor or provider event, and money effect. A move from authorized to captured may post a receivable; a move from captured to refunded posts a separate refund journal. A denied transition posts nothing. Keep terminal states explicit, but do not assume every provider uses the same names. Map provider statuses into your local model with a versioned adapter. If the provider introduces a new status, route it to review until its meaning is defined.

Guard a transition under concurrency

Read the current state and version, then update only if both still match inside the transaction that writes the financial effect. If another worker moved the payment first, re-read and decide again. Do not return success because an update statement affected zero rows. For a repeated event, return the stored outcome only when its provider event ID and payload match the original. A different event asking for the same transition may be valid history but should not post a second money effect.

Separate commands from provider facts

A customer command asks your system to do something; a provider event reports something that already happened. Case PAY-01 in the test pack checks an internal capture command before customer approval. Reject that command before a provider call. Do not use the same rule to discard a genuine capture event that arrived before its authorization event.

For a synthetic ₦10,000 capture received first, save the verified event, fetch the provider payment, and confirm account, object ID, amount, currency, and status. If the provider confirms capture, record the capture once under its economic operation ID and link the missing history when it arrives. If lookup fails, keep a review item with no new money effect. Stripe documents unordered delivery; receipt order cannot define financial truth. Keep capture, refund, reversal, and settlement as separate facts rather than one status that hides earlier movements.

Use one unique key per money effect: provider account, effect type, and capture or refund ID. Two different refund IDs on one charge are two valid effects. Two event IDs for the same capture are one effect. A late authorization must not erase capture, and a capture must not erase a later linked refund. Test duplicate IDs, distinct IDs for the same object effect, provider lookup failure, and unknown provider status.

Evidence to retain

Save the transition table, rejected request, payment version, and ledger snapshot. A reviewer should see that an invalid move changed neither status nor money.

Sources

Put this into practice

Start with the endpoints that write final payment states. Review each route against the transition table, then run the out-of-order tests in staging. See our business logic flaws in payment platforms and payment gateway testing service. To check a live flow, request a security review.