Settlement walkthrough
One winner, three holds, in integer cents. Capture, remainder release, voids and the runner-up fallback, step by step.
The idea in one paragraph
When the hammer falls, GAVEL has one winning price and several holds. It charges the winner the winning price, releases the part of the winner's hold that was not used, and releases every other hold. The settlement saga does this as a journaled state machine, so a crash in the middle cannot charge twice or forget a hold. This page walks one concrete lot through it, in whole cents (money in GAVEL is always integer cents).
The worked example
Three buyers each hold 200.00 (20000 cents). The engine ranks the bids and prices each candidate as if every earlier candidate were out:
| Candidate | Hold ceiling | Price at the hammer |
|---|---|---|
| A (winner) | 20000 | 12500 |
| B (runner-up) | 20000 | 11000 |
| C | 20000 | 10000 |
These are the numbers of the nightly sandbox spec R7. Use the buttons under the figure to switch between the normal sale and the case where the winner's capture is refused.
Settlement walkthrough: three holds, one winner, integer cents
Scroll the figure sideways, or open it full size.
Three holds. Buyers A, B and C each hold 200.00 (20000 cents). Hatched means held: nothing is charged. The auction is still BIDDING.
packages/payments/src/saga/plan.ts, capture.ts, fallback.ts, remainder.ts and void.ts. The amounts are those of nightly sandbox spec R7 in packages/e2e/sandbox/r5-r8.spec.ts (three holds of 20000, prices 12500, 11000, 10000; winner captured 12500; saga SETTLED; 0 differences against PayPal). That the 7500 remainder is released is inferred (gate G1 decision A), not observed. The fallback branch has run against the fake PayPal only.Text version of this diagram
Worked example, in cents. Three holds of 20000. Prices from the engine: A 12500, B 11000, C 10000.
- Three holds. Buyers A, B and C each hold 200.00 (20000 cents). Hatched means held: nothing is charged. The auction is still BIDDING.
- Hammer: the plan. The engine hands over its candidates in order, each priced as if every earlier candidate were out: A at 12500, B at 11000, C at 10000. A candidate with no hold, or a price above its own hold, is left out of the plan. The plan is saved to the journal. State: WINNER_SELECTED.
- Intent first. Before any PayPal call the saga saves the intent: capture, hold A, 12500, with a request id derived from those facts. Then it captures with final_capture true. Nothing has moved yet in the picture.
- Capture the winner. PayPal answers the capture: 12500 of the 20000 is now charged (solid). The 7500 left on A is still shown as held until the next step. State: CAPTURED.
- Check the remainder. The saga reads A again. With the final capture in place PayPal reports the authorization as CAPTURED, so the 7500 remainder counts as released and no void is sent. Release is inferred from that reading and from the G1 sandbox runs; no run read a buyer balance.
- Void everyone else. B and C are not the winner: their authorizations are voided, each under its own fixed request id. State: VOIDING. A void answered "already voided" or "expired" counts as released.
- Settled. Every hold is charged or released and the journal says SETTLED. The ledger balances: 12500 charged plus 47500 released is the 60000 that was held.
Variant: the winner's capture is refused for good.
- Three holds. Same start: A, B and C each hold 20000 cents. The auction is BIDDING.
- Hammer: the plan. Same plan: A at 12500, B at 11000, C at 10000, saved to the journal. State: WINNER_SELECTED.
- A capture is refused. The saga saves the intent for A and sends the capture. PayPal refuses it for good (a definite failure that names this hold, such as a declined or expired authorization). An unknown answer would not do this: it would wait.
- Truth check, then B at its own price. Before moving on, the saga reads A: no capture exists. It records the failure and moves to the next candidate, B, at B's own planned price of 11000, not at A's 12500 and not at B's ceiling. The capture succeeds. State: FALLBACK_RUNNER_UP, CAPTURING, CAPTURED.
- Check the remainder. B's remainder (9000) is checked the same way: closed by PayPal after the final capture, release inferred, no void sent.
- Void the others. A (a failed candidate) and C are released: voided, or already expired. State: VOIDING.
- Settled. The ledger balances: 11000 charged plus 49000 released is the 60000 that was held. This branch ran against the fake PayPal only.
What each step does
Plan
planFromOutcome turns the engine's hammer outcome into a plan: the candidates in the engine's order at the engine's prices, plus each bidder's hold. A candidate with no hold, or whose price is above its own hold's ceiling, cannot be charged and is left out, so the next candidate becomes the fallback instead of the whole plan being rejected. The plan is the first thing saved to the journal.
Intent, then capture
Before the capture call the saga saves the intent to the journal: capture, the hold, the amount and a request id. The capture is final_capture for the candidate's own price, with an invoice id. A retry or a resume sends the same request id, so PayPal answers it once. See Idempotency.
Remainder
After CAPTURED the saga reads the winner's authorization again. Gate G1 observed on the sandbox that after a final capture the authorization reads CAPTURED and the order COMPLETED, and that a later void answers 422 PREVIOUSLY_CAPTURED. So by default (decision A) the saga records the remainder as closed by PayPal and sends no void. If PayPal ever reports an open remainder, the saga voids it explicitly (decision B), and a void refused because the authorization is already captured, voided or expired counts as released.
Void the others
Every hold except the winner's is voided, sorted by bidder id, at most three at a time (voidConcurrency), each under its own fixed request id. A void answered "already voided" or "expired" counts as released. A void that keeps failing is retried on later runs; after five resumes (maxVoidResumes) the settlement goes to review.
Settled
When every hold is charged or released, the journal moves to SETTLED. The ledger balances: held cents equal charged plus released.
If the winner's capture is refused
Only a definite refusal that names this hold (declined, expired, already voided, not found) moves the settlement on. The saga first reads the hold: if a capture already exists it is adopted. If not, it records the failure and charges the next candidate at that candidate's own planned price, never at the failed winner's price and never at a runner-up's maximum bid. In the example, B is charged 11000, not 12500 and not 20000.
An unknown answer (a timeout or a 5xx) is not a refusal. The settlement stays in CAPTURING and waits; it never moves to the next bidder, because the capture may have happened. After ten minutes it goes to review. See Retries and unknown outcomes.
Prop
Type
What is verified, and what is not
Where this lives in the code
- packages/payments/src/saga/plan.tsplanFromOutcome and planWithSkipped
- packages/payments/src/saga/capture.tscapture phase: intent, capture, truth check, unknown handling
- packages/payments/src/saga/fallback.tsthe runner-up fallback
- packages/payments/src/saga/remainder.tsthe winner remainder policy (G1 decision A, B or C)
- packages/payments/src/saga/void.tsvoid and release phase
- packages/payments/src/saga/machine.tsthe transition table
- packages/e2e/sandbox/r5-r8.spec.tsR7: the three-hold lot on the sandbox
- docs/GATES.mdG1 method, evidence and decision
Last updated on