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
| Stage | What it settles |
|---|---|
| Security, rate limit, session | Scripted traffic and anonymous requests never reach the service |
| Derive the market | From the account's country. There is no users.market column, and the client never says which market it is in |
marketMoneyGate | Are payments switched on for this market right now? |
| Resolve the checkout session | A completed session means the charge already happened — the retry gets the original result, not a second charge |
| Re-check the cart snapshot | The same check the loader ran. A cart that changed underneath the buyer sends them back |
| Requote the rate | The shipping price and route are re-fetched server-side; a changed route is a rejection, not a silent adjustment |
validateCheckoutRequest + validateBuyerProfile | Items exist and are buyable, prices match the listing or an accepted offer, the buyer's profile is complete. Run in parallel |
| Seller-id match | The sellerId the client sent is the listing's actual seller |
sellerEligibilityGate | checkSellerCanReceiveOrders — the same deriveEligibility every other gate uses |
| Fulfillment-method guard | A meetup or same_day choice is only valid if every listing in the bundle allows it |
| Money assembly | Discount allocation, buyer protection fee, installment interest, the split. All recomputed server-side |
| The durable saga | A Restate virtual object keyed by the checkout session creates the charge and the order |
| After | Vault 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:
shippingis 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.meetupandsame_dayneed 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
| Layer | Where | Owns |
|---|---|---|
| Transport | apps/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 |
| Orchestration | features/checkout/services/run-checkout.service.ts | The sequence above |
| Gates | features/checkout/services/checkout-gates.service.ts | The market and seller rejections |
| Pre-payment validation | features/checkout/queries/validate-checkout.query.ts | Availability, prices, offers |
| Pure rules | packages/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.