Skip to content

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 : b or a 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 = true and PPPriced = false simultaneously. They mean different things: we produced numbers versus the two ways of totalling them agree.
  • Priced is 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:

  1. Selling below cost uses the selling price as the base for cost tax, so tax never exceeds the revenue.
  2. 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:

  1. Ring-fences[3]/[4] retrieve PricingRule_SupplierRingFence and PricingRule_SupplierRingFenceFlight; the rule can name qualifying suppliers and discountable flight suppliers separately.
  2. Exclusions[5] GetPCG_Exclusions narrows the party cost groups in play.
  3. Per cost group, [10] finds the booking lines that qualify.
  4. Lead time[17]/[18]: $Lead = round(daysBetween($Booking/createdDate, $BookingLine/Day_First)), kept when $Lead >= MinimumLeadDays. Note this uses the booking created date, unlike FilterLastMinute, which uses the party created date.
  5. 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

  1. PPPriced uses an absolute 0.1 tolerance — too loose for small lines, too tight for large ones. [14].
  2. 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.
  3. Tax rate arms are asymmetric. The markup rate ignores TaxApply; the cost rate includes A and B. An A row is counted in both. §4.
  4. The negative-VAT note contradicts the code. §4, [33].
  5. MUpPerc is 0 for zero-cost lines, where the real markup is unbounded. [42].
  6. Discount is a residual, not a sum of applied rules — anything that moves SellingPrice reads as a discount. [36].
  7. Two different "created date" conventions. FilterLastMinute uses the party created date; Sub_CheckDiscFlying_List [17] uses the booking created date. Whether that is intended is not stated.
  8. A property can be counted repeatedly toward camp minimums since 2021-10-19. §7.
  9. Sole-use accommodation is priced by the non-accommodation path[8].
  10. TaxOnCostOnly / TaxOnMUonly are set but never cleared in this flow, so they can persist after the condition that set them no longer holds. [42]/[44].
  11. Mixed dynamic-pricing lines are priced twiceDPSwitch = 2 routes through both paths. Whether the second pass can overwrite the first was not traced.
  12. Currency is grouped, never converted in the totals. A multi-currency booking produces per-currency totals. Conversion does exist in the subsystem — DataManagement.GetReal_ERate with BidRate, 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_Siteminder and Sub_CreatePricingNon_Accom are 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_List was 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.
  • GetAgencyTaxInicator and GetMarkupTaxRate internals, and the meaning of the tax indicator digits.
  • Rounding. Only MUpPerc (round(…, 2)) and the daysBetween roundings were seen. Where monetary values are rounded, and to what precision, was not established.
  • Actual rates. Pricing.TaxTable rows, agency tax indicators, MarkupToRack and RackFactor values 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