Busflow Docs

Internal documentation portal

Skip to content

Cancellation & Partial Modification Protocol โ€‹

This document specifies the partial cancellation saga โ€” the Hasura Actions that remove individual passengers or ancillaries from an active booking, calculate cancellation fees, process Mollie partial refunds, and maintain FinancialLedger + Invoice consistency.

Contexts involved: Commerce (booking/passenger mutations, refund processing) ยท Communications (cancellation notifications via n8n) ยท Backoffice (CancellationPolicy configuration) DDD pattern: Saga coordination as synchronous domain service. All cascades execute as single ACID transactions.


CancellationPolicy Value Object โ€‹

The CancellationPolicy is a JSONB Value Object stored on backoffice.operators (tenant default) and backoffice.tour_templates (per-product override). It defines a sliding-scale fee schedule based on days before departure.

typescript
interface CancellationPolicy {
  tiers: CancellationTier[];        // sorted DESC by days_before_start
  minimum_fee: number | null;       // optional absolute floor (e.g., โ‚ฌ25 manager fee)
  currency: string;                 // ISO 4217
}

interface CancellationTier {
  days_before_start: number;        // if cancellation occurs >= N days before departure
  fee_percentage: number;           // percentage of passenger's total price retained as fee
}

Example (typical DACH operator):

json
{
  "tiers": [
    { "days_before_start": 30, "fee_percentage": 20 },
    { "days_before_start": 15, "fee_percentage": 50 },
    { "days_before_start": 7,  "fee_percentage": 80 },
    { "days_before_start": 0,  "fee_percentage": 100 }
  ],
  "minimum_fee": 25,
  "currency": "EUR"
}

Resolution cascade: tour_templates.cancellation_policy โ†’ operators.cancellation_policy โ†’ system default [TBD โ€” legal review needed per ยง 651h BGB].

Fee calculation at runtime:

days_until_departure = tour_offering.start_date - now()
applicable_tier = first tier WHERE days_until_departure >= tier.days_before_start
cancellation_fee = max(passenger_price ร— tier.fee_percentage / 100, minimum_fee)
refund_amount = passenger_price - cancellation_fee

Hasura Action Definitions โ€‹

cancelPassenger โ€‹

PropertyValue
Action namecancelPassenger
Input{ booking_id: UUID, passenger_id: UUID, reason: String }
Handler routePOST /hasura/actions/cancel-passenger
GuardsBooking.status โˆˆ {DEPOSIT_PAID, FULLY_PAID}. Passenger.status = ACTIVE. Passenger belongs to this booking. Booking has > 1 ACTIVE passenger (last passenger โ†’ use cancelBooking instead).
TransactionSee cascade sequence below
Output{ passenger_id, refund_amount, cancellation_fee, refund_payment_id }
Events emittedPassengerCancelled { booking_id, passenger_id, tenant_id, refund_amount }
ErrorsBookingNotModifiable (422), PassengerNotFound (404), PassengerAlreadyCancelled (409), LastPassengerError (422 โ€” direct to cancelBooking), RefundAmountExceedsPayment (500 โ€” internal error), PeriodLockedException (423 โ€” FinancialLedger period is locked)

cancelAncillary โ€‹

PropertyValue
Action namecancelAncillary
Input{ booking_id: UUID, ancillary_id: UUID, reason: String }
Handler routePOST /hasura/actions/cancel-ancillary
GuardsBooking.status โˆˆ {DEPOSIT_PAID, FULLY_PAID}. Ancillary.status = ACTIVE. Ancillary belongs to this booking.
Transaction1. Resolve CancellationPolicy โ†’ compute refund (V1: same tiers as passenger cancellation) 2. Update Ancillary.status โ†’ CANCELLED (transitions to REFUNDED when Mollie refund.settled webhook arrives via processPaymentWebhook) 3. Create Payment (type: PARTIAL_REFUND, refund_ancillary_id, status: PENDING, amount: negative refund) 4. Initiate Mollie partial refund 5. Update Booking.total_amount -= ancillary.price ร— ancillary.quantity 6. Update FinancialLedger.realized_revenue -= refund_amount 7. If booking has an ISSUED Invoice โ†’ call InvoiceService.generateCreditNote() (see invoice-service-protocol.md)
Output{ ancillary_id, refund_amount, cancellation_fee, refund_payment_id }
Events emittedAncillaryCancelled { booking_id, ancillary_id, tenant_id, refund_amount }
ErrorsBookingNotModifiable (422), AncillaryNotFound (404), AncillaryAlreadyCancelled (409), PeriodLockedException (423)

addPassengerToBooking โ€‹

