Skip to content

PayPal sandbox only. No real money moves.

BuildersBuildersPackages

payments

PayPal holds, the settlement saga, webhooks, reconciliation and the fake PayPal.

Verifiedbacked by a real run or committed evidence

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

@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) has createOrder, getOrder, authorizeOrder, getHoldTruth, capture, voidAuthorization and reauthorize. Its comment states the rule the saga builds on: a PayPalError with outcome definite means the request provably did not take effect; outcome unknown means it may have, and the caller must read the truth first.
  • Transport with retry. transport.ts retries 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) derives gv1- 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.ts checks the signature with PayPal's own verification call on the original bytes; dedupe.ts keys events by PayPal's event id; merge.ts and orphans.ts make the final hold status independent of delivery order.
  • Reconciliation. reconcile/reconcile.ts compares 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.ts is the separate step that acts.
  • Bridge to the engine. bridge/settlement-bridge.ts turns 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.ts persists the engaged state and wraps a gateway so money-moving calls throw kill_switch_engaged before 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.md lists 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.

EntryPoints atUse
@gavel/paymentssrc/index.tsgateway, holds, saga, webhooks, stores, bridge
@gavel/payments/fakesrc/fake/server.tsthe fake PayPal server
@gavel/payments/contract/websrc/contract/web.tstype-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

transport.ts
requestId.ts
guard.ts
killswitch.ts
config.ts
FileNote
saga/machine.tsstep(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.tsan unknown capture stays CAPTURING, never moves to the next bidder
saga/fallback.tsafter a definite failure, read the truth first, then move to the next candidate at that candidate's own price
saga/remainder.tswhat to do with the winner's unused authorization, by the G1 remainderMode
saga/reauth.tsreauthorization is an explicit call only; the saga never calls it
webhooks/verify.tsthe five transmission headers, your webhook id and the event, spliced as original bytes
holds/recover.tsbrings hold records back after a crash
gates/*.tsthe sandbox gate scripts, run with the gate:* scripts in package.json

Invariants and the tests that guard them

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.

ScriptGateStatus
gate:g0G0G0 PASS
gate:g1G1G1 PASS
gate:g2G2G2 PASS
gate:g3G3G3 IN PROGRESS
gate:g4G4G4 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.

Edit on GitHub

Last updated on