Skip to content

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_Wilderness and 23 per-type Sub_Check*_List flows, 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.PricingRule is 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.LateBookingDiscount is 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.DiscountPercentage default 100 — a new discounted-flying rule is free flying until someone lowers it. Also NrOfNights 3, NoOfCamps 1, LengthOfStay 2.
  • FreeActivity.DiscountPercentage default 100, FreeNightOnUs.DiscountPercentage default 100 — same pattern, and consistent with their names.
  • LongStay, TargettedAreaDiscount, TargetedOption, UseOfWW, PimpedLeads, AdminFee, EarlyBird all default 0.0 — inert until configured.
  • AgencyRates carries BaseRate and VolumeRate rather than a percentage.
  • Discount_PricingRule is a separate entity holding NetDiscount : Decimal default 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

Sub_PriceBookingAll($Booking, $ReApplyPricingRules : Boolean, $ResumTotals : Boolean) : Boolean

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.AddNewOwnArrangementSeviceToBookingAPI.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 = Confirmed when status is C, else Priced = Yes
  • [47] PriceRuleCostReallocations($Booking) — redistributes cost between lines
  • [49] SumDetailedBookingLines($Booking, true) when $ResumTotals
  • [50][53] self-healing: any open Logging.SystemLog for this booking whose description contains 'This booking failed to price at Sub_PriceBookingAll' or 'The booking did not price on entering step 3 ' is marked Resolved by 'System' with resolution 'Ran successully' (sic)
  • [59]/[60] failure → Priced = No
  • [61] error handler → logs Critical / System_Issue, sets Priced = No [commit YesWithoutEvents], returns false

3. Qualification — Pricing.ApplyPricingRules_List

ApplyPricingRules_List($Booking, $BookingLineList, $Agency)

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.

FilterAgencyAllAgencies 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:

[3] LIST OP SORT $PricingRuleList_ToCheck by Pricing.PricingRule.Weighting Ascending

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_AlternateOptionSub_CheckAltOptPackage_List, DiscountedFlyingSub_CheckDiscFlying_List, FreeActivitySub_CheckFreeActivity_List, SingleStaySub_CheckSingleStay_List, LongStaySub_CheckLongStay_List, FreeNightOnUsSub_CheckFreeNightOnUs_List, ShareholderCheckShareholderPricing_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 (default false) — lets one exclusive rule coexist with another.
  • Universal (default false), 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_Exclude and sets Booking.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 statementPax_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

  1. FIX this is in the model. [14] of Sub_PriceBookingAll, twice. Clearing a rule forces a reprice even when the same rule requalifies.
  2. One bad line silences every rule on the booking. Gate 2 above — an empty DaysNights on any line abandons qualification for all of them, with a log the user may not see.
  3. Siteminder/BC bookings skip pricing rules with no trace. No log, no flag, no attribute recording that rules were not considered.
  4. Hardcoded agency codes. ZZZ016, ZZZ026, ZZZ038, ZZZ012, SAF096 live in a microflow condition. Onboarding a new members' agency is a model change and a deployment.
  5. The decoding note is already out of step — it documents ZZZ017, which the condition does not test.
  6. Ring-fence silently disables exclusions. FilterAgency [11] only consults the exclude list when the ring-fence list is empty.
  7. A last-minute rule with no lead time is dropped, not universal. FilterLastMinute [5].
  8. An unhandled subtype qualifies and does nothing. The type switch's (other type) arm CONTINUEs silently.
  9. DiscountPercentage is unvalidated — see §6.
  10. Dead attributes ship in the rule table. AllowPartialAppllication is documented "NOT USED" (and misspelled), plus LastMinuteBookingActivateFromDate_OLD, Residents.AnyTimeDiscount_Old, LongStay.MinNightsInCampDNU ("DNU"). They are all rw for Pricing.Administrator, so they are editable in the UI.
  11. 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.
  12. 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_PCG and the cost/sell calculation in Sub_AddPriceToDetailedBookingLine_PCG. Still not traced: SumDetailedBookingLines (65 callers), CalculateDetailedBookingLineTax, CalcRackFactor, AccumulatePrice_PartyCostGroup*, or PriceRuleCostReallocations.
  • 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_List and the rest. Each encodes that rule type's own eligibility conditions — camps, nights, suppliers, occupancy — and none are described here.
  • The Residents / SADC path. CheckResidentsPricing at [38], the Pricing.Residents high-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 against OCh_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 Pricing are untraced, including whichever screen reads HasPricingThatCanInclude.
  • Pricing has 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