# Booking Lifecycle — Design Spec Status: **draft for sign-off** · Date: 2026-05-21 · Phase: lifecycle state model + admin overview UI + email service shell + daily cron. Decisions locked with the owner: - **Milestone columns** model (lean status enum; contract/deposit tracked as columns + the existing `invoice` row) — not extra statuses, not a long-running workflow. - **Admin records everything.** No client-facing UI this phase. - **Deadline anchor = contract email send date** (the 1-year-before-start mark = day 0). - **On 14-day unpaid: flag for admin review only.** No auto-cancel, no auto-revert; soft reservation stays. - Email = **service shell** (named methods that log + no-op). Real templates later. --- ## 1. Status enum (the "bucket") Reshaped from the current 7 states down to 4 active + 1 terminal. `form_received` and `deposit_paid` are **removed** — those moments become milestone columns / derived facts. | State | Meaning | |---|---| | `enquiry` | Client submitted an enquiry; awaiting admin approve/decline. | | `reserved` | Dates soft-reserved (block others). The whole contract/deposit dance happens **inside** this state. | | `confirmed` | Admin recorded deposit received → reservation guaranteed. | | `completed` | Event has taken place. | | `cancelled` | Terminal. Declined enquiry, or cancelled at any non-terminal stage. | ### Transitions (server-enforced, audited) | From | To | Driver | |---|---|---| | `enquiry` | `reserved` | MANUAL — admin approves | | `enquiry` | `cancelled` | MANUAL — admin declines | | `reserved` | `confirmed` | MANUAL — admin records deposit received | | `reserved` | `cancelled` | MANUAL — admin cancels (e.g. after overdue flag, or contract declined) | | `confirmed` | `completed` | AUTOMATIC — daily cron when `endDate < today` (admin may also force) | | `confirmed` | `cancelled` | MANUAL — late cancellation (refunds out of scope) | | `completed` / `cancelled` | — | terminal | `cancelled` reachable from every non-terminal state. Every transition writes an `auditLog` row (existing format). --- ## 2. New milestone columns on `booking` (migration 0006) All nullable ISO-8601 TEXT (matching `created_at` style). camelCase ↔ snake_case. | Column (TS) | Type | Meaning | |---|---|---| | `contractSentAt` | TEXT NULL | When contract+deposit email was sent. **Day-0 anchor** + cron idempotency guard. | | `contractResponse` | TEXT NULL | `'approved' \| 'declined' \| null` (null = pending). Admin-recorded. | | `contractResponseAt` | TEXT NULL | When admin recorded the response. | | `depositReminderSentAt` | TEXT NULL | Day-7 reminder sent (idempotency guard). | | `flaggedAt` | TEXT NULL | Set when something needs admin review (deposit overdue / short window / contract declined). | | `flagReason` | TEXT NULL | `'deposit_overdue' \| 'short_window' \| 'contract_declined'`. | **Deposit lives on the existing `invoice` table** (no duplicate columns): - Deposit invoice: `kind='deposit'`, created by cron R1. - Due date = `invoice.dueOn` = `contractSentAt + 14d`. - Received = `invoice.paidOn IS NOT NULL` (+ `status='paid'`). On contract approval, also stamp the existing `agbAcceptedAt` / `agbYear` (the contract IS the AGB acceptance). **Data remap** (migration + seed): `form_received → reserved`; `deposit_paid → confirmed` (with its deposit invoice marked paid). Migration includes `UPDATE booking SET status=... WHERE status IN ('form_received','deposit_paid')` for safety on any non-reset DB. --- ## 3. Daily cron — `booking-lifecycle-daily` `wireScheduler({ name: 'booking-lifecycle-daily', schedule: '0 6 * * *', func })`. All date math in **Europe/Berlin** local calendar days. Idempotent (safe to run twice) via the `*SentAt IS NULL` / `flaggedAt IS NULL` guards. For each booking where `status NOT IN ('cancelled','completed')`: ``` R1 Contract dispatch (≈1yr before start, evaluated daily with <=, not ==) if reserved and contractSentAt is null and (startDate - today) <= 365d: create deposit invoice (dueOn = today + 14d) email.sendContractAndDepositEmail(bookingId) set contractSentAt = now R2 Day-7 reminder if reserved and contractSentAt set and depositReminderSentAt is null and deposit unpaid and (today - contractSentAt) >= 7d: email.sendDepositReminderEmail(bookingId) set depositReminderSentAt = now R3 Day-14 expiry → FLAG ONLY if reserved and contractSentAt set and deposit unpaid and flaggedAt is null and (today - contractSentAt) >= 14d: set flaggedAt = now, flagReason = 'deposit_overdue' R4 Auto-complete if confirmed and endDate < today: setBookingStatus(bookingId, 'completed') // system ``` Using `<=` for R1 means a booking approved *inside* the 1-year window sends on the next daily tick (handles "approved late → send soon" for free). If `startDate` is < ~14d away at approval, flag `short_window` instead of running a deadline that lands past the event. --- ## 4. Email service shell A singleton `email` service (registered in `services.ts`, added to `SingletonServices`), each method logs + no-ops for now. | Method | Fires when | Driver | |---|---|---| | `sendEnquiryReceivedEmail(bookingId)` | enquiry submitted | enquiry-create RPC | | `sendEnquiryDeclinedEmail(bookingId)` | admin declines enquiry | `setBookingStatus enquiry→cancelled` | | `sendReservationConfirmedEmail(bookingId)` | admin approves → reserved | `setBookingStatus enquiry→reserved` | | `sendContractAndDepositEmail(bookingId)` | day 0 (cron R1) | cron | | `sendDepositReminderEmail(bookingId)` | day 7 unpaid (cron R2) | cron | | `sendBookingConfirmedEmail(bookingId)` | admin records deposit received | `recordDepositReceived` | | `sendCancellationEmail(bookingId)` | admin cancels a non-enquiry booking | `setBookingStatus → cancelled` | Day-14 expiry sends **no** client email (flag only). --- ## 5. Backend RPCs | Action | RPC | New/reuse | Effects | |---|---|---|---| | Approve enquiry | `setBookingStatus(id,'reserved')` | reshape | + reservation email; flag `short_window` if start < ~14d | | Decline enquiry | `setBookingStatus(id,'cancelled')` | reshape | + declined email | | Record contract response | `recordContractResponse(id, 'approved'\|'declined')` | **NEW** | sets `contractResponse(_at)`; approved → `agbAcceptedAt`/`agbYear`; declined → `flaggedAt`/`flagReason='contract_declined'` | | Mark deposit received | `recordDepositReceived(id)` | **NEW** | `invoice.paidOn`+`status='paid'`; clear `flaggedAt`; → `confirmed`; + confirmed email | | Cancel (any stage) | `setBookingStatus(id,'cancelled')` | reshape | deposit invoice → `cancelled`; + cancellation email | | Mark completed (override) | `setBookingStatus(id,'completed')` | reshape | normally cron R4 | | (Optional) Send contract now | `triggerContractEmail(id)` | **NEW (small)** | runs R1 immediately — useful for testing + early sends; otherwise the 1-yr wait blocks all manual flows | `setBookingStatus` fires the email matching the transition it owns; milestone side-effects (deposit, contract response, AGB) live in the dedicated `record*` RPCs. All `record*` RPCs write audit rows. `getAdminBookingDetail` output extends with: `contractSentAt`, `contractResponse`, `contractResponseAt`, `depositReminderSentAt`, `flaggedAt`, `flagReason`, and a deposit-invoice summary (`dueOn`, `paidOn`) to drive the UI time cues. ### `nextAction(booking, depositInvoice)` — derived, drives the UI CTA Priority order: enquiry→"Approve or decline" (admin) · flagged→`flagReason` (admin) · reserved+no contractSentAt→"Awaiting 1-yr mark" (system) · contractSentAt+no response→"Client to sign contract" (client) · approved+unpaid→"Client to pay deposit" (client, due `invoice.dueOn`) · declined→"Cancel — contract declined" (admin) · confirmed→"Awaiting event" (system) · terminal→none. --- ## 6. Admin overview UI — `BookingStatusPanel` New `` panel mounted **above the Tabs**, below the header, in `admin.bookings.$bookingId.tsx`. Header `Badge` swapped for `StatusPill`. Lift the transition map into `lib/status.ts` (`BOOKING_TRANSITIONS`) and reuse in both the panel and the existing `StatusChanger`; trim `BookingStatus` type + `BOOKING_STATUS` palette to the 5 states. Layout: `SimpleGrid cols={{ base:1, md:2 }}` — **Timeline left**, **Next-action + transitions right**. - **Timeline (Mantine `Timeline`)** shows milestone progression (richer than the enum): `Enquiry → Reserved → Contract sent → Contract signed → Deposit paid → Confirmed → Completed`, each completed step with its date; current = plum ring; upcoming = gray hollow; `cancelled` = terminal red item replacing the unreached tail. - **Next-action `Alert`** — single most important outstanding item from `nextAction()`, color/icon by urgency, with the inline action button + "Owner: Admin/Organiser". - **Transition controls** — one `Button` per valid transition (data-driven off `BOOKING_TRANSITIONS`); destructive (cancel/decline) separated below a `Divider`, red outline; reuse the existing confirm `Modal` for terminal transitions. - **Time cues** (from `contractSentAt`): "Day X of 14" + `Progress` bar (plum ≤6, yellow 7–13, red ≥14 unpaid); reminder-sent `Badge`; overdue → prominent red `Alert` at top of panel + "Needs review" pill by the header, booking stays `reserved`. - **Empty/early states**: contract not yet due (start >1yr out) → gray info alert "Contract email scheduled ~{date}"; cancelled/completed → terminal note, no actions; reserved but no deposit invoice yet → link to invoices tab. i18n under `pages.adminBooking.status.*` (panelTitle, nextAction.*, actions.*, cues.*, empty.*, party.*), reusing existing `pages.bookingStatus.label.*`. Mirror en + de. Responsive: timeline stacks above actions on `base`; transition buttons `fullWidth` on narrow. --- ## 7. Build order 1. Migration 0006 (milestone columns + status remap) + seed remap → `db reset` + regen. 2. `lib/status.ts` trim + `BOOKING_TRANSITIONS`; update `set-booking-status` enum/transitions/emails. 3. Email service shell (`services/email.ts`) + register + `SingletonServices` type. 4. New RPCs: `recordContractResponse`, `recordDepositReceived`, `triggerContractEmail`; extend `getAdminBookingDetail`. 5. Daily cron `booking-lifecycle-daily` + scheduler wiring. 6. `BookingStatusPanel` component + mount + header pill swap + i18n (en/de). 7. Typecheck, smoke-test cron + transitions. ## Open points (defaults chosen; flag if you disagree) - **Auto-complete** `confirmed → completed` via cron when the event has passed (admin can still force). Default: **yes, automatic.** - **`triggerContractEmail`** manual admin action included (otherwise the 1-year wait blocks all manual/testing flows). Default: **include.** - `deposit_paid` seed/data rows remap to **`confirmed`** (deposit was received).