<!--
  Build a Spa/Salon Management Platform — complete implementation prompt.

  Hand this entire file to a coding agent (Claude Code, etc.) as a single prompt,
  or work through it yourself part by part.

  Source: https://vlad-kost.com/guides/build-a-zenoti-clone
-->

# Build a Zenoti Clone — Complete Implementation Prompt

**Target:** a full spa/salon management platform (appointment book, POS, card-present payments, guest self-service booking, staff portal, two-way SMS, AI assistant, and a migration path off Zenoti).

  

**Provenance:** this is a from-scratch build specification reverse-engineered from a real, working spa management platform that runs a day spa in production. Identifying details of that business have been removed. It is written to be handed to an engineer or a coding agent as a single, self-contained prompt.

  

**Non-negotiable global requirement:** *every* configurable value described in this document must be editable by the business owner in the in-app **Settings** section. Nothing operational may live only in an environment variable or a hardcoded constant. See **Part 3 — Settings** (the largest and most important section of this spec).

  

## PART 0 — HOW TO USE THIS PROMPT

Build the system described below. Work in this order:

  

1.  **Part 1** — stack, conventions, repo layout. Establish these first; they are load-bearing for everything else.
2.  **Part 2** — the data model. Build the full schema before any UI.
3.  **Part 3** — Settings. Build this early, not last. Every later feature reads its configuration from here.
4.  **Parts 4–9** — the surfaces (admin dashboard, guest booking, staff portal, terminal, server logic, integrations).
5.  **Part 10** — the Zenoti migration subsystem (only if migrating off Zenoti).
6.  **Part 11** — security model. Apply throughout, verify at the end.
7.  **Part 12** — build milestones and acceptance criteria.

  

Design rules that apply everywhere:

  

  - **Degrade, never crash.** Every module must work with no database (sample data), no SMS provider, no Stripe key, no AI key. Missing configuration produces a friendly disabled state, never an exception.
  - **The server owns money and time.** The client is trusted only for *choices* (which service, which slot). Prices, durations, and totals are always recomputed server-side from the database. Never trust a client-supplied amount.
  - **Idempotency over transactions.** The reference runs on a serverless Postgres HTTP driver with no transaction support. Use idempotency keys, conditional WHERE-guarded updates, and reconciliation jobs instead of BEGIN/COMMIT. Design for this even if your driver supports transactions — it makes webhook and retry handling correct by construction.
  - **One source of truth for captured money:** the payment processor's webhook. Devices may report progress but must never be able to declare a payment successful.

  

## PART 1 — STACK, CONVENTIONS, LAYOUT

### 1.1 Stack

  - **Next.js (App Router)** + **React** + **TypeScript**. Server Components fetch data; Client Components handle interaction; Server Actions perform writes.
  - **Postgres** (Neon serverless in the reference) + **Drizzle ORM** (drizzle-orm/neon-http). Note: the HTTP driver has **no transactions** — see the idempotency rule above. Switch to a pooled/websocket driver if you want real transactions.
  - **Tailwind CSS** for styling.
  - **Stripe** — Terminal (card-present, BBPOS/Stripe readers), PaymentIntents, SetupIntents (cards on file), refunds, webhooks.
  - **SMS** — pluggable Twilio / RingCentral behind one dispatcher.
  - **Email** — Resend over its REST API (no SDK).
  - **AI** — pluggable Claude / OpenAI / Gemini behind one agent interface; OpenAI Realtime for live voice.
  - **Tablet kiosk apps** — Expo / React Native (iOS + Android), using the native Stripe Terminal SDK (a Bluetooth reader cannot be driven from a browser).

### 1.2 Core conventions

|  |  |
| :-: | :-: |
| \*\*Convention\*\* | \*\*Rule\*\* |
| \*\*Money\*\* | Always integer \*\*cents\*\* in \\\*\\\_cents integer columns. Never floats. Format at the edge (formatMoney(12000) → "$120.00"). |
| \*\*Time storage\*\* | Always UTC timestamptz. |
| \*\*Time display\*\* | Always converted through the \*\*business timezone\*\* from Settings. The server runs in UTC; every wall-clock render and every "what day is it" bucket must go through the configured zone. |
| \*\*Time math\*\* | Day boundaries and date bucketing for reminders/reports are computed \*\*in SQL\*\* (AT TIME ZONE) so Postgres handles DST correctly. |
| \*\*Primary keys\*\* | uuid defaultRandom() everywhere, except singleton/keyed rows (settings row keyed "default"; templates keyed "${event}.${channel}"; presence/health keyed by register name). |
| \*\*Timestamps\*\* | A shared helper adds created\\\_at + updated\\\_at (both timestamptz, defaultNow(), updated\\\_at auto-touched). Applied to entity tables; omitted on pure join/counter tables. |
| \*\*Soft deletes\*\* | Entities carry active boolean default true. Sync reconciliation flips it false. Rows are not deleted. Purchased packages additionally use status='deleted' + an audit note. |
| \*\*Phones\*\* | Canonical form is the \*\*last 10 digits\*\* of the US national number. Match with right(regexp\\\_replace(phone,'\\\\D','','g'),10) = $1. Store display-formatted (303) 880-3079. This one rule threads through booking, guest matching, SMS threading, and card-on-file. |
| \*\*External sync provenance\*\* | Every table that can be imported carries a nullable unique zenoti\\\_id. \*\*zenoti\\\_id IS NULL\*\* \*\*means "born in our system"\*\* and is the guard that protects locally-collected money from being clobbered by a re-import. |
| \*\*Slugs\*\* | Catalog entities (categories, services, packages) carry a unique URL-safe slug for deep links. |
| \*\*Client trust\*\* | Server actions are Zod-validated at the boundary. Write actions that touch money re-derive amounts from the DB. |

### 1.3 Repo layout

src/

  

  app/

  

    (dashboard)/        staff dashboard (shared sidebar layout)

  

    (guest)/            guest-facing pages (own brand shell)

  

    portal/\[token\]/     employee self-service portal

  

    webstoreNew/        legacy deep-link redirects (migration)

  

    messages/           chrome-free pop-out Conversations window

  

    api/                route handlers (webhooks, crons, device APIs)

  

  components/

  

    calendar/           the appointment book + invoice/payment modals

  

    guest/ portal/ settings/ assistant/ comms/ payments/ …

  

  server/               all business logic + server actions

  

    assistant/          agent loop, tools, prompts, per-provider adapters

  

    zenoti/             migration/sync subsystem

  

  db/

  

    schema/             one module per domain, re-exported from index

  

    seed.ts             baseline + demo-day data

  

  lib/                  pure helpers (money, phone, tz, slug, calendar geometry, templates)

  

drizzle/                generated SQL migrations

  

scripts/deploy-migrate.mjs   applies migrations at deploy time

  

ipad/ android/          Expo kiosk apps

  

## PART 2 — DATA MODEL

Build every table below. Enums are shared across modules.

### 2.1 Enums

  - appointment\_status: booked, confirmed, checked\_in, in\_service, completed, cancelled, no\_show
  - booking\_source: internal, online
  - schedule\_block\_kind: break, time\_off, blocked
  - invoice\_status: open, paid, partially\_paid, void, refunded
  - invoice\_line\_kind: service, addon, package, gift\_card, product, fee, tip
  - payment\_method: card\_on\_file, terminal, gift\_card, cash, other, card
  - payment\_status: pending, succeeded, failed, refunded, partially\_refunded
  - dispute\_status: warning\_needs\_response, warning\_under\_review, warning\_closed, needs\_response, under\_review, won, lost
  - commission\_level: all, category, service · commission\_type: flat, percent
  - notification\_channel: sms, email · notification\_event: appointment\_confirmed, reminder\_day\_before, reminder\_day\_of, provider\_appointment\_details · notification\_audience: guest, provider · notification\_direction: outbound, inbound · notification\_origin: automated, system, manual · notification\_status: queued, sent, delivered, failed, undelivered
  - pay\_period\_status: open, calculated, paid
  - schedule\_change\_status: pending, approved, declined, discuss, superseded

  

Deliberately **not** enums (states evolve without migrations): terminal\_sessions.status, employees.role, waitlist.status, guest\_packages.status.

### 2.2 Guests

**guests** — id, zenoti\_id (unique), code, first\_name, last\_name, preferred\_name, phone, email, date\_of\_birth, active (default true), is\_placeholder (virtual second person on a couples booking), host\_guest\_id (self-FK, set null), block\_online\_booking, block\_editing\_custom\_data, stripe\_customer\_id, card\_link\_code (unique — permanent short code for a card-capture link), sms\_opt\_in, sms\_opt\_in\_at, sms\_opt\_in\_source, timestamps.

  

**guest\_payment\_methods** — guest\_id (cascade), stripe\_payment\_method\_id, brand, last4, exp\_month, exp\_year, is\_default, timestamps.

  

