Busflow Docs

Internal documentation portal

Skip to content

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_sql or direct Postgres client.

submitCheckout β€” DRAFT β†’ PENDING_PAYMENT ​

PropertyValue
Action namesubmitCheckout
Input{ checkout_session_id: UUID }
Handler routePOST /hasura/actions/submit-checkout
GuardsCheckoutSession.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.
Transaction1. 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 emittedNone (booking is not yet confirmed β€” waiting for payment)
ErrorsSessionExpired (410), PriceVersionMismatch (409), TourNotAvailable (422), SeatUnavailable (409), DoorPickupCapacityReached (422)

Deposit Calculation ​

The submitCheckout handler calculates the deposit amount via a cascading resolution:

typescript
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) ​

PropertyValue
RoutePOST /webhooks/mollie
Input{ id: string } (Mollie transaction ID) β€” Mollie sends only the payment ID, handler fetches full status via Mollie API
Signature verificationValidate webhook signature using Mollie API key (HMAC)
IdempotencyLookup 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 ​

StepEffect
1Update Payment.status β†’ COMPLETED, processed_at = now()
2Update Booking.status β†’ DEPOSIT_PAID
3Update all SeatReservation WHERE booking_id AND status = HELD β†’ CONFIRMED
4Ticket 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
5Create FinancialLedger if not exists (first booking for this TourOffering): snapshot planned_cost from CostingSheet, planned_revenue from PriceMatrix
6Update FinancialLedger.realized_revenue += Payment.amount
7Emit BookingConfirmed β†’ Backoffice (CostingSheet auto-lock via ADR-009), Communications (confirmation email/WhatsApp)
8Return HTTP 200

Payment paid + type FINAL_PAYMENT β†’ Booking: DEPOSIT_PAID β†’ FULLY_PAID ​

StepEffect
1Update Payment.status β†’ COMPLETED, processed_at = now()
2Guard: SUM(payments.amount WHERE status=COMPLETED) >= booking.total_amount
3Update Booking.status β†’ FULLY_PAID
4Ticket issuance (conditional): if ticket_issuance_trigger = FULLY_PAID AND no tickets exist: create Ticket rows
5Update FinancialLedger.realized_revenue += Payment.amount
6Emit BookingFullyPaid β†’ Communications (full payment confirmation)

Payment failed β†’ Booking: PENDING_PAYMENT β†’ CANCELLED (if no retry window) ​

StepEffect
1Update Payment.status β†’ FAILED
2Check: does booking have any COMPLETED payments? If yes β†’ no state change (partial payment scenario)
3Update Booking.status β†’ CANCELLED
4Release all SeatReservation β†’ RELEASED
5Emit BookingCancelled β†’ Communications

Refund settled β†’ Booking: CANCELLED β†’ REFUNDED ​

StepEffect
1Update Payment.status β†’ REFUNDED (the refund Payment row)
2Update Booking.status β†’ REFUNDED
3Update FinancialLedger.realized_revenue -= refund.amount
4Emit BookingRefunded β†’ Communications, optionally trigger invoice Storno

cancelBooking β€” Dispatcher/Passenger cancellation ​

PropertyValue
Action namecancelBooking
Input{ booking_id: UUID, reason: String }
Handler routePOST /hasura/actions/cancel-booking
GuardsBooking.status ∈ {DRAFT, PENDING_PAYMENT, DEPOSIT_PAID, FULLY_PAID}. Caller has dispatcher role or is primary contact.
Transaction1. 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 }
ErrorsBookingNotModifiable (422), Unauthorized (403)

Scheduled Trigger Definitions ​

Implemented as Hasura Cron Triggers via @golevelup/nestjs-hasura @HasuraCronTrigger() decorator.

checkout_abandoned_sweep ​

PropertyValue
Schedule*/5 * * * * (every 5 minutes)
Handler routePOST /hasura/cron/checkout-abandoned-sweep
QueryUPDATE checkout_sessions SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND expires_at < now() RETURNING *
Per expired session1. 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)
IdempotencyStatus check (ACTIVE) in WHERE clause prevents double-processing

payment_timeout_sweep ​

PropertyValue
Schedule*/5 * * * * (every 5 minutes)
Handler routePOST /hasura/cron/payment-timeout-sweep
QuerySELECT * FROM bookings WHERE status = 'PENDING_PAYMENT' AND created_at < now() - interval '30 minutes'
Per timed-out bookingExecute cancelBooking internally (same cascade: release seats, void tickets if any, emit BookingCancelled)
Note30-minute timeout is aligned with CheckoutSession TTL and SeatReservation hold (ADR-013)

seat_hold_cleanup ​

PropertyValue
Schedule* * * * * (every 60 seconds)
Handler routePOST /hasura/cron/seat-hold-cleanup
QueryUPDATE seat_reservations SET status = 'RELEASED' WHERE status = 'HELD' AND hold_expires_at < now() RETURNING *
Per released holdEmit 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.

