Skip to content

Manual adjustments and discount approval — developer reference

Audience: engineers working on Pricing/ManualDiscounts, the consultant discount screens, or anything that reads Booking.BookingLine.Override_Manual.

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.

Third document in the pricing set, after pricing-rules.md (which discounts attach automatically) and pricing-calculation.md (how a number is produced). This one covers the human path: a consultant overriding a price by hand, and the approval and limit machinery that constrains it.

Why this exists as a correction. The first pricing pass traced the call graph from Pricing.Sub_PriceBookingAll and never saw this subject. Pricing/ManualDiscounts is 61 microflows and is unreachable from repricing, because it is user-initiated rather than called. tools/scope.py exists to make that class of omission visible; see CLAUDE.md § How to run a documentation pass.


1. Two different things called "discount"

Pricing rules Manual adjustments
Origin rows in Pricing.PricingRule, applied automatically a consultant typing a price
Trigger Sub_PriceBookingAll requalifies on every change a button on a booking line
Recorded on PartyCostGroup_BL_PricingRule_Qualifying Booking.ManualAdjustment + Override_Manual
Governed by rule configuration a category, a USD limit and an approver
Folder MF_Trans, MF_Config ManualDiscounts (61 flows)

They meet in one place: both set BookingLine.OverridePrice = true, which is why a line can be excluded from automatic pricing by either mechanism.


2. Applying an adjustment — CreateManDisc

CreateManDisc($DetailedBookingLine, $BookingLine, $DetailedBookingLineList,
              $ManualAdjustmentList, $CrossesRatePeriods) : Boolean

The consultant edits a detailed booking line, and the adjustment propagates from there.

[4] normalises the two override fields — GrossUnitSP_AfterManualDisc and GrossUnitCP_AfterManualDisc default to 0 rather than empty — and sets DetailedBookingLine.OverRidePrice = true.

[6][8] then copy the same figures onto sibling detailed lines matching on Date_from, PriceType, TP_AgeCategory, AgeCategory, BCQ_ItemNumber, option and party cost group, excluding cost-allocation rows:

[6] RETRIEVE Booking.DetailedBookingLine where
       [DetailedBookingLine_BookingLine = $BookingLine] [Date_from = …] [PriceType = …]
       [TP_AgeCategory = …] [AgeCategory = …] [BCQ_ItemNumber = …]
       [DetailedBookingLine_Option = …] [DetailedBookingLine_PartyCostGroup = $PartyCostGroup]
       [id != $DetailedBookingLine/id] [CostAllocation = false]
[8] CHANGE … GrossUnitSP_AfterManualDisc, GrossUnitCP_AfterManualDisc,
              RepriceSet = true, OverRidePrice = true

An adjustment is therefore never to one row. Editing one date's price silently rewrites every matching row in the same cost group. [19][21] repeat this without the cost-group constraint when the line has no party cost group, which widens the blast radius further.

What it stamps on the booking line — [11]

OverridePrice = true
Override_Manual = true
ManualOverrideAppliedDate = [%CurrentDateTime%]
ManualDiscountAppliedBy = if empty then $currentUser/Name else <unchanged>
OverrideOption = false

ManualDiscountAppliedBy is first-writer-wins — the second consultant to adjust the same line is not recorded. (inferred) that is a bug rather than a policy; nothing in the model says otherwise, and the audit trail for a contested discount will name the wrong person.

The percentages — [15]

ManualDiscountPercSP = if GrossUnitSP = 0 then 0
                       else if GrossUnitSP_AfterManualDisc = 0 then 100
                       else (GrossUnitSP - GrossUnitSP_AfterManualDisc) / GrossUnitSP * 100

Identically for ManualDiscountPercCost over GrossUnitCP. Note the second branch: an override to zero is recorded as 100%, not as a division by zero — and a line whose original price was zero records 0% however much it changed.

ManualAdjustment.Applied is set false here. The record exists before it takes effect.

[14] NOTE: "Do not reprice here as FIXING rates 'adjustment' don't need a reprice"


3. The limit gate — IVK_ApplyManualDisc_fromBL_Unit_New

Roles: Pricing.Employee, Pricing.Manager.

The order of operations is the surprising part:

[8]  ManAdj_SubCategory  <- from the booking line
[10] $AdjLimit = if HasAdjLimit then ManAdjLimitUSD else 0
[15] ApplyManualDisc_fromBL_Unit_New(...)         <- the adjustment is APPLIED
[18] AGGREGATE Sum SellingPrice   -> $SumSellingPrice        (before)
[19] AGGREGATE Sum CostPrice      -> $SumCostPrice           (before)
[20] CALL Pricing.Sub_PriceBookingAll($Booking, false, false) <- WHOLE BOOKING REPRICED
[22] AGGREGATE Sum SellingPrice   -> $SumSellingPrice_New    (after)
[24] CALL Dashboard.SumBookingLine_CheckLimit(...)  -> $Applicable
[26] if applicable: commit the new totals

