Regression test cases — pricing rules¶
Audience: QA. Scope: pricing end to end — which rules attach to a booking and why (groups A–L), and how the resulting number is assembled (groups P–T).
Every case traces to a node in the model at commit 83d87ba8e. [n] refers to the rendered
flow listing: python3 tools/mxrender.py <flow> or model/Pricing/flows.txt. Where a case
depends on configuration this repo cannot see (rule rows, agency lists, constants overridden
per environment), it says so.
Scope. Groups A–M assert attachment and exclusion — which rules apply, and why. Groups N–R assert the arithmetic: reconciliation, tax, rack factor and totals, per pricing-calculation.md. Still not covered anywhere: where a rate comes from.
Sub_GetOptionsForBookingline,CheckDynamicPricing,Price_SiteminderandSub_CreatePricingNon_Accomare untraced, so no case asserts that a looked-up rate is correct — only that what the system does with it is.
What to assert on¶
| Observable | Where it lives |
|---|---|
| Rule attached to a cost group | PartyCostGroup_BL_PricingRule_Qualifying |
| Rule attached to a line | BookingLine_PricingRuleQualifying |
| Rule excluded for this booking | Booking_PricingRule_Exclude |
| Selectable rule available but not applied | Booking.HasPricingThatCanInclude = true |
| Line forced to manual pricing | BookingLine.OverridePrice = true |
| Booking priced state | Booking.Priced = Yes / Confirmed / No |
| Failure visible to support | Logging.SystemLog, operation System_Issue / User_issue |
A. The age guard — Pricing.Sub_PriceBookingAll [3]¶
| # | Status | Trip end date | Expected |
|---|---|---|---|
| A1 | Draft |
5 years ago | Reprices. The 3-month limit does not apply to this status |
| A2 | Quote_only |
5 years ago | Reprices |
| A3 | C (confirmed) |
5 years ago | Reprices |
| A4 | Traveled |
2 months ago | Reprices — inside the window |
| A5 | Traveled |
4 months ago | Blocked. Message 'You cannot change pricing on a booking that is more than 3 months after trip end date', returns false |
| A6 | CX (cancelled) |
4 months ago | Blocked, same message |
| A7 | Traveled |
exactly 3 months ago | Boundary. Condition is addMonthsUTC(TripEndDate,3) > [%CurrentDateTime%] — strictly greater, so exactly 3 months blocks |
A5–A7 log severity Critical, operation User_issue.
B. Configuration kill switches — [33]¶
| # | Holiday type | Wish rate type | Expected |
|---|---|---|---|
| B1 | not set | not set | Rules apply (default is true) |
| B2 | applyPricingRules = false |
anything | No rules applied to the whole booking |
| B3 | anything | AllowPricingRules = false |
No rules applied |
| B4 | applyPricingRules = true |
AllowPricingRules = true |
Rules apply |
B2/B3 are silent — no log, no user-facing message. Assert on absence of attachments, not on a message.
C. Channel exclusion — Pricing.ApplyPricingRules_List [3]¶
| # | API_System |
Channel |
Expected |
|---|---|---|---|
| C1 | Siteminder |
BC |
No rules considered. Silent — no log, no flag |
| C2 | Siteminder |
any other | Normal qualification |
| C3 | any other | BC |
Normal qualification |
C1 is a regression risk precisely because nothing records it. Assert zero attachments.
D. The malformed-line trap — [7], [43]¶
| # | Setup | Expected |
|---|---|---|
| D1 | All chargeable lines have DaysNights |
Normal qualification |
| D2 | One line has empty DaysNights |
No rules on any line of the booking. Log Critical / System_Issue: 'Missing days/nights on bookingline' … ' Pricing rules not checked - please reprice or refer to Support if problem persists' |
| D3 | The offending line also has OverrideOption = true |
Rules do apply — [6] filters override lines out before the check |
D3 is the subtle one: the same data is fatal or harmless depending on an unrelated flag.
E. Candidate query — [17]¶
Rule must satisfy all of: Active, SADC_only = false, LeadBookingsOnly = false,
and FirstTravelDay <= ToDate and LastTravelDay >= FromDate.
| # | Rule window vs itinerary | Expected |
|---|---|---|
| E1 | Rule covers the whole trip | Candidate |
| E2 | Rule covers the first night only | Candidate — overlap is enough |
| E3 | Rule covers the last night only | Candidate |
| E4 | Rule ends the day before the trip starts | Not a candidate |
| E5 | Rule starts the day after the trip ends | Not a candidate |
| E6 | Rule Active = false, otherwise perfect |
Not a candidate |
| E7 | Rule SADC_only = true |
Not a candidate on the standard path |
| E8 | Rule in Booking_PricingRule_Exclude |
Removed at [18] |
Boundary check for E2/E3: comparison is <= / >=, so a rule whose ToDate equals the
first travel day does qualify.
F. Last-minute filter — FilterLastMinute¶
Only rules with LastMinuteBooking = true are examined. LastMinuteDate = TripStart −
BookingLeadTime days; the rule survives when PartyCreatedDate >= LastMinuteDate.
| # | BookingLeadTime |
Party created | Trip start | Expected |
|---|---|---|---|---|
| F1 | 30 | 10 days before travel | — | Kept |
| F2 | 30 | 40 days before travel | — | Removed |
| F3 | 30 | exactly 30 days before | — | Kept — comparison is >= |
| F4 | empty | any | any | Removed. No lead time is not "unlimited" |
| F5 | 30 | 10 days before travel, booking repriced a year later | — | Kept — uses party created date, not today |
| F6 | 30, but rule has LastMinuteBooking = false |
any | any | Untouched by this filter |
F4 and F5 are the two that get misremembered. Test both explicitly.
G. Agency filter — FilterAgency¶
| # | AllAgencies |
Ring-fence list | Exclude list | Agency | Expected |
|---|---|---|---|---|---|
| G1 | true | — | — | any | Kept |
| G2 | false | contains agency | empty | matching | Kept |
| G3 | false | does not contain agency | empty | other | Removed |
| G4 | false | empty | contains agency | matching | Removed |
| G5 | false | empty | does not contain agency | other | Kept |
| G6 | false | contains agency | also contains agency | matching | Kept — the exclusion is ignored |
G6 is the important defect case. The exclude list is only consulted when the ring-fence list is empty ([11]). A rule configured with both will apply to an agency the business believes is excluded.
H. Booking-created-date filter — filterBookingCreatedDate¶
| # | BookingsCreatedFromDate |
BookingsCreatedToDate |
Party created | Expected |
|---|---|---|---|---|
| H1 | 1 Jan | 31 Mar | 15 Feb | Kept |
| H2 | 1 Jan | 31 Mar | 15 Apr | Removed |
| H3 | 1 Jan | empty | 15 Apr | Kept — open-ended |
| H4 | empty | 31 Mar | 15 Feb | Kept |
Note BookingsCreatedFromDate defaults to the current date/time on a new rule, so a rule
created today and saved without touching that field excludes every existing booking. Worth an
explicit case.
I. Exclusivity and weighting — Pricing.Sub_CheckExclusivePricingRules_List [3], [8]¶
| # | Setup | Expected |
|---|---|---|
| I1 | Two exclusive rules, weightings 10 and 20, both qualify | Weighting 10 is applied first — lowest first |
| I2 | Two exclusive rules, both weighting 0 | Order is the list's; assert only that one applies, not which (inferred) |
| I3 | One exclusive + one non-exclusive | Exclusive evaluated first ([26] before [27]) |
| I4 | Exclusive rule with CanCombineOtherExclusiveRules = true |
May coexist with another exclusive rule |
| I5 | Rule with Universal = true |
Applies in addition to an exclusive rule |
| I6 | Rule of a subtype with no case in the type switch | Qualifies, then does nothing — silent no-op at [17] / (other type) |
I6 is the regression to run after any new rule type is added.
J. Selectable rules — CheckPricingRuleBL¶
| # | Selectable |
In IncludeSelectable? |
Expected |
|---|---|---|---|
| J1 | false | — | Applies automatically |
| J2 | true | no | Not applied. Added to Booking_PricingRule_Exclude; HasPricingThatCanInclude = true |
| J3 | true | yes | Applies, exactly as a non-selectable rule |
| J4 | true, OnlyManagerCanInclude = true |
attempted by non-manager | Inclusion refused (inferred — the check was not traced to a page) |
| J5 | Booking has BookingLite = true |
— | Different branch ([28] onward); rule attaches to the line, not the cost group |
K. Side effects of attaching a rule — [10], [13]¶
When a rule attaches to a cost group, assert all of:
| # | Assertion |
|---|---|
| K1 | PartyCostGroup_BL.Override = true — unless the rule is AlternateOptionOnly, then false |
| K2 | DiscountedFlightsBlocked copied from the rule's BlockFreeFlying |
| K3 | BookingLine.HasAlternateOptionPricing copied from AlternateOptionOnly |
| K4 | BookingLine.OverridePrice = true ([12], when the rule was not already on the cost group) |
| K5 | Eleven cost fields zeroed: Pax_Adults_Std_Cost, Pax_Adults_Std_Cost_ManDisc, Pax_Adults_SingleSupp_Cost, _ManDisc, Pax_Adults_SingleSuppOnly_Cost, Pax_Adults_SingleSupp_StdCost, Pax_Children_Cost, _ManDisc, Pax_Staff_Cost, _ManDisc, Pax_Total_Cost_ManDisc, Pax_Total_Cost_Net |
| K6 | Reprice = true, then Reprice_DeleteDBLs removes the detailed lines |
K5/K6 mean the costing is destroyed and rebuilt. A test that asserts costs survive rule attachment is asserting the wrong thing.
L. Rule maintenance validation — ValidatePricingRule¶
| # | Input | Expected message |
|---|---|---|
| L1 | FromDate empty |
'You must have both a from date and a to date' |
| L2 | ToDate empty |
same |
| L3 | FromDate = ToDate |
'From date must be less than the to date' — equal is rejected |
| L4 | FromDate > ToDate |
same |
| L5 | No Wish rate type | 'You must assign a wish rate type' |
| L6 | Description empty |
'Description is required' |
| L7 | ExclusiveDiscountInd = true, Weighting 0 or empty |
'You must give a weighting, the lowest weighting rules will be applied first' |
| L8 | ExclusiveDiscountInd = false, Weighting 0 |
Accepted — weighting only required for exclusive rules |
| L9 | AllAgencies = false, ring-fence empty |
'You must select at least one agency if you are going to limit the rule to certain agencies' |
| L10 | DiscountPercentage = 150 |
Accepted. No range validation exists |
| L11 | DiscountPercentage = -10 |
Accepted. Same gap |
L10/L11 document a real absence. If validation is added later, these cases invert.
M. Outcome and self-healing — [45], [50]–[53], [61]¶
| # | Setup | Expected |
|---|---|---|
| M1 | Pricing succeeds, status C |
Booking.Priced = Confirmed |
| M2 | Pricing succeeds, status not C |
Booking.Priced = Yes |
| M3 | Pricing fails | Booking.Priced = No |
| M4 | Exception during pricing | Priced = No, log Critical / System_Issue 'This booking failed to price at Sub_PriceBookingAll, please ensure it successfully completes pricing', returns false |
| M5 | Booking had an open "failed to price" log, then prices successfully | Prior logs auto-resolved: Status = Resolved, ResolvedBy = 'System', Resolution = 'Ran successully' (sic) |
M5 matters for anyone reporting on SystemLog — failures self-clear, so a point-in-time count
understates how often pricing failed.
N. The price/per-person reconciliation — Pricing.Sub_PriceBookingLine_Wilderness [14]¶
PPPriced = abs(TotalSell_PP − TotalSell) <= 0.1, where the two totals are computed
independently — per guest, and per detailed line.
| # | Setup | Expected |
|---|---|---|
| N1 | Totals agree exactly | PPPriced = true |
| N2 | Totals differ by 0.05 | PPPriced = true |
| N3 | Totals differ by exactly 0.1 | true — the test is > 0.1, so 0.1 passes |
| N4 | Totals differ by 0.11 | PPPriced = false; line reprices on the next pass |
| N5 | Booking worth 500,000 with a genuine 0.09 discrepancy | true — the tolerance is absolute, not proportional. Known weakness |
| N6 | OverrideOption = true, IndicativeCostInd = false |
Priced = false even when pricing succeeded |
| N7 | OverrideOption = true, IndicativeCostInd = true |
Priced = true |
N3 and N5 are the boundary cases worth automating.
O. Guest allocation guard — [25], [55]¶
| # | Setup | Expected |
|---|---|---|
| O1 | All guests allocated to rooms | Line prices |
| O2 | One guest unallocated | Line unpriced (not zero-priced); Warning / User_issue log 'Missing rooming' naming the line |
| O3 | O2, then allocate and reprice | Line prices; assert the earlier log's fate — see Q1 |
P. Tax — CalculateDetailedBookingLineTax¶
Tax is inclusive: amount × rate / (100 + rate).
| # | Cost | Sell | Cost rate | Markup rate | Expected |
|---|---|---|---|---|---|
| P1 | 115 | 230 | 15 | 15 | TaxOnCost = 115 × 15/115 = 15; TaxOnMarkup = (230−115) × 15/115 = 15; TaxOnSell = 30 |
| P2 | 100 | 100 | 15 | 15 | Markup tax 0 — sell equals cost |
| P3 | 200 | 150 | 15 | 15 | Cost-tax base is the selling price (150), not 200 — [32] |
| P4 | 200 | 150 | 15 | 15, CostsAllocatedToOtherBLs = false |
Negative markup tax clamped to 0 — [33] |
| P5 | as P4 but CostsAllocatedToOtherBLs = true |
Negative markup tax kept | |||
| P6 | Cost rate 0, markup rate non-zero | TaxOnMUonly = true set on the booking line |
|||
| P7 | Cost rate non-zero, markup rate 0 | TaxOnCostOnly = true set |
|||
| P8 | No tax rate found for the option | TaxedSuccessfully = false; Critical log 'No Tax Rate found for Option (…)' |
|||
| P9 | Detailed line with no booking line / no booking / no agency | TaxedSuccessfully = false, Critical log naming which |
P4/P5 carry a known contradiction: a canvas note says negative VAT should be allowed "because TP calcs it anyway", but the clamp is still in place. If Tourplan reconciliation disagrees on below-cost lines, this is why.
R-rate selection¶
| # | BCQ_ItemNumber |
Expected TaxCodePrice |
|---|---|---|
| P10 | 3 | OPT |
| P11 | 6 | OPT — anything < 7 |
| P12 | 7 | EX1 |
| P13 | 8 | EX2 |
| P14 | 9 | EX3 |
| P15 | Summed rates come to a negative | Clamped to 0.0 — [16] |
| P16 | A TaxTable row marked TaxApply = 'A' |
Contributes to both the markup rate and the cost rate — asymmetry documented in the deep dive §4 |
| P17 | A row marked 'S' (Sell Only) |
Contributes to the markup rate, not the cost rate |
Agency tax indicator — CalculateTaxOnMarkup¶
| # | Indicator contains | Expected markup rate |
|---|---|---|
| P18 | 0, 1 or 8 |
from GetMarkupTaxRate(TourplanInstance) |
| P19 | 4, 6 or 7 |
the passed-in input rate |
| P20 | 2, 3, 5 or 9 only |
0 — no markup tax |
| P21 | Indicator empty | 0, returned immediately |
Q. Log lifecycle¶
| # | Setup | Expected |
|---|---|---|
| Q1 | Line had 'There is no pricing for…', 'No Tourplan pricing found for…' or 'No pricing found for supplier…', then prices successfully | Those logs are DELETED — [19]/[20]. Not resolved, not archived |
| Q2 | Booking had 'This booking failed to price at Sub_PriceBookingAll', then prices | Marked Resolved, ResolvedBy = 'System', Resolution = 'Ran successully' |
Q1 vs Q2 is a deliberate assertion of inconsistency: two failure paths, two different outcomes, one of which destroys the record.
R. Rack factor and totals¶
CalcRackFactor:
| # | Room factor | Line factor | PriceSell vs PriceCost |
MarkupToRack |
Expected |
|---|---|---|---|---|---|
| R1 | 1.4 | — | different | — | 1.4 |
| R2 | 1.4 | — | equal | — | 1 |
| R3 | none/0 | 1.2 | different | — | 1.2 |
| R4 | none/0 | none/0 | different | 0.3 | (PriceCost × 1.3 − PriceSell) / PriceSell + 1 |
| R5 | none/0 | none/0 | different | 0/empty | 1 |
| R6 | none/0 | none/0 | PriceSell = 0 |
any | 1 |
SumDetailedBookingLines:
| # | Setup | Expected |
|---|---|---|
| R7 | Sell 900, before-discount 1000 | Discount = 100, DiscountPercentage = 10 |
| R8 | Before-discount total 0 | DiscountPercentage = 0, no division error |
| R9 | Cost 800, sell 1000 | MUpPerc = round(200 × 100 / 800, 2) = 25.00 |
| R10 | Cost 0, sell 500 | MUpPerc = 0 — not infinity, not an error. Complimentary items depress reported markup |
| R11 | Lines in two currencies | Totals produced per currency; assert no implicit conversion |
| R12 | Line with a cancellation fee adjustment | _CanFeeAdj sums populated per guest category |
R7/R10 matter commercially: Discount is a residual of before/after selling price, so any
mechanism that lowers sell reads as a discount regardless of cause.
U. Manual adjustment limits — Dashboard.SumBookingLine_CheckLimit¶
| # | Sub-category limit | Currency | Adjustment moves totals by | Expected |
|---|---|---|---|---|
| U1 | HasAdjLimit = false |
any | any | Accepted — $AdjLimit is 0, so [2] returns true |
| U2 | 500 USD | USD | 400 | Accepted |
| U3 | 500 USD | USD | 600 cost | Rejected, cost-price message, adjustment removed |
| U4 | 500 USD | USD | 600 sell | Rejected, sell-price message |
| U5 | 500 USD | USD | exactly 500 | Accepted — test is > LimitUSD |
| U6 | 500 USD | ZAR, rate available | 600 USD-equivalent | Rejected, after conversion by BidRate |
| U7 | 500 USD | ZAR, no exchange rate | 6,000 ZAR (≈330 USD) | Rejected — wrongly. [18] compares unconverted, so the limit behaves as 500 ZAR. Known defect |
| U8 | 500 USD | ZAR, no rate | 400 ZAR | Accepted — also for the wrong reason |
U7/U8 are the pair that demonstrate the defect in both directions. Assert no log is produced.
V. Approval¶
| # | Setup | Expected |
|---|---|---|
| V1 | No approver on the booking | CheckManDiscApproval returns false, opens Pricing.AddDiscountAuthorisation |
| V2 | Approver set, someone else | returns true |
| V3 | Approver is the current user | nomination cleared; "Unfortunately you are not allowed to approve your own discounts…" |
| V4 | Approver is the booking owner | same clearing and message — [5] |
| V5 | Manager approving a booking they own | Blocked by V4. Expected behaviour, not a permissions fault |
| V6 | ManualDiscApproval false → true |
committed with events |
| V7 | ManualDiscApproval true → false |
committed without events, then IVK_ClearManualDiscount_BL_All deletes every manual discount |
V7 is the destructive one. Assert the discounts are gone, not merely unapproved.
W. Applying an adjustment — CreateManDisc¶
| # | Setup | Expected |
|---|---|---|
| W1 | Adjust one detailed line, cost group present | Every sibling line matching date, price type, age category, option and cost group gets the same figures; RepriceSet = true |
| W2 | Same, no cost group | The match runs without the cost-group constraint — wider blast radius ([19]) |
| W3 | First adjustment on a line | ManualDiscountAppliedBy = currentUser, ManualOverrideAppliedDate set |
| W4 | Second adjustment by another consultant | ManualDiscountAppliedBy unchanged — first-writer-wins. Known audit weakness |
| W5 | Override a 100 price to 0 | ManualDiscountPercSP = 100 |
| W6 | Override a 0 price to anything | ManualDiscountPercSP = 0 regardless of magnitude |
| W7 | Any adjustment | OverridePrice = true, Override_Manual = true, OverrideOption = false, ManualAdjustment.Applied = false |
X. Allocation reconciliation — CheckAllocation_BL¶
| # | Setup | Expected |
|---|---|---|
| X1 | Consistent booking line | Temp_totals.NetErrorDBls = 0 |
| X2 | Discount not reflected in SellingPrice |
NetErrorDBls != 0 — and no error is raised. Assert on the value, not on a message |
| X3 | Run the check twice | Previous Temp_totals rows are deleted first ([3]), so no history accumulates |
Y. Late booking discount — IVK_ResidentsLateBookingDisc_Save¶
| # | Input | Expected |
|---|---|---|
| Y1 | DaysBeforeTravel = 0 |
'You must set a valid number of days (eg 30)' |
| Y2 | DaysBeforeTravel = 720 |
Rejected — bound is < 720 |
| Y3 | DaysBeforeTravel = 719, discount 20, membership set |
Accepted |
| Y4 | Discount = 0 |
'You must set a valid discount (eg 20 for 20%)' |
| Y5 | No Membership |
'You must set which membership type this discount is v…' |
Z. Stay-pay tiers — GetFreeDays¶
| # | Stay2 |
Stay3 |
Days |
Expected |
|---|---|---|---|---|
| Z1 | 1 | any | any | Free1 — tier disabled at <= 1 |
| Z2 | 7 | 1 | 8 | Free2 — third tier disabled |
| Z3 | 7 | 14 | 8 | Free2 |
| Z4 | 7 | 14 | 14 | Free3 — boundary is >= |
| Z5 | 7 | 14 | 6 | Free1 |
S. Suggested smoke path¶
- Draft booking, one chargeable camp line, agency with
AllAgenciesrule in window → rule attaches. - Set the rule
Selectable→ rule no longer applies;HasPricingThatCanInclude = true. - Include it manually → applies; assert K1–K6.
- Blank
DaysNightson the line → all rules disappear; assert the D2 log. - Restore, then set the agency onto both ring-fence and exclude lists → rule still applies (G6).
- Age the booking to
Traveled4 months back → repricing blocked with the A5 message.
T. Environment notes¶
- Rule rows, agency ring-fences and discount percentages are runtime data. Seed them explicitly per environment; do not assume a test environment mirrors production.
BookingMasterData.R1Markup(1.1629),_01Markup(1.111111) andSupplier_Portal.DMCMarkup(8) are model defaults and may be overridden per environment. Confirm the deployed values before asserting on any figure derived from them.Pricingcarries no documentation text on any of its 637 microflows, so the model offers no intent to compare behaviour against. Canvas notes are the only commentary, and at least one is already stale (see the deep dive, §8.5).