Pricing calculation — developer reference¶
Audience: engineers who need to know how a booking line becomes a number — cost, markup, tax, discount and the totals a consultant sees.
Source of truth: the Mendix model, read via model/. Microflow names, node numbers,
XPath, formulas and message strings are verbatim from commit 83d87ba8e, Mendix
10.24.21.108016. Node numbers in [n] match python3 tools/mxrender.py <flow>.
Anything the model does not state is marked (inferred). Quoted NOTE: lines are Studio
Pro canvas annotations — where a note and the logic disagree, the logic wins and this
document says so.
This is the companion to pricing-rules.md, which covers which discounts attach and why. This document covers what happens once they have.
Division notation. The renderer prints Mendix division as
a : bora div b. Both mean division;:appears inside( )in expression text. Formulas below are transcribed as ordinary division.
1. Five pricing sources, one dispatcher¶
Pricing.Sub_PriceBookingLine_Wilderness($Booking, $BookingLine, $DMCSupplierList, …) decides
where a line's numbers come from. [8] splits on line type:
[8] SPLIT if BookingLineType = Accomodation and SoleUse = false and Supplier/ExclusiveUse = false
or BookingLineType = Exploration
| Path | Reached by | Flow |
|---|---|---|
| Non-accommodation | [9], the false arm |
Sub_CreatePricingNon_Accom |
| Siteminder | [27] → [50] | Price_Siteminder |
| Standard options | [31] | Sub_GetOptionsForBookingline |
| Dynamic pricing | [42] | CheckDynamicPricing |
| Sole use / exclusive use | [8] false arm |
non-accommodation path |
So sole-use accommodation and exclusive-use suppliers are priced by the non-accommodation route — a naming trap worth knowing before you go looking for it.
Two guards before any of it¶
[25] CheckAllGuestsAllocated. Fail → [55] logs Warning / User_issue:
'Missing rooming' … 'Not all guests have been allocated to rooms in booking {desc} Please edit the booking line in booking file and amend the room configuration to correctly allocate the guests. '
and the line returns false — unpriced, not zero-priced.
[5] Pricing.ArchivePrices($BookingLine) runs first, before anything is recalculated.
(inferred) this preserves the previous prices for comparison or audit; the archive
entity was not traced.
Dynamic pricing — DPSwitch¶
[40] calls Pricing.DPSwitch($BookingLine), which classifies the line's required rooms:
| Return | Condition | Meaning |
|---|---|---|
0 |
no BookThis room config, or no option with CanUseDynamicePricing |
not dynamic — use standard options |
1 |
all options are dynamic-capable | fully dynamic |
2 |
mixed — some dynamic, some not | dynamic, then fall through to options at [30] |
2 and HasNoBar both route back through [30] at [48], so a mixed line is priced
twice — dynamic first, then the standard option path over the remainder.
Failure at [43] produces a user-facing message, not just a log:
"We could not price the booking line {1} on dynamic pricing. Please change this to On Request and use an override option and manually price"
If bar rates are configured but the API returned nothing, [49] logs Critical /
System_Issue 'No pricing received from API (Bar rates) for : '.
2. The reconciliation that decides PPPriced — [14]¶
This is the single most useful node in the subsystem. After pricing, two independent totals are computed over different object graphs:
[12] AGGREGATE Sum of PartyCostGroup_BL.Pax_Total_Cost_Net over $PartyCostGroup_BLList_thisBL
-> $SumPax_Total_Cost_Net
[13] AGGREGATE Sum of DetailedBookingLine.SellingPrice over $DetailedBookingLineList
-> $SumSellingPrice
[14] CHANGE $BookingLine set {
TotalSell_PP = $SumPax_Total_Cost_Net
TotalSell = $SumSellingPrice
PPPriced = if abs($SumPax_Total_Cost_Net - $SumSellingPrice) > 0.1 then false else true
Priced = if $BookingLine/OverrideOption and $BookingLine/IndicativeCostInd = false
then false else true
Reprice = false }
PPPriced is a self-check. The per-person roll-up (TotalSell_PP) and the per-detailed-
line roll-up (TotalSell) are computed by different code over different objects and must
agree within 0.1 of the booking currency. When they don't, PPPriced = false — which is
one of the three conditions at [16] of Sub_PriceBookingAll that force a reprice.
Consequences worth knowing:
- 0.1 is absolute, not relative. On a booking worth hundreds of thousands, a genuine 0.09 discrepancy passes; on a $50 line, a 0.11 rounding artefact fails.
- A line can be
Priced = trueandPPPriced = falsesimultaneously. They mean different things: we produced numbers versus the two ways of totalling them agree. Pricedis deliberately false for override lines that are not indicative-cost, so a manually priced line stays flagged as needing attention.
Self-healing logs — [19], [20]¶
[19] RETRIEVE Logging.SystemLog where [SystemLog_BookingLine = $BookingLine]
[starts-with(Description,'There is no pricing for')
or starts-with(Description,'No Tourplan pricing found for')
or starts-with(Description,'No pricing found for supplier')]
[20] DELETE $SystemLogList
These are deleted, not resolved. Sub_PriceBookingAll [50]–[53] marks its own
failures Resolved with an audit trail; this one destroys the evidence. Any analysis of how
often lines fail to price will undercount, and there is no record that a deletion happened.
3. Cost allocation across guests — AccumulatePrice_PartyCostGroup¶
One DetailedBookingLine amount is spread over the guest categories in a party cost group.
Only lines with IncludeInQuote participate ([4]).
The pax basis — [8]¶
[8] VAR $Pax : Integer = if $PriceType = BookingMasterData.PriceType.Extra
and $PartyCostGroup_BL/Pax_Total < $PaxIn
then $PartyCostGroup_BL/Pax_Total else $PaxIn
For Extra price types the divisor is capped at the group's actual pax, so an extra priced
for more people than are present does not dilute below the real headcount.
The cascade¶
A counter $Pax_Ctr starts at $Pax and is consumed by each category in turn — adults
standard → single supplement → children → staff. Each category takes:
share = if $Pax_Ctr > <category count> then <category count> else $Pax_Ctr
Category_Cost += AmountBeforeManDisc * (share / $Pax)
Category_Cost_ManDisc += ManualDiscount * (share / $Pax)
$Pax_Ctr -= <category count>
Every one of these is guarded if $Pax = 0 then 0 else … — division by zero is handled
consistently, which is worth noting given how many sites there are.
Manual discounts are tracked in parallel, not subtracted in place. Each category carries
both _Cost and _Cost_ManDisc, and the net is computed only at [17]:
[17] Pax_Total_Cost_Net = Pax_Adults_SingleSupp_Cost - Pax_Adults_SingleSupp_Cost_ManDisc
+ Pax_Adults_Std_Cost - Pax_Adults_Std_Cost_ManDisc
+ Pax_Children_Cost - Pax_Children_Cost_ManDisc
+ Pax_Staff_Cost - Pax_Staff_Cost_ManDisc
That is the number PPPriced reconciles against. SingleSuppPerc is stamped at the same
node.
(inferred) the cascade order matters when $Pax_Ctr runs out mid-way — later categories
receive nothing. Whether that ordering is deliberate policy or incidental is not stated
anywhere in the model.
4. Tax — CalculateDetailedBookingLineTax¶
Tax is computed per detailed booking line, in two parts, from two different rates.
The rates — GetTaxRate¶
The tax code group is chosen by BCQ_ItemNumber ([4], [25]–[30]):
BCQ_ItemNumber |
TaxCodePrice |
|---|---|
< 7 |
OPT |
= 7 |
EX1 |
= 8 |
EX2 |
= 9 |
EX3 |
Then rates are summed from Pricing.TaxTable for the booking's Tourplan instance, and
clamped at [16]: if $TotalRate < 0.0 then 0.0.
The two arms are asymmetric, and this looks like a defect:
[10] SPLIT if $TaxApply = Pricing.TaxApply.A
[12] A : RETRIEVE TaxTable where [TourplanInstance] [Code] [Active]
[20] B : RETRIEVE TaxTable where [TourplanInstance] [TaxApply = 'A' or TaxApply = 'B'] [Code] [Active]
The markup rate (A, "After Markup") sums every active row for the code, ignoring
the TaxApply column entirely. The cost rate (B, "Before Markup") filters to rows
marked A or B. So an A-marked row contributes to both rates, and an S
("Sell Only") row contributes to the markup rate but not the cost rate. Flagged rather
than explained — the model gives no rationale.
The formulas — tax is inclusive¶
CalculateTaxOnCost: TaxOnCost = CostPrice * ( rate / (100 + rate) ) [8], if rate > 0
CalculateTaxOnMarkup: TaxOnMarkup = (SellingPrice - CostPrice) * ( rate / (100 + rate) ) [14]
rate / (100 + rate) is VAT extraction — the tax is already inside the price, not added
to it. A 15% rate yields 15/115 ≈ 0.1304 of the gross.
Which markup rate applies — agency tax indicator¶
CalculateTaxOnMarkup [5]–[16] switches on GetAgencyTaxInicator($Agency, $Booking):
| Indicator contains | Markup rate used |
|---|---|
0, 1 or 8 |
GetMarkupTaxRate($Booking/TourplanInstance) |
4, 6 or 7 |
the passed-in $InputTaxRate |
| anything else | 0 — no markup tax |
| indicator empty | 0, returned immediately at [18] |
Digits 2, 3, 5 and 9 fall through to zero markup tax. (inferred) these are
Tourplan tax-indicator conventions; the model does not document them.
Assembling TaxOnSell — [32]–[34]¶
[32] CostTaxForSellTax_IfLessThanCost = CalculateTaxOnCost(
if SellingPrice < CostPrice then SellingPrice else CostPrice, CostTaxRate)
[33] CheckTaxOnMarkup = if TaxOnMarkup < 0 and BookingLine/CostsAllocatedToOtherBLs = false
then 0 else TaxOnMarkup
[34] TaxOnSell = CostTaxForSellTax_IfLessThanCost + CheckTaxOnMarkup
Two deliberate behaviours here:
- Selling below cost uses the selling price as the base for cost tax, so tax never exceeds the revenue.
- Negative markup tax is clamped to zero — unless costs were allocated to other booking lines, in which case the negative is kept.
NOTE (unattached): "undo and allow negative vat, because TP calcs it anyway"
That note contradicts [33], which is still clamping. Either the "undo" was never done or it was reverted. The logic clamps. If Tourplan does compute negative VAT and this does not, the two systems disagree for lines sold below cost — worth confirming with whoever owns the Tourplan reconciliation.
Diagnostic flags — [36]–[44]¶
When exactly one of the two rates is non-zero, the booking line is stamped:
TaxOnMUonly = true when there is a markup rate but no cost tax; TaxOnCostOnly = true in
the reverse case. These are set but never cleared in this flow.
Failure to find any rate → [46] logs Critical:
'No Tax Rate found for Option (…) for Bookingline …' and sets TaxedSuccessfully = false.
The same flag is set false when the detailed line has no booking line [6], no booking
[11], or no agency [23].
5. Rack factor — CalcRackFactor¶
Rack rate is the published price a discount is measured against. Three sources, in priority
order, all returning 1 when there is nothing to compute:
[8] Rack = PriceCost * (1 + BookingLine/MarkupToRack)
[10] return (Rack - PriceSell) / PriceSell + 1
| Situation | Returns |
|---|---|
RequiredRoom has a non-zero RackFactor and PriceSell ≠ PriceCost |
that RackFactor [25] |
RequiredRoom present but PriceSell = PriceCost |
1 [23] |
No room factor, line has non-zero RackFactor, PriceSell ≠ PriceCost |
line's RackFactor [19] |
No factor anywhere, PriceSell = 0 or PriceSell = PriceCost |
1 [14] |
No factor, no MarkupToRack |
1 [12] |
No factor, MarkupToRack set |
the computed expression [10] |
1 means "no rack uplift" — sell is rack. Note PriceSell = PriceCost short-circuits to
1 in every branch: a line sold at cost is never treated as discounted off rack.
5a. Stay-pay promotions — GetFreeDays¶
"7 for 6" and its relatives are not pricing rules. They come from Tourplan rate data, as
three tiers on $TourplanPricing:
[3] Stay2 <= 1 -> return Free1
[4] Days >= Stay2:
[5] Stay3 <= 1 -> return Free2
[6] Days >= Stay3 -> return Free3
otherwise -> return Free2
otherwise -> return Free1
So Stay2/Free2 and Stay3/Free3 are thresholds with their free-night counts, Free1 is
the floor, and a Stay threshold of 1 or less disables that tier. The comparison is on
$Days — nights already clipped to the relevant period by the caller. (inferred) a 7-for-6
deal is Stay2 = 7, Free2 = 1; the model holds no such labels.
6. Booking totals — SumDetailedBookingLines¶
The 230-node roll-up, grouped by currency ($DetailedBookingLineList_CCY) — a booking
with lines in several currencies is summed per currency, not converted.
Headline figures — [34]–[42]¶
[36] Discount = sumOriginalSellPrice - sumSellPrice
[38] DiscountPercentage = if sumOriginalSellPrice = 0 then 0
else (Discount / sumOriginalSellPrice) * 100
[42] MUpPerc = if SumCostPrice > 0
then round((sumSellPrice - SumCostPrice) * 100 / SumCostPrice, 2)
else 0
Discount is derived from SellingPriceBeforeDisc minus SellingPrice on the detailed
lines — so the discount reported to the business is a residual, not a sum of applied
rules. Anything that lowers SellingPrice without touching SellingPriceBeforeDisc
appears as a discount, whatever caused it.
MUpPerc is markup on cost ((sell − cost)/cost), rounded to 2 decimals, and is 0
when cost is zero — including for a zero-cost line with a non-zero sell price, where the true
markup is infinite.
Per-category sums — [61] onward¶
Each guest category is summed three ways: the cost, the manual discount, and a
_CanFeeAdj variant — Pax_Adults_Std_Cost_CanFeeAdj,
Pax_Adults_SingleSupp_Cost_CanFeeAdj, Pax_Children_Cost_CanFeeAdj,
Pax_Staff_Cost_CanFeeAdj. That is the cancellation-fee adjustment documented in
cancellation-and-fees.md — cancellation and pricing meet here, in
the totals, not in the line pricing.
Also summed: TaxOnSell, CostPrice, RackEstimate.
A second reconciliation, in another module folder¶
Pricing/PCG_Allocations runs an identity check the totals path does not:
NetErrorDBls = SellingPrice - (SellingPriceBeforeDisc - SellingPriceDiscount
- SellingPrice_CancellationAdjustment)
Net sell should equal gross minus discount minus cancellation adjustment, so a non-zero result
is arithmetic that does not add up. Like PPPriced it is recorded rather than raised. Detail
in pricing-manual-adjustments.md §7.
7. What a per-type rule flow actually does — Sub_CheckDiscFlying_List¶
One exemplar of the 23. The shape they share, using discounted flying:
- Ring-fences — [3]/[4] retrieve
PricingRule_SupplierRingFenceandPricingRule_SupplierRingFenceFlight; the rule can name qualifying suppliers and discountable flight suppliers separately. - Exclusions — [5]
GetPCG_Exclusionsnarrows the party cost groups in play. - Per cost group, [10] finds the booking lines that qualify.
- Lead time — [17]/[18]:
$Lead = round(daysBetween($Booking/createdDate, $BookingLine/Day_First)), kept when$Lead >= MinimumLeadDays. Note this uses the booking created date, unlikeFilterLastMinute, which uses the party created date. - Window clipping — [22]/[24] clamp the line's stay to the rule's window:
FirstdayInPeriod = if Day_First within [FromDate, ToDate] then Day_First
else if Day_First < FromDate then FromDate else empty
LastdayInPeriod = if Day_Last within [FromDate, ToDate] then Day_Last
else if Day_Last > ToDate then ToDate else empty
Either being empty skips the line. So a stay overlapping the window counts only its
overlapping nights.
6. Nights — [27] $NIghts = round(daysBetween(First, Last) + 1) (sic), tested
against LengthOfStay, then accumulated toward NrOfNights and NoOfCamps.
7. Threshold shortcut — [12] skips the counting entirely when FlightThreshhold is
set with NrOfNights = 0 and NoOfCamps = 0.
NOTE at [9]: "JG 191021 Removed this, which means a property can be counted many times, and for other flight rules"
A deliberate, dated removal of a de-duplication guard: one property can now satisfy the camp count more than once. That is behaviour the business would likely want to know about.
The other 22 flows follow the same skeleton — ring-fence, exclude, clip to window, count, compare against the subtype's thresholds — with different fields. (inferred), from this one plus the type-switch structure; the remaining 22 were not read.
8. Defects and traps¶
PPPriceduses an absolute 0.1 tolerance — too loose for small lines, too tight for large ones. [14].- Pricing-failure logs are deleted, not resolved — [19]/[20]. Failure rates are
unmeasurable after the fact, and unlike
Sub_PriceBookingAll's equivalent, nothing records that a deletion occurred. - Tax rate arms are asymmetric. The markup rate ignores
TaxApply; the cost rate includesAandB. AnArow is counted in both. §4. - The negative-VAT note contradicts the code. §4, [33].
MUpPercis 0 for zero-cost lines, where the real markup is unbounded. [42].Discountis a residual, not a sum of applied rules — anything that movesSellingPricereads as a discount. [36].- Two different "created date" conventions.
FilterLastMinuteuses the party created date;Sub_CheckDiscFlying_List[17] uses the booking created date. Whether that is intended is not stated. - A property can be counted repeatedly toward camp minimums since 2021-10-19. §7.
- Sole-use accommodation is priced by the non-accommodation path — [8].
TaxOnCostOnly/TaxOnMUonlyare set but never cleared in this flow, so they can persist after the condition that set them no longer holds. [42]/[44].- Mixed dynamic-pricing lines are priced twice —
DPSwitch = 2routes through both paths. Whether the second pass can overwrite the first was not traced. - Currency is grouped, never converted in the totals. A multi-currency booking produces
per-currency totals. Conversion does exist in the subsystem —
DataManagement.GetReal_ERatewithBidRate, used by the manual-adjustment limit check — but not on this path. See pricing-manual-adjustments.md §4, which also records that a missing rate compares unconverted amounts.
9. What this document does not cover¶
- The four pricing sources' internals.
Sub_GetOptionsForBookingline,CheckDynamicPricing,Price_SiteminderandSub_CreatePricingNon_Accomare named and routed to, but not read. That is where a rate is actually looked up. - 22 of the 23 per-type rule flows. Only
Sub_CheckDiscFlying_Listwas traced; §7's claim that the others share its skeleton is (inferred). Pricing.ArchivePrices— what is archived, and whether anything reads it.UpdateSpecialPricing,PriceRuleCostReallocations,CheckPriceChangesBL,CheckForPricingRuleNotification.- The manual-adjustment path is now covered separately in pricing-manual-adjustments.md; it was missing from this document's first version entirely.
- Currency conversion. See trap 12 — the mechanism was not located.
GetAgencyTaxInicatorandGetMarkupTaxRateinternals, and the meaning of the tax indicator digits.- Rounding. Only
MUpPerc(round(…, 2)) and thedaysBetweenroundings were seen. Where monetary values are rounded, and to what precision, was not established. - Actual rates.
Pricing.TaxTablerows, agency tax indicators,MarkupToRackandRackFactorvalues are runtime data.
10. Reproducing this analysis¶
grep -A70 'MICROFLOW Pricing.Sub_PriceBookingLine_Wilderness' model/Pricing/flows.txt
grep -A66 'MICROFLOW Pricing.CalculateDetailedBookingLineTax' model/Pricing/flows.txt
grep -A12 'MICROFLOW Pricing.CalculateTaxOnCost' model/Pricing/flows.txt
grep -A20 'MICROFLOW Pricing.CalculateTaxOnMarkup' model/Pricing/flows.txt
grep -A34 'MICROFLOW Pricing.CalcRackFactor' model/Pricing/flows.txt
grep -A26 'MICROFLOW Pricing.DPSwitch' model/Pricing/flows.txt
## the reconciliation and the totals
grep -n 'PPPriced' model/Pricing/flows.txt | head
grep -n 'MUpPerc\|DiscountPercentage' model/Pricing/flows.txt | head
## every site that writes a monetary attribute
grep -rn 'Pax_Total_Cost_Net =' model/*/flows.txt
11. See also¶
- pricing-rules.md — which discounts attach, and why
- cancellation-and-fees.md —
_CanFeeAdjoriginates there - quote-to-booking.md — where pricing sits in the lifecycle
- ../business/how-it-works-pricing-rules.md
- ../qa/regression-pricing-rules.md