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.
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):
{
"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_feeHasura Action Definitions โ
cancelPassenger โ
| Property | Value |
|---|---|
| Action name | cancelPassenger |
| Input | { booking_id: UUID, passenger_id: UUID, reason: String } |
| Handler route | POST /hasura/actions/cancel-passenger |
| Guards | Booking.status โ {DEPOSIT_PAID, FULLY_PAID}. Passenger.status = ACTIVE. Passenger belongs to this booking. Booking has > 1 ACTIVE passenger (last passenger โ use cancelBooking instead). |
| Transaction | See cascade sequence below |
| Output | { passenger_id, refund_amount, cancellation_fee, refund_payment_id } |
| Events emitted | PassengerCancelled { booking_id, passenger_id, tenant_id, refund_amount } |
| Errors | BookingNotModifiable (422), PassengerNotFound (404), PassengerAlreadyCancelled (409), LastPassengerError (422 โ direct to cancelBooking), RefundAmountExceedsPayment (500 โ internal error), PeriodLockedException (423 โ FinancialLedger period is locked) |
cancelAncillary โ
| Property | Value |
|---|---|
| Action name | cancelAncillary |
| Input | { booking_id: UUID, ancillary_id: UUID, reason: String } |
| Handler route | POST /hasura/actions/cancel-ancillary |
| Guards | Booking.status โ {DEPOSIT_PAID, FULLY_PAID}. Ancillary.status = ACTIVE. Ancillary belongs to this booking. |
| Transaction | 1. 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 emitted | AncillaryCancelled { booking_id, ancillary_id, tenant_id, refund_amount } |
| Errors | BookingNotModifiable (422), AncillaryNotFound (404), AncillaryAlreadyCancelled (409), PeriodLockedException (423) |
addPassengerToBooking โ
| Property | Value |
|---|---|
| Action name | addPassengerToBooking |
| Input | { booking_id: UUID, passenger: PassengerInput, seat_identifier?: String } |
| Handler route | POST /hasura/actions/add-passenger |
| Guards | Booking.status โ {DEPOSIT_PAID, FULLY_PAID}. TourOffering has remaining capacity. |
| Transaction | 1. 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 emitted | PassengerAdded { booking_id, passenger_id, tenant_id, additional_amount } |
| Errors | BookingNotModifiable (422), TourOfferingFull (422), SeatUnavailable (409), PeriodLockedException (423) |
| Note | May require a supplementary payment. If additional_payment_required > 0, a new Mollie payment link is generated and returned. |
updatePassengerDetails โ
| Property | Value |
|---|---|
| Action name | updatePassengerDetails |
| Input | { booking_id: UUID, passenger_id: UUID, updates: PassengerUpdateInput } |
| Handler route | POST /hasura/actions/update-passenger |
| Guards | Booking.status โ {DEPOSIT_PAID, FULLY_PAID}. Passenger.status = ACTIVE. |
| Transaction | 1. 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 emitted | PassengerUpdated { booking_id, passenger_id, tenant_id, changes: string[] } |
Cascade Sequence: cancelPassenger โ
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 โ REFUNDEDMollie Partial Refund Strategy โ
Which payment is refunded? The most recent COMPLETED payment for this booking, in reverse chronological order:
- If
FINAL_PAYMENTexists andstatus = COMPLETED: refund against the final payment - Else: refund against the
DEPOSITpayment
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 โ
| Action | Effect 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_delta | Recomputed: (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:
| Scenario | Invoice 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 Case | Expected Behavior |
|---|---|---|
| 1 | Last passenger removal | cancelPassenger 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." |
| 2 | Concurrent partial cancellations | Both 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. |
| 3 | Partial refund after period lock | Guard: PeriodLockService.validateMutation(). If locked โ PeriodLockedException (423). Dispatcher must unlock the period via manager action. |
| 4 | Mollie refund API failure | Transaction rolls back. No database changes committed. Dispatcher sees error message. Retry is manual. NestJS does NOT retry automatically (non-idempotent โ could trigger double refund). |
| 5 | Partial cancellation on DEPOSIT_PAID booking | Works 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. |
| 6 | Ancillary with quantity > 1 | cancelAncillary 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. |
| 7 | Insurance ancillary with third-party claim | If ancillary.type = INSURANCE, cancellation may require coordination with the insurance provider. V1: treat like any other ancillary. V2 โ third-party cancellation API. |
| 8 | Passenger cancellation AFTER departure | Guard: 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 โ
| Table | Schema Doc | Detail |
|---|---|---|
operators.cancellation_policy | schema-backoffice.md | Tenant-level default CancellationPolicy VO |
tour_templates.cancellation_policy | schema-backoffice.md | Per-product override (nullable โ fallback) |
passengers.status | schema-commerce.md | ACTIVE / CANCELLED lifecycle |
ancillaries.status | schema-commerce.md | ACTIVE / CANCELLED / REFUNDED lifecycle |
payments.refund_passenger_id | schema-commerce.md | Links PARTIAL_REFUND to specific passenger |
invoice_cancellations | schema-commerce.md | Credit note linkage |
| Event payloads | event-contracts-commerce.md | PassengerCancelled, AncillaryCancelled, PassengerAdded, PassengerUpdated |
| Full booking cancellation | booking-lifecycle-protocol.md | cancelBooking โ used when last passenger is removed |
| Invoice credit notes | invoice-service-protocol.md | generateCreditNote operation |