Checkout pipeline
The POST /api/v1/checkout request lifecycle — what runs before payment, what runs after, and the policies that gate each step
Overview
apps/web/app/api/v1/checkout/route.ts is the single entry point for placing an order. It charges Pagar.me and creates the database order. Every eligibility check runs before the gateway call — once the card is authorized, only fulfillment outcomes can fail the order, never eligibility. This is the standard state-machine pattern used by Vendure, Medusa, and Shopify: Draft → AddingItems → SettingShipping → SettingPayment → ArrangingPayment → PaymentAuthorized.
Pipeline
The request flows through nine ordered steps. Each one either returns an error or proceeds.
| # | Step | What it owns | Failure status |
|---|---|---|---|
| 1 | Bot/rate-limit guard | Block scripted traffic and abusive IPs | 429 |
| 2 | Session check | Authenticated user required | 401 |
| 3 | Delivery-amount sanity | Meetup orders can't have a fee; max R$ 500 | 400 |
| 4 | validateCheckoutRequest + validateBuyerProfile (parallel) | Items exist, are available, prices match (listed price or accepted offer). Buyer has CPF/name/phone | 400 / 404 / 409 / 422 |
| 5 | Seller ID match | The sellerId from the client matches the item's actual seller | 400 |
| 6 | checkSellerCanReceiveOrders | Seller has CPF + recipient ID (can_sell in JWT) | 422 |
| 7 | checkFulfillmentMethodAllowed | If buyer picked meetup or same_day, the item must allow it | 422 |
| 8 | calculateSplitPayment | Seller has a charge account with the checkout market's provider (resolveSellerChargeAccount); sum of splits equals order total | 422 |
| 9 | gateway.createTransaction (Pagar.me) | Card authorization, PIX QR generation, anti-fraud | 402 / 502 |
| 10 | createDatabaseOrder | Persist the order row + line items | 500 |
Steps 1–8 are pure validation. Step 9 is the only one that takes money. Step 10 has no eligibility logic — it trusts that the gates have already been cleared.
The before-charge invariant
A guard failure after gateway.createTransaction is a billing bug: the buyer's card was charged but no order exists in the database. Every eligibility predicate must run before step 9.
Concretely, do not add any of these inside createDatabaseOrder or any post-payment path:
- "Is the seller allowed to receive this order?"
- "Is this fulfillment method valid for this item?"
- "Are the items still available?"
- "Does the price match the listed price?"
If a new check is needed, it goes in the route handler between step 4 and step 9.
Fulfillment policy contract
Lives at packages/commerce/orders/policies/fulfillment-methods.policy.ts.
What it enforces:
shipping— always allowed. Route feasibility (does this CEP pair ship?) is the carrier API's job. By the time the buyer reaches checkout review they've already fetched a Melhor Envio quote; thefulfillments.providerServiceIdis the proof.meetup/same_day— allowed only ifitem.fulfillmentMethodsincludes"meetup". That flag is set by the seller'sallowPickuptoggle on the item form. For a multi-item bundle, the intersection rule applies: every item must allow meetup.
What it deliberately does NOT enforce:
- No metro / postal-code checks. Earlier versions blocked cross-metro meetup (e.g., SP buyer picking up from BH seller). That guarded a non-threat — buyer chose meetup voluntarily, so they accept the travel. Worst case: order fails at fulfillment and gets refunded. No money loss.
- No buyer address requirement. Meetup doesn't need a shipping address. We don't compute distance, eligibility based on saved addresses, or anything geographic at this layer.
- No carrier route validation. That's Melhor Envio's responsibility. If they couldn't quote the route, the buyer never reached the review step.
The policy exports three pure functions:
mapShippingTierToFulfillmentMethod(fulfillmentMethod)—"carrier" → "shipping","local" → "same_day","meetup" → "meetup". Unknown values default to"shipping".intersectDeliveryOptions(perItem)— bundle intersection. A bundle is meetup-eligible only if every item is.getAvailableFulfillmentMethods({ itemDeliveryOptions })— the rule. Returns the array of allowed methods for the items in this order.
The guard wrapper checkFulfillmentMethodAllowed(method, ctx) lives next door in pre-creation-guards.policy.ts and just wraps the above with a result type.
Where things live
The split between pure rules and orchestration matches the enforcement-layers pattern:
| Layer | Location | Owns |
|---|---|---|
| Pure rules | packages/commerce/orders/policies/ | getAvailableFulfillmentMethods, intersectDeliveryOptions, mapShippingTierToFulfillmentMethod |
| Orchestration | apps/web/app/api/v1/checkout/route.ts | Fetches items, calls rules, returns 422 on failure, sequences the pipeline |
| Persistence | packages/features/checkout/actions/create-checkout-order.action.ts | Maps the validated request to a DB row. No eligibility logic |
The policy file never reaches into the DB. The route handler does that and passes the data in. createDatabaseOrder doesn't validate eligibility — by the time it runs, the gates have already been cleared.
Validation surface for validateCheckoutRequest
packages/features/checkout/queries/validate-checkout.query.ts is the pre-payment validation hub. It owns:
- Items exist and are available for checkout (
isAvailableForCheckout(status)) - Item prices match the request (either listed price or an accepted offer the buyer owns)
- No items have a null price
- Buyer's customer email is present
It returns { success: true, amountCents, availableItems } on the happy path. availableItems carries id, status, name, price, sellerId, and fulfillmentMethods — the route handler uses fulfillmentMethods for step 7 without a second DB round trip.
Split-payment contract
Pagar.me requires that the sum of split amounts equals the order amount. The route enforces this implicitly by deriving everything from actualItemAmountCents (recomputed server-side from the items, never trusted from the client) plus the buyer-protection fee and delivery amount.
If the math is ever off, Pagar.me returns 422 with "The sum of the splitted amounts must be equal to the order amount". That error means a bug in calculateSplitPayment or in how deliveryAmountCents is being computed — never the buyer's fault.
When you're adding a new guard
- Decide: is this an eligibility check (runs before charge) or a fulfillment outcome (runs after)?
- If eligibility: add a pure function in
packages/commerce/orders/policies/, then a step inroute.tsbetween step 4 and step 9. - If fulfillment: add it to the order workflow under
packages/commerce/orders/workflows/. - Never put eligibility checks in
createDatabaseOrder. Never put them in webhook handlers.