**guest\_notes** — guest\_id (cascade), author\_employee\_id (set null), body, timestamps.

### 2.3 Resources & catalog

**equipment**, **rooms** — id, zenoti\_id (unique), code, name, active, timestamps. **room\_equipment** — room\_id, equipment\_id, quantity (default 1). No timestamps.

  

**service\_categories** — zenoti\_id (unique), code, name, catalog\_order, slug (unique), timestamps.

  

**services** — zenoti\_id (unique), code, name, description, category\_id (set null), is\_addon (an add-on is just a service with this true), service\_time\_minutes, recovery\_time\_minutes, price\_cents, slug (unique), active, timestamps.

  

**service\_addons** — service\_id → addon\_service\_id (both cascade). Which add-ons are offered for a parent service.

  

**service\_equipment\_options** — service\_id, equipment\_id, group\_index, unique(service\_id, equipment\_id). **AND-of-ORs semantics:** same group\_index = interchangeable alternatives (OR); different indexes = all required (AND).

### 2.4 Employees & scheduling config

**employees** — zenoti\_id (unique), code, first\_name, last\_name, phone, email (NOT NULL, unique), password\_hash, address, notes, job\_title, color (calendar accent), role (text: provider | front\_desk | manager | admin, default provider), show\_in\_schedule, participate\_in\_payroll, active, timestamps.

  

**employee\_services** — employee\_id, service\_id, additional\_time\_minutes (per-employee extra time for that service), unique(employee\_id, service\_id).

  

**employee\_commissions** — employee\_id, level (all|category|service), category\_id, service\_id, type (flat|percent), value numeric(12,2). More specific overrides broader.

  

**employee\_work\_hours** — employee\_id, day\_of\_week (0=Sun…6=Sat), start\_time, end\_time, effective\_from, effective\_to. The recurring weekly pattern.

  

**employee\_schedule\_overrides** — employee\_id, date, is\_off, start\_time, end\_time, unique(employee\_id, date). **A row here wins over the weekly pattern for that single date.**

  

**schedule\_blocks** — zenoti\_id (unique), exactly one of employee\_id / room\_id / equipment\_id, kind, starts\_at, ends\_at, note, timestamps.

### 2.5 Scheduling

**appointment\_groups** — one *visit*. zenoti\_id (unique), primary\_guest\_id (restrict), host\_guest\_id (set null), invoice\_id (set null — group-level payment), status, source, notes, card\_requested\_at, card\_hold\_expires\_at, card\_link\_code (unique), timestamps.

  

**appointments** — one service line = one calendar block. zenoti\_id (unique), group\_id (cascade), guest\_id (restrict), employee\_id (set null), service\_id (restrict), starts\_at, ends\_at, duration\_minutes, recovery\_minutes, room\_id, equipment\_id, price\_cents, invoice\_id (set null — **per-appointment** paid state, enables couple splits), status, requested\_provider, note, rebooked\_from\_appointment\_id (self-FK), cancelled\_at, cancellation\_reason, cancellation\_source (staff | guest\_online | guest\_sms), timestamps.

  

**appointment\_addons** — appointment\_id (cascade), addon\_service\_id (restrict), price\_cents, extra\_minutes.

  

**waitlist** — guest\_name (plain text, not an FK), guest\_phone, service\_id (set null), preferred\_date, note, status (default "waiting"), timestamps.

### 2.6 Billing

**invoices** — zenoti\_id (unique), number (unique, human-readable), guest\_id (restrict), status, subtotal\_cents, tax\_cents, tip\_cents, total\_cents, notes, paid\_at, timestamps.

  

**invoice\_line\_items** — invoice\_id (cascade), kind, description, provider\_name (plain text — this is what per-stylist revenue and tip attribution key on), reference\_id (soft ref, **no FK**), quantity, unit\_price\_cents, amount\_cents.

  

**payments** — zenoti\_id (unique; imported rows keyed sale:{invoice\_no}:{method}), invoice\_id (restrict), guest\_id (restrict), method, status, amount\_cents, tip\_cents (**always tracked separately from amount**), refunded\_cents, stripe\_payment\_intent\_id, stripe\_charge\_id, card\_brand, card\_last4, livemode (which Stripe universe the charge lives in), timestamps.

  

**disputes** — payment\_id (set null), stripe\_dispute\_id (unique), amount\_cents, reason, status, evidence\_due\_by, opened\_at, resolved\_at, timestamps.

### 2.7 Packages & gift cards

