Skip to content

Booking and checkout

This is the highest-risk path in the product. Two production incidents have come out of it. Before you touch create-payment-intent, the Stripe webhook, reservation status handling or the amount check, read docs/booking-payments/architecture.md and decisions.md in the repo.

  1. Hold. Adding an item calls createHold, which takes pg_advisory_xact_lock(hashtext(id)) and inserts the reservation in the same transaction. Reservations start as held with an expiry.
  2. Checkout. POST /api/create-payment-intent recomputes the total from the held reservations — the client total is never trusted — then creates or reuses the PaymentIntent and claims the cart.
  3. Webhook. payment_intent.succeeded turns the PaymentIntent into a booking. payments.stripe_payment_intent_id is unique, because Stripe redelivers.
  4. Cleanup. The cleanup-expired cron expires stale holds and sends the abandonment email.

Three invariants hold it together:

  • One cart is fulfilled by exactly one PaymentIntent. Anything else is real money — record it, but never create a second booking.
  • A succeeded PaymentIntent always leaves a row: a booking with a payment, or an orphan payment flagged for review. Never nothing.
  • The webhook never throws on a structural problem. A 500 makes Stripe retry for three days against a condition that will not resolve.
Item Window
Signing slot 15 minutes
Entry ticket 15 minutes
Comic cover 15 minutes
Booth 60 minutes (45 once payment starts)

time_slots.slot_kind has several values but only signing is bookablecreateHold refuses the rest.

bookings.status has six values, not three. Revenue and attendance queries must count confirmed and partially_paid; partially_paid is a real, common state (deposit taken, balance outstanding), not an error state. pending, cancelled and refunded are the ones you will meet next.

Presale is a per-package flag (is_presale) and splits the amount 50/50 through calculatePresaleAmounts. Flipping it on a live package mid-checkout causes an amount mismatch on every in-flight PaymentIntent — do it between events.

Vendors pay through /checkout/booth/[applicationId], which follows the same PaymentIntent rules with the longer hold window. The application itself is reviewed in the admin portal first; see Vendors and partners.

Use Stripe’s pm_card_* payment-method fixtures. Never POST a raw card number, not even in a throwaway script — the raw-card API is disabled account-wide and attempts trigger security emails. bun run security:stripe checks the repo for violations, and CI runs it.

Local webhook forwarding:

Terminal window
bun run dev:stripe # stripe listen --forward-to localhost:3000/api/webhooks/stripe