PropertyValue
Action nameaddPassengerToBooking
Input{ booking_id: UUID, passenger: PassengerInput, seat_identifier?: String }
Handler routePOST /hasura/actions/add-passenger
GuardsBooking.status โˆˆ {DEPOSIT_PAID, FULLY_PAID}. TourOffering has remaining capacity.
Transaction1. Create Passenger row (status: ACTIVE) 2. If seat_identifier provided: create SeatReservation (status: CONFIRMED) 3. If Booking is past ticket issuance trigger: create Ticket 4. Recalculate Booking.total_amount += passenger_price 5. Update FinancialLedger.realized_revenue += price_delta (amount owed โ€” not yet paid)
Output{ passenger_id, new_total_amount, additional_payment_required }
Events emittedPassengerAdded { booking_id, passenger_id, tenant_id, additional_amount }
ErrorsBookingNotModifiable (422), TourOfferingFull (422), SeatUnavailable (409), PeriodLockedException (423)
NoteMay require a supplementary payment. If additional_payment_required > 0, a new Mollie payment link is generated and returned.

updatePassengerDetails โ€‹

PropertyValue
Action nameupdatePassengerDetails
Input{ booking_id: UUID, passenger_id: UUID, updates: PassengerUpdateInput }
Handler routePOST /hasura/actions/update-passenger
GuardsBooking.status โˆˆ {DEPOSIT_PAID, FULLY_PAID}. Passenger.status = ACTIVE.
Transaction1. Update Passenger row 2. If name changed AND ticket exists: reissue Ticket (void old, create new with updated qr_hash) 3. Emit PassengerUpdated โ†’ Operations (manifest update)
Output{ passenger_id, ticket_reissued: boolean }
Events emittedPassengerUpdated { booking_id, passenger_id, tenant_id, changes: string[] }

Cascade Sequence: cancelPassenger โ€‹

mermaid
sequenceDiagram
    participant Dispatcher
    participant NestJS as NestJS Action Handler
    participant DB as Postgres
    participant Mollie
    participant Comms as Communications (n8n)

    Dispatcher->>NestJS: cancelPassenger({ booking_id, passenger_id, reason })
    
    NestJS->>DB: SELECT booking, passenger, tour_offering (guards)
    Note over NestJS: Resolve CancellationPolicy<br>(template โ†’ operator โ†’ system default)
    Note over NestJS: Calculate: days_until_departure โ†’ tier โ†’ fee โ†’ refund
    
    NestJS->>DB: BEGIN TRANSACTION
    NestJS->>DB: UPDATE passengers SET status='CANCELLED' WHERE id=$passenger_id
    NestJS->>DB: UPDATE seat_reservations SET status='RELEASED' WHERE passenger_id=$passenger_id AND status='CONFIRMED'
    NestJS->>DB: UPDATE tickets SET status='VOIDED' WHERE passenger_id=$passenger_id AND status='ACTIVE'
    NestJS->>DB: UPDATE bookings SET total_amount = total_amount - $passenger_price WHERE id=$booking_id
    
    NestJS->>DB: SELECT most_recent_completed_payment for this booking
    NestJS->>Mollie: POST /v2/payments/{payment_id}/refunds { amount: $refund_amount }
    NestJS->>DB: INSERT INTO payments (type: 'PARTIAL_REFUND', refund_passenger_id, amount: -$refund_amount, status: 'PENDING')
    
    NestJS->>DB: UPDATE financial_ledgers SET realized_revenue = realized_revenue - $refund_amount WHERE tour_offering_id = $tour_offering_id
    
    alt Booking has an ISSUED Invoice
        NestJS->>DB: SELECT invoice WHERE financial_ledger_id = $fl_id AND status = 'ISSUED'
        NestJS->>DB: InvoiceService.generateCreditNote({ booking_id, original_invoice_id, refund_amount, reason })
    end
    
    NestJS->>DB: COMMIT
    
    NestJS->>Comms: emit PassengerCancelled โ†’ n8n (notification to passenger + primary contact)
    
    NestJS-->>Dispatcher: { passenger_id, refund_amount, cancellation_fee }
    
    Note over Mollie,NestJS: Later: Mollie refund.settled webhook โ†’ update Payment.status โ†’ REFUNDED

Mollie Partial Refund Strategy โ€‹

Which payment is refunded? The most recent COMPLETED payment for this booking, in reverse chronological order:

  1. If FINAL_PAYMENT exists and status = COMPLETED: refund against the final payment
  2. Else: refund against the DEPOSIT payment

Rationale: Refunding the most recent payment minimizes Mollie transaction overhead and aligns with customer expectation (they see the refund on their latest statement).