The adjustment is applied and the entire booking repriced before anyone checks whether it was permitted. Rejection is a rollback, not a refusal — which is why the rejection message says the adjustment "has been removed" rather than "was not allowed".


4. The limit itself — Dashboard.SumBookingLine_CheckLimit

SumBookingLine_CheckLimit($BookingLine, $LimitUSD, $Currency,
                          $SumSellingPrice, $SumCostPrice,
                          $SumSellingPrice_New, $SumCostPrice_New) : Boolean
Node Behaviour
[2] LimitUSD = 0 → no limit; restore the original totals and return true
[5]/[6] CostDiff = before − after, SellDiff = before − after
[7] currency is 'USD' → compare directly
[17] otherwise DataManagement.GetReal_ERate('USD', $Currency, false, now)
[19]/[20] divide both differences by $ExchangeRates/BidRate
[9] abs(CostDiff) > LimitUSD → reject
[12] abs(SellDiff) > LimitUSD → reject

Rejection messages, verbatim:

"This adjustment changes the cost price by ${1} which is above the allowed limit. The adjustment has been removed. You will need to select an appropriate reason and supply documentation"

"This adjustment changes the sell price by an equivalent of ${1} which is above the allowed limit. The adjustment has been removed. You will need to select an appropriate reason and supply documentation"

The defect worth fixing first — [18]

[18] SPLIT if $ExchangeRates = empty
       case true: -> goto [8]        # compare anyway, UNCONVERTED

A missing exchange rate does not fail; it compares local-currency amounts against a USD limit. For a currency weaker than the dollar this silently tightens the limit — a ZAR booking is measured as though rands were dollars — and for a stronger one it loosens it. There is no log, no message, and no flag on the booking recording that the check ran unconverted.

This is also the answer to a gap pricing-calculation.md left open: currency conversion does exist in the pricing subsystem, via GetReal_ERate and BidRate. It is used for limit checking, not for producing booking totals.


5. Approval — who may bless a discount

Three flows, and the interesting logic is in the middle one.

CheckManDiscApproval($Booking) : Boolean — the gate:

[2] SPLIT if $Booking/Booking_EmployeeDiscApproval = empty
  [3] case false: END return true
  [4] case true:  OPEN PAGE Pricing.AddDiscountAuthorisation
  [5]             END return false

No approver on the booking means the caller is blocked and the authorisation page opens.

OCh_CheckApprover($Booking) — an on-change handler that prevents self-approval, in two distinct ways:

Node Condition Result
[3] approver is the current user clear Booking_EmployeeDiscApproval, warn
[5] approver is the booking's owner clear it, warn
[9] otherwise commit

Both warn with the same message:

"Unfortunately you are not allowed to approve your own discounts, a different person with the necessary rights must approve check and approve it"

So the rule is not merely "not yourself" but "not the person who owns the booking" — a consultant cannot nominate themselves or be nominated on their own booking.

ToggleDiscountApproval($Booking) — flips Booking.ManualDiscApproval, and the asymmetry matters:

[3] false -> true   : CHANGE ManualDiscApproval = true  [commit Yes]
[5] true  -> false  : CHANGE ManualDiscApproval = false [commit YesWithoutEvents]
[6]                   CALL Pricing.IVK_ClearManualDiscount_BL_All($Booking)

Revoking approval deletes every manual discount on the booking. Granting it commits with events; revoking commits without them, then wipes. There is no confirmation step in this flow. (inferred) the page presumably asks first; that was not traced.


6. Categories are the policy

A manual adjustment is classified by Pricing.ManAdjCategory and Pricing.ManAdj_SubCategory, and the sub-category is where the governance lives:

Field Meaning
HasAdjLimit, ManAdjLimitUSD the limit enforced in §4
ExplanatoryNoteRequired a note must be supplied
CanFixRate "Even if dates c…" — may fix a rate even when dates change
LoadCostingDoc, LoadRatesheet_Invoice, RatesheetChecked, LoadInvoice, LoadComms which documents must accompany it
IncorrectTPRates "this is for inc…" — flags a Tourplan rate error
WSAutoIncreaseFix, SpecificEmails special handling
Rara, Product, ResMgr, WW, Cluster, Approver who may select this sub-category (inferred: role gating)
Active the on/off switch

ManAdjCategory carries the same role flags without the limits, so the two-level taxonomy is category → sub-category, with policy at the leaf.


7. A second self-check — CheckAllocation_BL