PropertyValue
Schedule0 8 * * * (daily at 08:00)
Handler routePOST /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 ​

PropertyValue
Schedule0 6 * * * (daily at 06:00)
Handler routePOST /hasura/cron/booking-completion-sweep
QuerySELECT 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()
GuardAt least one BoardingEvent exists for this booking (cross-context soft check via Operations read model)
Per eligible booking1. Update Booking.status β†’ COMPLETED 2. Emit BookingCompleted β†’ Communications (post-trip review request)
NoteIf no BoardingEvent exists but 48h have passed: see no_show_detection_sweep

no_show_detection_sweep ​

PropertyValue
Schedule0 6 * * * (daily at 06:00, after completion sweep)
Handler routePOST /hasura/cron/no-show-detection-sweep
QuerySELECT 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()
GuardNo BoardingEvent for ANY Passenger on this Booking (cross-context check)
Per eligible booking1. Update Booking.status β†’ NO_SHOW 2. Emit BookingNoShow β€” triggers are operator-specific
NoteThis catches bookings that were NOT caught by booking_completion_sweep (no boarding events at all)

payment_reconciliation_sweep ​

PropertyValue
Schedule0 2 * * * (daily at 02:00)
Handler routePOST /hasura/cron/payment-reconciliation-sweep
QueryPer tenant: fetch all Mollie payments from last 48h via Mollie API β†’ compare against local payments table
Per stale/missing payment1. Create or update Payment row to match Mollie status 2. If payment status changed β†’ trigger corresponding booking state transition (same logic as processPaymentWebhook)
Idempotencyprovider_transaction_id uniqueness prevents duplicate rows. State transition guards prevent double-processing.
NoteRecovery mechanism for Mollie webhook delivery failures (Mollie retries up to 8h, but extended downtime requires reconciliation)

The final payment reminder includes a Magic Link that opens a pre-authenticated payment page:

StepComponentAction
1final_payment_escalation_sweepEmits FinalPaymentDue with { booking_id, passenger_email, amount_remaining }
2Communications (n8n)Generates a Magic Link: https://{booking-widget}/pay/{booking_id}?token={jwt}
3n8nSends email/WhatsApp with the link
4PassengerClicks link β†’ booking-widget opens with pre-filled booking details
5booking-widgetShows outstanding balance + Mollie checkout (same as initial payment flow)
6MollieProcesses 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) ​

mermaid
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 200

Booking Cancellation with Refund ​

mermaid
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→REFUNDED

Edge States ​

#Edge CaseExpected Behavior
1Duplicate Mollie webhookprocessPaymentWebhook 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.
2Out-of-order Mollie webhooksMollie 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.
3Concurrent cancelBooking on same bookingOptimistic locking: handler reads Booking.status in the transaction. If status has already changed (another handler committed first), the guard fails β†’ returns BookingNotModifiable (422).
4processPaymentWebhook during cancelBookingDatabase-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.
5SeatReservation expired but Booking in PENDING_PAYMENTseat_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.
6Mollie webhook delivery failureMollie 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.
7booking_completion_sweep vs no_show_detection_sweep overlapbooking_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.
8Ticket issuance at DEPOSIT_PAID but final payment never arrivesADR-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).
9FinancialLedger doesn't exist when first payment arrivesprocessPaymentWebhook handler uses INSERT ... ON CONFLICT (tour_offering_id) DO UPDATE. The first payment for a TourOffering creates the ledger; subsequent payments increment realized_revenue.
10BoardingEvent cross-context availabilityThe 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).
11Final payment received before first reminderNo 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.
12Multiple bookings for same passenger, different timelinesEach booking is evaluated independently. A passenger with a March and June booking receives separate reminder timelines.
13Operator changes FinalPaymentConfig mid-lifecycleConfig 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.
14Ticket voided but final payment arrives afterprocessPaymentWebhook 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).
15Near-departure booking with hold from previous sessionNear-departure bookings require full payment. SeatReservation TTL (30 min) still applies. If payment fails, seats are released normally.

Schema Cross-References ​

TableSchema DocDetail
bookingsschema-commerce.md8-state lifecycle, reference_number generation
checkout_sessionsschema-commerce.mdTTL, session_type discriminator
paymentsschema-commerce.mdprovider_transaction_id uniqueness, payment_type enum
seat_reservationsschema-commerce.mdPartial unique index, hold TTL alignment (ADR-013)
ticketsschema-commerce.mdqr_hash uniqueness, issuance trigger (ADR-014)
financial_ledgersschema-commerce.mdOPEN/CLOSED lifecycle, UPSERT on first payment
Event payloadsevent-contracts-commerce.mdBookingConfirmed, BookingCancelled, BookingFullyPaid, etc.
Deposit configPRODUCT_mollie-integration.md Β§3DepositConfig VO, cascading resolution
Final payment configPRODUCT_mollie-integration.md Β§4.2FinalPaymentConfig VO, escalation tiers

Internal documentation β€” Busflow