Playbook
Healing Paid-But-Unordered Payments (Healing Paid But Unordered)
An incident playbook: the customer was charged but no order exists; the multi-intent cart problem; and why dedup must be cleaned up carefully.
Distributed Payment Engine
Part 15 of 22
A series on distributed payment architecture — the gap between capture and complete.
The previous part showed how a reconciliation worker detects drift. This part covers the most uncomfortable shape that drift can take: the customer really was charged on the PSP's side, and yet no order exists locally.
This scenario triggers panic because both obvious fixes look tempting and both are wrong on their own: refunding immediately (the customer may have genuinely wanted that order, so this starts an unnecessary refund-retry loop), or silently creating a new order (without knowing which cart or intent the money belongs to, risking a wrong order).
PSP record: Charge #789 → Succeeded, amount: 249.00
Local record: (no Order or Payment row at all)
│
▼
Determine which cart this money belongs to
│
▼
Create the order (heal) OR refund safely
Where the concepts first show up
📦 Orphan charge
A charge that succeeded on the PSP's side but can't be linked to any local record.
📦 Multi-intent cart
A state where more than one payment intent was created for the same cart (e.g. the user refreshed the page twice).
📦 Heal
The act of retroactively linking an orphan charge to the correct cart or order.
📦 Correlation ID
An identifier generated at the start of the payment flow, linking a payment intent to the cart or request that created it.
An orphan charge is usually born from a correlation id getting lost somewhere: the id was recorded when the intent was created, and the process was interrupted before it ever reached the order-creation step.
The incident playbook: the first steps
When an orphan charge is detected (usually via the reconciliation worker, or a customer complaint), the first step is never to take action — it's to rebuild the correlation chain:
1. Extract the correlation id from the PSP's charge metadata
2. Search the local system for a cart/intent record matching that id
3. If found → determine exactly which step the process was interrupted at
4. If not found → lean toward refund (there's no target to heal against)
The correlation id must always be written into the PSP's metadata field when the charge is created — this single design decision is what makes orphan charges retroactively solvable at all.
Multi-intent carts: which money belongs to which order
If a customer opened the checkout page twice (a tab refresh, a double click, a retry after a network delay), two separate payment intents can end up created for the same cart. If both succeed on the PSP's side, the question the system faces is no longer 'a lost payment' — it's 'which payment wins, and what happens to the other one'.
Cart #A
├─ Intent #1 → PSP: Succeeded
└─ Intent #2 → PSP: Succeeded (same cart, two separate charges)
The correct behavior is to lock the cart (preventing a new intent from being created against it) and choose exactly one intent as the 'winner', automatically refunding the other — linking both to the order would produce a double charge.
Cleaning up dedup carefully: why 'aggressive' is risky
The biggest trap when cleaning up orphan charges is basing dedup logic on loose criteria like 'same customer, same amount, close in time'. That criterion can mistakenly treat two genuinely separate purchases (the customer really did buy two different things) as one.
❌ Loose dedup
Same customer + same amount + within 5 minutes → treat as one order
✓ Strict dedup
Same correlation id OR same idempotency key → treat as one order
A dedup decision should always rest on an identifier the system itself generated (correlation id, idempotency key); indirect signals like amount and time should only ever be a secondary confirmation layer, never the primary criterion.
Heal or refund: the decision table
| Situation | Correct action |
|---|---|
| A correlation id matches an unfinished cart | Heal — create the order, link the payment |
| The correlation id matches no record at all | Refund — there is nothing to heal against |
| Two succeeded intents on the same cart | Heal one, refund the other |
| The cart is already completed by another payment | Refund — this would be a double charge |
Every heal action should produce its own audit record: who or which process, at what time, based on what evidence, created this order. That record is also what prevents the same scenario from repeating unnoticed.
Distinctions that get blurred
❌ An orphan charge should always be refunded
✓ If a correlation id matches a real target, healing is often the right call
❌ Same amount + same customer = same order
✓ Dedup must rest on an identifier the system itself generated
❌ Multi-intent is an error state, safe to ignore
✓ Multi-intent is normal user behavior (refresh, double click) that must be designed for
Heal vs. refund comparison
| Criterion | Heal | Refund |
|---|---|---|
| Correlation id match | Present | Missing or ambiguous |
| Customer experience | Order appears, no disruption felt | Money returns, no order |
| Risk | Risk of a wrong match | Risk of customer dissatisfaction |
Incident response checklist
- Does the orphan charge's PSP metadata contain a correlation id? If not, that's the actual design gap.
- Does the dedup decision rest on an indirect signal (amount, time) or on an identifier the system generated?
- In a multi-intent scenario, does the cart get locked after the first successful intent?
- Does every heal action produce an audit record of who/when/based on what evidence?
- Before refunding, is 'no target found' verified more than once?
What to take away
- The key to solving orphan charges is generating the correlation id reliably at the very start of the payment flow.
- A multi-intent cart is a normal scenario; it must be designed for with cart locking and a single winning intent.
- A dedup decision should never rest on indirect signals like amount or time — always on an identifier.
- Whether to heal or refund should be an evidence-based decision, never a reflexive action.
The safest action during a panic isn't a quick fix — it's doing nothing until you've found the right evidence.
The next part goes to the root of this whole class of drift: why building a distributed transaction (2PC) across the PSP, the order, and finance is a trap, and why a saga plus reconciliation is the real answer.
FAQ
Frequently asked questions
What is Orphan charge?
A charge that succeeded on the PSP's side but can't be linked to any local record.
What is Multi-intent cart?
A state where more than one payment intent was created for the same cart (e.g. the user refreshed the page twice).
Is it true that "An orphan charge should always be refunded"?
If a correlation id matches a real target, healing is often the right call
What does this part lock in?
This scenario triggers panic because both obvious fixes look tempting and both are wrong on their own: refunding immediately (the customer may have genuinely wanted that order, so this starts an unnecessary refund-retry loop), or silently creating a new order (without knowing which cart or intent the money belongs to, risking a wrong order). The key to solving orphan charges is generating the correlation id reliably at the very start of the payment flow. The previous part showed how a reconciliation worker detects drift. This part covers the most uncomfortable shape that drift can take: the customer really was charged on the PSP's side, and yet no order exists locally.
Engineering Principles Learned
- The key to solving orphan charges is a correlation id generated reliably at the start.
- Dedup should always rest on an identifier, never on indirect signals like amount or time.
- The safest action in a panic is doing nothing until you find the right evidence.
Continue reading
Continue reading
Next in series
Why Eventual Consistency Beats Distributed Transactions
Building a 2PC across the PSP, the order, and finance is a trap. Saga plus reconciliation is the real answer this eight-part arc has been building toward.
Next in series
Building a Payment Reconciliation Worker
How sweepers heal drift: the PSP says succeeded while the local record says expired, and how aged FinalizePending records get resolved.
Same series
Optimistic Concurrency Under Webhooks
When a webhook and a synchronous response touch the same payment at once, how do a version token and a lease resolve the race — and why can a stale read…