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_PriceBookingAlland never saw this subject.Pricing/ManualDiscountsis 61 microflows and is unreachable from repricing, because it is user-initiated rather than called.tools/scope.pyexists to make that class of omission visible; seeCLAUDE.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]¶
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.DailyManualDiscountsandPricing.DailyManualDiscountLinesPricing.DailyOtherPricingAmendsandPricing.DailyOtherPricingAmendsDiscountLines- plus
_Summaryvariants 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¶
- The limit is checked after the booking is repriced — §3. Rejection is a rollback.
- A missing exchange rate compares local currency against a USD limit, silently — §4.
- 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.
ManualDiscountAppliedByis first-writer-wins, so the audit trail names the first consultant, not the one who made the change under review.- An override to zero records 100%, and an adjustment to a zero-priced line records 0% regardless of magnitude.
- Revoking approval deletes every manual discount on the booking, with no confirmation in the flow itself.
- 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.
NetErrorDBlsis computed and stored, never raised. A booking whose numbers do not reconcile looks normal.Temp_totalsis deleted before each check, so allocation failures leave no history.LateBookingDiscountis invisible to aPricingRulesurvey and its 720-day bound is unexplained.
12. What this document does not cover¶
- 56 of the 61
ManualDiscountsmicroflows.CreateManDisc, the limit gate, the three approval flows andIVK_ClearManualDiscount_BL_Allare traced; the rest — includingGetManualAdj_BL,ApplyManualDisc_fromBL_Unit_New,CheckCrossesRatePeriodand the wholeManualDiscounts/Formsfolder — are named at most. Booking.ManualAdjustmentas an entity: its full attribute set and lifecycle.- The screens.
Pricing.AddDiscountAuthorisationand 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 logic —
CrossesRatePeriodsthreads 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¶
- pricing-rules.md — the automatic half
- pricing-calculation.md — how a number is produced; §4 here closes its open question about currency conversion
- cancellation-and-fees.md —
SellingPrice_CancellationAdjustmentin the identity check comes from there - ../business/how-it-works-pricing-rules.md
- ../qa/regression-pricing-rules.md