Booking Lifecycle Protocol β
This document specifies the Booking State Machine β the Hasura Actions, Scheduled Triggers, and cascade sequences that drive a Booking through its lifecycle: DRAFT β PENDING_PAYMENT β DEPOSIT_PAID β FULLY_PAID β COMPLETED (happy path).
Contexts involved: Commerce (booking mutations, payment processing) Β· Communications (notifications via n8n) Β· Backoffice (CostingSheet lock via ADR-009) DDD pattern: Webhook-triggered state transitions as Hasura Actions; time-driven transitions as Scheduled Triggers. All cascades execute as single ACID transactions β no distributed saga compensation.
NOTE
Framework: The project uses @golevelup/nestjs-hasura. Event Triggers and Cron Triggers are declared as TypeScript decorators on NestJS handler methods. NestJS is the source of truth β triggers auto-register with Hasura's metadata API on startup. Trigger definitions are version-controlled code.
Hasura Action Definitions β
These are Hasura Actions β custom mutations routed to NestJS. The booking state machine transitions require validation guards (not just reactive DB triggers), so they are modeled as Actions, not Event Triggers. Each action handler performs the booking mutation + cascade in a single Postgres transaction via Hasura's
run_sqlor direct Postgres client.
submitCheckout β DRAFT β PENDING_PAYMENT β
| Property | Value |
|---|---|
| Action name | submitCheckout |
| Input | { checkout_session_id: UUID } |
| Handler route | POST /hasura/actions/submit-checkout |
| Guards | CheckoutSession.status = ACTIVE, expires_at > now(), TourOffering.status = SCHEDULED, price version matches active_price_matrix_id. If is_door_pickup = true: door pickup count for this TourOffering has not reached tour_offerings.max_door_pickups. |
| Transaction | 1. Create Booking (status: PENDING_PAYMENT, reference_number: auto-generated) 2. Create Passenger rows from selected_options.passengers β pass through is_door_pickup from options, and if true, also populate door_pickup_address JSONB 3. Create Ancillary rows from selected_options.ancillary_ids 4. For each passenger with a boarding point surcharge (stop or door pickup), auto-create a BOARDING_SURCHARGE ancillary (per-passenger, quantity: 1). Surcharge amount resolved from tour_offerings.available_boarding_points. Label auto-generated: "Zustiegszuschlag: {stop_name}" or "HaustΓΌrabholung: {stop_name}". boarding_point_id always references boarding_point_library. 5. Create SeatReservation rows (status: HELD, hold_expires_at = now() + 30min) from selected_options.seat_selections 6. Update CheckoutSession.status β CONVERTED, set booking_id 7. Create Mollie payment (type: DEPOSIT, amount from DepositConfig resolution β includes surcharges in total_amount) |
| Output | { booking_id, payment_redirect_url } |
| Events emitted | None (booking is not yet confirmed β waiting for payment) |
| Errors | SessionExpired (410), PriceVersionMismatch (409), TourNotAvailable (422), SeatUnavailable (409), DoorPickupCapacityReached (422) |
Deposit Calculation β
The submitCheckout handler calculates the deposit amount via a cascading resolution:
class DepositCalculationService {
/**
* Resolves the deposit amount for a booking.
*
* Resolution cascade:
* 1. TourTemplate.deposit_config (if set)
* 2. Operator.deposit_config (tenant default)
* 3. System default { percentage: 20, type: 'PERCENTAGE', min_amount: null }
*
* Special case: if departure is < 30 days away, full payment is required
* (no deposit/final-payment split β single FINAL_PAYMENT instead).
*/
calculateDeposit(booking: {
total_amount: number;
tour_offering: { start_date: Date; tour_template_id: string };
tenant_id: string;
}): { deposit_amount: number; is_full_payment: boolean } {
const daysUntilDeparture = differenceInDays(
booking.tour_offering.start_date, new Date()
);
// Near-departure bookings: full payment only
if (daysUntilDeparture < 30) {
return { deposit_amount: booking.total_amount, is_full_payment: true };
}
const config = this.resolveDepositConfig(
booking.tour_offering.tour_template_id,
booking.tenant_id
);
let deposit: number;
if (config.type === 'PERCENTAGE') {
deposit = booking.total_amount * (config.percentage / 100);
} else {
deposit = config.percentage; // type: 'FIXED', percentage field holds absolute value
}
if (config.min_amount && deposit < config.min_amount) {
deposit = config.min_amount;
}
return { deposit_amount: Math.round(deposit * 100) / 100, is_full_payment: false };
}
}Near-departure full payment bypass: When is_full_payment = true, submitCheckout creates a Payment with type = 'FINAL_PAYMENT'. The Booking skips DEPOSIT_PAID entirely: DRAFT β PENDING_PAYMENT β FULLY_PAID. This is valid within the existing state machine β no new transitions needed.
processPaymentWebhook β Mollie webhook handler (multiple transitions) β
| Property | Value |
|---|---|
| Route | POST /webhooks/mollie |
| Input | { id: string } (Mollie transaction ID) β Mollie sends only the payment ID, handler fetches full status via Mollie API |
| Signature verification | Validate webhook signature using Mollie API key (HMAC) |
| Idempotency | Lookup payments.provider_transaction_id. If Payment.status already matches the incoming status β return 200 (no-op). |
Sub-transitions based on Mollie status + payment_type:
Payment paid + type DEPOSIT β Booking: PENDING_PAYMENT β DEPOSIT_PAID β
| Step | Effect |
|---|---|
| 1 | Update Payment.status β COMPLETED, processed_at = now() |
| 2 | Update Booking.status β DEPOSIT_PAID |
| 3 | Update all SeatReservation WHERE booking_id AND status = HELD β CONFIRMED |
| 4 | Ticket issuance (conditional per ADR-014): read Operator.ticket_issuance_trigger (or TourTemplate override). If DEPOSIT_PAID: create Ticket rows (status: ACTIVE, generate ticket_number + qr_hash) for each Passenger |
| 5 | Create FinancialLedger if not exists (first booking for this TourOffering): snapshot planned_cost from CostingSheet, planned_revenue from PriceMatrix |
| 6 | Update FinancialLedger.realized_revenue += Payment.amount |
| 7 | Emit BookingConfirmed β Backoffice (CostingSheet auto-lock via ADR-009), Communications (confirmation email/WhatsApp) |
| 8 | Return HTTP 200 |
Payment paid + type FINAL_PAYMENT β Booking: DEPOSIT_PAID β FULLY_PAID β
| Step | Effect |
|---|---|
| 1 | Update Payment.status β COMPLETED, processed_at = now() |
| 2 | Guard: SUM(payments.amount WHERE status=COMPLETED) >= booking.total_amount |
| 3 | Update Booking.status β FULLY_PAID |
| 4 | Ticket issuance (conditional): if ticket_issuance_trigger = FULLY_PAID AND no tickets exist: create Ticket rows |
| 5 | Update FinancialLedger.realized_revenue += Payment.amount |
| 6 | Emit BookingFullyPaid β Communications (full payment confirmation) |
Payment failed β Booking: PENDING_PAYMENT β CANCELLED (if no retry window) β
| Step | Effect |
|---|---|
| 1 | Update Payment.status β FAILED |
| 2 | Check: does booking have any COMPLETED payments? If yes β no state change (partial payment scenario) |
| 3 | Update Booking.status β CANCELLED |
| 4 | Release all SeatReservation β RELEASED |
| 5 | Emit BookingCancelled β Communications |
Refund settled β Booking: CANCELLED β REFUNDED β
| Step | Effect |
|---|---|
| 1 | Update Payment.status β REFUNDED (the refund Payment row) |
| 2 | Update Booking.status β REFUNDED |
| 3 | Update FinancialLedger.realized_revenue -= refund.amount |
| 4 | Emit BookingRefunded β Communications, optionally trigger invoice Storno |
cancelBooking β Dispatcher/Passenger cancellation β
| Property | Value |
|---|---|
| Action name | cancelBooking |
| Input | { booking_id: UUID, reason: String } |
| Handler route | POST /hasura/actions/cancel-booking |
| Guards | Booking.status β {DRAFT, PENDING_PAYMENT, DEPOSIT_PAID, FULLY_PAID}. Caller has dispatcher role or is primary contact. |
| Transaction | 1. Update Booking.status β CANCELLED 2. Release all SeatReservation β RELEASED 3. Void all Ticket WHERE status = ACTIVE β VOIDED 4. Cancel all Ancillary WHERE status = ACTIVE β CANCELLED 5. If Booking had completed payments: initiate Mollie refund β create Payment (type: REFUND, status: PENDING) 6. Emit BookingCancelled |
| Output | { booking_id, refund_initiated: boolean } |
| Errors | BookingNotModifiable (422), Unauthorized (403) |
Scheduled Trigger Definitions β
Implemented as Hasura Cron Triggers via
@golevelup/nestjs-hasura@HasuraCronTrigger()decorator.
checkout_abandoned_sweep β
| Property | Value |
|---|---|
| Schedule | */5 * * * * (every 5 minutes) |
| Handler route | POST /hasura/cron/checkout-abandoned-sweep |
| Query | UPDATE checkout_sessions SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND expires_at < now() RETURNING * |
| Per expired session | 1. Release all SeatReservation WHERE passenger_id IN (session's passengers) AND status = HELD β RELEASED 2. Emit CheckoutAbandoned β Communications (re-engagement: Email at +1h, WhatsApp at +24h) |
| Idempotency | Status check (ACTIVE) in WHERE clause prevents double-processing |
payment_timeout_sweep β
| Property | Value |
|---|---|
| Schedule | */5 * * * * (every 5 minutes) |
| Handler route | POST /hasura/cron/payment-timeout-sweep |
| Query | SELECT * FROM bookings WHERE status = 'PENDING_PAYMENT' AND created_at < now() - interval '30 minutes' |
| Per timed-out booking | Execute cancelBooking internally (same cascade: release seats, void tickets if any, emit BookingCancelled) |
| Note | 30-minute timeout is aligned with CheckoutSession TTL and SeatReservation hold (ADR-013) |
seat_hold_cleanup β
| Property | Value |
|---|---|
| Schedule | * * * * * (every 60 seconds) |
| Handler route | POST /hasura/cron/seat-hold-cleanup |
| Query | UPDATE seat_reservations SET status = 'RELEASED' WHERE status = 'HELD' AND hold_expires_at < now() RETURNING * |
| Per released hold | Emit SeatHoldExpired (internal β Commerce self-consumer to update availability counts) |
final_payment_escalation_sweep β
Multi-tier escalation sweep for final payment reminders. Supersedes the simpler single-tier sweep.
| Property | Value |
|---|---|
| Schedule | 0 8 * * * (daily at 08:00) |
| Handler route | POST /hasura/cron/final-payment-escalation-sweep |
Logic per booking in DEPOSIT_PAID:
config = resolve FinalPaymentConfig (template β operator β system default)
days_until = tour_offering.start_date - now()
IF days_until <= config.flag_days_before_start (e.g., 7 days):
β Flag booking for dispatcher review (set booking.flagged = true)
β IF ADR-014 issued tickets: void tickets (Ticket.status β VOIDED)
β Emit FinalPaymentOverdue { booking_id, severity: 'CRITICAL' }
β V1: NO automatic cancellation (product decision)
ELSE IF days_until <= config.escalation_days_before_start (e.g., 14 days):
β Emit FinalPaymentDue { booking_id, severity: 'URGENT', channel: 'WHATSAPP' }
ELSE IF days_until <= config.reminder_days_before_start (e.g., 30 days):
β Check: was FinalPaymentDue already sent? (idempotency via event_log)
β If not: emit FinalPaymentDue { booking_id, severity: 'REMINDER', channel: 'EMAIL' }booking_completion_sweep β
| Property | Value |
|---|---|
| Schedule | 0 6 * * * (daily at 06:00) |
| Handler route | POST /hasura/cron/booking-completion-sweep |
| Query | SELECT b.* FROM bookings b JOIN tour_offerings t ON b.tour_offering_id = t.id WHERE b.status = 'FULLY_PAID' AND t.end_date + interval '24 hours' < now() |
| Guard | At least one BoardingEvent exists for this booking (cross-context soft check via Operations read model) |
| Per eligible booking | 1. Update Booking.status β COMPLETED 2. Emit BookingCompleted β Communications (post-trip review request) |
| Note | If no BoardingEvent exists but 48h have passed: see no_show_detection_sweep |
no_show_detection_sweep β
| Property | Value |
|---|---|
| Schedule | 0 6 * * * (daily at 06:00, after completion sweep) |
| Handler route | POST /hasura/cron/no-show-detection-sweep |
| Query | SELECT b.* FROM bookings b JOIN tour_offerings t ON b.tour_offering_id = t.id WHERE b.status = 'FULLY_PAID' AND t.end_date + interval '48 hours' < now() |
| Guard | No BoardingEvent for ANY Passenger on this Booking (cross-context check) |
| Per eligible booking | 1. Update Booking.status β NO_SHOW 2. Emit BookingNoShow β triggers are operator-specific |
| Note | This catches bookings that were NOT caught by booking_completion_sweep (no boarding events at all) |
payment_reconciliation_sweep β
| Property | Value |
|---|---|
| Schedule | 0 2 * * * (daily at 02:00) |
| Handler route | POST /hasura/cron/payment-reconciliation-sweep |
| Query | Per tenant: fetch all Mollie payments from last 48h via Mollie API β compare against local payments table |
| Per stale/missing payment | 1. Create or update Payment row to match Mollie status 2. If payment status changed β trigger corresponding booking state transition (same logic as processPaymentWebhook) |
| Idempotency | provider_transaction_id uniqueness prevents duplicate rows. State transition guards prevent double-processing. |
| Note | Recovery mechanism for Mollie webhook delivery failures (Mollie retries up to 8h, but extended downtime requires reconciliation) |
Magic Link Payment Flow β
The final payment reminder includes a Magic Link that opens a pre-authenticated payment page:
| Step | Component | Action |
|---|---|---|
| 1 | final_payment_escalation_sweep | Emits FinalPaymentDue with { booking_id, passenger_email, amount_remaining } |
| 2 | Communications (n8n) | Generates a Magic Link: https://{booking-widget}/pay/{booking_id}?token={jwt} |
| 3 | n8n | Sends email/WhatsApp with the link |
| 4 | Passenger | Clicks link β booking-widget opens with pre-filled booking details |
| 5 | booking-widget | Shows outstanding balance + Mollie checkout (same as initial payment flow) |
| 6 | Mollie | Processes FINAL_PAYMENT β webhook β processPaymentWebhook β FULLY_PAID |
JWT token: Short-lived (48h), encodes { booking_id, tenant_id, purpose: 'final_payment' }. Validated by the NestJS API before showing the payment page.
Cascade Sequence Diagrams β
Deposit Payment β BookingConfirmed (Happy Path) β
sequenceDiagram
participant Mollie
participant NestJS as NestJS Webhook Handler
participant DB as Postgres
participant EventBus as @nestjs/event-emitter
participant Backoffice
participant Comms as Communications (n8n)
Mollie->>NestJS: POST /webhooks/mollie { id: "tr_xxx" }
NestJS->>Mollie: GET /v2/payments/tr_xxx (fetch status)
Mollie-->>NestJS: { status: "paid", metadata: { booking_id, payment_type: "DEPOSIT" } }
NestJS->>DB: BEGIN TRANSACTION
NestJS->>DB: UPDATE payments SET status='COMPLETED' WHERE provider_transaction_id='tr_xxx'
NestJS->>DB: UPDATE bookings SET status='DEPOSIT_PAID' WHERE id=$booking_id
NestJS->>DB: UPDATE seat_reservations SET status='CONFIRMED' WHERE booking_id=$booking_id AND status='HELD'
alt ADR-014: ticket_issuance_trigger = DEPOSIT_PAID
NestJS->>DB: INSERT INTO tickets (passenger_id, ticket_number, qr_hash, status) ...
end
NestJS->>DB: INSERT INTO financial_ledgers (...) ON CONFLICT (tour_offering_id) DO UPDATE SET realized_revenue = realized_revenue + $amount
NestJS->>DB: COMMIT
NestJS->>EventBus: emit('BookingConfirmed', payload)
EventBus->>Backoffice: CostingSheet auto-lock (ADR-009)
EventBus->>Comms: Booking confirmation (n8n webhook)
NestJS-->>Mollie: HTTP 200Booking Cancellation with Refund β
sequenceDiagram
participant Dispatcher
participant NestJS as NestJS Action Handler
participant DB as Postgres
participant Mollie
participant Comms as Communications (n8n)
Dispatcher->>NestJS: cancelBooking({ booking_id, reason })
NestJS->>DB: BEGIN TRANSACTION
NestJS->>DB: SELECT booking WHERE id=$booking_id (guard: status β modifiable)
NestJS->>DB: UPDATE bookings SET status='CANCELLED'
NestJS->>DB: UPDATE seat_reservations SET status='RELEASED' WHERE booking_id=$booking_id
NestJS->>DB: UPDATE tickets SET status='VOIDED' WHERE passenger_id IN (booking passengers)
alt Booking had completed payments
NestJS->>Mollie: POST /v2/payments/{id}/refunds { amount }
NestJS->>DB: INSERT INTO payments (type: 'REFUND', status: 'PENDING')
end
NestJS->>DB: COMMIT
NestJS->>Comms: emit BookingCancelled β n8n (cancellation notification)
NestJS-->>Dispatcher: { booking_id, refund_initiated: true }
Note over Mollie,NestJS: Later: Mollie sends refund.settled webhook β processPaymentWebhook β CANCELLEDβREFUNDEDEdge States β
| # | Edge Case | Expected Behavior |
|---|---|---|
| 1 | Duplicate Mollie webhook | processPaymentWebhook checks Payment.status before processing. If already COMPLETED for a paid webhook, returns 200 (idempotent no-op). Uniqueness on provider_transaction_id prevents duplicate Payment rows. |
| 2 | Out-of-order Mollie webhooks | Mollie may send pending after paid. Handler fetches current status via Mollie API (GET) instead of trusting the webhook payload directly. Mollie's own status is the source of truth. |
| 3 | Concurrent cancelBooking on same booking | Optimistic locking: handler reads Booking.status in the transaction. If status has already changed (another handler committed first), the guard fails β returns BookingNotModifiable (422). |
| 4 | processPaymentWebhook during cancelBooking | Database-level: both operate in transactions. If cancelBooking commits first β payment handler finds Booking.status = CANCELLED β updates Payment to COMPLETED but does NOT transition booking (already terminal). Revenue is recorded but the booking remains cancelled. If payment commits first β cancel handler finds Booking.status = DEPOSIT_PAID β proceeds with cancellation and initiates refund. |
| 5 | SeatReservation expired but Booking in PENDING_PAYMENT | seat_hold_cleanup releases the hold. When payment succeeds, processPaymentWebhook attempts to confirm seats but they're already RELEASED. Handler must re-acquire seats: check availability β if available, set CONFIRMED; if taken by another booking, reject the payment (return Mollie refund, cancel booking, emit error). This is the ADR-013 mitigation β aligned TTLs (30min) reduce this window to near-zero. |
| 6 | Mollie webhook delivery failure | Mollie retries with exponential backoff (up to 8 hours). NestJS handler is idempotent. If NestJS is down, payments complete on Mollie's side but booking status is stale. Recovery: the daily payment_reconciliation_sweep fetches all Mollie payments for the tenant and reconciles against local Payment records. |
| 7 | booking_completion_sweep vs no_show_detection_sweep overlap | booking_completion_sweep runs first (checks end_date + 24h). no_show_detection_sweep runs after (checks end_date + 48h). Both filter on status = FULLY_PAID, so a booking processed by completion_sweep (status β COMPLETED) is excluded from no_show_sweep. |
| 8 | Ticket issuance at DEPOSIT_PAID but final payment never arrives | ADR-014 specifies: if ticket_issuance_trigger = DEPOSIT_PAID, a Scheduled Trigger voids tickets when departure_date - 7 days arrives and booking is still in DEPOSIT_PAID. This is part of the overdue payment escalation (see final_payment_escalation_sweep). |
| 9 | FinancialLedger doesn't exist when first payment arrives | processPaymentWebhook handler uses INSERT ... ON CONFLICT (tour_offering_id) DO UPDATE. The first payment for a TourOffering creates the ledger; subsequent payments increment realized_revenue. |
| 10 | BoardingEvent cross-context availability | The booking_completion_sweep checks Operations' boarding_events via a read model (Hasura relationship or view across schemas). If the Operations schema is unavailable, the sweep skips the booking (logged as warning, retried next day). |
| 11 | Final payment received before first reminder | No reminders sent. The sweep checks NOT EXISTS (payments WHERE type='FINAL_PAYMENT' AND status IN ('PENDING','COMPLETED')). If final payment is already in progress or completed, the booking is skipped. |
| 12 | Multiple bookings for same passenger, different timelines | Each booking is evaluated independently. A passenger with a March and June booking receives separate reminder timelines. |
| 13 | Operator changes FinalPaymentConfig mid-lifecycle | Config is resolved at sweep time (not snapshotted at booking creation). If operator changes reminder_days from 30 to 45, all existing DEPOSIT_PAID bookings are re-evaluated with the new config on the next sweep. |
| 14 | Ticket voided but final payment arrives after | processPaymentWebhook handles FINAL_PAYMENT normally (β FULLY_PAID). If tickets were voided due to escalation, the handler must re-issue tickets (create new Ticket rows with status ACTIVE). |
| 15 | Near-departure booking with hold from previous session | Near-departure bookings require full payment. SeatReservation TTL (30 min) still applies. If payment fails, seats are released normally. |
Schema Cross-References β
| Table | Schema Doc | Detail |
|---|---|---|
bookings | schema-commerce.md | 8-state lifecycle, reference_number generation |
checkout_sessions | schema-commerce.md | TTL, session_type discriminator |
payments | schema-commerce.md | provider_transaction_id uniqueness, payment_type enum |
seat_reservations | schema-commerce.md | Partial unique index, hold TTL alignment (ADR-013) |
tickets | schema-commerce.md | qr_hash uniqueness, issuance trigger (ADR-014) |
financial_ledgers | schema-commerce.md | OPEN/CLOSED lifecycle, UPSERT on first payment |
| Event payloads | event-contracts-commerce.md | BookingConfirmed, BookingCancelled, BookingFullyPaid, etc. |
| Deposit config | PRODUCT_mollie-integration.md Β§3 | DepositConfig VO, cascading resolution |
| Final payment config | PRODUCT_mollie-integration.md Β§4.2 | FinalPaymentConfig VO, escalation tiers |