11 KiB
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
invoicerow) — 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 <Paper> 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 fromnextAction(), color/icon by urgency, with the inline action button + "Owner: Admin/Organiser". - Transition controls — one
Buttonper valid transition (data-driven offBOOKING_TRANSITIONS); destructive (cancel/decline) separated below aDivider, red outline; reuse the existing confirmModalfor terminal transitions. - Time cues (from
contractSentAt): "Day X of 14" +Progressbar (plum ≤6, yellow 7–13, red ≥14 unpaid); reminder-sentBadge; overdue → prominent redAlertat top of panel + "Needs review" pill by the header, booking staysreserved. - 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
- Migration 0006 (milestone columns + status remap) + seed remap →
db reset+ regen. lib/status.tstrim +BOOKING_TRANSITIONS; updateset-booking-statusenum/transitions/emails.- Email service shell (
services/email.ts) + register +SingletonServicestype. - New RPCs:
recordContractResponse,recordDepositReceived,triggerContractEmail; extendgetAdminBookingDetail. - Daily cron
booking-lifecycle-daily+ scheduler wiring. BookingStatusPanelcomponent + mount + header pill swap + i18n (en/de).- Typecheck, smoke-test cron + transitions.
Open points (defaults chosen; flag if you disagree)
- Auto-complete
confirmed → completedvia cron when the event has passed (admin can still force). Default: yes, automatic. triggerContractEmailmanual admin action included (otherwise the 1-year wait blocks all manual/testing flows). Default: include.deposit_paidseed/data rows remap toconfirmed(deposit was received).