Files
seminarhof-e2e-mrfyfnqt/docs/booking-lifecycle.md
2026-07-11 07:59:25 +02:00

169 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<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 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 713, 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).