Pricing rules — developer reference¶
Audience: engineers working on Pricing, Booking/Dynamic Pricing, the partner API
pricing operations, or anything that reads Booking.BookingLine.OverridePrice.
Source of truth: the Mendix model, read via model/. Microflow names, node numbers,
XPath and message strings are verbatim from commit 83d87ba8e, Mendix 10.24.21.108016.
Node numbers in [n] match python3 tools/mxrender.py <flow>, or the listings in
model/Pricing/flows.txt.
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.
Scope warning, stated up front. This document covers rule qualification — which pricing rules attach to which booking lines, and why. It does not cover the arithmetic that turns an attached rule into a number; that lives in
Pricing.Sub_PriceBookingLine_Wildernessand 23 per-typeSub_Check*_Listflows, none of which are traced here. See §9 for the exact boundary. Nothing below tells you what a booking will cost.
1. The shape of the thing¶
Pricing has no rule engine. There is exactly one Mendix Rule document in the whole
application that touches pricing (LiveRequest.Rule_RoomTypeRateExists), out of 22 in total.
What "pricing rules" means here is a data-driven design:
Pricing.PricingRuleis a persistable entity with 54 attributes, each a switch or threshold. Rows in that table are the rules.- 23 entities specialise it, each adding its own fields and its own qualification flow.
- Qualification is a pipeline of list filters over that table; application is a type-switch that dispatches to a per-subtype flow.
So a "rule" is a database row whose shape is in the model and whose values are not. This document describes the shape and the pipeline. The rows are runtime data.
One pricing rule is not a
PricingRule.Pricing.LateBookingDiscountis a standalone entity —DaysBeforeTravel,Membership,Discount— so it appears in no survey of subtypes and is not subject to anything below. It is documented in pricing-manual-adjustments.md §8.
The 23 rule types¶
ENTITY <name> : Pricing.PricingRule, all in model/Pricing/domain-model.txt:
| Discount-bearing | Structural / package | Eligibility-based |
|---|---|---|
LongStay, SingleStay |
Accomodation_AlternateOption |
Residents, SADC |
TargettedCampDiscount |
Packages_Seq |
Shareholder |
TargettedAreaDiscount |
FlightsServices_AltOption |
PimpedLeads |
TargetedOption |
AgencyRates |
EarlyBird |
DiscountedFlying |
AdminFee |
LastMinute (a flag, not a type) |
FreeActivity, FreeNightOnUs |
UseOfWW, UseOfWildernessAir_ByBooking |
OccupancyDiscount |
TakingUpPotentialDeadBeds |
NonUseOfWildernessAir |
Most carry DiscountPercentage : Decimal. Defaults are worth knowing because they are what a
newly created rule starts as:
DiscountedFlying.DiscountPercentagedefault 100 — a new discounted-flying rule is free flying until someone lowers it. AlsoNrOfNights3,NoOfCamps1,LengthOfStay2.FreeActivity.DiscountPercentagedefault 100,FreeNightOnUs.DiscountPercentagedefault 100 — same pattern, and consistent with their names.LongStay,TargettedAreaDiscount,TargetedOption,UseOfWW,PimpedLeads,AdminFee,EarlyBirdall default 0.0 — inert until configured.AgencyRatescarriesBaseRateandVolumeRaterather than a percentage.Discount_PricingRuleis a separate entity holdingNetDiscount : Decimaldefault 0.0.
(inferred) The split between "defaults to 100" and "defaults to 0.0" tracks whether the rule type means give this away or take a percentage off. The model does not say so.
2. Entry — Pricing.Sub_PriceBookingAll¶
89 callers — the single busiest microflow in the pricing subsystem. Callers break down as
Booking 43, API 20, Pricing 16, Dashboard 7, BookingTemplates 2, DataManagement 1.
The partner API alone reaches it through API.AddAccomodationToBooking,
API.AddAccomodationToStartEnd, API.AddDaysToBooking, API.ChangeAccomNumberOfNights,
API.CreateWindowBooking, API.CreateWindowBooking_270526, API.DeleteAccomodation,
API.ReplaceBooking, API.ReplaceBooking_010626, API.PriceWiggleRoomLine,
API.DMCOverrides_AddPrice and API.PriceDMCLine_AddSellPrice_270526 — so any itinerary
mutation over the partner API reprices. Several reach pricing indirectly rather than by
calling it: API.AddFlightToStartEnd and API.BookQuote arrive via
Booking.ProceedToScreen3_CreateBLs, API.CancelBooking via Booking.CancelBooking into
Pricing.SumDetailedBookingLines, API.CopyBooking via Booking.CopyBookingLines, and
API.GetQuote_B2B via API.CreateWindowBooking — a read-sounding name on a write path,
the same trap quote-to-booking.md records.
Five more reach Pricing.Sub_PriceBookingAll directly and are easy to miss because their
names describe the itinerary, not the money: API.ReplaceQuote, API.SwitchAccomodation,
API.UpdateBookingService, API.UpdateDMCOverrideLine and API.GetQuote_V2 (via
API.CreateWindowBooking). API.AddTravelHubAndFlightToStartEnd reaches pricing further
downstream still, through API.AddNewOwnArrangementSeviceToBooking →
API.AddOwnArrangementOtherBookingline. Every one of these requalifies rules, so a partner
integration can change a booking's discounts without any pricing-shaped call being made.
The age guard — [3]¶
[3] SPLIT if $Booking/WindowStatus = C or P or Draft or Quote_only or Travelling
or (Traveled or CX) and addMonthsUTC($Booking/TripEndDate,3) > [%CurrentDateTime%]
Fail → [4] logs Critical / User_issue:
'You cannot change pricing on a booking that is more than 3 months after trip end date'
…and [6] returns false. Note the operator precedence: the three-month window applies
only to the Traveled/CX arm. A Draft booking reprices regardless of age.
The reset — [7]¶
One CHANGE clears six flags in a single statement: PriceChanges, ExposureCalced,
PricingError, HasUnconfirmedPrices, TotalsChanged, BookingLite — all to false. It
also stamps EffectiveCreatedDate (first time only, and only for statuses that are not
C/D/Traveled/Travelling) and FirstQuotedDate (first time only).
[8] Pricing.ResetSelectablePricingRules($Booking) — clears the user's manual
selections before requalifying. See §5.
Which lines are in scope — [9]–[13]¶
[10] RETRIEVE Booking.BookingLine where [BookingLine_Booking = $Booking]
[LIVE] [Chargeable] [BookingLineStatus != 'Cancelled']
sort by aSequence Ascending
Non-chargeable lines (travel nodes and similar) never price. Cancelled lines are excluded here, which is why cancellation and pricing interact only through the fee engine documented in cancellation-and-fees.md.
The unpricing loop — [13]–[28]¶
For each chargeable line, [15] resets PriceDaily (preserved only when
OverrideOption) and TotalPriceChanged. Then [16] decides whether the line needs work:
Priced = false or Reprice = true or PPPriced = false.
When $ReApplyPricingRules is set, [18] Booking.ClearPricingRules strips existing rule
attachments and reports whether the line had one. If it did, [21]–[26] unprice it:
Priced = false, recreate party cost groups, ClearPricingBL, queue for repricing.
[14] NOTE: "If had a pricing rule, we set it to reprice because it could lose this rule - FIX this"
That is a developer-acknowledged defect marker, repeated as an unattached note on the same flow. The behaviour it describes is real: clearing rules forces a reprice even when the same rule would requalify, so rule-bearing lines churn on every repricing pass.
Configuration gates — [31]–[36]¶
[33] VAR $ApplyPricingRule_Config : Boolean =
if $HolidayTypes != empty and $HolidayTypes/applyPricingRules = false then false
else if $WishRateTypes != empty and $WishRateTypes/AllowPricingRules = false then false
else true
Two independent kill switches — the booking's holiday type and its Wish rate type —
either of which disables rule application for the whole booking. Default when both are empty
is true, so rules apply unless something says otherwise.
[36] requires both $ReApplyPricingRules and $ApplyPricingRule_Config to reach
[65] ApplyPricingRules_List. Otherwise qualification is skipped entirely and pricing
proceeds with whatever rules are already attached.
Pricing and outcome — [43]–[64]¶
[43] Sub_PriceBooking_Wilderness does the actual work. Then:
- [45]
Priced = Confirmedwhen status isC, elsePriced = Yes - [47]
PriceRuleCostReallocations($Booking)— redistributes cost between lines - [49]
SumDetailedBookingLines($Booking, true)when$ResumTotals - [50]–[53] self-healing: any open
Logging.SystemLogfor this booking whose description contains'This booking failed to price at Sub_PriceBookingAll'or'The booking did not price on entering step 3 'is markedResolvedby'System'with resolution'Ran successully'(sic) - [59]/[60] failure →
Priced = No - [61] error handler → logs
Critical/System_Issue, setsPriced = No[commit YesWithoutEvents], returnsfalse
3. Qualification — Pricing.ApplyPricingRules_List¶
This is where "which rules apply" is decided. Six gates, in order.
Gate 1 — channel exclusion, [3]¶
[3] SPLIT if $Booking/API_System = API.API_System.Siteminder and $Booking/Channel = Booking.Channel.BC
True → [5] END. Siteminder bookings on channel BC get no pricing rules at all,
silently. No log, no flag.
Gate 2 — line eligibility, [6]–[8]¶
[6] drops lines with OverrideOption set. [7] then checks the remainder for empty
DaysNights; if any line has one, [43] logs Critical / System_Issue:
'Missing days/nights on bookingline' … ' Pricing rules not checked - please reprice or refer to Support if problem persists'
and qualification is abandoned for the entire booking. One malformed line suppresses every rule on every other line.
Gate 3 — the direct/SADC fork, [13]–[14]¶
[13] SPLIT if $Agency/IsDirect
[14] SPLIT if ($Agency/Code = 'ZZZ016' or 'ZZZ026' or 'ZZZ038' or 'ZZZ012' or 'SAF096')
and $Booking/SADCPricing
Five agency codes are hardcoded in the microflow. The canvas note decodes some of them:
NOTE: "ZZZ012 - SADC ZZZ016 - REsidents members ZZZ026 - Bots Members ZZZ017 International - can get other discounts"
Note the note documents ZZZ017, which does not appear in the condition — either the
note is stale or the code is missing a case. The logic is what runs. ZZZ038 and SAF096
are undocumented. (inferred) SAF096 is a South Africa agency and ZZZ038 another
membership scheme; the model does not say.
Matching codes with SADCPricing set take the Residents path at [34]; everything
else falls to the standard path at [16].
Gate 4 — the base query, [17]¶
[17] RETRIEVE Pricing.PricingRule where [Active] [SADC_only=false()] [LeadBookingsOnly=false()]
[$FirstTravelDay/TravelDate <= ToDate and $LastTravelDay/TravelDate >= FromDate]
Date overlap is against the first and last travel day, not the booking's trip dates —
so a rule qualifies if it overlaps the itinerary at all, not only if it covers it. [18]
then subtracts Booking_PricingRule_Exclude, the per-booking manual exclusions.
Gate 5 — the three filters, [20]–[22]¶
| Flow | Rule attribute | Removes a rule when |
|---|---|---|
filterBookingCreatedDate |
BookingsCreatedFromDate / ToDate |
party creation date outside the window |
FilterLastMinute |
LastMinuteBooking, BookingLeadTime |
see below |
FilterAgency |
AllAgencies, ring-fence / exclude associations |
see below |
FilterLastMinute — only rules with LastMinuteBooking = true are considered:
[4] SPLIT if $IteratorPricingRule/BookingLeadTime = empty
[5] case true: LIST $PricingRuleList remove $IteratorPricingRule
[6] case false: VAR $LastMinuteDate = addDaysUTC($TripStart, $BookingLeadTime * -1)
[7] SPLIT if $PartyCreatedDate >= $LastMinuteDate → keep, else remove
A last-minute rule with no lead time is silently dropped, not treated as unlimited. The
comparison uses the party created date (falling back to the booking's, per [12] of
ApplyPricingRules_List), not "now" — so re-pricing an old booking does not make it
last-minute.
FilterAgency — AllAgencies keeps the rule for everyone. Otherwise the ring-fence list
(PricingRule_AgencyRingFence) must contain the agency. Only when the ring-fence list is
empty does the exclude list (PricingRule_AgencyExclude) get consulted. So a rule with
both populated ignores its exclusions entirely — ring-fence wins.
Gate 6 — exclusive vs non-exclusive, [23]–[27]¶
[23] splits on ExclusiveDiscountInd (default true). Exclusive rules are checked
first ([26] Sub_CheckExclusivePricingRules_List), then non-exclusive ([27]), then
[28] UpdatePricingRules_Booking writes the result to the booking.
4. Weighting decides precedence¶
Sub_CheckExclusivePricingRules_List opens with:
and ValidatePricingRule [13] states the intent in a user-facing message:
'You must give a weighting, the lowest weighting rules will be applied first'
Lowest weighting wins. Weighting is mandatory for exclusive rules and unvalidated for
non-exclusive ones (Weighting default 0).
[8] is then a SPLIT on type of PricingRule — a type switch dispatching each subtype to
its own flow: Accomodation_AlternateOption → Sub_CheckAltOptPackage_List,
DiscountedFlying → Sub_CheckDiscFlying_List, FreeActivity → Sub_CheckFreeActivity_List,
SingleStay → Sub_CheckSingleStay_List, LongStay → Sub_CheckLongStay_List,
FreeNightOnUs → Sub_CheckFreeNightOnUs_List, Shareholder →
CheckShareholderPricing_List, FlightsServices_AltOption → … and so on.
[17] case Pricing.PricingRule (the base type) and the (other type) fallback both
CONTINUE — a rule whose subtype has no case is skipped silently. Adding a 24th subtype
without adding a case here yields a rule that qualifies and then does nothing.
Per detailed line, exclusivity is a selection — ChooseExclusiveDiscount_DBL¶
Weighting order decides which rule is considered first; this flow decides what a single detailed booking line actually receives:
[4] LIST OP SORT $PricingRuleList by Weighting Ascending
[5] LOOP over the sorted rules
[14] CALL Pricing.Sub_ChooseExclusiveDiscount_DBL(rule, DBL, option, BL, booking, $CurrentDiscount) -> $Disc
[15] SET $CurrentDiscount = if $CurrentDiscount + $Disc > 100 then 100 else $CurrentDiscount + $Disc
[16] BREAK (leave loop)
[23] END return $CurrentDiscount
Three things follow. It accumulates rather than replaces, it caps at 100%, and [16] breaks after the first rule that yields a discount — so in practice one exclusive rule applies per detailed line even though the accumulator would allow more. The canvas note records both decisions and their dates:
NOTE: "5/7/24 - accumulate discount here - so there must never be more than one exclusive discount here unless specifically allowed 070826 - add leave after first disc applied"
The BREAK is dated 070826 — 7 August 2026, days before the revision this document
describes. Anything derived from an older extract will describe accumulation without it.
Two line types are handled specially at [12]/[17]: for Flight and Road_Transfer
lines a type switch skips DiscountedFlying rules entirely and treats everything else
normally. For Extra price types, [7]–[9] apply ExcludeMandatoryExtras,
IncludeCertainMandatoryExtrasOnly and CheckExtraApply before the rule is considered at all.
Two attributes modify exclusivity and are worth knowing:
CanCombineOtherExclusiveRules(defaultfalse) — lets one exclusive rule coexist with another.Universal(defaultfalse), documented "applies in addition to any exclusive rule".
5. Selectable rules — the manual override¶
Selectable (default false) is documented on the attribute itself:
"This mean it will not apply automatically, but initially be excluded from pricing unless it is manually applied/selected"
CheckPricingRuleBL implements it. For a selectable rule, [20]–[23] (and [30]–
[43] on the BookingLite path) check whether the rule is in
Booking_PricingRule_IncludeSelectable:
- included → the rule attaches like any other
- not included → [23] adds it to
Booking_PricingRule_Excludeand setsBooking.HasPricingThatCanInclude = true[commit YesWithoutEvents]
That flag is how the UI knows to offer "there are discounts you could apply". OnlyManagerCanInclude
(default false) gates who may do the including. (inferred) the flag drives a banner or
badge; the page tree was not traced to confirm which widget reads it.
When a rule does attach, [10]/[13] do something worth flagging: the party cost group
is set to Override = true (unless AlternateOptionOnly), DiscountedFlightsBlocked is
copied from BlockFreeFlying, and eleven cost fields are zeroed in one statement —
Pax_Adults_Std_Cost, Pax_Adults_SingleSupp_Cost, Pax_Children_Cost, Pax_Staff_Cost,
Pax_Total_Cost_Net and their _ManDisc variants — followed by Reprice = true and
Reprice_DeleteDBLs. Attaching a rule therefore destroys the existing costing for that
cost group; it is rebuilt on the next pricing pass.
6. Rule maintenance validation¶
Pricing.ValidatePricingRule($PricingRule) : Boolean — the only guard on rule data entry:
| Node | Condition | Outcome |
|---|---|---|
| [2] | FromDate or ToDate empty |
message 'You must have both a from date and a to date' |
| [3] | FromDate >= ToDate |
validation on FromDate: 'From date must be less than the to date' |
| [6] | no WishRateTypes_PricingRule |
message 'You must assign a wish rate type' |
| [9] | Description empty |
validation: 'Description is required' |
| [12] | Weighting empty/0 and ExclusiveDiscountInd |
validation: 'You must give a weighting…' |
| [15]/[19] | AllAgencies = false and ring-fence empty |
validation: 'You must select at least one agency…' |
Nothing validates DiscountPercentage. There is no range check, so a 150% discount, or a
negative one, is accepted by this flow. (inferred) a page-level constraint may exist;
the widget tree was not traced.
7. Constants that move money¶
Three constants carry markup factors, and the extract shows their model defaults:
| Constant | Default |
|---|---|
BookingMasterData.R1Markup |
1.1629 |
BookingMasterData._01Markup |
1.111111 |
Supplier_Portal.DMCMarkup |
8 |
These are defaults, not necessarily what production runs. Mendix allows a constant to be overridden per environment, and that override is runtime configuration invisible to the model. Confirm against the deployed environment before quoting them. 101 constants across the application carry a default value.
Tax handling is enumerated rather than hardcoded — Pricing.TaxApply is
B "Before Markup" / A "After Markup" / S "Sell Only", and Pricing.TaxCode is
NT "No Tax" / VT1 "Vat 1" / EXP "Exempt" / ZER "Zero Rated". VAT codes per
country are constants (Pricing.BOT_VAT_Code = BOT, Pricing.CAY_VAT_Code = CAY,
Pricing.KY_VAT_CODE), all ExportLevel: Hidden.
8. Defects and traps¶
FIX thisis in the model. [14] ofSub_PriceBookingAll, twice. Clearing a rule forces a reprice even when the same rule requalifies.- One bad line silences every rule on the booking. Gate 2 above — an empty
DaysNightson any line abandons qualification for all of them, with a log the user may not see. - Siteminder/BC bookings skip pricing rules with no trace. No log, no flag, no attribute recording that rules were not considered.
- Hardcoded agency codes.
ZZZ016,ZZZ026,ZZZ038,ZZZ012,SAF096live in a microflow condition. Onboarding a new members' agency is a model change and a deployment. - The decoding note is already out of step — it documents
ZZZ017, which the condition does not test. - Ring-fence silently disables exclusions.
FilterAgency[11] only consults the exclude list when the ring-fence list is empty. - A last-minute rule with no lead time is dropped, not universal.
FilterLastMinute[5]. - An unhandled subtype qualifies and does nothing. The type switch's
(other type)armCONTINUEs silently. DiscountPercentageis unvalidated — see §6.- Dead attributes ship in the rule table.
AllowPartialAppllicationis documented "NOT USED" (and misspelled), plusLastMinuteBookingActivateFromDate_OLD,Residents.AnyTimeDiscount_Old,LongStay.MinNightsInCampDNU("DNU"). They are allrwforPricing.Administrator, so they are editable in the UI. - Everything commits without events.
[without events]on [29], [30], [53] and[commit YesWithoutEvents]on [62], [13], [23]. Entity event handlers do not fire during pricing. - A stale annotation records a removed safeguard: "JG 150725 Removed check for overlap with exclusive rules. If has reached here, then we have al;ready checked if it qualifies - which may be true even of multiple exclusive rules" — the overlap check is gone; whether the claimed prior check is equivalent was not verified here.
9. What this document does not cover¶
Stated precisely, because a pricing document that looks complete and is not is worse than none:
- The arithmetic is now traced separately, in
detailed booking lines: the dispatcher
Sub_PriceBookingLine_Wilderness, both pricing paths,Sub_CreateDetailedBookingLines_PCGand the cost/sell calculation inSub_AddPriceToDetailedBookingLine_PCG. Still not traced:SumDetailedBookingLines(65 callers),CalculateDetailedBookingLineTax,CalcRackFactor,AccumulatePrice_PartyCostGroup*, orPriceRuleCostReallocations. - The 23 per-type qualification flows.
Sub_CheckDiscFlying_List,Sub_CheckLongStay_List,Sub_CheckFreeNightOnUs_List,CheckShareholderPricing_List,Sub_CheckAltOptPackage_List,Sub_CheckSingleStay_List,Sub_CheckFreeActivity_Listand the rest. Each encodes that rule type's own eligibility conditions — camps, nights, suppliers, occupancy — and none are described here. - The Residents / SADC path.
CheckResidentsPricingat [38], thePricing.Residentshigh-season constraints, and the entity doc's claim that "Directs cannot book more than a year out" and "if booking in high season - must book within 30 days of trip start" — unverified againstOCh_DateRangeValidation. - Rule values. Every
DiscountPercentage, rate, threshold and agency ring-fence is a database row. The model gives the shape; only the running system has the numbers. - Per-environment constant overrides — see §7.
- The pricing UI. 82 pages and 29 price-named pages in
Pricingare untraced, including whichever screen readsHasPricingThatCanInclude. Pricinghas no documentation text of its own. Not one of its 637 microflows carries Studio Pro documentation, so everything above is read from logic and canvas notes.
10. Reproducing this analysis¶
## the hub and its callers
python3 tools/mxinspect.py callers Pricing.Sub_PriceBookingAll
grep -A80 'MICROFLOW Pricing.Sub_PriceBookingAll' model/Pricing/flows.txt
## qualification
grep -A50 'MICROFLOW Pricing.ApplyPricingRules_List' model/Pricing/flows.txt
grep -A20 'MICROFLOW Pricing.FilterLastMinute' model/Pricing/flows.txt
grep -A20 'MICROFLOW Pricing.FilterAgency' model/Pricing/flows.txt
## the rule table and its 23 subtypes
grep -A60 '^ENTITY PricingRule' model/Pricing/domain-model.txt
grep -E '^ENTITY \S+ : Pricing.PricingRule' model/Pricing/domain-model.txt
## the markup constants, with their model defaults
grep -A4 '^CONSTANT.*Markup' model/*/other.txt
## completeness check for this document
python3 tools/coverage.py Booking.BookingLine \
--attr Priced,Reprice,OverridePrice,PriceDaily \
docs/deep-dives/pricing-rules.md docs/qa/regression-pricing-rules.md
## -> 17 entry points, 17 documented, 0 unknown
What that 17/17 does and does not mean. It means every published entry point that can
mutate Priced, Reprice, OverridePrice or PriceDaily is named in these documents.
It does not mean each is explained — coverage.py measures mention, not treatment, and
says so itself. Most of the 17 are named once, in the entry-point list in
§2, precisely so that nobody has to rediscover that
API.SwitchAccomodation reprices. What each does to a booking is quote-to-booking
territory, not this document's.
The measurement was also the reason six of them are here at all: the first run scored 6 unknown, which is how they were found.
11. See also¶
- quote-to-booking.md — where pricing sits in the booking lifecycle
- cancellation-and-fees.md — the other money path, and the only fee ladder that is fully traced
- ../../reports/module-dependencies.md —
Pricing's coupling toBooking,BookingMasterDataandAPI - ../05-conventions-and-risks.md — application-wide debt,
including the
_Old/DNUnaming pattern this module contributes to - ../business/how-it-works-pricing-rules.md — the same subject without the node numbers
- ../qa/regression-pricing-rules.md — test cases derived from the branches above