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.
The flow
Section titled “The flow”- Hold. Adding an item calls
createHold, which takespg_advisory_xact_lock(hashtext(id))and inserts the reservation in the same transaction. Reservations start asheldwith an expiry. - Checkout.
POST /api/create-payment-intentrecomputes the total from the held reservations — the client total is never trusted — then creates or reuses the PaymentIntent and claims the cart. - Webhook.
payment_intent.succeededturns the PaymentIntent into a booking.payments.stripe_payment_intent_idis unique, because Stripe redelivers. - Cleanup. The
cleanup-expiredcron 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.
Hold windows
Section titled “Hold windows”| Item | Window |
|---|---|
| Signing slot | 15 minutes |
| Entry ticket | 15 minutes |
| Comic cover | 15 minutes |
| Booth | 60 minutes (45 once payment starts) |
Slot kinds
Section titled “Slot kinds”time_slots.slot_kind has several values but only signing is bookable —
createHold refuses the rest.
Booking status
Section titled “Booking status”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
Section titled “Presale”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.
Booth checkout
Section titled “Booth checkout”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.
Testing payments
Section titled “Testing payments”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:
bun run dev:stripe # stripe listen --forward-to localhost:3000/api/webhooks/stripe