Amount exceeds single payment? If refund_amount > most_recent_payment.amount (unlikely but possible for multi-passenger removals in batch): split the refund across multiple Mollie refund calls, oldest payment first.


FinancialLedger Impact โ€‹

ActionEffect on FinancialLedger
Passenger cancelled (refund issued)realized_revenue -= refund_amount (the refunded portion)
Ancillary cancelled (refund issued)realized_revenue -= refund_amount
Passenger added (additional payment)realized_revenue += additional_payment (only when payment completes)
margin_deltaRecomputed: (realized_revenue - realized_expense) - (planned_revenue - planned_cost)

Period lock check: Before modifying FinancialLedger, the handler MUST call PeriodLockService.validateMutation(tenantId, ledger.created_at). If the period is locked, the partial cancellation is rejected with PeriodLockedException. The dispatcher must contact accounting to unlock the period first.


Invoice Interaction โ€‹

Partial cancellation creates a credit note (Gutschrift), not a full Storno:

ScenarioInvoice Action
Booking has NO invoice yet (still in DEPOSIT_PAID)No invoice action needed โ€” booking amount is simply adjusted
Booking has an ISSUED invoice (in FULLY_PAID)Create a credit note (negative-amount Invoice) linked to the original via InvoiceCancellation.cancelled_invoice_id. The original invoice remains unchanged (GoBD compliance). The credit note amount = -refund_amount.

Note: This extends the existing InvoiceCancellation entity. A partial credit note sets InvoiceCancellation.replacement_invoice_id = NULL (no replacement invoice needed โ€” it's a standalone credit, not a full reissue). The reason field captures "Partial cancellation: Passenger {name} removed".


Edge States โ€‹

#Edge CaseExpected Behavior
1Last passenger removalcancelPassenger guard checks COUNT(passengers WHERE status='ACTIVE') > 1. If only 1 active passenger remains, returns LastPassengerError (422) with message "Use cancelBooking to cancel the entire booking."
2Concurrent partial cancellationsBoth transactions read the booking. The first to commit updates total_amount. The second either: (a) succeeds because it targets a different passenger (no conflict), or (b) fails if both target the same passenger (status already CANCELLED โ†’ PassengerAlreadyCancelled 409). total_amount uses SET total_amount = total_amount - $price (arithmetic, not absolute set) โ€” safe for concurrent updates.
3Partial refund after period lockGuard: PeriodLockService.validateMutation(). If locked โ†’ PeriodLockedException (423). Dispatcher must unlock the period via manager action.
4Mollie refund API failureTransaction rolls back. No database changes committed. Dispatcher sees error message. Retry is manual. NestJS does NOT retry automatically (non-idempotent โ€” could trigger double refund).
5Partial cancellation on DEPOSIT_PAID bookingWorks exactly the same. The refund is processed against the deposit payment. If refund_amount > deposit (e.g., very generous cancellation policy), flag for dispatcher review โ€” this scenario shouldn't normally occur.
6Ancillary with quantity > 1cancelAncillary cancels the entire ancillary (all units). If the user wants to reduce quantity from 3 to 2, use updateAncillary (not yet specified โ€” V2) instead of cancelAncillary.
7Insurance ancillary with third-party claimIf ancillary.type = INSURANCE, cancellation may require coordination with the insurance provider. V1: treat like any other ancillary. V2 โ€” third-party cancellation API.
8Passenger cancellation AFTER departureGuard: Booking.status โˆˆ {DEPOSIT_PAID, FULLY_PAID}. COMPLETED and NO_SHOW bookings are not modifiable. Post-departure refund requests go through customer service (manual process).

Schema Cross-References โ€‹

TableSchema DocDetail
operators.cancellation_policyschema-backoffice.mdTenant-level default CancellationPolicy VO
tour_templates.cancellation_policyschema-backoffice.mdPer-product override (nullable โ†’ fallback)
passengers.statusschema-commerce.mdACTIVE / CANCELLED lifecycle
ancillaries.statusschema-commerce.mdACTIVE / CANCELLED / REFUNDED lifecycle
payments.refund_passenger_idschema-commerce.mdLinks PARTIAL_REFUND to specific passenger
invoice_cancellationsschema-commerce.mdCredit note linkage
Event payloadsevent-contracts-commerce.mdPassengerCancelled, AncillaryCancelled, PassengerAdded, PassengerUpdated
Full booking cancellationbooking-lifecycle-protocol.mdcancelBooking โ€” used when last passenger is removed
Invoice credit notesinvoice-service-protocol.mdgenerateCreditNote operation

Internal documentation โ€” Busflow