The payment state model, and who is allowed to say "paid"
coreadvancedThe section states its rule first: **never assume a client-side success callback is the authoritative payment source.** The browser being redirected to `/success` proves the user reached a URL, nothing more — that request can be replayed, bookmarked, or typed. Only the provider, speaking server-to-server, can confirm money moved. So the payment gets its own model with its own state machine, separate from the order, and it changes state on provider events rather than on anything a browser says.
Think of it as
Separate the three things that a naive implementation collapses into one boolean. There is the *intent* — the customer wants to pay £49 for order 88 — which your system creates and owns. There is the *attempt* — a specific interaction with the provider, which may fail and be retried, so one intent can have several. And there is the *outcome*, which only the provider knows, arrives asynchronously, and is the sole thing entitled to move the payment to `succeeded`. Modelling those separately is what makes the awkward cases expressible: a customer whose card is declined twice and then succeeds has one payment and three attempts; a customer who closes the tab after authorising has an intent with an outcome you will learn about by webhook rather than by redirect. Once the split exists, the trust rule becomes obvious rather than a special precaution. The redirect to your success page is a *hint* — good for showing a spinner, useless as evidence, because it travels through the user's browser where anyone can send the same request. The webhook and a server-side status fetch are evidence, because they come from the provider over a channel the user cannot forge. The practical consequence is that the success page must not grant anything; it reads the payment's current state and, if the webhook has not arrived yet, says "confirming" and polls. That feels worse than instantly showing "paid" and is the whole point. Two details in the model itself repay attention. Store money as `Decimal` with an explicit currency, never a float — binary floating point cannot represent 0.10 exactly, and errors accumulate across totals in ways that surface as one-penny reconciliation failures nobody can explain. And store the provider's own identifier on the row, because it is the join key for every webhook, every refund, and every dispute; without it, matching a provider event back to your record becomes guesswork over amounts and timestamps.
What we're doing: A payment model that records the provider reference, keeps money exact, separates attempts from the payment, and refuses to reach `succeeded` without provider evidence.
- 11–12
- Naming the provider-only states as data is what lets the rule be checked once, in `transition_to`, instead of being remembered at every call site.
- 20–22
- `FloatField` for money is the classic defect: binary floating point cannot represent 0.10, so totals drift by fractions of a penny and only surface when reconciliation against the provider fails.
- 25–26
- The provider reference is indexed because every inbound webhook looks the payment up by it. Without it, matching an event to a payment means guessing from amount and timestamp.
- 33–37
- A unique constraint conditioned on the reference being non-empty — payments start with no reference, so unconditional uniqueness would reject every second `created` row.
- 45–48
- The section's core principle, enforced. A code path holding a browser callback cannot reach `succeeded`, whatever it claims, because it cannot present `source="provider"`.
Why this works: Money stays exact, provider events have a stable join key, retries do not multiply payments, and no browser-originated code path can mark a payment successful.
Granting on the success redirect
Wrong
Better
What you see: Orders are marked paid with no corresponding money, discovered when the settlement report is reconciled — or not discovered at all, if nobody reconciles.
Why: The redirect travels through the customer's browser, so the request is entirely under their control: it can be replayed, shared, bookmarked, or constructed by hand with a guessed order id. Treating it as proof means the "did they pay?" decision is made by the party with the strongest incentive to lie. Even honestly, it is unreliable — a customer who closes the tab after authorising never reaches the page, so a system that grants only there also fails to grant for legitimate payments. The webhook is both the secure path and the complete one.
- created
(intent — your system) (start)
- → processing (attempt submitted) when you submit the attempt
- processing
(attempt submitted)
- → requires_action (3-D Secure) when provider: authentication needed
- → succeeded (provider only) when verified provider event
- → failed — terminal (a retry is a NEW payment) when declined, expired, timed out
- requires_action
(3-D Secure)
- → processing (attempt submitted) when customer completes it
- succeeded
(provider only)
- → refunded when you initiate, provider confirms
- → disputed (weeks or months later) when chargeback — long after the order shipped
- failed — terminal (a retry is a NEW payment) (end)
- refunded (end)
- disputed (weeks or months later) (end)
What each signal actually proves
Together
The payment machine, and who may cause each move
Together
Remember: A browser redirect proves the user reached a URL; only a server-to-server provider signal proves money moved — so the success page reads state and may honestly say "confirming", while the webhook grants. Model intent, attempts and outcome separately: one payment can have several attempts, and overwriting the reference destroys the key their webhooks arrive on. Money is `Decimal` with an explicit currency, never a float. And encode the trust rule as code — a `source` argument on the transition, so a path holding a browser callback cannot reach `succeeded` at all.
See also: verifying a webhook and its raw body · duplicates ordering and transaction boundaries · valid and invalid transitions