Pricing/PCG_Allocations (8 microflows plus snippets and a page) reconciles party-cost-group allocations. CheckAllocation_BL($BookingLine) builds a Booking.Temp_totals row and, at [10], computes:

NetErrorDBls = SellingPrice
               - ( SellingPriceBeforeDisc - SellingPriceDiscount
                   - SellingPrice_CancellationAdjustment )

That is an identity check: net sell should equal gross sell minus discount minus cancellation adjustment, so any non-zero NetErrorDBls is arithmetic that does not add up. It sits alongside the PPPriced reconciliation in pricing-calculation.md §2 — two independent consistency checks on the same numbers, neither of which raises an error; both record a value and move on.

[3] deletes the previous Temp_totals rows before recreating them, so the check is point-in-time only and nothing accumulates a history of failures.


8. Late booking discount — a rule type that is not a rule

Pricing.LateBookingDiscount looks like it belongs with the 23 PricingRule subtypes and does not: it is a standalone entity, not a specialisation. Three fields — DaysBeforeTravel, Membership (GuestManagement.MembershipType) and Discount — plus an association to Pricing.Residents.

Validation, in IVK_ResidentsLateBookingDisc_Save:

Node Rule Message
[2] 0 < DaysBeforeTravel < 720 'You must set a valid number of days (eg 30)'
[3] Discount > 0 'You must set a valid discount (eg 20 for 20%)'
[4] Membership set 'You must set which membership type this discount is v…'

So it is a residents' membership-tiered late-booking discount, capped at just under two years of lead time, and it is invisible to any survey of PricingRule subtypes. Maia's own pricing documentation lists it as rule type #8, which is how the discrepancy was found — see ../05-conventions-and-risks.md.


9. Where adjustments go for reporting

Four entities exist purely to be extracted, and none are written by the pricing path:

  • Pricing.DailyManualDiscounts and Pricing.DailyManualDiscountLines
  • Pricing.DailyOtherPricingAmends and Pricing.DailyOtherPricingAmendsDiscountLines
  • plus _Summary variants of both line entities

DailyManualDiscountLines is denormalised for a report rather than for the model — BookingReference, Agency, Consultant, Approver, BookingType, Reason, TravelDate and AdjustmentType are all String, including the date. Wilderness is documented "WS owned, managed, marketed". Alongside them: OriginalSP, DiscountedSP, Discount, DiscountPerc, Currency.

The writer is DailyOtherPricingAmendsDiscountLines at flows.txt [19] of its creating flow; the manual-discount equivalent was not traced. (inferred) these feed a daily management report, given the names and the string-typed dates; nothing in the model states what consumes them.


10. Other configuration entities in Pricing

Named here so that a reader knows they exist and that this document does not explain them. Reference counts are mentions in flow listings:

Entity Flow references Apparent purpose (inferred)
Pricing.PricingConfiguration 5 subsystem-wide switches
Pricing.CostOfSales 5 cost-of-sales figures
Pricing.AnytimeDiscounts 2 always-available discounts
Pricing.CountryPriceConfig 1 per-country pricing configuration
Pricing.SpecialPricing 1 associated to PricingRule via PricingRule_SpecialPricing
Pricing.LRRateCode, Courier, TPDocPack, BushBuddy, MinLosPerCamp, FreeNightsTotal, Itinerary_Pricing, OccupancyDiscountSetup few supporting reference data
Pricing.PricingLog pricing audit records
Supplier/option join entities — DiscountedFlyingOptionSupplier, FreeActivity_Supplier_Option, FreeNightOnUsSupplierOptions, NonUseOfWAirSupplier, NonUseOfWAirSupplierOptions, PackageSupplier, PackageLocation_Seq, Package_OptionMap, AlternateOptionMap, AlternateOptionSupplier the ring-fence and eligibility joins behind per-type rule conditions

Tier tables — how SADC and Shareholder pricing actually vary

Three of the 23 rule types do not carry a single DiscountPercentage; they read a tier table:

Entity Fields Meaning (inferred)
Pricing.SADCTiers DaysOut, OccupancyPercentage SADC discount varies by lead time and occupancy, not a flat percentage
Pricing.ShareholderTiers DaysOut, PercentageDiscount shareholder discount by lead time
Pricing.SupplierDiscount DiscountPercentage the per-supplier rate behind LongStay.HasSeparateDiscounts
Pricing.TimePeriods StartDate, EndDate a reusable date window

That corroborates a claim in Maia's own pricing documentation — "SADC and Shareholder pricing uses tiered discount structures" — which this repo could not previously confirm. The tier resolution logic is not traced: nothing here explains how a DaysOut band is chosen or what happens between bands.

