Work / Booking platform

A Studio Booking and Payments Platform That Held 21 of 21 Concurrency Invariants Under Parallel Load

A boutique pilates studio ran its schedule, memberships, and class credits through a hosted booking SaaS that kept losing count of members' classes. A custom booking and payments platform, built spine-first on the studio's own Stripe account, proved every capacity, credit, and webhook invariant under genuinely parallel load before a single screen was designed.

Anonymized. Client, employer, project, manufacturer, part, and pricing identifiers are removed; the mechanism and the numbers are real.

The user and their week

A boutique pilates studio owner sold class packs and memberships through a hosted booking SaaS, with the studio's own Stripe account connected underneath it for the actual charges. Members bought a pack of credits, booked classes against it, and cancelled when life moved. The complaint that came up in discovery was a counting problem: credits went missing or classes went uncounted, and the owner had no ledger to reconcile against, only a balance the vendor displayed. The studio had received a five-figure proposal for a replacement and let it go unfunded. I built the platform anyway, to own the spine and productize it for the next studio in the same position.

Why it mattered

Leaving a billing SaaS usually means the card migration: getting saved card numbers re-exported from a vendor with no reason to help, which is both a PCI problem and a hostage position. One discovery question dissolved it. The SaaS never processed payments; the studio had connected its own Stripe account, and I had read access. Customers, saved cards, and active subscriptions already lived where the new app would point. What remained to migrate was schedule, booking history, and credit balances, and what remained to get right was the seam where money meets concurrency: two members booking the last spot in the same second, one member's two devices spending the same credit twice, Stripe retrying a webhook after a half-finished grant. I put roughly 70% of the project's risk in that seam and sequenced the build around it.

The bet, and what was rejected

The proposal front-loaded the booking UI and pushed payments to the back of the schedule, which produces a demo that looks finished while the load-bearing risk waits for the thinnest budget. I bet on spine first: the credit ledger, the booking and cancel procedures, and the Stripe webhook dispatcher as Postgres functions, proven under load, before any screen. Three calls shaped the spine. Credits live in an append-only, lot-based ledger with FIFO debits and a balance computed as the sum of non-expired lots, because a stored counter is the exact shape of the bug being replaced. Every booking and money operation runs as a database procedure under a per-member advisory lock, because a row lock on the session guards capacity and leaves the shared credit lot exposed. The Checkout and Customer Portal functions derive the member from the signed-in user's token, so a client can never spend from another account.

Acceptance criteria, set before build

Written into the spec before the first migration: concurrent booking at capacity never oversells; debit and booking commit together or roll back together; refunds and expiries reconcile; webhook replays and out-of-order deliveries are idempotent. Phase 0 could exit only on a two-session test that fired genuinely parallel database clients at the live schema, because a single-session test suite cannot produce the race it claims to close. Phase 2 could exit only when a member completed a full book and cancel loop end to end. The spec's risk register named 14 items, each owed a hard-failing test or a dated waiver at ship.

What shipped

  • Five Postgres migrations: core schema with an immutable credit ledger; book, cancel, and an atomic Stripe event dispatcher as database functions; row-level security and grants; the member-app layer (account claim on a verified email, schedule and balance helpers that never reveal who booked); a DST-safe recurring-schedule generator that materializes bookable sessions idempotently
  • Three Supabase Edge Functions: a verify-and-dispatch Stripe webhook, Checkout session creation, and Customer Portal session creation, member derived from the JWT
  • Two pgTAP suites, 17 assertions, including a direct daylight-saving spring-forward check
  • A concurrency harness that fires parallel database clients at the live schema and checks 21 invariants
  • A React member app: sign in and account claim, day-grouped schedule with spots left and credit cost, bookings with a cutoff-aware cancel, credit balance with lot expiry and full ledger history, a shop wired to Checkout and Portal, and a checkout-return route that confirms payment without a reload
  • The human gate: the platform sits on the studio's own Stripe account with keys only the owner installs; Checkout is scaffolded and smoke-verified, and no live charge has run, because the studio never supplied keys

What failed, and what changed

An adversarial review of the spec, before any SQL, found seven correctness holes, two of them severe: a member booking two sessions at once could spend one credit twice, and a webhook handler that failed after inserting its event row would let Stripe's retry return success without ever granting the credits. Both closed in the spec (the advisory lock, and a replay gate on the processed timestamp inside one transaction) and both were later proven closed under load. Two more surfaced in verification. Postgres has no max() over uuid, so a lot view failed until the id was cast through text. The bigger miss: the security migration wrote row-level read policies and never granted table access, so every member read returned permission denied, and the 21-of-21 concurrency gate had passed anyway because it ran as the database superuser. The fix was one migration; the lesson was to verify at the consumer's privilege level, and it is now a standing rule in how I build.

Result

21 / 21 invariants held two runs, exit 0 20 parallel bookings, 1 spot 1 booked / 19 CAPACITY_FULL 10 parallel debits, 1 credit 1 debit / 9 NO_CREDITS 10 copies of one paid webhook 1 processed / 9 replay rollback mid-grant, then retry 0 rows / granted once / replay pgTAP 17 / 17 alongside; every race the spec named came back closed
I fired genuinely parallel database clients at the live schema, twenty on one spot, ten on one credit, ten copies of one webhook, and every race the spec named came back closed.

The concurrency gate held 21 of 21 invariants, exit 0, on two back-to-back runs. Twenty parallel bookings against one remaining spot: 1 success, 19 CAPACITY_FULL. Ten parallel debits against one credit: 1 success, 9 NO_CREDITS, balance 0, never negative. Ten concurrent deliveries of the same paid webhook: 1 processed, 9 replay, balance 10, never 100. A handler rolled back mid-grant left zero event rows, the retry granted once, the third delivery replayed. pgTAP ran 17 of 17. A headless browser drove the member app through sign in, book (10 to 9 credits, ledger row present), and cancel before cutoff (9 to 10), with zero console or HTTP errors. The studio never funded the project; the platform is a working prototype on a proven spine.

Adoption, and what carries forward

The studio kept its SaaS. What carries forward is the spine and the method. The schema carries a studio id seam from the first migration, so the same platform serves the next studio wanting off a hosted booking product with a Stripe Connect swap. The build order carries further: put the spine where the risk is, get an adversary to read the spec before the code, match the test's concurrency to the bug's concurrency, and verify at the privilege level of the person who will actually use it.

More work