Cancellation, amendment and cancellation fees — developer reference¶
Audience: engineers working on Booking/BookingWizard/General, Booking/Receivables,
Booking/Waitlist Management, Booking/Dynamic Pricing/Cancellation, or the Tourplan/Wish
cancel paths in DataManagement.
Source of truth: the Mendix model, read via model/. Microflow names,
node numbers, XPath and message strings were verbatim from commit 255b346f3, Mendix 10.24.21.
Node numbers in [n] match python3 tools/mxrender.py <flow>.
model/has since moved to commit83d87ba8e. Of the flows this document cites, the rendering ofAPI.BookQuote,API.CreateWindowBooking,API.GetQuote_B2B,API.ReplaceQuoteandAPI.UpdateDMCOverrideLinechanged length, so[n]references into those five need re-checking. The cancellation and fee flows inBookingandDataManagementare byte-identical between the two revisions.
Anything the model does not state is marked (inferred). Quoted NOTE: lines are Studio Pro
canvas annotations, reproduced verbatim — they record intent, but where a note and the logic
disagree, this document says so and the logic wins.
Revised 2026-08-07 against a re-generated extract that no longer drops microflow-valued call parameters, canvas annotations, Rule-based split conditions or sort orders. See
quote-to-booking.md§12. Sections changed as a result are marked (corrected) or (new).
This is the sequel to
quote-to-booking.md and assumes its vocabulary — the four status axes,
BookingStatus, BookingLineStatus.
1. Cancellation has three different outcomes¶
"Cancel" is one button and three genuinely different operations. Which one you get is decided
inside Booking.CancelBooking, not by the caller.
| Outcome | When | What happens to the record |
|---|---|---|
| Hard delete | No TravelLocation exists, WindowStatus = Draft, and TourPlanReference is empty or ≤ 0 |
DELETE $Booking — the row is gone. An ArchiveLog is written first. |
| Cancel, no fees | Draft / Placeholder / Quote-only, or a booking with no Wish reference | WindowStatus = CX, InventoryStatus = CX, lines cancelled |
| Cancel with costs | A confirmed booking where the fee engine finds a chargeable fee | WindowStatus = CC ("CCost"), fee-bearing lines stay LIVE carrying the fee |
The distinction between CX and CC is the commercial one: CX is gone and costs nothing,
CC is gone but somebody owes money. CancelBooking [35] preserves an existing CC:
WindowStatus = if $Booking/WindowStatus = BookingStatus.CC then BookingStatus.CC else BookingStatus.CX
so once a booking is CC a later cancel cannot demote it to CX.
The line-level markers¶
Three attributes together encode a cancelled line. Reading only one of them is the most common mistake in this area.
| Attribute | Meaning |
|---|---|
BookingLine.Cancel |
Model doc: "Set to pass through Delete/cancel process. May be deleted or cancelled, and may or may not end up LIVE (Depending if fees)" — an in-flight marker, not a final state |
BookingLine.LIVE |
false once cancelled. Almost every retrieve in the app filters on [LIVE] |
BookingLine.aSequence |
Bumped by +1000 on cancellation (CancelBL [4]). This is how cancelled lines are later found — TP_ReviveCancelledBookingLines [5] matches on aSequence >= 1000 |
BookingLineStatus |
Cancelled or CancelledWithCosts |
A fee-bearing line is deliberately not removed — it stays as the carrier of the charge.
That is why Cancel = true does not imply LIVE = false.
Cancellation attributes on Booking¶
| Attribute | Notes |
|---|---|
Cancelled |
Boolean, default false — set only when the Wish cancel succeeded |
CancelledByUser |
String, username |
CancellationDate |
Set at [35]; note the delete/no-fee path at [24] sets ChangeDate instead |
CancelBookingReason |
Booking.CancellationReason enum |
CancelBookingReasonDesc |
Free text |
NoCancelFees |
Model doc: "Do not calc any cancel fees for this booking" — the commercial override, see §5 |
RelaxedPmtTermsApply |
Model doc: "Covid, if a new booking, relaxed terms will apply (up until 31 Dec 21), then no longer" — feeds Pricing.GetPmtTerms |
Insured |
Gates an extra confirmation page on line cancellation |
Booking.CancellationReason values: Client_cancelled, Client_postponed_trip,
Trip_postponed_no_fees_charged, Cancelled_but_not_charging_fees_as_a_favour_to_agent,
Superceded_by_a_new_booking, Moving_booking_to_correct_Tourplan, Other.
Two of those — "not charging fees as a favour to agent" and "trip postponed no fees charged" —
describe a fee waiver but do not themselves set NoCancelFees. The reason is recorded
independently of the override. Reporting on waivers must read NoCancelFees, not the reason.
Every live flow that writes the cancellation flags¶
| flow | folder | step | sets_to | runnable_by |
|---|---|---|---|---|
| Booking.CancelBooking | BookingWizard/General/MF | 24 | true | Booking.Agent, Booking.Debug, Booking.Employee, Booking.ExternalConsultant |
| Booking.CancelBooking | BookingWizard/General/MF | 35 | if $CancelSuccessWish then true else false | Booking.Agent, Booking.Debug, Booking.Employee, Booking.ExternalConsultant |
| Booking.IVK_Uncancel | BookingOverview/MF | 2 | false | Booking.Debug |
| Tools.IVK_CancellBookings | MF | 8 | true | Tools.Debug |
expr is verbatim; a conditional write is one row, not a decision table.
| entity | moment | event | runs | aborts_transaction |
|---|---|---|---|---|
| Booking.TourplanBookingServiceLine | Before | Commit | Booking.BeforeCommit_SetPaymentStatus | no |
| Booking.TravelLocation | Before | Delete | Booking.BDe_TL_UpdateSequences | yes |
| Booking.VoucherTotals | Before | Commit | Booking.BeforeCommit_AddTotals | yes |
2. Entry points and their guards¶
IVK_CancelBooking_Confirm is the main route. It does not cancel — it picks a confirmation page.
[2] WindowStatus in (Traveled, Travelling)
-> "You cannot cancel a booking that is already travelling or has travelled
(why would you do that?) Please get your manager to speak to WW support"
[5] Source = Ex_window
-> "Booking is ex-Window and cannot be cancelled via the Window"
[6] TourPlanFullReference not empty
-> "As this booking has been confirmed in Tourplan, please cancel from the booking file"
[7] WindowStatus in (Draft, empty, Placeholder, Quote_only)
-> page Booking.CancelBooking_confirm (simple path)
otherwise
-> page Booking.CancelBookingConfirmation (full path, captures reason)
Roles: Administrator, Agent, Employee, Manager, Debug, ExternalConsultant.
The _NoRedirect variant diverges¶
IVK_CancelBooking_Confirm_NoRedirect has the same role list but two different rules:
| Rule | IVK_CancelBooking_Confirm |
..._NoRedirect |
|---|---|---|
| Travelled / travelling | Hard block for everyone | [12-15] IsManager() — a manager may proceed |
TourPlanFullReference populated |
Blocked, "cancel from the booking file" | No check at all |
So the same booking is cancellable from one button and not the other, and the Tourplan-confirmed guard is enforced on only one of the two paths. See defect C2.
The other callers¶
| Flow | Roles | Notes |
|---|---|---|
IVK_CancelBooking_Overview |
Admin, Agent, Employee, Manager, Debug, ExtConsultant | Writes ArchiveLog, then CancelBooking($Booking, true) |
IVK_CancelBooking_Overview_NoCancelFees |
same | Sets NoCancelFees = true first, then cancels — see §5 |
IVK_CancelBookings |
Debug |
Bulk loop, no per-booking confirmation |
Sub_CancelOrphanDraftBookings |
— | Housekeeping, see §8 |
API.CancelBooking |
— | Partner API path |
Booking.SE_CancelExpiredBookings |
— | Documented in quote-to-booking.md §7 |
IsCancellationValid¶
The only shared validity gate, called from CancelBooking [5]:
[2] WindowStatus in (Traveled, Travelling)
-> "Booking is in an invalid state ( Status = {1}) to be Cancelled in the Window."
-> return false
[5] otherwise return true
That is the whole of it. Note what it does not check: Tourplan state, ex-Window source, or
role. Those live in the IVK wrappers, so any caller reaching CancelBooking directly bypasses
them — IVK_CancelBookings (bulk, Debug) and Sub_CancelOrphanDraftBookings both do.
3. Booking.CancelBooking($Booking, $ShowConfirmationAfterCancel)¶
Folder BookingWizard/General/MF. Roles Agent, Debug, Employee, ExternalConsultant.
Returns Boolean.
Four annotations sit on this flow (new):
NOTE: "Sets Wish inventory lines to cancelled (inventory status)"
NOTE: "if cancel from step 2 - need to send ARCs" — an unimplemented requirement left on the canvas. (inferred: ARC = an availability-release notification to the camp.) Worth asking whether step-2 cancellations are silently skipping a supplier notification.
NOTE: "2021-10-20 : Put this in while we sort out the statuses - Al" — a temporary workaround, still in place five years later.
NOTE: "Removed empty retrieve"
[4] $IsConfirmedBooking = WindowStatus = C <- computed, never used (defect C5)
[5] IsCancellationValid -> false: return false
[8] LogEvent(Event.Cancellation, 'Booking cancelled by user')
[9] retrieve first TravelLocation under the booking's legs
[10] no TravelLocation AND WindowStatus = Draft:
[68] TourPlanReference empty or <= 0
-> [70] ArchiveLog (Reason 'No travellocations so delete')
-> [71] DELETE $Booking ; return true
otherwise fall through to the normal path
[12] BHL_LogEntry_Create(..., 'Pricing change on booking', 'SumDetailedBookingLines', Event.Update)
[15] Explorations.ClearExploration($Booking)
[16] branch on whether Wish is involved:
WishBookingReference empty, or Source in (Placeholder, Quote_only) -> [17] simple path
otherwise -> [59] Wish path
Simple path — no Wish involvement¶
[17] If WindowStatus is Draft, Placeholder, Quote_only or empty:
[18] BookingGuestManagement.CancelBooking_Guests
[19] Logging.LogMessage 'Manual delete draft booking ...'
[21] if $ShowConfirmationAfterCancel -> "Your booking has been successfully cancelled."
[24] CHANGE: WindowStatus = CX; InventoryStatus = CX;
TourplanStatus = Draft if it was Draft else Pending;
CancelledByUser = currentUser; Cancelled = true; ChangeDate = now
[25] EmailConsultantsAgentBooking(..., BookingStatus.CX)
Note [24] sets ChangeDate, not CancellationDate. The full path at [35] sets
CancellationDate. Anything reporting on when a booking was cancelled must handle both.
Wish path¶
[59] DataManagement.CancelWishHeader($Booking)
failure -> [61] LogMessage Critical 'There is an issue cancelling the booking in Wish...'
[62] MESSAGE error "Your booking could not be cancelled die to Wish error"
return $Cancelled (false)
success -> $CancelSuccessWish = true, continue to [29]
The Critical log carries a user-facing resolution string: "The cancellation of this booking
failed due to a system issue. The booking has been handed over to a consultant to complete the
cancellation." — i.e. a failed Wish cancel becomes a manual support task.
Common tail — [29] onwards¶
[30] BookingGuestManagement.CancelBooking_Guests
[31] retrieve live lines not already Cancelled/CancelledWithCosts
[33] set Cancel = true on each
[34] Booking.Sub_DeleteBookingline_Mem(true, $Booking, true, $BookingLineList_Live) <- fee engine runs here
[35] CHANGE: WindowStatus = CC if already CC else CX
InventoryStatus = CX if the Wish cancel succeeded, else unchanged
TourplanStatus = Draft if it was Draft else Pending
CancelledByUser / Cancelled = only if the Wish cancel succeeded
CancellationDate = now
[36] DataManagement.CancelBooking_LR (third-party / LiveRequest inventory)
[38] per PartyCostGroup: Itinerary.DeleteItineraryInWetu
[40] EmailConsultantsAgentBooking(..., BookingStatus.CX)
[41] Pricing.SumDetailedBookingLines($Booking, false)
[43] BHL_LogEntry_Create(..., 'Cancel Booking', 'IVK_CancelBooking', Event.Cancel)
(and, on the confirmation branches, executeMicroflowInBackground(
microflow=Onbase.BookingStatus_Post, $Booking) — OnBase is told about the cancellation)
[45] DataManagement.RefreshWishHistoryById_FromBooking
[46] retrieve open Logging.SystemLog rows for this booking (Status not Resolved/Audit)
[48] mark them Resolved by 'System' with Resolution 'Booking cancelled'
[50] if $ShowConfirmationAfterCancel:
TourPlanReference > 0 -> "A consultant has been informed of the cancellation of this
booking and will be in contact with you in the event of any
financial settlement. Please be aware that we can not show
this financial settlement in the Wilderness Window."
otherwise -> "Your booking has been successfully cancelled and the reserved
inventory has been released where applicable"
[46-49] auto-resolves the booking's open system logs. Any unresolved error on a booking is silently closed when it is cancelled. That is reasonable operationally but means cancellation destroys the error trail — relevant when investigating "why did this booking fail" after the fact.
EmailConsultantsAgentBooking is called with BookingStatus.CX on both paths, including when
the booking ends up CC. (inferred: the e-mail template does not distinguish, so a
cancelled-with-costs booking is announced as a plain cancellation.) Worth checking the
template before relying on it.
4. Line-level cancellation¶
Booking.CancelBL($BookingLine, $Booking)¶
Folder Receivables/MF. The primitive every cancel path funnels into.
[2] Sub_UpdateBookinglineSequenceNoTL_delete(aSequence, $Booking)
[3] $TPUpdateReq = Chargeable and TourPlanStatus != Draft
[4] CHANGE $BookingLine set {
aSequence = aSequence + 1000; <- the cancelled-line marker
LIVE = false;
BookingLine_TravelDay = empty;
BookingLine_TravelLocation = empty;
TPUpdateRequired = $TPUpdateReq;
TourPlanStatus = Draft if it was Draft else Pending;
Cancel = true
} [commit YesWithoutEvents]
[5] CHANGE $Booking set {
ReSumPricingTotals = true if the line was Chargeable;
TourplanStatus = Pending, unless already CC or CX
}
Detaching TravelDay and TravelLocation is what removes the line from the itinerary while
leaving the row intact for financial history.
Booking.IVK_CancelBookingLineFully($BookingLine, $Booking)¶
Roles Debug, Employee. Adds one guard before CancelBL:
[2] if $Booking/Insured:
[8] $WS = Supplier/OwnershipID in (A1, B1, C1, G1, I1, J3, K2, K3, K4, Q1)
[9] first live Window-created Tourplan service line on the booking, RVLine = false
[11] if $WS and $TourplanBookingServiceLine/R_Invoiced > 0
-> [12] OPEN PAGE Booking.BookingLine_CancelCheck (stop, ask the user)
otherwise -> proceed
[4] CancelBL($BookingLine, $Booking)
[5] Pricing.SumDetailedBookingLines($Booking, true)
That $WS list is a hardcoded set of supplier ownership codes standing for Wilderness-owned
suppliers (inferred from the contrast with the $IsAP list in §5). See defect C1.
5. The cancellation-fee engine¶
Three microflows: decide whether a fee applies, work out the percentage, apply it.
Booking.CancelFees($Booking, $BookingLine, $FullBookingCancel) : Boolean¶
Gate order — any of these returns false, meaning no fee:
| # | Node | Condition for no fee |
|---|---|---|
| 1 | [2] | $Booking/NoCancelFees is true |
| 2 | [3] | the line is not Chargeable |
| 3 | [4] | BookingLineStatus != Confirmed |
| 4 | [7] | the line has no TourplanBookingServiceLine records |
| 5 | [15] | the computed fee percentage is exactly 0 |
Gate 4 is worth dwelling on: a line that was never written to Tourplan attracts no cancellation fee regardless of how close to travel it is. Provisional-only bookings therefore cancel free.
[8] $SumInvoiced = Sum of TourplanBookingServiceLine.R_Invoiced
[9] $SumCredited = Sum of TourplanBookingServiceLine.R_Credited
[10] $Invoiced = $SumInvoiced >= $SumCredited <- never referenced again (defect C4)
[12] GetCancelFeePerc($Booking, $BookingLine, $Supplier) -> $Fee
[13] $CancelFee = if $Fee = empty then 1 else $Fee
[14] $HasCancelFee = if $Fee = empty then true else $Fee > 0
[17] if $HasCancelFee -> ApplyCancelFees($BookingLine, $CancelFee, $Booking, $FullBookingCancel)
empty means 100%. GetCancelFeePerc returning empty is not "unknown, skip" — [13]
converts it to a full-value charge. Every path that returns empty is a 100% fee.
Booking.GetCancelFeePerc($Booking, $BookingLine, $Supplier) : Decimal¶
Folder Dynamic Pricing/Cancellation. Returns a fraction (0.25 = 25%), not a percentage.
[2] Day_First empty -> return empty => 100%
[3] $IsAP = Supplier/OwnershipID in (M1, J1, J2, K1, CR, DM, N1, TN)
[4] $DaysBeforeTravel = round(daysBetween(now, BookingLine/Day_First))
[5] Pricing.GetPmtTerms($DaysBeforeTravel, Day_First, $Supplier,
$Booking/RelaxedPmtTermsApply, $BookingLine, $Booking)
[7] payment terms found -> return CancellationFee / 100 (0 if CancellationFee is empty)
[8] no payment terms:
[9] $IsAP -> return empty => 100%
[11] Accommodation / Exploration / Private_Activity
-> DaysBeforeTravel > 60 ? 0.25 : 1
[14] Flight -> >7 days : 0
>3 days : 0.25
>2 days : 0.5
>1 day : 0.75
otherwise: 1
[13] anything else -> 0
The configured PaymentTerms/CancellationFee is the primary source. Everything below [8] is
a fallback for missing master data, and the fallback is punitive: an associated-product
supplier with no payment terms charges the client 100%.
The previous ladder is preserved on the canvas (new):
NOTE: "if confimed <31 Dec 20 if $DaysBeforeTravel >= 56 then 0.20 else if $DaysBeforeTravel >= 28 then 0.45 else if $DaysBeforeTravel >= 21 then 0.50 else if $DaysBeforeTravel >= 14 then 0.60 else 1"
That is the pre-2021 fallback, and it is a genuinely graduated scale — 20% / 45% / 50% / 60% / 100% across five bands. The current accommodation fallback has two bands, 25% and 100%, with the cliff at 60 days. Whatever replaced the old ladder made the fallback markedly blunter and, between 60 and 56 days, markedly more expensive (100% where it used to be 20%).
This matters for the argument in §11 that the fallback is a master-data failure mode rather than policy: the graduated version looks like a commercial position, the current one looks like a default nobody revisited. Worth putting to whoever owns cancellation policy.
Fallback ladder as a table:
| Line type | > 60 days | 8–60 days | 4–7 days | 3 days | 2 days | ≤ 1 day |
|---|---|---|---|---|---|---|
| Accommodation / Exploration / Private Activity | 25% | 100% | 100% | 100% | 100% | 100% |
| Flight | 0% | 0% | 25% | 25% | 50% | 75–100% |
Associated product ($IsAP) |
100% | 100% | 100% | 100% | 100% | 100% |
| Anything else | 0% | 0% | 0% | 0% | 0% | 0% |
The flight ladder's boundaries are strict >, so day 7 exactly falls into the 25% band, day 3
into 50%, day 1 into 100%.
Booking.ApplyCancelFees($BookingLine, $CancelFee, $Booking, $FullBookingCancel)¶
Rewrites the money on every DetailedBookingLine under the line.
[6] $SellFee = SellingPrice * $CancelFee
[7] $CostFee = CostPrice * $CancelFee
[8] CHANGE $IteratorDetailedBookingLine set {
SellingPriceAfterDiscounts = SellingPrice if currently 0 else unchanged;
SellingPrice_CancellationAdjustment = SellingPrice - $SellFee; <- written off
SellingPrice = $SellFee; <- retained as the fee
CostPrice_CancellationAdjustment = CostPrice - $CostFee;
CostPriceAfterDiscounts = CostPrice if currently 0 else unchanged;
CostPrice = $CostFee;
GrossProfit = $SellFee - $CostFee;
CancelFee_Cost = round($CancelFee * 100, 2);
CancelFee_Sell = round($CancelFee * 100, 2);
GrossProfitPerc = (SellingPrice - CostPrice) / SellingPrice * 100
}
[9] recompute tax: if TaxRate_Cost and TaxRate_Mkup are both set, compute inline;
otherwise Pricing.CalculateDetailedBookingLineTax
[15] COMMIT $DetailedBookingLineList [without events]
[16] Booking.CancelFee_PCG_BL($BookingLine, 1 - $CancelFee) <- note the inverted fraction
[17] CHANGE $BookingLine set { CancelFeeAdj_Cost = $CancelFee * 100;
CancelFeeAdj_Sell = $CancelFee * 100 }
Two things to be careful with:
- The price fields are destructively overwritten.
SellingPricebecomes the fee, and the original is recoverable only by addingSellingPrice_CancellationAdjustmentback. Any report readingSellingPriceon a cancelled line is reading the fee, not the original price. GrossProfitPercat [8] is computed from the pre-change values. All expressions inside a single MendixCHANGEevaluate against the object's state on entry, so this stores the original margin percentage whileGrossProfitimmediately above stores the post-fee cash margin. Verify against intent — see defect C6.- [16] passes
1 - $CancelFee— the waived fraction, not the charged one. ReadCancelFee_PCG_BLwith that in mind.
Rounding is inconsistent: DetailedBookingLine.CancelFee_* is round(x, 2),
BookingLine.CancelFeeAdj_* is unrounded.
6. The NoCancelFees override¶
The commercial escape hatch. Booking.NoCancelFees short-circuits CancelFees [2] for
every line on the booking.
Two ways to set it:
Booking.IVK_ToggleCancelFees($Booking) roles: Employee, ExternalConsultant
[2] NoCancelFees = not NoCancelFees [commit YesWithoutEvents]
[3] LogEvent(Event.Cancellation, 'Cancellation fees turned off' / '...turned on')
Booking.IVK_CancelBooking_Overview_NoCancelFees($Booking)
[3] NoCancelFees = true [commit YesWithoutEvents]
[4] LogEvent(Event.Cancellation,
'No cancellation fees to be calculated for this booking - assume a postponement')
[7] CancelBooking($Booking, true)
Exposed on Sn_BookingMenu as a pair of buttons, "Override cancellation fees" and
"Cancel fees overridden", toggling on NoCancelFees.
There is no approval step and no manager gate. Roles are Employee and
ExternalConsultant — an external consultant can waive every cancellation fee on a booking, and
the only trace is a LogEvent entry. Given this directly determines whether the client is
charged, it is the highest-value control finding in this document. See defect C3.
The NoCancelFees flag is also read by page visibility conditions
(Sn_BookingMenu, BookingFile_Agent_050526) to hide financial-settlement panels on
cancelled bookings.
7. Undo — revive a cancelled booking¶
Booking.IVK_Uncancel($Booking) — role Booking.Debug only¶
Surfaced as the button "Revive cancelled booking" on BookingFile_Agent_050526 and
Sn_BookingMenu. Because the role list is Debug alone, ordinary users cannot run it —
there is no self-service undo. This is a support tool.
[2] CHANGE: WindowStatus = Draft; InventoryStatus = Pending; Cancelled = false;
WishBookingReference = empty; WishPartyGroupId = empty;
WishUpdateStatus = Update_required; WishPartyGroupStatus = Update_required;
ReadyForTravel = false
[4] clear WishGuestId on every guest
[8] reset WishPartyGroupId to 0 on every PartyCostGroup
[12] reset every Wish AvailableRoomConfig: WishStatus = Draft, WishSectorBookingId = empty,
Waitlist = No, Booked_* cleared, all BookedRoomCount_* to 0, HoldingSpace = false
[16] Booking.Uncancel_CC_BL($Booking)
[17] LogEvent(Event.Update, 'Booking UNCANCELLED')
[18] WizardStep2_FromBookingFile($Booking, false)
[19] Booking.Step2Checks($Booking)
[22] Sub_AuditBookingCheck
[23] DataManagement.TP_UncancelBooking($Booking)
The Wish linkage is destroyed, not restored. WishBookingReference is cleared, so the
revived booking has no memory of its previous Wish holds and must be re-provisioned from
scratch. Reviving does not recover the original inventory — it recreates the opportunity to ask
for it.
DataManagement.TP_UncancelBooking($Booking) : Boolean¶
[2] TP_ResetTPHeaderStatus($Booking)
success -> [4] TourplanStatus = Pending
[5] TP_ReviveCancelledBookingLines($Booking)
return true
failure -> return false
DataManagement.TP_ReviveCancelledBookingLines($Booking)¶
Re-attaches orphaned Tourplan service lines to the new live lines:
[2] for each live chargeable BookingLine:
[5] find a cancelled sibling: aSequence >= 1000,
InventoryBookingSectorStatus = 'Cancelled',
same Option, same Day_First
[7] its live Window-created TourplanBookingServiceLine rows
[10] re-point them at the new line, TPServiceLineStatus = VY
[11] new line TourPlanStatus = Pending
The match is on (Option, Day_First) only. Two lines for the same option on the same day —
different guests, different room configurations — are indistinguishable here, and first takes
whichever the database returns. See defect C7.
8. Waitlist release — Booking.SE_WaitlistRelease¶
Folder Waitlist Management. Roles Administrator, Debug.
[2] if today is Sat or Sun -> do nothing
[3] $CutOffTime = start of day - 2 days
[4] set A: AvailableRoomConfig with a live Waitlisted line, Waitlist = Yes,
WaitlistAvailableFrom <= $CutOffTime, WaitListNowAvailable,
booking Source in (Wilderness_window, EX_Tourplan)
[5] $TravelCutOffTime = today + 42 days
[6] set B: AvailableRoomConfig with a live Waitlisted line whose booking TripStartDate
<= $TravelCutOffTime, Waitlist = Yes, same Source filter
[7] union A and B
[9] per config:
[13] branch on the type of the booking owner
[14] Employee: send "WARNING - we have released your waitlist of :<supplier>
for booking :<ref>" to the branch/department e-mail,
or the owner's own address if there is none
[23] set Cancel = true on the matching live lines
[24] Sub_DeleteBookingline_Mem(false, $Booking, false, $BookingLineList_All)
[26] any other owner type: CONTINUE — nothing happens
The flow's own annotation states the intended rule (new):
NOTE: "This microflow clears 1 - waitlisted items that have become available but were not taken up by the agent within 48 hours, and 2 - clears waitlisted items not taken up for trips with less than 8 weeks to go"
Rule 1 matches: addDaysUTC([%BeginOfCurrentDay%], -2) is 48 hours.
Rule 2 does not match. The note says 8 weeks (56 days); the code says
addDaysUTC([%CurrentDateTime%], 42) — 6 weeks. Waitlists are being released two weeks
earlier than the documented intent. See defect C14.
Two rules with commercial weight:
- Set B releases on proximity to travel alone. Any waitlist on a trip departing within 42 days is released whether or not space came free, and whether or not the two-day availability rule in set A applies.
- Only
Employee-owned bookings are processed. [26] skips every other owner type, so an agent-owned booking's waitlist is neither released nor notified. See defect C8.
Sub_DeleteBookingline_Mem is called with $FullBookingCancel = false here, versus true from
Booking.CancelBooking [34] — the fee engine sees a different flag on the two paths.
9. Housekeeping — Booking.Sub_CancelOrphanDraftBookings¶
[2] Source = Wilderness_window AND BookingStep = Step1 AND PartyName empty
AND BookingReferenceDescriptive empty AND WishBookingReference empty
AND TripStartDate empty AND TripEndDate empty AND NotificationEmail = false
AND WindowStatus not in (CX, CC)
[4] ArchiveLog (Reason 'Step 1 no party name or reference or dates')
[5] CancelBooking($I_Booking, false)
Every one of those records satisfies CancelBooking's delete condition (no TravelLocation,
Draft, no TourPlanReference), so in practice this job deletes rather than cancels,
despite the name. There is no limit on the retrieve at [2], unlike
SE_CancelExpiredBookings which caps every sweep at 150. See defect C9.
10. Integration touchpoints¶
| System | When | Call |
|---|---|---|
| Wish | booking cancel | DataManagement.CancelWishHeader; WishAPI.CancelWishBooking, CancelWishBookingById |
| Wish | uncancel | linkage cleared in IVK_Uncancel, re-provisioning required |
| Tourplan | line cancel | TPUpdateRequired / TourPlanStatus = Pending set by CancelBL; Booking.CancelBL_TP |
| Tourplan | uncancel | TP_ResetTPHeaderStatus, TP_ReviveCancelledBookingLines |
| LiveRequest / third party | booking cancel | DataManagement.CancelBooking_LR |
| Wetu (itineraries) | booking cancel | Itinerary.DeleteItineraryInWetu per PartyCostGroup |
| NightsBridge | channel cancel | NightsBridge_V5.Cancel_NightsBridge_Booking |
| Partner API | inbound cancel | API.CancelBooking, CancelBookingLines, CancelDMCLines, CancelFailedBooking |
| Explorations | booking cancel | Explorations.ClearExploration |
11. Defects and traps¶
C1 — Supplier ownership codes are hardcoded in two places, with different lists.
GetCancelFeePerc [3] hardcodes M1, J1, J2, K1, CR, DM, N1, TN as $IsAP.
IVK_CancelBookingLineFully [8] hardcodes A1, B1, C1, G1, I1, J3, K2, K3, K4, Q1 as $WS.
Neither is master data; onboarding a supplier under a new ownership code silently changes its
cancellation-fee treatment with no configuration change and no error. These belong on
BookingMasterData.Supplier as a flag.
C2 — The two cancel-confirmation entry points enforce different rules.
IVK_CancelBooking_Confirm blocks travelled bookings outright and blocks anything with a
TourPlanFullReference. IVK_CancelBooking_Confirm_NoRedirect lets a manager cancel a travelled
booking and omits the Tourplan check entirely. Same roles on both. Whichever is correct, they
should not disagree.
C3 — Cancellation fees can be waived with no approval and no manager gate.
IVK_ToggleCancelFees is Employee, ExternalConsultant. It flips NoCancelFees, which
suppresses every fee on the booking at CancelFees [2]. The only record is a LogEvent.
Compare IVK_ExtendExpiry, which is Manager-only for a materially smaller decision.
C4 — CancelFees computes an invoiced/credited check and discards it.
[8-10] sum R_Invoiced and R_Credited and derive $Invoiced, which is then never
referenced. Either the fee calculation was meant to depend on whether the line had been invoiced,
or the three activities are dead. Given the surrounding logic, the former looks likelier.
C5 — CancelBooking computes $IsConfirmedBooking and never uses it.
[4]. Same pattern as C4 — an intended branch that was removed or never wired up.
C6 — GrossProfitPerc is stored as the pre-cancellation margin.
ApplyCancelFees [8] sets GrossProfit from the post-fee figures but computes
GrossProfitPerc from SellingPrice and CostPrice, which inside a single CHANGE still hold
their pre-change values. The result is a percentage that does not correspond to the cash margin
recorded beside it. Confirm intent before "fixing" — reporting may already depend on it.
C7 — Revive matches cancelled lines on (Option, Day_First) and takes the first.
TP_ReviveCancelledBookingLines [5]. Two lines for the same option on the same day cannot be
told apart, so Tourplan service lines can be re-attached to the wrong booking line. Most likely
to bite on multi-room or multi-party-cost-group bookings.
C8 — Waitlist release ignores agent-owned bookings.
SE_WaitlistRelease [26] hits CONTINUE for any owner type other than Employee. Those
waitlists are never released and no e-mail is sent. Either agent-owned bookings are handled
elsewhere, or they accumulate indefinitely.
C9 — Sub_CancelOrphanDraftBookings has no batch limit.
The retrieve at [2] is unbounded, and each iteration performs a delete plus an ArchiveLog
insert. Every comparable housekeeping sweep in the app caps at 150.
C10 — CancellationDate is not set on the simple cancel path.
[24] sets ChangeDate; [35] sets CancellationDate. Draft, placeholder and quote-only
cancellations therefore have a null CancellationDate. Any report filtering on it silently
excludes them.
C11 — Cancellation auto-resolves the booking's open system logs.
[46-49] marks every non-Resolved, non-Audit Logging.SystemLog row for the booking as
Resolved by 'System'. Deliberate housekeeping, but it erases the diagnostic trail for
bookings that were cancelled because something went wrong.
C12 — Sn_BookingMenu visibility conditions list every enum value.
The NoCancelFees cancel buttons carry
visible-if(attr Booking.Booking.TourplanStatus; values C,CC,CX,P,X,D,Draft,Traveled,Travelling,Pending,I,Placeholder,Quote_only,Paid,EX_Tourplan,(empty))
— every member of BookingStatus plus empty, i.e. always visible. This reads as an intended
restriction that was never narrowed.
C14 — Waitlist release fires two weeks earlier than its documented intent. (new)
SE_WaitlistRelease's own annotation says it clears waitlists "for trips with less than 8 weeks
to go". $TravelCutOffTime is addDaysUTC([%CurrentDateTime%], 42) — 6 weeks. Either the note
is stale or the constant is wrong; given the note also correctly describes the 48-hour rule, the
constant is the likelier error. Commercially this drops waitlists a fortnight sooner than the
business appears to expect.
C15 — CancelBooking carries an unimplemented supplier-notification requirement. (new)
Annotation: "if cancel from step 2 - need to send ARCs". No corresponding call exists in the
flow. If step-2 cancellations are meant to notify camps of released availability, they are not.
C16 — A 2021 "temporary" workaround is still live. (new) Annotation: "2021-10-20 : Put this in while we sort out the statuses - Al". The statuses were evidently not sorted out. Worth identifying what the workaround guards before touching the cancellation status logic.
C13 — Dated clones in this area.
CancelBooking_230226, SE_WaitlistRelease_230226, SE_WaitlistRelease_260326,
IVK_CheckForWaitlistBehind_120126. Any fix must account for them — see
reports/dated-clones.md.
12. Reproducing this analysis¶
python3 tools/mxrender.py Booking.CancelBooking
python3 tools/mxrender.py Booking.CancelFees
python3 tools/mxrender.py Booking.GetCancelFeePerc
python3 tools/mxrender.py Booking.ApplyCancelFees
python3 tools/mxrender.py Booking.IVK_Uncancel
python3 tools/mxrender.py Booking.SE_WaitlistRelease
## every write of a cancelled status
grep -rn 'BookingStatus.CX\|BookingStatus.CC' model/*/flows.txt
## every reader of the fee override
grep -rn 'NoCancelFees' model/*/flows.txt model/*/pages.txt
## the hardcoded ownership-code lists
grep -rn "OwnershipID = '" model/*/flows.txt
## who can cancel
python3 tools/mxinspect.py callers Booking.CancelBooking
13. What this document does not cover¶
Generated from journeys/cancellation-and-fees.toml and the model - not recalled, and not
maintained by hand. applies_to counts the live documents each decision covers, so an
exclusion that has stopped matching anything shows as 0 instead of quietly passing.
| excluded | live_documents | reason | source |
|---|---|---|---|
| Booking/Receivables/* | 286 | Invoicing and credit notes downstream of a cancellation fee. Fee calculation is covered; what finance does with the resulting balance is a separate journey. (inferred - confirm with the team) | |
| API.SwitchAccomodation | 1 | BookingLine.Cancel is not a cancellation-only flag - the model's own doc string says it marks a line to pass through the delete/cancel process. The partner API's itinerary-editing operations set it while amending, so they belong to the booking-mutation journey, not this one. | docs/deep-dives/cancellation-and-fees.md#13 |
| API.ChangeAccomNumberOfNights | 1 | Amendment surface: removes lines during an edit, reusing cancellation machinery. | docs/deep-dives/cancellation-and-fees.md#13 |
| API.DeleteAccomodation | 1 | Amendment surface, as above. | docs/deep-dives/cancellation-and-fees.md#13 |
| API.AddDaysToBooking | 1 | Amendment surface, as above. | docs/deep-dives/cancellation-and-fees.md#13 |
this is the "does not cover" section, generated from journeys/.toml plus the model rather than from memory of where the author stopped reading*.
The measured gaps behind those decisions:
| kind | item | detail |
|---|---|---|
| island | Administration.TmpTest_ClearOwnedAgencyBookings | in Administration/DebugScreens/MF, unreachable from the declared roots |
| island | Booking.SE_UpdateBookingStatus | in Booking/BookingWizard/General/MF, unreachable from the declared roots |
| island | Tools.IVK_CancellBookings | in Tools/MF, unreachable from the declared roots |
| undocumented_trigger | Booking.IVK_CancelBookingLineFully | button: Booking.Sn_Financials / actionButton24 |
| undocumented_trigger | Booking.IVK_CancelBooking_ClosePopup_NoCancelFees | button: Booking.CancelBookingConfirmation_NoRedirect / actionButton3 |
| undocumented_trigger | Booking.IVK_CancelBooking_Confirm | button: Booking.BookingOverview_Bookings / microflowButton77 |
| undocumented_trigger | Booking.IVK_CancelBooking_Confirm_FromBookingUserHistory | button: Booking.BookingOverview_Agent / microflowButton61 |
| undocumented_trigger | Booking.IVK_CancelBookings | button: Booking.BookingOverview_Support / actionButton14 |
| undocumented_trigger | Booking.IVK_ClearLeg | button: Booking.Booking_ClearByLeg / actionButton1 |
| undocumented_trigger | Booking.IVK_ConfirmWishOnly | button: Booking.BookingFile_Agent_050526 / microflowTrigger59 |
| undocumented_trigger | Booking.IVK_OverrideOption_Cancellation | button: Booking.Sn_Financials / actionButton70 |
| undocumented_trigger | Booking.IVK_RemoveFromWindow | button: Booking.ExWindowConfirmation / microflowButton1 |
| undocumented_trigger | Booking.IVK_ToggleCancelFees | button: Booking.Sn_BookingMenu / actionButton1 |
| undocumented_trigger | Booking.IVK_Uncancel | button: Booking.BookingFile_Agent_050526 / microflowTrigger15 |
| undocumented_trigger | Booking.IVK_UpdateTourplanTo_CC | button: Booking.BookingFile_150726 / actionButton52 |
| undocumented_trigger | Tools.IVK_CancellBookings | button: Tools.CancelTestBookings / actionButton1 |
| undocumented_writer | API.CancelFailedBooking | writes Booking.Booking.WindowStatus and is never named |
| undocumented_writer | Administration.TmpTest_ClearOwnedAgencyBookings | writes Booking.Booking.WindowStatus and is never named |
| undocumented_writer | Booking.IVK_CancelBooking_ClosePopup_NoCancelFees | writes Booking.Booking.NoCancelFees and is never named |
| undocumented_writer | Booking.IVK_ClearLeg | writes Booking.Booking.WindowStatus and is never named |
| undocumented_writer | Booking.IVK_ConfirmWishOnly | writes Booking.Booking.WindowStatus and is never named |
| undocumented_writer | Booking.IVK_OverrideOption_Cancellation | writes Booking.Booking.NoCancelFees and is never named |
| undocumented_writer | Booking.IVK_RemoveFromWindow | writes Booking.Booking.CancelledByUser and is never named |
| undocumented_writer | Booking.IVK_UpdateTourplanTo_CC | writes Booking.Booking.WindowStatus and is never named |
| undocumented_writer | Booking.NewBooking_fromLead | writes Booking.Booking.WindowStatus and is never named |
| undocumented_writer | Booking.SE_UpdateBookingStatus | writes Booking.Booking.WindowStatus and is never named |
| undocumented_writer | BookingTemplates.CreateTripBooking | writes Booking.Booking.WindowStatus and is never named |
| undocumented_writer | Tools.IVK_CancellBookings | writes Booking.Booking.Cancelled, Booking.Booking.WindowStatus and is never named |
Cancellation itself is complete. Every published or scheduled entry point that writes
Cancelled, CancelledByUser, CancellationDate, NoCancelFees or CancelBookingReason is
covered here — 1 of 1, API.CancelBooking.
python3 tools/coverage.py Booking.Booking \
--attr Cancelled,CancelledByUser,CancellationDate,NoCancelFees,CancelBookingReason \
docs/deep-dives/cancellation-and-fees.md # -> 0 uncovered
But BookingLine.Cancel is not a cancellation-only flag, and running the same check
against it returns 14 uncovered entry points. That is not a gap in this document — it is the
attribute doing two jobs. The model's own doc string says so: "Set to pass through
Delete/cancel process. May be deleted or cancelled, and may or may not end up LIVE (Depending
if fees)". The partner API's itinerary-editing operations set Cancel = true when removing
a line during an amendment, reusing the same machinery:
| Operation | Sets Cancel via |
|---|---|
API.ChangeAccomNumberOfNights |
UnselectAndRemoveBLs → ClearAccommodationRelatedBookingLines |
API.DeleteAccomodation |
Booking.DeleteTravelLocation |
API.AddDaysToBooking |
AmendTLDates_CheckForTP → UpdateTLDays_X → AdjustBookingLinesForDayChanges |
API.AddAccomodationToBooking, AddAccomodationToStartEnd, BookQuote |
ProceedToScreen3_CreateBLs → Sub_CreateBookingLines |
API.AddFlightToStartEnd, AddTravelHubAndFlightToStartEnd |
IVK_NextStepFrom2 → Step2Checks |
Practical consequence: BookingLine.Cancel = true does not mean the guest cancelled
anything. Any report or query treating it as a cancellation signal will count itinerary edits
as cancellations. Use BookingLineStatus in (Cancelled, CancelledWithCosts) for that, and
Booking.Cancelled at booking level.
Those amendment operations belong to a separate journey — the partner API's booking-mutation
surface, listed in
quote-to-booking.md §10 — which no
document in this set yet covers.
Also out of scope here: the pricing engine that computes the pre-cancellation figures
(Pricing.Sub_PriceBookingAll), invoicing and credit notes for a CC booking, and the
supplier-side cancellation messaging beyond SendAutomatedEmails_Confirmation.