Booking kit: sell session packages as owned prepaid rows, not ghost one-off appointments

A one-off appointment is the wrong unit for a coaching practice that sells ten-packs. If checkout only creates a single calendar row, every remaining session lives in a spreadsheet, a memory, or a sandbox chat that "remembers" the client bought a pack. That is ghost inventory. A booking product you own should treat the pack as a prepaid balance in Postgres: catalog row in, Stripe Session for the pack, remaining sessions as a number you decrement when a booking confirms — not a regenerate of a single appointment when the client texts again.
This post is that prepaid path on OTF's Booking kit (Cadence). It is not the write-time conflict spine at /blog/booking-kit-no-double-booking (range exclusions + live slots). It is not the ownership tour at /blog/booking-kit-own-the-repo, not the greenfield path at /blog/how-to-build-a-booking-system-with-ai, and not the scheduling-link compare at /blog/otf-vs-cal-calendly-booking. If your buyer question is "how do session packages stay as owned balances instead of ghost one-offs?", stay here.
What the kit claims on the storefront
OTF lists the Booking kit on https://otf-kit.dev/templates/booking-kit. Standalone price is $99; it is also in the Everything Bundle at $149 on https://otf-kit.dev/pricing. Public claims that matter for this angle:
- Session packages beside service detail, realtime slots, and Stripe checkout
- Booking ticket + QR after confirm; my bookings for history
- Supabase Auth + RLS + Realtime; gist constraint blocks double-booking
- Hono +
@supabase/serverfor Stripe, webhook, and reminders - React Native + Expo tree with CLAUDE.md,
.cursorrules, and 20+ prompts - Live preview on the kit page: https://booking-preview.otf-kit.dev
Treat demo chrome as demo UI. The durable promise: the prepaid balance is a row you own, checkout buys the pack, and each confirmed booking consumes one session.
Why a single appointment row is not a package
Builders who ship their first booking flow often model only service → slot → pay → appointment. That works for drop-ins. It fails for packs:
- No remaining-count object — after the first paid hour, nothing says "nine left." Staff invents a sheet.
- Ghost regenerate — a sandbox "creates another appointment like last time" for week two. No shared pack id, no decrement.
- Event-type scheduling products — Cal.com and Calendly optimize for meeting links. Prepaid session balance is outside that job. Link /blog/otf-vs-cal-calendly-booking for the compare; do not rehash it. An event type is not a session-balance ledger.
A package is a SKU with session count and price. A balance is how many sessions remain after checkout. A booking is a consume against that balance — still under the slot integrity rules in the no-double-booking post.
11 production screens. Login, database, payments — all wired.
The SaaS Dashboard Kit ships everything already connected. Nothing to set up. Live demo at saas.otf-kit.dev.
Catalog first: name, session count, price
Before Stripe, name the catalog. Illustrative only — match the kit's public "session packages" claim after you clone; not a private schema dump:
-- illustrative package catalog
CREATE TABLE session_package (
id uuid PRIMARY KEY,
name text NOT NULL,
session_count int NOT NULL CHECK (session_count > 0),
price_cents int NOT NULL CHECK (price_cents > 0),
currency text NOT NULL DEFAULT 'usd',
active boolean NOT NULL DEFAULT true
);Catalog fields that matter: name (package picker copy), session_count (starting balance after purchase), price (what Checkout charges for the pack). Keep drop-in single-session services as a separate path if you still sell them. Do not silently rewrite every service into a prepaid SKU unless agents are told that rule explicitly.
Stripe checkout buys the pack, not a ghost appointment
The storefront pairs session packages with Stripe checkout. Honest order:
- Client selects a catalog package (not free-text "ten sessions").
- Privileged route creates a Checkout Session for that package price — not a client-typed amount.
- Webhook (or confirmed session retrieve) is the only money-cleared signal.
- On success, insert an owned balance for that client + package with
remaining = session_count. - Do not mark a calendar appointment confirmed solely because pack Checkout succeeded. Pack purchase and slot booking are different writes.
See Stripe Checkout. Never invent a Checkout URL in samples — create Sessions through your privileged Hono route after clone.
-- illustrative client balance after pack purchase
CREATE TABLE package_balance (
id uuid PRIMARY KEY,
client_id uuid NOT NULL,
package_id uuid NOT NULL REFERENCES session_package(id),
remaining int NOT NULL CHECK (remaining >= 0),
purchased_at timestamptz NOT NULL DEFAULT now()
);If Checkout succeeds and the balance insert fails, that is refund-or-retry ops — not a reason to invent sessions in a chat log.

