Busflow Docs

Internal documentation portal

Skip to content
Reviewed 02 May 2026

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 Busflow Communications pipeline; n8n only as optional prototype adapter) ยท 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

Semantic Authority for Cancellation Amounts โ€‹

The cancellation command is the semantic authority for retained amounts. Payments prove that money moved; invoices and credit notes document the commercial correction; the cancelPassenger / cancelAncillary command is the only point that binds the original price, resolved cancellation policy, refunded amount, retained cancellation_fee, affected passenger or ancillary, and price snapshot into one classified economic fact.

FinancialLedger.realized_revenue is only a profitability accumulator. It MUST NOT be treated as the authority for tax classification at ledger close, because a scalar net amount can hide whether money came from delivered travel services, cancellation-derived retained consideration, an ancillary, or an onboard sale. Ledger close must consume classified economic facts produced by the booking/cancellation/payment lifecycle, not reconstruct legal meaning from SUM(payments.amount).

Durable cancellation facts must preserve at least:

FieldPurpose
booking_id, passenger_id / ancillary_idIdentifies the cancelled economic object
original_price_amount, price_matrix_version_idKeeps the original sale semantics and price snapshot
refund_amountCash returned to the customer
cancellation_feeRetained amount classified as cancellation-derived consideration
classificationDistinguishes TRAVEL_SERVICE_REVENUE, CANCELLATION_FEE, ancillary revenue, and other regimes before tax folding
reason, occurred_atAudit context for GoBD traceability

WARNING

Updating realized_revenue -= refund_amount preserves the net management number but does not preserve the semantic split. If the retained cancellation_fee is not durably projected, a later ยง 25 close can fold cancellation-derived money into ordinary travel-service revenue and produce a plausible but legally wrong aggregate.


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, cancellation_fee, original_price_amount, price_matrix_version_id, classification: 'CANCELLATION_FEE' }
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. Persist/emit the classified cancellation fact, including retained fee and original ancillary price semantics 8. 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, cancellation_fee, original_price_amount, classification }
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 โ€‹

100%

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.

Tax classification warning: The FinancialLedger update is not sufficient for ยง 25 close. realized_revenue may remain arithmetically correct while losing the distinction between delivered travel-service revenue and retained cancellation consideration. The classified cancellation fact is the durable input for later TaxLedgerEntry generation.


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