Skip to content
OTFotf
All posts

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

D
DaveAuthor
8 min read
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/server for 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:

  1. No remaining-count object — after the first paid hour, nothing says "nine left." Staff invents a sheet.
  2. Ghost regenerate — a sandbox "creates another appointment like last time" for week two. No shared pack id, no decrement.
  3. 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.

See the live demo

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:

  1. Client selects a catalog package (not free-text "ten sessions").
  2. Privileged route creates a Checkout Session for that package price — not a client-typed amount.
  3. Webhook (or confirmed session retrieve) is the only money-cleared signal.
  4. On success, insert an owned balance for that client + package with remaining = session_count.
  5. 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.

Catalog balance consume architecture: package catalog to prepaid remaining to booking decrement, not a ghost one-off appointment

Consume on booking: decrement, then confirm the ticket

When the client later picks a realtime slot:

  1. Derive open slots from live availability, not a stale list.
  2. Require remaining > 0 for prepaid clients (or run single-session Checkout for drop-ins).
  3. 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.
  4. Decrement remaining in the same transaction (or tightly ordered writes) as the confirmed booking.
  5. 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

StepGhost one-off / sandbox regenerateOwned prepaid package
PurchasePay for "an appointment" or chat "add a 10-pack"Checkout Session for a catalog package
InventoryStaff memory, sheet, or regenerated screenpackage_balance.remaining in Postgres
Next bookNew one-off appointment inventedSlot pick + consume decrement
ConflictHope the UI disabled PayGist / exclusion still blocks overlaps
TicketScreenshot-shaped confirmBooking 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 remaining on 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

QuestionIf yesIf no
Do clients buy multi-session packs?Need catalog + balance + consumeSingle-session Checkout may suffice
Must remaining survive staff turnover?Store it in Postgres you ownSpreadsheets will drift
Do clients race the same provider slot?Keep gist / exclusion from the sibling postPacks alone will not save you
Branded client app (phone + web)?Prefer an owned booking kit spineScheduling-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

  1. Confirm session packages still sit beside Stripe checkout and the booking ticket on Booking kit.
  2. Read Stripe Checkout for Session creation and webhook confirmation.
  3. After purchase (or on a branch), test a 3-pack: remaining = 3, three consumes → 0, fourth consume fails cleanly.
  4. Trace pack Checkout → balance → slot → exclusion confirm → decrement → ticket. Fail if confirmed without a surviving balance update.
  5. Add one agent prompt that forbids inventing appointments when remaining = 0 and 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.

Live slot claimed with scannable booking ticket while prepaid balance decrements on confirm

Sources

kitsarchitecturetemplates
OTF SaaS Dashboard Kit

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