Checkout pipeline

What runs before the money moves, what runs after, and the invariant that separates them

Shape

POST /api/v1/checkout is transport only — security, rate limit, auth, JSON in and out. All of it is 77 lines. The work lives in packages/features/checkout/services/run-checkout.service.ts.

Everything that could reject the order runs before the provider is asked for money. Once a charge is authorized, only fulfillment outcomes can fail an order — never eligibility.

The order of operations

StageWhat it settles
Security, rate limit, sessionScripted traffic and anonymous requests never reach the service
Derive the marketFrom the account's country. There is no users.market column, and the client never says which market it is in
marketMoneyGateAre payments switched on for this market right now?
Resolve the checkout sessionA completed session means the charge already happened — the retry gets the original result, not a second charge
Re-check the cart snapshotThe same check the loader ran. A cart that changed underneath the buyer sends them back
Requote the rateThe shipping price and route are re-fetched server-side; a changed route is a rejection, not a silent adjustment
validateCheckoutRequest + validateBuyerProfileItems exist and are buyable, prices match the listing or an accepted offer, the buyer's profile is complete. Run in parallel
Seller-id matchThe sellerId the client sent is the listing's actual seller
sellerEligibilityGatecheckSellerCanReceiveOrders — the same deriveEligibility every other gate uses
Fulfillment-method guardA meetup or same_day choice is only valid if every listing in the bundle allows it
Money assemblyDiscount allocation, buyer protection fee, installment interest, the split. All recomputed server-side
The durable sagaA Restate virtual object keyed by the checkout session creates the charge and the order
AfterVault the card if asked, clear the purchased items from the cart, revalidate the caches

The before-charge invariant

A guard that fails after the charge is a billing bug: the buyer paid and no order exists.

So none of these may appear in the saga, in order creation, or in a webhook handler:

  • Is the seller allowed to receive this order?
  • Is this fulfillment method valid for these listings?
  • Are the listings still available?
  • Does the price match?

A new eligibility check goes into the service, before the saga. Nowhere else.

Idempotency is the session

The checkout session is the idempotency key. A session already claimed by an attempt is neither rejected nor charged again — the caller attaches to the attempt already running. That is why the charge carries no separate idempotency key of its own: the durable saga owns it.

Fulfillment-method rules

The pure rules live in packages/commerce/fulfillments/ (fulfillment.rules.ts, fulfillment.methods.ts); checkout-gates.service.ts wraps them with a rejection type.

Enforced:

  • shipping is always allowed. Whether a route is actually serviceable is the gateway's answer, and the buyer already has a quote by the time they reach review.
  • meetup and same_day need the listing to allow them. For a bundle, the intersection rule applies: every listing must allow it.

Deliberately not enforced:

  • No distance or postal-code check on meetup. A buyer who picks meetup has accepted the travel. Blocking it guarded a non-threat; the worst case is a failed fulfillment and a refund.
  • No buyer address requirement for meetup. There is nothing to ship.
  • No carrier route validation. If the gateway could not quote it, the buyer never got to review.

Where things live

LayerWhereOwns
Transportapps/web/app/api/v1/checkout/route.ts (the browser), apps/api/app/api/v1/checkout/[sessionId]/pay/route.ts (the app, on cute-api)Security, rate limit, auth, JSON
Orchestrationfeatures/checkout/services/run-checkout.service.tsThe sequence above
Gatesfeatures/checkout/services/checkout-gates.service.tsThe market and seller rejections
Pre-payment validationfeatures/checkout/queries/validate-checkout.query.tsAvailability, prices, offers
Pure rulespackages/commerce/fulfillments/, packages/commerce/pricing/Methods, fees, splits

The rule modules never touch the database. The service reads and passes the data in.

The split has to add up

Every provider that splits a charge requires the parts to equal the whole. The service derives all of it from the server-recomputed item total plus the protection fee and delivery amount — never from client numbers.

If a provider ever rejects the split for not summing, that is a bug in the fee or delivery math, never the buyer's doing.