Consume on booking: decrement, then confirm the ticket
When the client later picks a realtime slot:
- Derive open slots from live availability, not a stale list.
- Require
remaining > 0for prepaid clients (or run single-session Checkout for drop-ins). - Confirm the appointment under the same integrity rules as /blog/booking-kit-no-double-booking — gist / exclusion still applies; a pack does not excuse overlaps.
- Decrement
remainingin the same transaction (or tightly ordered writes) as the confirmed booking. - Issue the booking ticket + QR only after appointment row and decrement both succeed.
-- illustrative consume when booking confirms
UPDATE package_balance
SET remaining = remaining - 1
WHERE id = $balance_id
AND remaining > 0
RETURNING remaining;If RETURNING is empty, refuse the booking. If the appointment insert fails exclusion, do not decrement. Partial success that leaves inventory lying is worse than a clean retry. Reminders should fire only for bookings that survived confirmation.
Contrast: ghost one-off vs owned prepaid row
| Step | Ghost one-off / sandbox regenerate | Owned prepaid package |
|---|---|---|
| Purchase | Pay for "an appointment" or chat "add a 10-pack" | Checkout Session for a catalog package |
| Inventory | Staff memory, sheet, or regenerated screen | package_balance.remaining in Postgres |
| Next book | New one-off appointment invented | Slot pick + consume decrement |
| Conflict | Hope the UI disabled Pay | Gist / exclusion still blocks overlaps |
| Ticket | Screenshot-shaped confirm | Booking ticket + QR after durable confirm |
| Agent edit | "Make another booking like last week" | "Add package SKU; never bypass remaining" |
The ghost path feels fast in a demo. It fails the week a client disputes remaining sessions. The owned path makes the dispute a SELECT.
How agents should extend packages without inventing a second ledger
OTF kits ship CLAUDE.md, .cursorrules, and tested prompts so Cursor or Claude Code extend the product. For packages, keep invariants agents cannot shrug off:
- New SKUs go on the catalog (or the kit's real equivalent after clone) — not hard-coded prices in a screen file.
- Pack Checkout creates or tops up a balance; it does not invent ten appointment rows up front unless you document holds that way.
- Prepaid booking confirmation must check and decrement
remainingon the same honesty path as slot confirmation. - Do not "fix" zero balance by regenerating a free appointment in the client.
- Keep the double-booking guard sacred — point agents at the no-double-booking how-to.
Models change; catalog + balance + consume stays the product rule.
Decision checklist before you buy or extend
| Question | If yes | If no |
|---|---|---|
| Do clients buy multi-session packs? | Need catalog + balance + consume | Single-session Checkout may suffice |
| Must remaining survive staff turnover? | Store it in Postgres you own | Spreadsheets will drift |
| Do clients race the same provider slot? | Keep gist / exclusion from the sibling post | Packs alone will not save you |
| Branded client app (phone + web)? | Prefer an owned booking kit spine | Scheduling-link product may be enough for meetings |
If your pain is scheduling embeds vs owning discovery + pay + ticket, read /blog/otf-vs-cal-calendly-booking. If your pain is overlapping slots, use /blog/booking-kit-no-double-booking. Return here when the SKU is a prepaid pack.
What to do today
- Confirm session packages still sit beside Stripe checkout and the booking ticket on Booking kit.
- Read Stripe Checkout for Session creation and webhook confirmation.
- After purchase (or on a branch), test a 3-pack:
remaining = 3, three consumes →0, fourth consume fails cleanly. - Trace pack Checkout → balance → slot → exclusion confirm → decrement → ticket. Fail if confirmed without a surviving balance update.
- Add one agent prompt that forbids inventing appointments when
remaining = 0and forbids removing the decrement for demos.
Session packages stop being ghost inventory when the pack is a catalog row, Checkout buys that row, and every booking consumes an owned balance.

Sources
- OTF Booking kit storefront
- OTF pricing
- Stripe Checkout
- Stripe Checkout custom success / redirect
- OTF blog: Booking kit no double-booking
- OTF blog: Cal.com / Calendly vs booking kit
- OTF blog: Booking kit own the repo
Ship the product, not the setup.
- 11 production screens — auth, billing, team, analytics, settings
- Real database, payments, and login — all wired on day 1
- AI configs pre-tuned so your agent extends instead of regenerates