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.

#StepWhat it ownsFailure status
1Bot/rate-limit guardBlock scripted traffic and abusive IPs429
2Session checkAuthenticated user required401
3Delivery-amount sanityMeetup orders can't have a fee; max R$ 500400
4validateCheckoutRequest + validateBuyerProfile (parallel)Items exist, are available, prices match (listed price or accepted offer). Buyer has CPF/name/phone400 / 404 / 409 / 422
5Seller ID matchThe sellerId from the client matches the item's actual seller400
6checkSellerCanReceiveOrdersSeller has CPF + recipient ID (can_sell in JWT)422
7checkFulfillmentMethodAllowedIf buyer picked meetup or same_day, the item must allow it422
8calculateSplitPaymentSeller has a charge account with the checkout market's provider (resolveSellerChargeAccount); sum of splits equals order total422
9gateway.createTransaction (Pagar.me)Card authorization, PIX QR generation, anti-fraud402 / 502
10createDatabaseOrderPersist the order row + line items500

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; the fulfillments.providerServiceId is the proof.
  • meetup / same_day — allowed only if item.fulfillmentMethods includes "meetup". That flag is set by the seller's allowPickup toggle 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:

LayerLocationOwns
Pure rulespackages/commerce/orders/policies/getAvailableFulfillmentMethods, intersectDeliveryOptions, mapShippingTierToFulfillmentMethod
Orchestrationapps/web/app/api/v1/checkout/route.tsFetches items, calls rules, returns 422 on failure, sequences the pipeline
Persistencepackages/features/checkout/actions/create-checkout-order.action.tsMaps 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

  1. Decide: is this an eligibility check (runs before charge) or a fulfillment outcome (runs after)?
  2. If eligibility: add a pure function in packages/commerce/orders/policies/, then a step in route.ts between step 4 and step 9.
  3. If fulfillment: add it to the order workflow under packages/commerce/orders/workflows/.
  4. Never put eligibility checks in createDatabaseOrder. Never put them in webhook handlers.