payments
PayPal holds, the settlement saga, webhooks, reconciliation and the fake PayPal.
@gavel/payments (packages/payments) is everything that talks to PayPal, and the fake PayPal that stands in for it in tests. Its one runtime dependency is zod; it uses node:crypto and node:fs itself. PayPal is the sandbox only: loadConfig throws payments_not_sandbox unless PAYPAL_ENV is exactly sandbox, and guard.ts refuses any host other than the sandbox API and approve hosts (loopback only in fake mode).
Responsibilities
- A gateway to PayPal.
PaymentsGateway(ports.ts) hascreateOrder,getOrder,authorizeOrder,getHoldTruth,capture,voidAuthorizationandreauthorize. Its comment states the rule the saga builds on: aPayPalErrorwith outcomedefinitemeans the request provably did not take effect; outcomeunknownmeans it may have, and the caller must read the truth first. - Transport with retry.
transport.tsretries network errors and the statuses 429, 500, 502, 503 and 504; every attempt of one logical request carries the same request id. The per-attempt timeout defaults to 10 seconds. - Holds.
HoldService(holds/service.ts) creates a bidder's hold order, completes the approval and returns the same order for a double click or a second tab, because calls for one auction and bidder run one after the other. - The settlement saga. A pure state machine (
saga/machine.ts) driven by runners for create, capture, fallback, unknown outcome, void, remainder, reauthorization and the kill switch. Each transition is saved to a journal before the effect runs. - Idempotency.
buildRequestId(requestId.ts) derivesgv1-plus a hash of auction id, operation (order,authorize,capture,void,reauth), hold id, amount in cents and a generation. The generation changes only when parameters change, never on retry or resume. - Webhooks.
webhooks/verify.tschecks the signature with PayPal's own verification call on the original bytes;dedupe.tskeys events by PayPal's event id;merge.tsandorphans.tsmake the final hold status independent of delivery order. - Reconciliation.
reconcile/reconcile.tscompares journal and hold records with what PayPal reports and returns findings (AMOUNT_MISMATCH,EXTRA_CAPTURE,LIVE_HOLD_AFTER_SETTLED,MISSING_CAPTURE,REFUNDED_CAPTURE,STATUS_CONFLICT,STATUS_DRIFT,UNOBSERVABLE). It only reads.repair.tsis the separate step that acts. - Bridge to the engine.
bridge/settlement-bridge.tsturns saga progress into engine commands (PromoteRunnerUp,RecordSettlement,CompleteLot,CloseAuction), reading the engine first so a retry never sends a command twice. - Kill switch and stores.
killswitch.tspersists the engaged state and wraps a gateway so money-moving calls throwkill_switch_engagedbefore the network; voids and reads pass.stores/holds the in-memory stores and JSON-file stores written with fsync, rename and directory fsync. - The fake PayPal.
src/fake/is an HTTP server and in-process gateway with fault injection, idempotency and webhooks.src/fake/ASSUMPTIONS.mdlists what the fake assumes about PayPal without having observed it in the sandbox, each row naming the gate that confirms or corrects it.
Public interface
package.json exports three entries.
| Entry | Points at | Use |
|---|---|---|
@gavel/payments | src/index.ts | gateway, holds, saga, webhooks, stores, bridge |
@gavel/payments/fake | src/fake/server.ts | the fake PayPal server |
@gavel/payments/contract/web | src/contract/web.ts | type-only re-export of the hold route contract that lives in @gavel/shared |
Prop
Type
The names with SAGA_DEFAULTS_ stand for the fields of SAGA_DEFAULTS in src/config.ts. Environment names read by loadConfig are PAYPAL_ENV, PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_WEBHOOK_ID, PAYPAL_BASE_URL, GAVEL_MAX_CEILING_CENTS and GAVEL_HOLD_MAX_AGE_MS. See configuration.
Key files
| File | Note |
|---|---|
saga/machine.ts | step(journal, event) is pure: no I/O, no clock, no randomness; returns a new journal and the effects to run after it is saved |
saga/unknown.ts | an unknown capture stays CAPTURING, never moves to the next bidder |
saga/fallback.ts | after a definite failure, read the truth first, then move to the next candidate at that candidate's own price |
saga/remainder.ts | what to do with the winner's unused authorization, by the G1 remainderMode |
saga/reauth.ts | reauthorization is an explicit call only; the saga never calls it |
webhooks/verify.ts | the five transmission headers, your webhook id and the event, spliced as original bytes |
holds/recover.ts | brings hold records back after a crash |
gates/*.ts | the sandbox gate scripts, run with the gate:* scripts in package.json |
Invariants and the tests that guard them
The same seed gives byte-identical journals; each transition is one compare-and-set save before the next effect. Tests: test/saga/determinism.test.ts, test/saga/machine.test.ts, test/saga/property.test.ts, test/saga/crash.test.ts, test/saga/crashChain.test.ts, test/holds/crashWindow.test.ts.
Timeouts and 5xx on a POST leave the settlement CAPTURING with an unknown clock (a 429 is not unknown: PayPal refused to run the request, so no clock starts, see the header of saga/unknown.ts); the truth is read before acting; after the deadline it goes to review. Tests: test/saga/unknown.test.ts, test/saga/rateLimited.test.ts, test/timeout.test.ts, test/saga/capture.test.ts.
Tests: test/saga/fallback.test.ts, test/saga/hrd04.test.ts, test/saga/hrd46.test.ts.
Request ids do not change on retry or resume. Tests: test/requestId.test.ts, test/transport.test.ts, test/fake/idempotency.test.ts, test/gates/idempotency.test.ts.
Tests: test/saga/void.test.ts, test/saga/remainder.test.ts, test/saga/reviewRelease.test.ts, test/saga/capture.test.ts.
Failed verification answers 401 and leaves an audit row; PayPal unreachable answers 503 so it redelivers; a duplicate event id has no effect and still answers 200; an early event is parked for 10 minutes. Tests: test/webhooks/verify.test.ts, test/webhooks/dedupe.test.ts, test/webhooks/order.test.ts, test/webhooks/redelivery.test.ts, test/webhooks/merge.test.ts, test/webhooks/budget.test.ts, test/webhooks/fast.test.ts.
Tests: test/config.test.ts, test/guard.test.ts, test/security.test.ts, test/killswitch.test.ts, test/saga/killswitch.test.ts.
Tests: test/ceiling.test.ts, test/holds/service.test.ts, test/holds/hrd07Races.test.ts, test/holds/abandonHold.test.ts, test/holds/abandonRace.test.ts.
test/conformance/run.test.ts is the conformance suite that can run against the fake and the sandbox; differences are appended to src/fake/ASSUMPTIONS.md when CONFORMANCE_ASSUMPTIONS is set. Tests of the fake itself: test/fake/*.test.ts.
Gates in this package
The gate scripts live in packages/payments/gates/ and run against the PayPal sandbox when invoked with credentials. Their status is read from docs/GATES.md at build time.
| Script | Gate | Status |
|---|---|---|
gate:g0 | G0 | G0 PASS |
gate:g1 | G1 | G1 PASS |
gate:g2 | G2 | G2 PASS |
gate:g3 | G3 | G3 IN PROGRESS |
gate:g4 | G4 | G4 PASS |
Saga behaviour is verified against the fake PayPal; the sandbox gates cover single calls. Read gates for the method and limits of each, and simulation for the multi-day run, which is simulated (test/simdays/).
Where it sits
payments may import shared and engine (ALLOWED.payments in .dependency-cruiser.cjs). In src it imports @gavel/shared types only (contract/web.ts, erased at run time) and nothing from the engine: the saga reads the engine's hammer result through the structural type PlanOutcome. server imports payments and wires it in boot/compose.ts; web does not import it. See Settlement saga, Money flow and Webhooks.
- packages/payments/src/index.tspublic exports
- packages/payments/src/saga/machine.tsthe transition table and step()
- packages/payments/src/requestId.tsdeterministic request ids
- packages/payments/src/webhooks/handler.tsthe webhook route handler
- packages/payments/src/transport.tsretry rules
- packages/payments/src/fake/ASSUMPTIONS.mdwhat the fake assumes
- packages/payments/gatessandbox gate scripts
Last updated on