Remaining join and option-map entities, named so a reader knows they exist and that nothing here explains them: Pricing.SingleStayAltOption, Pricing.SingleStayOptionMap, Pricing.TargetedOptionSupplier, Pricing.DailyManualDiscountLines_Summary and Pricing.DailyOtherPricingAmendsDiscountLines_Summary.

PricingDynamic — three entities, no logic

The module has no microflows at all: three entities and one enumeration.

Entity Fields
PricingDynamic.DynamicPrice ExternalSystem, DateFrom, DateTo, Nights, RoomTypeId, RoomRateId, RoomRateDescription, AdultPax, …
PricingDynamic.CancellationCosts DaysBeforeTravel, CancellationCost, CancellationTax, Percentage
PricingDynamic.DynamicPricingHistory UserName, DateSourced, Active

So dynamic pricing stores its data here and is driven from Pricing/Dynamic and the bar-rate integration described in pricing-calculation.md §1. CancellationCosts is notable: a second, dynamic-pricing-specific cancellation cost ladder distinct from the fee engine in cancellation-and-fees.md(inferred) supplier-imposed rather than commercial policy, since it carries a tax field. Neither the sourcing nor the relationship between the two ladders is traced.

Pricing.LogPricingQueue is the module's only queue, BasicQueueConfig with ParallelismExpression: 1 and ExportLevel: Hidden — a single serialised lane, the same throughput shape as the background-handoff queue in ../../reports/async-handoffs.md.


11. Defects and traps

  1. The limit is checked after the booking is repriced§3. Rejection is a rollback.
  2. A missing exchange rate compares local currency against a USD limit, silently — §4.
  3. One adjustment rewrites every matching detailed line in the cost group, and every matching line in the booking line when there is no cost group — §2.
  4. ManualDiscountAppliedBy is first-writer-wins, so the audit trail names the first consultant, not the one who made the change under review.
  5. An override to zero records 100%, and an adjustment to a zero-priced line records 0% regardless of magnitude.
  6. Revoking approval deletes every manual discount on the booking, with no confirmation in the flow itself.
  7. Self-approval prevention covers the current user and the booking owner — worth knowing, because it means a manager cannot approve discounts on bookings they own, which may look like a permissions fault.
  8. NetErrorDBls is computed and stored, never raised. A booking whose numbers do not reconcile looks normal.
  9. Temp_totals is deleted before each check, so allocation failures leave no history.
  10. LateBookingDiscount is invisible to a PricingRule survey and its 720-day bound is unexplained.

12. What this document does not cover

  • 56 of the 61 ManualDiscounts microflows. CreateManDisc, the limit gate, the three approval flows and IVK_ClearManualDiscount_BL_All are traced; the rest — including GetManualAdj_BL, ApplyManualDisc_fromBL_Unit_New, CheckCrossesRatePeriod and the whole ManualDiscounts/Forms folder — are named at most.
  • Booking.ManualAdjustment as an entity: its full attribute set and lifecycle.
  • The screens. Pricing.AddDiscountAuthorisation and the adjustment pages are not traced, so who can reach the buttons is unverified beyond the two roles on the limit gate.
  • What consumes the Daily* reporting entities, and which flow writes the manual-discount variant.
  • The rate-period logicCrossesRatePeriods threads through every flow here and is never explained.
  • MF_Config (167 flows), the pricing configuration maintenance surface. Named, untraced.
  • Actual limits, categories and rates — all runtime data.

13. Reproducing this analysis

python3 tools/scope.py Pricing --roots Pricing.Sub_PriceBookingAll --all   # how this was found

grep -A45 'MICROFLOW Pricing.CreateManDisc'                    model/Pricing/flows.txt
grep -A30 'MICROFLOW Pricing.IVK_ApplyManualDisc_fromBL_Unit_New' model/Pricing/flows.txt
grep -A24 'MICROFLOW Dashboard.SumBookingLine_CheckLimit'      model/Dashboard/flows.txt
grep -A8  'MICROFLOW Pricing.CheckManDiscApproval'             model/Pricing/flows.txt
grep -A16 'MICROFLOW Pricing.OCh_CheckApprover'                model/Pricing/flows.txt
grep -A10 'MICROFLOW Pricing.ToggleDiscountApproval'           model/Pricing/flows.txt
grep -A14 'MICROFLOW Pricing.CheckAllocation_BL'               model/Pricing/flows.txt
grep -A16 'MICROFLOW Pricing.IVK_ResidentsLateBookingDisc_Save' model/Pricing/flows.txt

grep -A24 '^ENTITY ManAdj_SubCategory' model/Pricing/domain-model.txt
grep -A18 '^ENTITY DailyManualDiscountLines' model/Pricing/domain-model.txt

14. See also