**packages** — zenoti\_id, code, name, description, price\_cents, slug (unique), active, timestamps. **package\_services** — package\_id (cascade), service\_id (restrict), quantity. **guest\_packages** — zenoti\_id (unique), status (active/not\_started/frozen/expired/cancelled/closed/**deleted**), delete\_note, deleted\_at, guest\_id (cascade), package\_id (restrict), purchase\_invoice\_id (set null), purchased\_at, expires\_at, timestamps. **guest\_package\_services** — guest\_package\_id (cascade), service\_id (restrict), total\_quantity, redeemed\_quantity. **package\_redemptions** — guest\_package\_service\_id (cascade), appointment\_id (set null), redeemed\_at.

  

**gift\_cards** — zenoti\_id (unique), code (unique), initial\_balance\_cents, current\_balance\_cents, purchased\_at, expires\_at, purchaser\_guest\_id, recipient\_guest\_id, purchase\_invoice\_id, active, timestamps. **gift\_card\_transactions** — gift\_card\_id (cascade), amount\_cents (**positive = load, negative = redeem**), payment\_id (set null), timestamps.

### 2.8 Communications

**notifications** — the unified message log. guest\_id (set null), channel, direction, origin (automated | system | manual), address (**normalized 10-digit phone or email — this is the thread key**, so inbound and outbound thread together even with no guest match), subject, body, media\_urls text\[\], status, provider\_message\_id, sent\_at, delivered\_at, timestamps.

  

**sms\_media** — content\_type, data (base64 bytes), timestamps. Outbound MMS images live here and are served by UUID so the SMS provider can fetch them from a public URL.

  

**notification\_templates** — id = "${event}.${channel}", event, channel, audience, subject (email only), body, enabled, timestamps. DB rows are **overrides merged over built-in defaults**.

### 2.9 Settings & payroll

**settings** — a **singleton row keyed** **id = "default"**. Its columns are enumerated exhaustively in **Part 3**, which also mandates a large number of *new* columns this reference does not yet have.

  

**pay\_periods** — start\_date, end\_date, status, timestamps. **payroll\_entries** — pay\_period\_id (cascade), employee\_id (cascade), commission\_cents, tips\_cents, total\_cents, calculated\_at.

### 2.10 Terminal / kiosk

**terminal\_sessions** — the polling "bus" between the front desk and the payment device (no websockets anywhere). id, register (station id), status (free text: pending → awaiting\_payment → processing → succeeded | failed | canceled | expired), appointment\_id + appointment\_ids jsonb (all appointments this charge covers), guest\_id / guest\_name / guest\_phone / guest\_email (denormalized), currency, subtotal\_cents, tip\_cents, total\_cents, line\_items jsonb (denormalized cart incl. provider name), notes, gift\_card\_id / gift\_card\_code / gift\_card\_balance\_cents / gift\_card\_applied\_cents, stripe\_payment\_intent\_id, stripe\_charge\_id, card\_brand, card\_last4, invoice\_id, payment\_id, error\_message, expires\_at, timestamps. All id references are **soft (no FKs)**.

  

**terminal\_presence** — PK register; last\_seen\_at, reader\_connected, reader\_label, source ('poll' | 'heartbeat'), timestamps. **terminal\_health\_state** — PK register; alerted\_status, alerted\_at, timestamps. Alert de-duplication for the watchdog.

### 2.11 Auth & assistant

**otp\_codes** — phone10, code\_hash (SHA-256 — never store the code), expires\_at, attempts, consumed\_at, timestamps. **assistant\_memories** — content, created\_at. Durable facts injected into the assistant's system prompt. **schedule\_change\_requests** — employee\_id (cascade), date, current\_start/current\_end (snapshot at request time), new\_start/new\_end, break\_start/break\_end, note, status, admin\_phones (comma-separated, who was texted), resolved\_by, resolved\_at, timestamps.

### 2.12 Seed data

An **idempotent** seed script (TRUNCATE … RESTART IDENTITY CASCADE on booking/catalog/staff tables, never on settings) that creates: the settings row, \~4 equipment, \~3 rooms, \~3 categories, \~10 services (one an add-on), \~5 employees with 7-day 09:00–18:00 hours, \~8 guests, a demo day of \~8 appointments, and one fully-populated showcase guest (card on file, a past paid invoice with tip, a gift card with partial balance, a purchased package with some credits redeemed, notifications, notes). Print the demo-day URL when done.

  

## PART 3 — SETTINGS (THE CORE REQUIREMENT)

**This is the section that matters most.** In the reference implementation, only six things are editable in Settings (Zenoti sync, AI provider, SMS provider, Stripe mode, terminal reader, message templates). Everything else — the business name, the timezone, tax rate, tip presets, the spa's own address, OTP rules, card-hold duration, gift-card pricing, reminder times, register codes, alert phone numbers, session lifetimes, backup schedule, AI models, TTS voices, cron times — lives in environment variables, hardcoded constants, or DB columns with no UI.

  

**In the system you build, all of it must be editable in Settings by a non-technical owner.**

### 3.1 Rules for the Settings system

1.  **One source of truth.** Every value below is a column on the settings singleton (or a dedicated settings table for list-shaped config). Code reads it through a typed accessor, never process.env directly.
2.  **Environment variables are for secrets and bootstrapping only** — API keys, the database URL, the deploy environment. Anything a spa owner might reasonably want to change is a Settings field. Where the reference reads an env var for a *behavioral* value (fee percentage, alert hours, model names, PIN), that becomes a Settings field with the env var demoted to a first-boot default.
3.  **Layered resolution, in this order:** Settings row → environment default → hardcoded fallback. Every read is wrapped in try/catch so the app boots before its migration has run.
4.  **Every field shows its effective value and its source** ("using default", "set here", "from environment").
5.  **Validation at the boundary.** Timezone must be a valid IANA zone; percentages 0–100; phone numbers normalize to 10 digits; URLs must parse. Invalid input is rejected with a specific message, never silently coerced.
6.  **Dangerous fields confirm.** Switching payments to live mode, changing gift-card pricing, clearing demo data, and rotating secrets each require an explicit typed confirmation.
7.  **Audit.** Record who changed what and when; show "last changed" on each section.
8.  **Settings changes take effect without a redeploy.** No value in this section may require a rebuild — that specifically includes the tablet kiosks (they must fetch their configuration from the server, not bake it into the bundle).

### 3.2 Settings sections to build

Organize /settings into these sections. Every field listed is required.

#### A. Business identity

  - Business name; legal name.
  - Public website URL; booking site URL.
  - **Physical address** (street, suite, city, state, postal code) — systems like this routinely hardcode the address inside a React component. Address appears on booking pages, receipts, and emails; it must come from here.
  - Public phone number; the "call us to cancel" number.
  - Support/contact email.
  - Privacy policy URL; terms URL; SMS opt-in form URL.
  - **Timezone** (IANA picker). Everything date-related depends on this.
  - **Currency** (ISO code + display symbol) — hardcoded USD in the reference.
  - Logo upload, favicon, brand colors (primary, accent, dark), email header color, email CTA color. These feed the dashboard shell, guest pages, transactional email templates, and the reader splash screen.

#### B. Business hours & booking policy

  - Default opening/closing hours per weekday (the calendar's visible window, currently hardcoded 8:00 AM–8:00 PM).
  - Calendar grid slot size (currently 30 min) and drag snap increment (currently 5 min).
  - Booking horizon in days (how far out guests can book; reference: 62).
  - Same-day lead time in minutes (reference: 60).
  - Slot step for guest booking (reference: 15 min).
  - Cancellation cutoff hours for guest self-cancel (reference: 24).
  - Whether couples/side-by-side booking is offered.
  - Employee booking priority order (drag-to-reorder staff list used when a guest picks "Any provider").
  - Default appointment duration and default price fallback when a service has none.

#### C. Payments

  - **Processor mode: test / live**, with configured-badges per mode and a hard confirmation on going live.
  - Credential status per mode (configured / not configured) — keys themselves stay in env, but the page must show status and name the missing variable.
  - **Processing fee percentage + fixed cents + display label** (reference: 2.7% + $0.05, env-driven). Drives the "net to business" math on invoices and staff payroll deductions — the owner must be able to correct it when their rate changes.
  - **Tax rate.**
  - **Tip presets** for the card reader (reference: hardcoded 15/20/25) and for the tablet kiosk (reference: hardcoded 18/20/22/25 in the app bundle). One list, editable, pushed to devices at runtime.
  - Whether tips are collected at all; whether a "no tip" option shows.
  - Minimum charge amount (reference: hardcoded 50¢ Stripe floor for split remainders).
  - Invoice numbering scheme: starting number, optional prefix, and whether imported numbers are excluded from the sequence.
  - **Invoices PIN** and its session lifetime (reference: hardcoded default PIN 646706 and a 12-hour cookie). Must be settable, and must not ship with a default.
  - Card capture behavior (immediate capture vs authorize-and-capture).

#### D. Locations & registers

The reference hardcodes two locations (f102 "F102 old" and f101 "F101 new"), their register names (front, f101), which one has a smart reader, and the device photos. This must become a managed list.

  

  - **Locations table/editor**: id/code, display label, address (if multi-site), which registers belong to it, whether a smart reader is stationed there, device photo upload, and a default flag.
  - **Registers editor**: register key, friendly name, device type (tablet + Bluetooth reader | standalone smart reader | virtual card-on-file), and pairing state.
  - Session lifetimes: reader handoff TTL (reference: 15 min), card-on-file TTL (reference: 3 min), presence TTL (reference: 12 s), reconcile window (reference: 3 min–48 h).
  - Kiosk idle timeout before auto-cancel (reference: 3 min) and poll/heartbeat intervals (reference: 2 s / 5 s).
  - **Device tokens**: generate/rotate per-device tokens from this page. The reference bakes one shared token into the app bundle at build time — replace with server-issued, rotatable, per-device tokens that never require an app rebuild.

#### E. Terminal & reader management

  - Active device type per location (tablet + reader vs smart reader).
  - Registered reader list with online/offline status; register a new reader by code; reset a stuck reader.
  - Reader splash/branding image upload + apply.
  - **Health alerts:** recipient phone numbers (reference: two hardcoded numbers), active-hours window (reference: env 8-21), offline threshold (reference: 120 s), watchdog interval.

#### F. Messaging — SMS

  - **Provider: auto / Twilio / RingCentral**, with configured badges and auto-order.
  - **Master switch: send automated texts** (confirmations, reminders, provider alerts) — manual texts unaffected.
  - Sender identity per provider; signature verification toggle.
  - **Forward-replies destination**: which staff member receives inbound guest texts (reference: env with a hardcoded fallback number 6467063785).
  - **Supervisor alert rules** — the reference hardcodes "if the provider's first name is larisa or sana, also copy 6467063785". This must be a general, editable rule table: *for these providers → also notify these numbers*.
  - Provider heads-up window rule (reference: same-day, or next-day after 8 PM).
  - MMS upload size cap (reference: 5 MB).
  - Opt-out keyword handling and the opt-in consent copy.

#### G. Messaging — email

  - Provider status; from-name and from-address; reply-to.
  - Email branding (header color, CTA color, logo, footer text) — currently hardcoded hex values.

#### H. Message templates

Per (event × channel), with audience badge, enable toggle, subject (email), body, clickable variable chips, and a live preview rendered with sample values:

  

  - Appointment confirmed (guest) — SMS + email
  - Reminder, day before (guest) — SMS + email
  - Reminder, day of (guest) — SMS + email
  - Provider, new appointment (provider) — SMS + email
  - Plus: gift-card receipt, gift-card delivery, package receipt, card-link request, OTP code, cancellation confirmation, schedule-change request/outcome.

  

Variables: guestFirstName, guestName, serviceName, providerName, date, time, whenLabel, sentTime, duration, addon, businessName, businessUrl, and any location/address fields.

#### I. Reminders & scheduled jobs

The reference hardcodes every cron time in a deploy config file. Expose:

  

  - Reminder send time (day-before and day-of), in business time.
  - Sync schedule and window (past days / future days / reconcile on).
  - Webhook-subscription renewal schedule.
  - Terminal watchdog interval.
  - Backup schedule + retention + failure-notification recipients (reference: a GitHub workflow cron at 4/20 UTC, 90-day retention, hardcoded failure number).
  - A "run now" button and a last-run/last-result readout for each job.

#### J. Guest booking & identity

  - **OTP config**: code length (reference: 3), TTL (reference: 10 min), max codes per window (reference: 3), max attempts (reference: 5), and the SMS copy.
  - **Guest session lifetimes**: remembered (reference: 365 d) and un-remembered (reference: 12 h).
  - **Card-on-file at booking**: required / optional / off; the policy text shown to guests.
  - **Card-link hold duration** (reference: hardcoded 10 min) and the link copy.
  - Whether new guests are auto-created on first booking.

#### K. Gift cards & packages

  - **Gift-card denominations** (reference: hardcoded $50/$100/$150/$200) — editable preset list.
  - **Promotional pricing rules** (reference: hardcoded "$500 for $400") — an editable list of value→price rules.
  - Custom amount min/max (reference: $25–$1000) and whole-dollar constraint.
  - Gift-card code format and expiry policy.
  - **Package validity days** (reference: hardcoded 365).
  - Whether packages/gift cards are sold online.

#### L. AI assistant

  - **Brain: auto / Claude / OpenAI / Gemini**, with configured badges naming the missing key.
  - **Model per provider** (reference: env-only, defaults claude-opus-4-8, gpt-5, gemini-2.5-flash).
  - Reasoning effort; max tokens; max tool iterations (reference: 10); SQL row cap (reference: 200).
  - **Voice provider** (auto / OpenAI / Gemini) + **TTS model and voice** (reference: env-only, gpt-4o-mini-tts/nova, gemini-2.5-flash-preview-tts/Kore); realtime model + voice.
  - Which write tools require confirmation (they all should by default) and whether the assistant may send SMS at all.
  - Standing memories: view/edit/delete the facts the assistant remembers.
  - Cost display toggle and per-model pricing table (used for the cost estimates shown in chat).

#### M. Payroll

  - Pay period length (reference: 7 in the column, 14 in the portal code — reconcile this) and anchor start date.
  - Whether processing fees are deducted from commission/tips.
  - Whether imported (pre-migration) invoices count toward payroll.
  - Default commission rule for new staff.

#### N. Integrations & migration

  - Zenoti (or other source system): bearer token, API base, center id, last-sync time, last-result summary, manual sync scopes, reconcile toggle, and a token re-capture helper.
  - Google Ads conversion id + purchase label; analytics/GTM container id (the reference injects GTM with no UI control).
  - Dispute notification phone + email.
  - Clear-demo-data tool with a preview count.

#### O. Access & security

  - **Staff access key / dashboard gate** (reference: env STAFF\_ACCESS\_KEY).
  - Secret rotation UI for the HMAC secrets that sign guest sessions, card links, and portal links (reference: env vars that silently fall back to a hash of the database URL — a real weakness; make the fallback impossible and the rotation explicit).
  - Session/cookie lifetimes.
  - Role definitions and what each role may see.

  

## PART 4 — ADMIN DASHBOARD

### 4.1 Shell

  - Flex row, full viewport, no page scroll: fixed 240 px sidebar + scrollable main + a floating AI assistant launcher.
  - **One toggle, two behaviors:** on desktop (≥1024 px) it collapses the sidebar; on mobile it opens a drawer with a backdrop.
  - Sidebar: brand badge + name, nav list with inline SVG icons, active item filled dark, version footer.
  - **Nav order:** Appointments · Assistant · Guests · Services · Employees · Provider Hours · Rooms & Equipment · Packages · Gift Cards · Invoices & Payments · Cancellations · Disputes · Settings.
  - PageShell: 64 px header (toggle + title + description + right-side actions) over a scrollable padded body.
  - **Toasts:** bottom-center pill, \~2.2 s, on every action.
  - **Modals:** a shared ModalShell (colored header bar, ✕, backdrop click-to-close, optionally draggable/resizable with size persisted to localStorage).

### 4.2 The appointment book — geometry

  - Visible window from Settings (reference default 8:00 AM–8:00 PM); grid line every slot-size (30 min); **1.4 px per minute** (30 min = 42 px).
  - Time column 64 px; provider column base 240 px, floor 140 px; width = max(140, floor((viewportW − 64) / columnCount)) measured with a ResizeObserver. Few columns stretch; many shrink then scroll horizontally.
  - **Lane assignment:** overlapping items are laid out side-by-side via greedy interval coloring within connected clusters; each item gets lane/lanes and renders at 100%/lanes width.
  - **Current-time line:** red line across all columns with a dot and a time chip in the gutter; only when viewing today and inside the window; ticks every 30 s; starts null so SSR never renders it.

### 4.3 The appointment book — views and columns

  - Three views: **Rooms · Provider (default) · Equipment**. The grid is column-generic: a column is a provider, room, or equipment unit. Unassigned items get a trailing "No room"/"No equipment" column.
  - In Rooms/Equipment view an item renders under its room/equipment id while its real provider is preserved (needed for rebook/copy). A move in Rooms view changes room\_id, not the stylist.
  - Provider headers: name, Utilization:(NN.NN%), job title, accent underline — **clickable** to open the per-date hours editor. Equipment headers show total quantity.
  - **Off-hours shading:** the working window is white; outside is grey. A provider with no hours and no appointments is grey all day. Providers appear only if scheduled that weekday **or** having ≥1 appointment.
  - **Column ordering:** providers with appointments first (busiest → left by booked minutes), then scheduled, then alphabetical.
  - **Utilization** = booked minutes ÷ working-window minutes, to 2 decimals.

### 4.4 The appointment book — interactions

  - **BOOK slots:** each empty in-window slot is an invisible button showing "BOOK" on hover; double-click opens the booking panel prefilled for that column and time; single-click pastes when something is parked.
  - **Color coding:** break = indigo; paid = blue; requested-provider = orange fill with a **green left bar**; normal = orange. Markers: ↻ rebooked, ✓ confirmed (card on file), ð³ card link sent, ð³\! link expired with no card.
  - **Drag to move:** 5 px threshold, ghost card follows the cursor showing the new time, drop-target highlight previews the landing rectangle, snap 5 min. Moving a *requested-provider* appointment to a different provider raises a "Change requested provider?" confirmation.
  - **Resize:** bottom handle, live preview, floating clock badge with new end time and duration, snap 5 min, min 5 min.
  - **Drag-select empty space** → block-out modal for that range.
  - **Optimistic persistence:** real (uuid) items get an in-place override so they land instantly with no snap-back, then persist; the override clears when refreshed server data matches, or stays as a client-only placement with a "Saved locally (not persisted)" toast if the write fails.
  - **Live cross-client sync:** poll a cheap day fingerprint (count : max(updated\_at) : md5(overrides)); when it changes, background-refresh — **paused during an active drag/resize/select** so the grid never shifts under the cursor.
  - **Cut/paste clipboard:** "Move" parks the appointment in a right-side panel (origin slot shows a dashed hatched outline); BOOK slots read "Move here"; paste by click or drag; "Return to original slot" cancels. Survives day navigation.
  - **Copy panel:** parks a duplicate; pasting creates a *new* appointment; original stays.
  - **Rebook:** a month calendar with a green dot on every day the provider works *and* has a long-enough opening; picking a date parks the rebook; placing creates a new appointment linked via rebooked\_from\_appointment\_id. After a successful payment on a fresh appointment, prompt "Would you like to rebook?" (deferred until the invoice modal closes).

### 4.5 Appointment popover

Opened by left-click. 340 px, viewport-clamped. Contains: guest header (avatar, name, phone, profile link); service details with a status chip whose dropdown offers Cancel and Delete (armed confirm); edit / move / copy / more icons; service name, time range, provider, room, add-ons; an inline comments editor; a **card-on-file link row** (status + send/resend) for unconfirmed visits; payment actions (paid → "Show invoice"; unpaid → "Send payment link" + "Take payment"); and a footer grid (Message, Cancel, Print, More).

  

**Rule enforced everywhere: a paid appointment cannot be edited — it opens its receipt instead.**

### 4.6 Booking panel

Bottom-anchored full-width panel with three panes:

  

1.  **Guest** — mobile/first/last with typeahead comboboxes; picking a suggestion fills contact and loads visit stats (total visits, last visit, provider); profile link.
2.  **Service builder** — service combobox (excludes add-ons), request Any/Specific, provider, start (5-min steps), duration (fixed list + current value), computed end, room; "Add Service" appends a cart row and advances the start.
3.  **Cart** — inline-editable table (service, request, provider, price, room, start, duration, end) with per-row delete and an add-on picker scoped to that service's configured add-ons; add-ons render as indented child rows; rose total.

  

Header actions: error chip, Messages, Repeat, Add Note, Take Payment, **Save + Card Link** (saves then texts the card-capture link), Save. Validation: ≥1 service, first name required, mobile required for the card-link action. In edit mode the first row is the existing appointment and extra rows become new siblings **in the same group**.

### 4.7 Notes modal

Four tabs with counts: **Cancellations** (guest, was-time, service, booked/cancelled timestamps, source label, reason, Restore with 2-step confirm), **Wait list** (add form + removable rows), **Notes** (appointments carrying a staff note), **Unpaid**.

### 4.8 Invoice modal — collect

Two columns.

  

**Left (invoice):** header (business name, invoice no, guest or inline walk-in guest entry); line-item table (delete, add-on +, item + "Sale By: {provider}", qty, price, discount, final); split checkboxes when the invoice spans ≥2 appointments; totals (net, discount — flat or %, tax, sum); "paid so far"/"balance due" when a partial payment exists; discount input, coupon placeholder, comments. Sub-tabs: **Service** (combobox + qty + a **mandatory** "Sale by" provider, grouped into "can do this service · working today" vs "other staff working today", with a large picker modal) and **Gift Card** (sell: value + price with a confirm-on-change guard for promo pricing, card \# with scan/generate, duplicate blocking, rapid-scan focus behavior). Other tabs are placeholders.

  

**Right (collect):** Send Payment Link; method list (Cash, Credit/Debit, Card on file when available, Custom, Gift card, Points); amount (defaults to balance due), tip (only for non-card tenders — card tips are captured on the device), change, store checkbox, collect-tips checkbox; guard: "every service needs a provider before payment".

  

Paths:

  

  - **Card** → device handoff (below).
  - **Card on file** → one button "Charge the card on file" (server confirms synchronously; same status panel renders the result).
  - **Gift card covering with tip on tablet** → hand the redemption to the device, or record it here.
  - **Cash/custom/gift/points** → "Add Payment".

  

**Device handoff:** a location picker (remembered per computer in a cookie), one-tap device buttons with device photos and amounts, and a **live presence badge** polling every \~3.5 s ("tablet connected · register · reader ready" / "reader not connected" / "no tablet connected · last seen…"). A status panel polls every 1.5 s and renders a 4-step stepper (Sent → Tip → Card → Paid) for tablets, a single honest state for smart readers, and on success an emerald "Payment received" with the processing-fee breakdown and net. Closing the modal cancels a live handoff via sendBeacon so the guest's tip screen drops back to Welcome — but a card already in flight still completes via webhook.

  

**Couple split:** per-line checkboxes + "Create separate invoice"; add-ons follow their parent; the checked lines are queued as a fresh invoice ("collect this one, then the next"). Terminal is disabled for split invoices.

### 4.9 Invoice modal — receipt

Read-only: header with status badge (Paid in full / Partially refunded / Refunded); actions (**Refund** + View in processor, Print, Email, Text, Message); Invoice Details table; Payment Details table; an admin-only fee + net box (hidden from print); Tips Details.

### 4.10 Other dashboard sections

  - **Guests** — searchable table (avatar, display id, blocked badge, contact, visits, last visit, lifetime spend) → profile with tabs: General (editable info, access toggles, SMS opt-in with source, **cards on file** + text/copy card link, account block with a type-name-to-confirm delete), Appointments (upcoming/past, deep link to the day, inline cancel), Payments, Communications (live SMS thread + email history), Gift Cards, Packages (per-service progress bars), Notes.
  - **Services** — grouped by category with counts and search; editor modal with Details (name, category, price, time, recovery, description, add-on/active flags) and Add-ons (checklist of add-ons to attach); create/update/delete with armed confirm.
  - **Employees** — rebook leaderboard (last 30 days: appointments, rebooks, rate bar); master-detail editor with tabs General (incl. role, calendar color, **portal link** with copy/open/text), Services (per-category checklists with per-service extra minutes), Commissions (rule rows), Schedule (weekly hours + copy-first-day-to-all), Communication (live SMS thread).
  - **Provider Hours** — weekly-default grid (per-provider × weekday start/end + off toggles, per-row save, "populate from bookings") and by-week view (dated columns, override cells highlighted amber with Revert).
  - **Packages** — tiles (sold, with credits, outstanding, in catalog), sold table with per-service credits and a soft-delete requiring an audit note, collapsible catalog table.
  - **Gift Cards** — tiles (count, with balance, outstanding), searchable table with computed status badges. Read-only; issuance happens at checkout.
  - **Cancellations** — searchable across all dates; rose cards with source label and reason; Restore with confirm.
  - **Invoices & Payments** — **PIN-gated** (data isn't queried until unlocked); tabs Invoices/Reports; URL-synced filters (today toggle, date range, status, search); table with Date, Invoice, Guest, Provider, Status, Methods, Tip, Total, **Net** (total − commission − tips − fee) and footer sums; detail modal with fee summary and refund actions.
  - **Disputes** — build it properly (the reference is a placeholder): handle processor dispute webhooks, list open disputes with evidence deadlines, and alert the configured phone/email.
  - **Rooms & Equipment** — build it properly (also a placeholder in the reference): manage rooms, equipment, quantities per room, and the service→equipment AND-of-ORs mapping.

### 4.11 Conversations (two-way SMS)

  - Thread list with category tabs and counts: **Custom** (has a reply or a manual outbound) / Automated / Employees (phone matches staff) / Other. System sends (payment links, receipts, codes) do not promote a thread out of "other".
  - "+ New" starts a thread by phone with validation; rows show name, last-message time (time-today / Yesterday / date), an unread dot for inbound, and a preview. Polls every 5 s.
  - Thread pane: day separators, outbound-right/inbound-left bubbles, inline MMS images (proxied — see 8.3), per-message time + delivery status + segment/credit label.
  - Composer: attach / paste / drag-drop images (uploaded, size-capped, image-only), draft persistence per phone, Enter to send, and a **live segment/credit counter**.
  - Available as an in-calendar modal (draggable, resizable, persisted size), inside guest and employee profiles, and as a chrome-free pop-out window.

### 4.12 AI assistant UI

  - Floating launcher (stays mounted so the conversation survives navigation; resizable; expandable) plus a full-page route.
  - Header: brain selector (Claude/ChatGPT/Gemini, disabled when unconfigured), voice selector, **Compare** (fan one question out to all configured providers in parallel, each column continuing its own thread, with per-reply model/tokens/cost/seconds, saved to localStorage), New chat.
  - Messages: user right / assistant left, inline images, **consecutive assistant turns merge into one bubble**, per-reply read-aloud toggle, and a meta line (provider · model · tokens · estimated cost).
  - **Confirmation card** for every write action: title, human summary, key/value input dump, Approve / Cancel. Composer locked until resolved.
  - Composer: photo attach (vision), mic (hands-free loop: speak → auto-send → spoken reply → mic reopens; "yes"/"no" approves a pending write; bounded silence auto-stop), and a **live voice** button (realtime speech-to-speech over WebRTC, read-only tools, transcript saved into the main chat on end).

  

## PART 5 — GUEST-FACING SURFACES

All under a branded shell (fonts, colors, analytics from Settings).

### 5.1 Booking wizard (/book)

Deep links: ?service=\<slug\>, ?category=\<slug\>, ?package=\<slug\> (renders the package storefront instead).

  

Four steps:

  

1.  **Services** — solo or **couple** ("2 guests, side-by-side") with a per-guest cart (guest 1's picks pinned at the top of guest 2's list); category tabs + search; cards expand to show description and add-ons; running total.
2.  **Provider** — "Any provider" or a specific one; candidates = staff who can perform *every* service in the cart (a service with no capability rows is unrestricted).
3.  **Date & time** — month calendar marking days with ≥1 opening; slot grid.
4.  **Sign in + card + confirm** — OTP gate, then card-on-file (if required), then book.

  

**Availability engine** (pure, in business time): load weekly hours + per-date overrides (override wins; off = no window) + busy intervals (non-cancelled appointments and blocks). A start is open when the provider's window fits the whole chain without overlapping busy time, respecting slot step, same-day lead, and horizon. **Couple mode:** a start is open when provider A fits guest 1's chain *and a different* provider B fits guest 2's at the same start.

  

**On submit the server re-validates everything**: rebuilds each chain with server-side prices and durations, rejects add-ons not linked to their service and stale/inactive services, re-checks the slot against live availability, picks concrete providers (distinct per guest for couples), and returns a confirmation code — or slot-taken, which bounces the wizard back to step 3.

### 5.2 Guest identity (OTP)

  - Codes are **hashed** (SHA-256) in otp\_codes, keyed by 10-digit phone, with TTL, per-window throttle, and max attempts — all from Settings.
  - The code is deliberately **memorable** (reference: 3 digits, one doubled, never leading zero) and the SMS is **origin-bound** (@host \#code suffix) so iOS Security Code AutoFill and Android WebOTP offer it automatically.
  - UI: single-digit boxes with paste distribution, autocomplete="one-time-code", WebOTP retrieval, auto-submit on the last digit, resend / change number / locked-out states.
  - **Session cookie:** base64url(payload).base64url(HMAC), httpOnly, sameSite=lax, secure in production; remember-me consent controls lifetime; sliding renewal on activity; constant-time verification. **The signing secret must be a real configured secret** — never derived from the database URL.

### 5.3 Card on file

  - Required only when the processor is configured, the guest is signed in, and no card exists. Never blocks booking when unconfigured.
  - Find-or-create a customer keyed by verified phone; create a SetupIntent (off\_session, card only) with metadata (purpose, phone10, guest id, optional group id).
  - **On save, trust the metadata, not the id:** re-retrieve the intent and require purpose match, phone match, and succeeded status before recording.
  - Record: find-or-create the guest by the *same phone rule* booking uses, link the customer, upsert the payment method, make the newest default.
  - **Webhook backup** records the card if the guest closed the tab.

### 5.4 Account cabinet (/signin, /account)

Upcoming visits with self-cancel (allowed outside the cutoff; inside it, show "call us" with the configured number); packages with per-service remaining; gift cards with balances; sign out. Cancelling sets cancellation\_source = 'guest\_online'.

### 5.5 SMS cancellation

Inbound "2"/"cancel" from a known guest cancels everything on their **earliest upcoming business day** (one reply = one visit; later visits are mentioned), sets cancellation\_source='guest\_sms', texts a confirmation, and forwards a summary to staff. Gated by the automated-SMS master switch. Never throws.

### 5.6 Gift cards & packages online

  - Storefront with denominations, promo pricing, and custom amounts from Settings.
  - **Save-card requires an OTP-verified phone** matching the session.
  - **Fulfillment is idempotent** (keyed on the payment intent) and **repriced server-side**: reject unless the charged amount equals the recomputed price. Issue the card (unique code with collision retry) or the package (with per-service credit counters and the configured validity), write a paid invoice + line + payment, tag the intent with the invoice number, and send receipt + delivery emails. Runs from *both* the success screen and the webhook — whichever lands first wins; the other returns the existing result.

### 5.7 Card-capture link for phone bookings

  - Token = HMAC of the group id (or g:-prefixed guest id for a permanent profile link); a short base62 code (/c/\<code\>) redirects to the canonical URL so the text fits one segment.
  - States: invalid / expired / already-confirmed / ready (visit summary + card form, or one-tap "keep this card & confirm") / profile.
  - **The hold gates the link, not the slot** — the booking keeps its time regardless. Resend restarts the window and reuses the code so the earlier text still works.
  - Saving is deliberately **not** gated on the window: if the guest submits as it lapses, the card was already saved upstream, so record it.
  - Saving flips the group and its appointments booked → confirmed (idempotent, and reachable only from verified actions and the signed webhook).

### 5.8 Legacy deep-link redirects

Translate the old booking platform's URLs (service/category/package/gift-card links) to the new routes by resolving external ids to slugs, **forwarding ad attribution params** (gclid/fbclid/utm\_\*) while consuming source params, scanning for a UUID anywhere in a param value, and falling back to the catalog rather than 404ing.

  

## PART 6 — EMPLOYEE PORTAL

/portal/\<token\> — token = HMAC of the employee id, constant-time verified, resolved by scan. **Every portal action takes the token, not an employee id.** Invalid → 404 with a friendly "ask your manager" page. Note the tradeoff to fix in your build: this is a bearer link with no expiry or revocation — add rotation and revocation from Settings.

  

  - **Schedule tab:** Monday-first week, effective shift per day (override wins), non-cancelled appointments, prev/next week, hours-scheduled total, today highlighted.
  - **Payroll tab:** pay periods anchored per Settings, closed/in-progress badge, and — importantly — **computed from paid invoices' provider-attributed service lines, not from appointments** (so walk-ins and separately-collected services count and tips split correctly). Eligibility: paid invoices in the window with ≥1 locally-collected payment. Commission: most-specific rule wins. Tips: split across service lines by price. **Processing fees deducted** (spread across lines, remainder on the last) so the period total matches per-invoice figures to the cent. Read-only invoice view with a "Your pay" card.
  - **Schedule-change requests:** propose a new start/end and/or a break for a date; the server rejects proposals that would strand booked appointments; a new request supersedes any pending one; **every admin is texted** and replies **1 = approve** (which *actually applies* the override and creates the break), **2 = decline**, **3 = discuss**. Only the newest pending request is actionable, only from a phone on its admin list. The employee is texted the outcome; the admin gets an ack. Apply-failure leaves it pending and says so.

  

## PART 7 — TERMINAL / KIOSK

### 7.1 Model

The front desk and the payment device share state through a terminal\_sessions row that both sides poll. Statuses: pending → awaiting\_payment → processing → succeeded | failed | canceled | expired. TTLs from Settings.

  

**Three flows:**

  

1.  **Tablet + Bluetooth reader** — desk opens a session; tablet polls, shows the cart, collects a tip, creates the intent, collects the card.
2.  **Standalone smart reader** — server-driven: confirm a reader is online *before* creating the intent, then push the charge to the reader (tip is chosen on the reader's own screen and read back from the intent).
3.  **Card on file** — no device; the whole charge happens server-side (create unconfirmed intent → write session → confirm off-session). Tip comes from the desktop form and is baked in.

### 7.2 Rules that make it safe

  - **Success is never reportable by the device.** Devices may report only processing | failed | canceled. The webhook is the only thing that can mark a session succeeded.
  - **Last-send-wins:** opening a session supersedes stale handoffs on that register/appointment (cancelling them and voiding their intents) — *except* a same-appointment processing session, which **blocks** to prevent a double charge.
  - **Register auto-heal:** a stale browser tab asking for a register no device serves is rerouted to the one live device (never across configured locations).
  - **Already-paid guards** on both the group invoice and the appointment's own invoice (split partner).
  - **Beacon cancel:** a closing tab cancels a *pending* session with no auth header — the unguessable session id is the credential, and only a pending session can be killed.

### 7.3 Gift card on the tablet

Guest picks a tip; the server re-reads the **live** balance (never the snapshot). If it covers service + tip → redeem in full, session succeeds, no processor involved. Otherwise return a shortfall breakdown; nothing is debited yet. On confirm, **tip-first split**: empty the card (leaving the invoice partially\_paid), then collect the remainder on the reader — **the webhook settles the same invoice** (one bill, not two). Remainder below the processor minimum → "settle at the desk". Once the card is debited, an intent-creation failure does **not** unwind it — surface it so the desk collects the remainder by another tender.

### 7.4 Finalization & reconciliation

finalizeFromPaymentIntent (webhook + inline card-on-file), idempotent on the intent id:

  

  - Trust the intent for money (tip from the intent's tip details, else the session).
  - Reuse the group's open/partially-paid invoice **only when the charge covers the whole group**; a split never claims the group invoice; an already-paid group's extra charge gets its own invoice so it stays refundable.
  - Write invoice + provider-attributed line items (only if empty) + a payment row (method, livemode stamped); mark exactly the covered unpaid appointments paid; flip the group only once **fully** covered; tag the intent with the invoice number.

  

**Watchdog cron (every \~2 min):** classify each register healthy | offline | reader\_disconnected; send **edge-triggered**, de-duplicated SMS alerts to the configured numbers within the configured hours (out-of-window changes held until it opens; recovery message on heal). Same job **reconciles stuck sessions**: re-check sessions with an intent, older than a few minutes and younger than 48 h, against the processor — finalize captured money the webhook missed, fail declined ones, and correctly handle the cancel-vs-capture race without stamping "canceled" over collected money.

  

**Diagnostics endpoint:** one call answering "why can't we take a card?" — processor config/mode/location, DB reachability, schema drift, register mismatch vs recent payments, recent session breakdown. Returns non-200 when unhealthy so uptime checks catch it.

### 7.5 Tablet apps

Expo/React Native, iOS + Android, native Terminal SDK, single guest-facing kiosk screen, keep-awake, MDM/Guided-Access/Screen-Pinning for lockdown.

  

  - Reader lifecycle: permissions (Location; Android 12+ BLE) → fetch config → initialize → discover (Bluetooth scan) → auto-connect to the first reader and register it to the location → handle firmware update progress and auto-reconnect.
  - Home: heartbeat every \~5 s (with reader state), poll for a session every \~2 s when idle and ready.
  - Payment: show the bill + tip selector → post tip → retrieve/collect/confirm on the reader → thank-you. Handle gift-covered (no reader), gift-shortfall (breakdown → pay by card), decline retry (**retry the same intent — never re-tip or re-redeem**), reconnect-mid-split resume, self-heal polling (drop to Welcome when the session dies), and idle auto-cancel.
  - Android: scale every dimension from a fixed design canvas for tablet size variance; runtime BLE permissions; navigation-bar handling.
  - **Change from the reference:** device config (server URL, register, token, tip presets, branding) must be **fetched from the server at runtime**, not baked in at build time. Changing a register or rotating a token must never require an app rebuild.

  

## PART 8 — SERVER BUSINESS LOGIC

### 8.1 Booking

  - createBooking — Zod-validated; match guest by the 10-digit phone rule or create; insert one **group** + one appointment per line (wall-clock minutes → UTC through the business zone); attach add-ons; fire notifications (never throwing — a booking must not fail because a text failed); revalidate.
  - updateAppointment — edit one line; extraLines become **new siblings in the same group**; add-ons replaced wholesale; guest contact synced.
  - moveAppointment / moveBlock — carry only the field for the active view; recompute duration; clearRequested when moving a requested-provider appointment elsewhere.
  - cancelAppointment (with source), restoreAppointment (guarded to only act on cancelled rows), deleteAppointment (hard; also removes an emptied group with no invoice), updateAppointmentNote.
  - createBlock / updateBlock / deleteBlock — exactly one subject.
  - **Gap to close in your build:** the reference has **no server-side conflict detection** — double-booking prevention is presentational only. Enforce it server-side: provider/room/equipment collision checks (respecting equipment quantity and the AND-of-ORs mapping) at write time, with a clear override path for staff who deliberately overlap.

### 8.2 Billing

  - **Invoice numbering:** plain incrementing integers (or the configured scheme), computed as max(number::int)+1 over locally-created invoices only (imported numbers excluded so the series stays clean and is never reused). The **unique constraint is the real guarantee** — catch unique violations and retry with n+1.
  - **recordPayment** closes one or more appointments on a single invoice:
      
      - Methods cash | card | gift\_card | other (card runs through the device/webhook path, which writes its own payment row — never double-write).
      - Per-appointment invoicing: stamp exactly the covered appointments; the group flips paid only when **all** its appointments are covered.
      - **Selling gift cards at checkout:** vet duplicates/taken codes *before* any write; issue real cards once the invoice exists with a +load ledger row; face value may exceed the paid amount (promo).
      - **Paying with a gift card:** vet (active, unexpired, sufficient), then **atomically decrement with a** **WHERE balance \>= amount** **guard** (no transactions available); credit back on any later failure; write a negative ledger row.
      - **Partial pay:** only a gift card may leave an invoice open (partially\_paid, hung off the group); refuse a partial on a split subset. A later tender settles the same invoice through the reuse path.
      - Reuse a group's existing open/partially-paid invoice rather than minting duplicates — but only when the payment covers the whole group.
      - Line items prefer the exact modal lines; otherwise rebuild from the paid appointments **with provider attribution**.
  - **Reporting:** filtered invoice list (date range on paid\_at, status, free-text on number or guest), capped with a capped flag, rolling up methods, paid, **fee**, providers, **commission**, and **net = total − commission − tips − fee**. Aggregate totals computed across the whole filter, not the page.
  - **Refunds:** full or partial against the stored intent, clamped to the remaining refundable amount; mark refunded/partially\_refunded and track refunded\_cents; **only a full refund reopens the invoice** as refunded. Resolve the charge's own live/test universe from the stored livemode (backfilling legacy nulls from the processor).
  - **Processor integration:** per-mode clients (test/live side by side, active mode from Settings); connection tokens; card-present intents; server-driven reader charges with tipping config; card-on-file two-step (create unconfirmed → write session → confirm off-session); webhook signature verification trying both mode secrets; card details from the expanded charge (with wallet fallback); reader tipping presets and splash branding applied to the account's terminal configuration.

### 8.3 Communications

  - **Dispatcher:** resolve provider (explicit-if-configured, else auto), normalize to E.164, send, then **log to the thread** with an origin classification (manual composer, automated lifecycle, system staff-triggered). Typed errors: not-configured, no-sender, invalid-number, send-failed (with the provider's real detail).
  - **Twilio:** messaging-service sender preferred (10DLC) or a from-number; per-message status callback; signature verification reconstructing candidate URLs from forwarded headers; strict rejection only when explicitly enabled.
  - **RingCentral:** JWT server-to-server OAuth with an in-memory token cache refreshed ahead of expiry and a shared in-flight refresh; **MMS uploads raw bytes as multipart** (no fetch-by-URL support), pulled from the outbound-media endpoint; **no static webhook** — an idempotent subscription must be (re)created on a schedule and after any URL change.
  - **One inbound webhook for both providers** with auto-detection (validation-token header or JSON → RingCentral; form-encoded → Twilio, answer with empty TwiML). **Inbound routing order:** schedule-change admin reply (1/2/3) → guest cancel ("2"/"cancel") → forward to the comms staffer.
  - **Delivery status** maps both vocabularies and **only ever moves forward** (queued \< sent \< delivered = undelivered = failed) so a late callback can't downgrade a delivered message.
  - **MMS media:** inbound is proxied server-side with authentication and a **strict host allowlist scoped to your own account** (SSRF guard), following the provider's redirect manually so credentials never cross origins; outbound images are stored and served by unguessable UUID with immutable caching.
  - **Forwarding** budgets to **one segment**: transliterate Unicode to GSM-7, compose {BusinessShortName} {name}: "{reply}" (prev: "{context}") against 160 chars, recomposing against 70 if anything non-ASCII survives.
  - **Email** via Resend REST: branded shell + templated payment link, receipt, confirmation, reminder, gift-card receipt/delivery, package receipt. Typed errors; never throws.
  - **Templates:** DB overrides merged over built-in defaults; {{var}} rendering; per-(event,channel) enable; email HTML wrapping with escaping and paragraph handling.
  - **notifyAppointmentBooked****:** one guest confirmation covering the whole visit (earliest start; distinct services/providers joined) + **one message per provider**. Provider SMS is an *imminent-only* heads-up per the configured rule; the add-on line is included only if the message still fits one segment. Supervisor copies per the configured rule table.
  - **runReminders****:** day-of and day-before buckets computed **in SQL against the business zone** (DST-safe), grouped by contact so one person gets one merged reminder listing every service. Gated by the master switch.
  - **Threading:** conversations keyed on the normalized address, with employee/guest classification and the category rules described in 4.11.

### 8.4 AI assistant

  - **System prompt** carries: role, today's date, a live spa snapshot (business name, active providers, categories, rooms), **standing memories**, and hard contracts:
      
      - *Be resourceful:* retry broader, decompose composite requests (a couples massage is two simultaneous appointments), only ask after exhausting interpretations.
      - *Arithmetic:* never do mental math — DB numbers go through SQL aggregates, conversation numbers through a calculator tool.
      - *SQL fallback:* never say "I can't look that up" — fetch the schema, then run a read-only query.
      - *Actions:* writes don't execute immediately; state intent first, then call exactly **one** write tool alone with complete arguments; report success/failure honestly.
  - **Read tools** (auto-run): calculator, day schedule, services, providers, rooms, equipment, guest search, guest history, gift-card lookup, revenue report, provider performance, rebooking report, top services, database schema, read-only SQL.
  - **Write tools** (confirmation-gated, each with a human summary): remember, forget, create booking, move appointment, record payment, create block, send SMS.
  - **SQL sandbox:** single statement, must start with SELECT/WITH, word-boundary forbidden-keyword list, no semicolons, row cap, cell truncation, and the Postgres error surfaced back so the model self-corrects.
  - **Agent loop:** provider-agnostic canonical block history (text / tool\_use / tool\_result / image) so the brain can be switched **mid-conversation**; bounded tool iterations; stream text deltas + tool activity + message snapshots as NDJSON; the **first write tool pauses the loop** and emits a pending action; a follow-up request carrying the decision resumes (running it if approved and the id matches, else recording a decline).
  - Per-provider adapters (prompt caching where available; reasoning effort; thinking budgets; friendly rate-limit handling) and a display-only cost table.
  - **TTS** with provider fall-through and a browser-speech fallback; **realtime voice** mints an ephemeral session carrying the same prompt but **read-only tools**, with a separate tool-bridge endpoint that refuses write tools.

  

## PART 9 — API ROUTES & JOBS

|  |  |  |
| :-: | :-: | :-: |
| \*\*Route\*\* | \*\*Auth\*\* | \*\*Purpose\*\* |
| POST/GET /api/sms/inbound | provider signature (opt-in strict) / validation-token echo | Unified inbound for both providers; routes to schedule-reply → cancel → forward |
| POST /api/sms/status | signature | Delivery callbacks (forward-only status) |
| GET /api/sms/media | host allowlist (SSRF guard) | Authenticated inbound MMS proxy |
| POST /api/sms/upload | — | Composer image upload (image-only, size-capped) |
| GET /api/sms/outbound-media/\\\[id\\\] | unguessable UUID | Serves outbound MMS bytes |
| POST /api/stripe/webhook | signature, gated on the \*\*event's own\*\* livemode | Terminal finalization, gift-card/package fulfillment, card-on-file setup intents. Return 5xx on transient finalize failure so it retries; handlers idempotent |
| POST /api/terminal/connection-token | device token | Reader SDK token |
| GET /api/terminal/config | device token | Reader discovery config |
| GET /api/terminal/current | device token | Session long-poll \*\*+ passive presence heartbeat\*\* |
| POST /api/terminal/heartbeat | device token | Explicit liveness + reader state |
| POST /api/terminal/session/\\\[id\\\]/tip | device token | Create intent / gift-card redeem or shortfall |
| POST /api/terminal/session/\\\[id\\\]/gift-split | device token | Confirm gift shortfall split |
| GET/POST /api/terminal/session/\\\[id\\\]/status | device token | Poll for death / report progress (never success) |
| POST /api/terminal/session/\\\[id\\\]/cancel | session UUID only | sendBeacon target for a closing tab |
| GET /api/terminal/diagnostics | device token | Health check; non-200 when unhealthy |
| POST /api/assistant | — | NDJSON agent stream |
| POST /api/assistant/tts | — | Audio, or 204 → browser fallback |
| POST /api/assistant/realtime/session | — | Ephemeral realtime key (read tools only) |
| POST /api/assistant/realtime/tool | — | Read-only tool bridge |
| POST /api/notify | cron secret | Ops SMS for out-of-band jobs (e.g. backup failures) |
| GET /api/appointments/version | — | Cheap day fingerprint for calendar polling |
| GET/POST /api/sync/\\\<source\\\> | cron secret; token via \*\*header\*\* (never query — it would land in logs) | Migration sync with per-pass flags |
| GET /api/cron/reminders | cron secret | Day-before / day-of reminders |
| GET /api/cron/sms-subscription | cron secret | Renew the inbound webhook subscription |
| GET /api/cron/terminal-health | cron secret | Watchdog + stuck-session reconcile |

  

**Cron schedules come from Settings** (the reference hardcodes them in a deploy config). Reference cadence: reminders daily, subscription renewal daily, sync daily over −2/+30 days with reconcile, watchdog every 2 minutes, backups twice daily with 90-day retention.

  

**Deploy-time migrations:** run pending migrations in the build command *before* the app builds, gated to production only (preview deploys share the prod database — migrating there would apply unmerged migrations). Fail the build if a migration fails, so a broken deploy never goes live.

  

**A note on the reference's auth pattern to fix:** cron and device endpoints are **open when their secret env var is unset** (a dev convenience). In your build, fail closed outside development.

  

## PART 10 — MIGRATION FROM ZENOTI (OR SIMILAR)

Only relevant if migrating. The reference's approach, worth copying:

  

  - **Client:** thin fetch wrapper; token normalized to a Bearer header; no-store; typed auth errors on 401/403 surfacing needsToken to the UI; page/size pagination with page caps. There is **no long-lived API key** — the bearer is lifted from a logged-in browser session and is short-lived, so Settings must let the owner paste a fresh one without a redeploy.
  - **Config resolution:** explicit → Settings row → env → defaults.
  - **Passes, in FK order:** categories → service→category map → services → employees → rooms → equipment → packages → guest book (chunked bulk upserts) → gift cards (per-guest walk, bounded concurrency) → guest packages (per-guest walk) → then, per date range: schedules (roster) → appointments (one call per day) → sales report → derived mappings → heal.
  - **Idempotency:** every table has the external id; all writes are upserts on it. **Lowercase the external id** — the source returns the same GUID in different case across endpoints and Postgres unique keys are case-sensitive (this alone prevents duplicate guests).
  - **Roster chunking:** the schedules endpoint's end date is exclusive and rejects spans \> 30 days → walk in ≤29-day chunks, querying one day past each inclusive end. Abort the whole pass on a malformed response rather than marking a stretch of dates "off".
  - **Protecting locally-collected money** (the most important rule):
      
      - zenoti\_id IS NULL = ours. Never touched by reconcile.
      - Before each invoice upsert, guard the open-downgrade: if the incoming status is open but a succeeded local payment exists, keep it paid. A local void can never be resurrected.
      - Delete-and-reinsert imported payments only (WHERE zenoti\_id IS NOT NULL).
      - Run a **heal pass at the end of every sync** that restores any invoice holding a succeeded local payment (status, paid\_at, tips, totals) — so a clobber from any path never survives one sync.
  - **Invoice-number collisions:** the source reuses numbers across years and its sales report carries no GUID — match by a candidate list (synthetic sale key, raw number, receipt number, prefix-stripped variant), update in place on a hit, insert keyed on a synthetic id otherwise. Wrap per-invoice writes in try/catch so one bad row can't abort a months-long import.
  - **Reconcile:** deactivate rows the source no longer returns — but **only when the fetched set is non-empty** (an empty set means a failed call, not an empty catalog). Gift-card reconcile only on a fully successful pass. Appointment reconcile scoped to imported rows only.
  - **Sales chunking:** ≤7-day windows (endpoint cap).
  - **Demo data:** rows with a null external id are seed data; provide preview + clear tools in Settings.

  

## PART 11 — SECURITY MODEL

1.  **Money is server-side.** Every amount is recomputed from the database. Online fulfillment requires the charged amount to equal the repriced amount.
2.  **Trust intent metadata, never ids.** When confirming a card save or a purchase, re-retrieve the intent and verify purpose + phone/group binding + status.
3.  **Webhooks:** verify signatures against the raw body; gate on the event's own live/test mode; keep handlers idempotent; return 5xx only for transient failures you want retried.
4.  **Devices can't declare success.** Only the webhook (or a reconcile against the processor) marks money collected.
5.  **HMAC tokens** for guest sessions, card links, and portal links; constant-time comparison; **real configured secrets with no derived fallback**; rotation and revocation exposed in Settings.
6.  **SSRF:** media proxying restricted to an allowlist scoped to your own account; manual redirect handling so credentials never cross origins.
7.  **Read-only SQL sandbox** for the assistant; write tools always human-confirmed; live voice gets read tools only.
8.  **Fail closed.** No endpoint may be open merely because its secret is unset in production.
9.  **Secrets never in query strings or logs** (sync token goes in a header).
10. **PIN/role gates** on financial pages, with data not even queried until unlocked.

  

## PART 12 — BUILD ORDER & ACCEPTANCE

### Milestones

1.  **Foundation** — stack, conventions, full schema, seed, deploy-time migrations.
2.  **Settings** — the whole of Part 3, with typed accessors used by everything downstream. *Do this before the features that read it.*
3.  **Catalog & staff** — services, categories, add-ons, rooms, equipment, employees, capabilities, commissions, weekly hours + per-date overrides.
4.  **Appointment book** — geometry, three views, booking panel, popover, move/resize/cut/paste/copy, block-out, notes/waitlist, live sync.
5.  **Billing & POS** — invoices, numbering, recordPayment with all tenders, gift-card sale/redemption, splits, refunds, PIN-gated invoice reporting.
6.  **Terminal** — sessions, three flows, presence, watchdog, reconcile, diagnostics, tablet apps with server-fetched config.
7.  **Communications** — dispatcher, both providers, unified inbound with routing, threading, media, templates, reminders.
8.  **Guest surfaces** — booking wizard (incl. couples), OTP, card on file, account cabinet, SMS cancel, gift cards, packages, card links, legacy redirects.
9.  **Employee portal** — schedule, fee-aware payroll, schedule-change requests with SMS approval.
10. **AI assistant** — tools, confirmation gating, multi-provider, voice, memories.
11. **Migration** — the sync subsystem, if needed.
12. **Hardening** — Part 11 end to end; then the gaps listed below.

### Hardening checklist — the things builds like this get wrong

These are the failure modes that a system of this shape tends to ship with. Treat
them as requirements, not polish — each one is cheap now and expensive later.

  - **Server-side booking conflict detection** (double-booking, room/equipment capacity, the AND-of-ORs capability mapping). Doing this client-side only is the single most common mistake here: the client is trusted for *choices*, never for *validity*.
  - **Disputes** — it is easy to define the schema and never wire the webhook handling, the UI, or the alerts. Close the loop.
  - **Rooms & equipment admin** — needs real CRUD, not a placeholder page.
  - **Package redemption at checkout** — counters are easy; the decrement path on the actual checkout is the part that gets skipped.
  - **Fail-closed auth** — cron and device endpoints must reject when their secret is unset. An endpoint that is open because nobody configured it is the worst kind of open.
  - **Real configured HMAC secrets** — never derive a signing secret from another value as a fallback.
  - **Pay-period length** — pick one number, drive it from Settings, and make the portal and the schema agree. Two components disagreeing about the pay period is a payroll bug.
  - **Tablet/device config must be server-fetched**, never baked into the build.
  - **Nothing operational in an env var or a constant** — the whole point of Part 3.

### Acceptance criteria

  - A non-technical owner can change the business name, address, timezone, hours, tax, tip presets, gift-card pricing, reminder times, alert numbers, AI model, and reader branding **entirely from Settings, with no redeploy and no app rebuild**.
  - Booking the same slot twice from two browsers loses cleanly (slot-taken), and the calendar in another tab updates on its own without a manual refresh.
  - Killing the webhook and taking a card payment still results in a correctly closed invoice within the reconcile window.
  - A gift card that can't cover the bill splits tip-first across two tenders onto **one** invoice.
  - A couples booking can be paid together or split apart, and the split partner's block stays unpaid-colored until it's actually paid.
  - Re-running the migration sync never turns a locally-paid invoice back to open.
  - The assistant refuses to invent a price, always confirms before writing, and can answer a question its tools don't cover via read-only SQL.
  - The system boots and renders with no database, no SMS provider, no processor key, and no AI key configured.

  

  

*This specification was reverse-engineered from a working production system, then de-identified. Where it flags a rule "to fix" or a hardening item, that reflects a real lesson learned in production — treat those as requirements for the new build, not optional polish.*