AFP engineering series · Chapter 16 of 25
Protect refund workflows from persuasive claims, concurrent requests, changed destinations, and uncertain processor outcomes.
The ledger is the final authority
A support assistant receives two plausible refund requests for the same order. Each asks for less than the original purchase amount. If two workers independently read the remaining balance and then submit refunds, both can appear valid while their combined value exceeds the available amount. No jailbreak is required; ordinary concurrency can create the same loss as deliberate abuse.
Define a ledger invariant: completed refunds plus active reservations must not exceed the eligible amount for an order, within the applicable currency and policy. The assistant can collect evidence and prepare a proposal, but the service owning this ledger must enforce the invariant atomically. A prompt instruction cannot serialize database writes.
Use precise amounts and canonical destinations
Represent money in the currency's defined minor units or another exact decimal representation. Do not assume every currency uses two decimal places. Reject negative, zero, non-finite, or ambiguously parsed amounts according to policy. Keep currency explicit and do not compare amounts across currencies without an approved conversion process.
Resolve refund destinations from the original transaction or a separate verified destination-change workflow. The model should not provide arbitrary bank details to the execution endpoint. An identifier such as original_payment_method is meaningful only when the backend resolves it against the specific order and authenticated customer.
Reserve before contacting the processor
Illustrative python; adapt the persistence and identity adapters to your service.
# Transactional pseudocode: the database adapter must enforce the lock.
with database.transaction() as tx:
order = tx.lock_order(order_id)
tx.require_owned_by(order, principal)
tx.require_matching_approval(proposal_id, approval_id)
remaining = order.eligible_minor - tx.committed_and_reserved(order_id)
if amount_minor > remaining:
raise ValueError("insufficient refundable balance")
tx.reserve(operation_id, order_id, amount_minor, currency)
# Submit using the same operation_id; reconcile uncertain outcomes.The transaction boundary is the important part of this example. The named database methods are application operations, not a particular ORM API. Use a row lock, serializable transaction, compare-and-swap, or another mechanism whose behavior you have verified under concurrency. A preflight query followed by an unrelated insert does not provide the same guarantee.
Reservations need lifecycle rules. A known processor rejection can release one; an uncertain timeout cannot safely be treated the same way. Expiry alone is dangerous if the processor may already have accepted the payment. Reconciliation must establish the external outcome before making the amount available again.
Distinguish request identity from action identity
A transport retry should carry the same operation identifier as the original attempt. A genuinely new refund proposal needs a new identity and a fresh eligibility decision. If clients can choose a new idempotency key on every retry, the application still needs ledger constraints to prevent duplicate business effects.
Stripe's idempotency documentation illustrates how a processor can return a stored result for repeated requests using a key. Provider semantics vary, including retention and parameter matching. Read the contract of the processor you use, and keep a local operation record that links request, approval, reservation, processor identifier, and final outcome.
Model uncertainty as a real state
| State | Meaning | Permitted next step |
|---|---|---|
| Prepared | Proposal exists, no money reserved | Review or reject |
| Reserved | Balance held for this operation | Submit once |
| Uncertain | Processor outcome unknown | Query and reconcile |
| Committed | Processor success confirmed | Show authoritative result |
| Rejected | Known failure with no effect | Release reservation according to policy |
Avoid a generic error state that encourages the assistant to start again. An uncertain operation needs investigation or a status query, not a fresh payment request. The UI should explain that processing is pending and provide a reference identifier without asserting success or failure prematurely.
Record state transitions as append-only events or an equivalent auditable history. A mutable status field alone can hide how an operation moved from uncertain to committed. Include actor, timestamp, previous version, and evidence for the transition.
Test adversarial and ordinary failure cases together
Use a sandbox processor that can accept a request and then drop the response. Test repeated delivery, concurrent different operation identifiers, changed amounts after approval, mismatched currency, another tenant's order, and a revoked approver. Assert both the service response and the final ledger totals.
For the race test, synchronize two workers so both attempt reservation at the same time. A sequential unit test will not exercise the vulnerability. The expected result is that the combined committed and reserved amount remains within the limit, even if one request must wait or fail. Add a recovery test that restarts the worker with an uncertain operation already persisted.
Worked race: two valid proposals exceed one balance
An order has 100 eligible units remaining. Two independently approved proposals each request 70 units. Both are valid in isolation against the original balance, but they cannot both execute. A test barrier pauses two workers immediately after they read the balance, then releases them together. A non-atomic implementation can commit 140 units.
Move the check and reservation into one transaction. The first worker reserves 70. The second then sees only 30 available and fails eligibility. Which worker wins is not important; the invariant is. Assert that completed plus reserved never exceeds 100 and that the rejected operation has no external processor submission.
Now simulate the first processor call accepting the payment and returning a timeout. Keep the reservation while the operation is uncertain. If the service releases it immediately, the second proposal can execute and create an excess refund once the first outcome is discovered. Reconcile the first operation using its stable identity before releasing or committing the reservation.
Repeat the same operation identifier twice to test idempotency, then use two different identifiers to test the ledger constraint. These are different cases. A processor's idempotency key can deduplicate identical requests but cannot know that two distinct requests violate the retailer's shared balance policy.
Finally, change the destination after approval and verify that the proposal digest no longer matches. The balance invariant does not protect destination integrity, so both controls are needed. This exercise produces three separate guarantees: the approved action is unchanged, retries do not duplicate it, and concurrent distinct actions cannot exceed the eligible balance. The assistant can explain the result, but the ledger and approval services prove it.
Use the assistant where it adds value
The assistant can explain eligibility, identify missing evidence, summarize a case for review, and communicate authoritative status. It should not invent a refund amount, waive policy because a story is compelling, or choose how to resolve an uncertain processor outcome. Those decisions require evidence and service semantics.
The refund lab compares approval binding, expiry, tenant checks, and idempotency using a synthetic ledger. It does not model a real payment network. Use it to understand the independent controls, then validate the same invariants against your actual database and processor before enabling automated money movement.
Practice and continue
Open the interactive companion lab to inspect the assumptions and outcomes with synthetic data. Then return to the 25-chapter AFP learning path.
Sources and implementation scope
Primary references checked October 15, 2026. Framework details follow the linked documentation; the Northstar cases, policy contracts, and test designs are original illustrative designs. Python snippets labeled illustrative are not complete production integrations. ADK package metadata checked for this series reported 2.12.0; pin and test the version used by your application.