Playbook
Immutable Payment Snapshot Design: Freezing the Cart at Intent Time (Immutable Payment Snapshot Design)
Re-reading the live basket during payment leaves amount and currency undecided. Without a snapshot frozen at intent time, finalization can't be trusted.
Distributed Payment Engine
Part 4 of 22
A series on distributed payment architecture — the gap between capture and complete.
The basket is a moving target
When a customer reaches checkout, the price, campaign, and stock information in the basket can still change: a promotion can expire, a price can be updated, the same customer can edit the basket in another tab. If you don't freeze this information the moment the payment intent is created, the amount sent to the PSP and the amount expected during finalization can end up representing two different truths.
Basket (live, mutable) --at intent time--> Payment Snapshot (frozen, immutable)
↓
amount sent to the PSP = amount in the snapshot
This part explains why “falling back to the live basket” is a risk, not a shortcut, and how an immutable snapshot solves it.
Concepts, defined where they first appear
📦 Payment Intent
A record representing the customer's intent to pay, carrying the amount and currency that will be sent to the PSP.
📦 Snapshot
A frozen, unchanging copy of a fact (price, quantity, tax, discount) as it stood at one point in time.
📦 Price-at-Intent
The principle that the price valid at the moment payment starts is preserved independently of later campaign or price changes.
📦 Basket Drift
When the basket diverges from the snapshot as the payment process proceeds (the user editing it in another tab, or background jobs changing it).
📦 Amount Reconciliation
Comparing the actual amount collected, as reported by the PSP, against the expected amount in the snapshot.
A snapshot isn't a “photo” of the basket — it's freezing the basket's state at that moment the way you'd freeze a legal record that can never be edited afterward.
Why reading the live basket is misleading
Some systems, once the payment intent is created, look back at the basket during finalization to get the “current amount”. This looks appealing because it feels like using “the freshest data” — but in reality it conflates two truths from two different points in time.
t0: Customer starts payment → Basket: 3 items, $450, promotion active
t1: Request for $450 goes to the PSP
t2: Promotion expires, basket now shows $480
t3: Finalization looks at the basket again → expects $480, but the PSP collected $450
This mismatch leaves the finalization saga unable to decide which amount counts as “correct”. The result: manual review, a customer complaint, or a silently wrong ledger entry.
What the snapshot solves
The right approach is to freeze the basket's state at the moment the payment intent is created — amount, currency, line items, tax, discounts — as a separate, immutable record. This record is independent of the basket table; even if the basket changes, the promotion ends, or a product's price is updated, the snapshot stays exactly the same.
PaymentSnapshot
amount: 450.00
currency: USD
lines: [...]
createdAt: t0
(even if the basket changes at t1, t2, t3, this record never changes)
The amount sent to the PSP is always read from the snapshot, never from the live basket. The finalization saga also references this same snapshot at every step — stock decrement, ledger entry, order confirmation. This means that no matter what happens to the basket afterward, every payment-related decision rests on the same fixed fact.
Validating amount and currency
Freezing isn't enough on its own — you also need to validate the actual amount the PSP reports having collected. If the amount in the PSP's response doesn't match the amount in the snapshot (a rounding difference, a currency conversion bug, an integration defect), that event should never be auto-marked as “completed” — it should fall into a mismatch queue.
PSP response: amount=450.00, currency=USD
Snapshot: amount=450.00, currency=USD
→ matches, continue processing
PSP response: amount=449.99, currency=USD
Snapshot: amount=450.00, currency=USD
→ mismatch, finalization HALTS, goes to review queue
This validation step catches both integration bugs and potential tampering early.
The temptation to fall back to the live basket
Some teams, even after building the snapshot mechanism, fall back to the basket during finalization anyway, saying “but stock might have changed, let's check current data.” This undermines the whole point of the snapshot: stock checking should be its own step, with its own failure and compensation logic — but the amount and currency decision should never revert to live data. Mixing the two is like reopening a legal record you already froze.
The mappings that get confused most often
❌ Re-reading the basket during finalization means using “the freshest data”
✓ Re-reading the basket during finalization conflates two different points in time
❌ A snapshot is just a logging/audit record
✓ A snapshot is the single source of truth for payment and finalization decisions
❌ The amount from the PSP always matches the expected amount, so validation is unnecessary
✓ An amount/currency mismatch should be an event that goes into a queue, not something silently accepted
❌ Stock freshness and payment amount can be checked with the same mechanism
✓ Stock checking is a separate step; the amount decision should never fall back to live data
A checklist for auditing your snapshot design
- Is the amount you send to the PSP read from the live basket, or from a frozen snapshot record?
- For every step in your finalization saga, which source (snapshot or live table) does it read amount/quantity from?
- What happens if the amount from the PSP doesn't match the snapshot amount? Does it pass through automatically, or does it stop?
- Have you tested that the snapshot stays unchanged even if the basket table changes after the snapshot was created?
- Does the snapshot's currency field always match the currency actually sent to the PSP; if a conversion happens, where is it logged?
If you can't answer even one of these five questions clearly, your system likely has a silent amount-drift risk.
What to take away from this part
- The moment a payment intent is created, the basket's amount, line items, and currency should be frozen into a separate, immutable snapshot.
- The finalization saga should never fall back to the live basket at any step; every decision should reference the snapshot.
- The actual amount reported by the PSP should be checked against the expected amount in the snapshot; a mismatch should never auto-pass — it should go to review.
- Stock freshness checking is a separate responsibility and should not be conflated with the amount/currency decision.
The basket is a draft open to the future; the payment is a decision frozen in the past. Reading both from the same record makes two different moments in time look like one truth.
FAQ
Frequently asked questions
What is Payment Intent?
A record representing the customer's intent to pay, carrying the amount and currency that will be sent to the PSP.
What is Snapshot?
A frozen, unchanging copy of a fact (price, quantity, tax, discount) as it stood at one point in time.
Is it true that "Re-reading the basket during finalization means using “the freshest data”"?
Re-reading the basket during finalization conflates two different points in time
What does this part lock in?
This part explains why “falling back to the live basket” is a risk, not a shortcut, and how an immutable snapshot solves it. The moment a payment intent is created, the basket's amount, line items, and currency should be frozen into a separate, immutable snapshot. When a customer reaches checkout, the price, campaign, and stock information in the basket can still change: a promotion can expire, a price can be updated, the same customer can edit the basket in another tab. If you don't freeze this information the moment the payment intent is created, the amount sent to the PSP and the amount expected during finalization can end up representing two different truths.
Engineering Principles Learned
- The moment a payment intent is created, the basket's amount, line items, and currency must be frozen into an immutable snapshot.
- Finalization decisions should never fall back to the live basket; they should always reference the snapshot.
- A mismatch between the PSP's reported amount and the snapshot should never auto-pass — it should become a reviewable event.
Continue reading
Continue reading
Next in series
Idempotency Beyond API (Application Programming Interface) Requests: A Layered Defense
Idempotency isn't one header. It's a defense stack that has to be built separately across five layers, from the API key down to the step marker.
Next in series
Why Payment Capture Is Easy but Finalization Is Hard
The PSP taking the money is one step. Finishing the order is a saga that needs stock, finance, notifications, and cleanup to all succeed.
Same series
Webhook Reliability in Payment Systems
Webhooks repeat, disappear, arrive out of order, and show up late. Verify the signature, ACK fast, and never run the heavy work synchronously.