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

> Keep Booking kit session packages as owned prepaid balances; Stripe buys the pack; each booking consumes a row — not ghost one-offs.
> By Dave · 2026-09-19
> Source: https://otf-kit.dev/blog/booking-kit-session-packages

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](/blog/booking-kit-no-double-booking) (range exclusions + live slots). It is not the ownership tour at [/blog/booking-kit-own-the-repo](/blog/booking-kit-own-the-repo), not the greenfield path at [/blog/how-to-build-a-booking-system-with-ai](/blog/how-to-build-a-booking-system-with-ai), and not the scheduling-link compare at [/blog/otf-vs-cal-calendly-booking](/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](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](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](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](/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.

## 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:

```sql
-- 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](https://docs.stripe.com/payments/checkout). Never invent a Checkout URL in samples — create Sessions through your privileged Hono route after clone.

```sql
-- 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](https://cdn.otf-kit.dev/blog/booking-kit-session-packages/inbody1-20260919f.png)

## 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](/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.

```sql
-- 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 `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

| 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](/blog/otf-vs-cal-calendly-booking). If your pain is overlapping slots, use [/blog/booking-kit-no-double-booking](/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](https://otf-kit.dev/templates/booking-kit).
2. Read [Stripe Checkout](https://docs.stripe.com/payments/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](https://cdn.otf-kit.dev/blog/booking-kit-session-packages/inbody2-20260919f.png)

## Sources

- [OTF Booking kit storefront](https://otf-kit.dev/templates/booking-kit)
- [OTF pricing](https://otf-kit.dev/pricing)
- [Stripe Checkout](https://docs.stripe.com/payments/checkout)
- [Stripe Checkout custom success / redirect](https://docs.stripe.com/payments/checkout/custom-success-page)
- [OTF blog: Booking kit no double-booking](https://otf-kit.dev/blog/booking-kit-no-double-booking)
- [OTF blog: Cal.com / Calendly vs booking kit](https://otf-kit.dev/blog/otf-vs-cal-calendly-booking)
- [OTF blog: Booking kit own the repo](https://otf-kit.dev/blog/booking-kit-own-the-repo)
