Checkout Flow
Checkout is where shopping carts become real orders. This page walks through how we transform a cart full of items into a paid transaction with orders ready for sellers to fulfill.
We use Stripe Checkout for the payment flow — this means we redirect users to Stripe’s hosted payment page, then handle webhooks when the payment succeeds.
The Big Picture
Before we dive into details, here’s what checkout looks like end-to-end:
Cart Validation
Before we can create a checkout session, the cart needs to pass several validation checks. Think of this as a “pre-flight check” that catches problems before we involve Stripe.
Items are not reserved when added to cart. This means another buyer could purchase the same item while you’re browsing. If an item sells out between adding it to cart and checking out, the buyer will see an “items unavailable” error.
Cart Constraints
These rules are enforced throughout the cart lifecycle, not just at checkout:
| Rule | Why it matters |
|---|---|
| Single currency | All items must use the same currency — we can’t mix USD and EUR in one payment |
| Single region | All sellers must be in the same region (NA or EU) — they use separate Stripe accounts |
| Shipping coverage | All sellers must ship to the buyer’s country — no point checking out if it can’t be delivered |
| Item availability | All inventory lines must still be available — items can sell while in your cart |
How Shipping is Calculated
Shipping isn’t a flat fee — it’s calculated per-seller based on what they’re charging and where the buyer is located. Here’s how we figure it out:
Determine Shipping Zone
Based on buyer’s country relative to seller’s country: domestic, EU/regional, or international
Calculate Package Size Per Item
Size depends on product type:
- Cards: xsmall (1-5 cards), small (6-50 cards), medium (51-250 cards), large (251+ cards)
- Sealed products: Based on category — cases are xlarge, boxes/bundles are large, boosters/packs are medium
Find Maximum Size Per Seller
The largest package size among all items from one seller determines their shipping rate
Look Up Rate
Each seller has configured rates for each zone + size combination
Sum Per-Seller Shipping
Each seller’s shipping is tracked separately (stored in shippingBreakdown on the transaction)
Buyer Fees
On top of the item prices and shipping, buyers pay a service fee. This is calculated as:
buyerFee = (subtotal Ă— feePercentage) + fixedFeeThe exact percentages and fixed amounts are configured per region/currency. This fee goes to the platform, separate from seller fees.
Order Guardrails
After calculating the total, the checkout service applies two payment guardrails before creating the Stripe session. These prevent tiny or Stripe-incompatible charges.
Minimum Order Amount
Orders below 100 cents (1.00 in any currency) are rejected at checkout — unless a promo code is applied:
| Scenario | Minimum | Behavior |
|---|---|---|
| No promo code | 100 cents (1.00) | ORDER_TOTAL_TOO_LOW error returned |
| Promo code applied | None | Minimum is bypassed to allow promotional free/cheap orders |
The check uses the full buyer total (items + shipping + buyer fee) before any coupon discount:
if (!couponCode && subtotalWithShipping + buyerFee.total < MINIMUM_ORDER_AMOUNT) {
return err(CheckoutErr.orderTotalTooLow(MINIMUM_ORDER_AMOUNT, cart.currency))
}The 100-cent minimum is a platform policy, not a Stripe requirement. It prevents micro-orders that would be unprofitable after payment processing fees. When a promo code is applied, we intentionally bypass this to support marketing campaigns like “first order free”.
Stripe Minimum Charge Auto-Absorb
Stripe rejects charges below a per-currency minimum with an amount_too_small error. When a coupon brings the payable total into the danger zone (between 1 cent and the Stripe minimum), we auto-absorb the remainder by increasing the discount to cover the full order:
| Currency | Stripe Minimum |
|---|---|
| USD | 50 cents ($0.50) |
| EUR | 50 cents (€0.50) |
| CAD | 50 cents (CA$0.50) |
| CHF | 50 centimes (CHF 0.50) |
| GBP | 30 pence (ÂŁ0.30) |
| SEK | 300 ore (3.00 SEK) |
| DKK | 250 ore (2.50 DKK) |
| NOK | 300 ore (3.00 NOK) |
| PLN | 200 groszy (2.00 PLN) |
| HUF | 175 forint (175 HUF) |
Here’s the logic in the checkout service:
const remainder = stripeTotal - cappedDiscount
const stripeMin = STRIPE_MINIMUM_CHARGE[cart.currency] ?? 50
if (remainder > 0 && remainder < stripeMin) {
cappedDiscount = stripeTotal // Absorb the entire remaining amount
}The auto-absorbed discount is persisted to the database, not just used for Stripe. The couponDiscountAmount stored on the transaction reflects the increased amount, so the DB always matches what Stripe actually charged (or didn’t charge).
Example: A buyer has a cart totaling 200 cents with a 180-cent coupon applied. The remainder is 20 cents, which is below the USD minimum of 50 cents. Instead of failing at Stripe, the discount is bumped from 180 to 200, making the order fully covered by the coupon.
Creating the Stripe Session
Once validation passes and we’ve calculated all the amounts, we create a Stripe Checkout Session. Here’s what we send:
| Parameter | Value |
|---|---|
mode | payment — we capture immediately, not authorize-then-capture |
line_items | Cart items converted to Stripe line items |
shipping_options | Combined shipping rates |
payment_intent_data.capture_method | automatic |
metadata | Cart snapshot, seller breakdown, fee info |
success_url | Frontend success page |
cancel_url | Frontend cart page |
We store a complete snapshot of the cart in the session metadata. This is critical — if prices change between session creation and payment, we use the snapshotted prices.
Webhook Processing
The real magic happens in the webhook handler. When Stripe sends checkout.session.completed:
Validate the Webhook
Check the signature to ensure it’s really from Stripe
Check Idempotency
If we’ve already processed this session, return success (webhooks can be retried)
Start Database Transaction
Everything that follows happens atomically — all or nothing
Create Transaction Document
Record the payment with all the amounts and Stripe IDs
Create Orders
One order per seller, grouped from the cart items
Update Inventory
Mark all purchased items as sold (removes them from listings)
Clear Cart
Remove the purchased items from the buyer’s cart
Commit Transaction
If everything succeeded, make it permanent
Send Notifications
Notify sellers of their new orders
The entire webhook operation runs in a MongoDB transaction. If any step fails — creating an order, updating inventory, clearing the cart — everything is rolled back. We never want to be in a state where money was charged but orders weren’t created.
Transaction States
Transactions have their own lifecycle, separate from orders:
Happy Path
Most transactions go straight from pending → processing → succeeded. The buyer completes payment, orders are created, everyone’s happy.
Data Snapshots
We “freeze” several pieces of data at checkout time. This protects both buyers and sellers from price changes:
| Snapshot | Purpose |
|---|---|
| Cart snapshot | Items, quantities, prices exactly as they were at checkout |
| Item price snapshot | Individual item prices stored in each order |
| Shipping breakdown | Per-seller shipping amounts |
| Fee snapshots | Buyer fee and seller fee percentages |
If you’re debugging a price discrepancy, always check the snapshotted values on the transaction/order, not the current prices on the inventory or listings.
Polling for Status
After the buyer is redirected back from Stripe, the frontend needs to know if the payment succeeded. It polls the /checkout/status endpoint:
// Frontend polling pattern
const pollStatus = async (transactionId: string) => {
const status = await api.checkout.getStatus({ transactionId })
switch (status) {
case 'succeeded':
// Navigate to order confirmation
break
case 'pending':
case 'processing':
// Continue polling
setTimeout(() => pollStatus(transactionId), 1000)
break
case 'failed':
case 'expired':
// Show error
break
}
}There’s a brief window between Stripe completing the payment and our webhook processing it. Polling handles this gracefully.
Error Handling
Here’s what can go wrong and what the buyer sees:
| Error | Cause | What to tell the user |
|---|---|---|
CART_EMPTY | No items in cart | ”Your cart is empty” |
ITEM_UNAVAILABLE | Inventory sold out, delisted, or not found | ”Some items are no longer available” |
PRICE_CHANGED | Item price increased since it was added to cart | ”The price of an item has changed — please review your cart” |
SELLER_CANNOT_SELL | Seller’s Stripe Connect account cannot receive payments | ”A seller in your cart is unable to accept payments” |
ORDER_TOTAL_TOO_LOW | Total below 100 cents (1.00) with no promo code | ”Minimum order amount is 1.00” |
COUPON_INVALID | Coupon code failed validation (see Coupons for specific reasons) | Varies by reason — “Invalid promo code”, “Promo code expired”, etc. |
STRIPE_SESSION_FAILED | Stripe API error when creating checkout session | ”Payment service error, please try again” |
TRANSACTION_NOT_FOUND | Transaction number doesn’t exist (status polling) | “Transaction not found” |
Code Locations
| Component | Location |
|---|---|
| Checkout Service | packages/backend/features/checkout/src/checkout.service.ts |
| Checkout Errors | packages/backend/features/checkout/src/checkout.errors.ts |
| Fee Calculator | packages/backend/features/checkout/src/fee-calculator.ts |
| Checkout Contracts | packages/core/api-dtos/src/lib/marketplace/checkout/ |
| Webhook Handler | packages/backend/features/checkout/src/webhook.handler.ts |
| Cart Model | packages/core/db/src/mongo/marketplace/cart.model.ts |
| Transaction Model | packages/core/db/src/mongo/marketplace/transaction.model.ts |
| Shipping Rates | packages/backend/shipping-rates/ |