Skip to content

PayPal sandbox only. No real money moves.

LearnHow it works

Settlement walkthrough

One winner, three holds, in integer cents. Capture, remainder release, voids and the runner-up fallback, step by step.

Code-checkedread against the code it describes; nothing was run

Verified againstWorked example read against the saga and ledger code (PR #1954). Inferred, simulated and fake-only parts are marked on the page.

Page last changed 7 Nov 2026 (from the git history)

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:

CandidateHold ceilingPrice at the hammer
A (winner)2000012500
B (runner-up)2000011000
C2000010000

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

Sale path: sandbox R7Fallback: fake PayPal
Settlement walkthrough: three holds, one winner, integer centsThree horizontal bars show the holds of buyers A, B and C, each 20000 cents. The saga plans the settlement, captures the winner at the hammer price, checks the winner's remainder, voids the other holds and ends SETTLED. A second scenario shows the winner's capture refused for good, the runner-up charged at its own price, and the others released. A ledger shows held, charged and released cents that always add up to 60000.Three holds of 200.00, shown in centsbar length is proportional to cents; the dashed line marks each candidate's planned priceAhold 20000held 20000Bhold 20000held 20000Chold 20000held 20000Saga stateBIDDINGLedger (cents)held60000charged0released0total60000Journal: each line is one compare-and-set save, written before the next effect1plan saved: candidates A 12500, B 11000, C 10000; holds A, B, C of 20000 each2intent saved before the call: capture, hold A, 12500, request id gv1-...3capture_ok: CAPTURED (intent cleared, capture id kept)4remainder:A recorded remainder_closed_by_paypal (release inferred, no void sent)5voids: B voided, C voided (sorted by bidder id, three at a time at most)6all_released: SETTLEDheldchargedreleasedneeds review

Scroll the figure sideways, or open it full size.

Scenario
Step 1 of 7. Arrow keys work too.

Three holds. Buyers A, B and C each hold 200.00 (20000 cents). Hatched means held: nothing is charged. The auction is still BIDDING.

Plan, capture, fallback, remainder and void behaviour are read from 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.

  1. Three holds. Buyers A, B and C each hold 200.00 (20000 cents). Hatched means held: nothing is charged. The auction is still BIDDING.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.

  1. Three holds. Same start: A, B and C each hold 20000 cents. The auction is BIDDING.
  2. Hammer: the plan. Same plan: A at 12500, B at 11000, C at 10000, saved to the journal. State: WINNER_SELECTED.
  3. 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.
  4. 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.
  5. Check the remainder. B's remainder (9000) is checked the same way: closed by PayPal after the final capture, release inferred, no void sent.
  6. Void the others. A (a failed candidate) and C are released: voided, or already expired. State: VOIDING.
  7. 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

Edit on GitHub

Last updated on