Skip to Content
MarketplaceCheckout Flow

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:

RuleWhy it matters
Single currencyAll items must use the same currency — we can’t mix USD and EUR in one payment
Single regionAll sellers must be in the same region (NA or EU) — they use separate Stripe accounts
Shipping coverageAll sellers must ship to the buyer’s country — no point checking out if it can’t be delivered
Item availabilityAll 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) + fixedFee

The 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:

ScenarioMinimumBehavior
No promo code100 cents (1.00)ORDER_TOTAL_TOO_LOW error returned
Promo code appliedNoneMinimum 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:

CurrencyStripe Minimum
USD50 cents ($0.50)
EUR50 cents (€0.50)
CAD50 cents (CA$0.50)
CHF50 centimes (CHF 0.50)
GBP30 pence (ÂŁ0.30)
SEK300 ore (3.00 SEK)
DKK250 ore (2.50 DKK)
NOK300 ore (3.00 NOK)
PLN200 groszy (2.00 PLN)
HUF175 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:

ParameterValue
modepayment — we capture immediately, not authorize-then-capture
line_itemsCart items converted to Stripe line items
shipping_optionsCombined shipping rates
payment_intent_data.capture_methodautomatic
metadataCart snapshot, seller breakdown, fee info
success_urlFrontend success page
cancel_urlFrontend 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:

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:

SnapshotPurpose
Cart snapshotItems, quantities, prices exactly as they were at checkout
Item price snapshotIndividual item prices stored in each order
Shipping breakdownPer-seller shipping amounts
Fee snapshotsBuyer 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:

ErrorCauseWhat to tell the user
CART_EMPTYNo items in cart”Your cart is empty”
ITEM_UNAVAILABLEInventory sold out, delisted, or not found”Some items are no longer available”
PRICE_CHANGEDItem price increased since it was added to cart”The price of an item has changed — please review your cart”
SELLER_CANNOT_SELLSeller’s Stripe Connect account cannot receive payments”A seller in your cart is unable to accept payments”
ORDER_TOTAL_TOO_LOWTotal below 100 cents (1.00) with no promo code”Minimum order amount is 1.00”
COUPON_INVALIDCoupon code failed validation (see Coupons for specific reasons)Varies by reason — “Invalid promo code”, “Promo code expired”, etc.
STRIPE_SESSION_FAILEDStripe API error when creating checkout session”Payment service error, please try again”
TRANSACTION_NOT_FOUNDTransaction number doesn’t exist (status polling)“Transaction not found”

Code Locations

ComponentLocation
Checkout Servicepackages/backend/features/checkout/src/checkout.service.ts
Checkout Errorspackages/backend/features/checkout/src/checkout.errors.ts
Fee Calculatorpackages/backend/features/checkout/src/fee-calculator.ts
Checkout Contractspackages/core/api-dtos/src/lib/marketplace/checkout/
Webhook Handlerpackages/backend/features/checkout/src/webhook.handler.ts
Cart Modelpackages/core/db/src/mongo/marketplace/cart.model.ts
Transaction Modelpackages/core/db/src/mongo/marketplace/transaction.model.ts
Shipping Ratespackages/backend/shipping-rates/
Last updated on