Files
seminarhof-e2e-mrfyultj/docs/booking-lifecycle.md
2026-07-11 08:11:01 +02:00

11 KiB
Raw Blame History

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).