Component Dynamic Discounts
Overview
A plan component can carry a volume discount expressed as a formula. At
billing time the formula receives the billed quantity (bound to the variable
usage) and returns a discount percentage; that percentage is applied to
the component's line charge and materialized as a separate negative invoice
line item, so the discount stays visible on the invoice.
This lets operators express "the more you use, the cheaper the unit gets"
without hard-coding tiers: a single discount_formula covers flat thresholds,
graduated tiers, logarithmic curves, and caps.
flowchart LR
PC["PlanComponent<br/>discount_formula"]
Q["Billed quantity<br/>(usage)"]
PCT["Discount %<br/>(clamped 0-100)"]
LINE["Component line<br/>price x quantity"]
ITEM["Negative discount<br/>invoice line item"]
Q --> PCT
PC --> PCT
PCT --> ITEM
LINE --> ITEM
This is a separate feature from the Affiliate program, which uses the same safe formula evaluator but for a different purpose.
The formula
PlanComponent.discount_formula is evaluated by the safe, side-effect-free
evaluator in waldur_mastermind.common.formula, which walks a whitelisted AST —
it never calls eval(). The single bound variable is usage (the billed
quantity for the period). The result is interpreted as a percentage and
clamped to [0, 100]; an empty formula means no discount.
Supported grammar:
- Numbers and the variable
usage - Operators
+ - * / // % **, unary minus, comparisons,and/or - Conditional expressions:
10 if usage >= 100 else 0 - Functions (case-insensitive):
MIN, MAX, LN, LOG10, FLOOR, CEIL, POW, ABS(LN= natural log,LOG10= decimal log; there is deliberately no plainLOG)
Formulas are validated at save time — the evaluator probe-runs them over a
range of sample quantities, so a formula that would throw (e.g. LN(usage) at
usage = 0) is rejected by the API before it can reach the billing pipeline.
Example formulas
| Formula | Behaviour |
|---|---|
10 if usage >= 100 else 0 |
flat 10 % once usage reaches 100 |
20 if usage >= 1000 else (10 if usage >= 100 else 0) |
graduated 0 → 10 → 20 % |
MIN(50, usage * 0.5) |
0.5 % per unit, capped at 50 % |
MIN(70, LN(MAX(1, usage)) * 10) |
logarithmic curve, capped at 70 % |
How the discount is applied
The discount scales on the volume (usage) but applies to the actual
line charge (unit_price × billed_quantity). What usage maps to depends on
the component's billing type — the discount is supported on all of them:
| Billing type | usage is bound to |
Line charge the discount reduces |
|---|---|---|
| FIXED / ON_PLAN_SWITCH | the component amount |
price × amount × period_fraction |
| ONE_TIME (incl. prepaid) | the billed quantity | that one-time charge |
| LIMIT | the limit quantity | price × duration-multiplied quantity |
| USAGE | the reported usage for the period | price × reported usage |
Each main line records the volume that feeds the formula in
details["discount_usage"] when it is billed. A separate pass
(billing_discount.apply_aggregated_volume_discounts) then builds the discount
lines for the whole invoice:
flowchart TB
ITEMS["Main lines carrying<br/>details.discount_usage"]
GROUP["Group by plan component<br/>(per customer or per resource)"]
EVAL{"Formula on the group's<br/>summed volume: percent > 0?"}
LINES["One negative line per main line<br/>(percent x its charge)"]
NONE["No discount lines"]
ERR["Formula error: log and skip<br/>(billing never blocked)"]
ITEMS --> GROUP --> EVAL
EVAL -->|yes| LINES
EVAL -->|no| NONE
EVAL -.->|on FormulaError| ERR
Estimated on the open invoice, final at month close
The pass runs at month close, before credit compensation and affiliate fees.
It also runs on the open invoice, so that the invoice total, the project credit
forecast (creditable_cost_this_month) and the estimated-cost policies show
what the month will be charged, not the gross figure:
- When. Daily, in
update-invoices-total-cost, and shortly after a resource is created or terminated, or switches plan or limit. Changes are debounced per customer, so a burst of changes is rebuilt once. Usage reports wait for the daily pass: usage only grows a line, so its discount lags behind and never runs ahead. - Marked as estimates. A line on an open invoice carries
details["is_estimate"] = trueand says "estimated" in its name. It is computed on the volume so far. For a limit component that volume is the limit, so the estimate already equals the final discount. For a usage component the volume grows during the month, so the estimate understates the final discount. - Final at close. Month close builds the lines once more and drops the
marker. The invoice serializer reports the lines' sum as
volume_discounts, a negative amount likecompensations, and it is already included inprice.
Key properties:
- Updated in place. Each line is matched to its main line by
details["discount_of_item"]. A pass changes only the lines whose amount or label moved, creates the missing ones and removes those that no longer apply, for example when usage drops below a tier. A pass over an unchanged invoice writes nothing and logs nothing. - Serialised per invoice. A pass locks the invoice row, so the daily pass, a queued refresh and month close never create the same line twice.
- Never blocks billing. A broken formula is logged and skipped. The main component line is always billed.
- Overage is not discounted. Prepaid overage top-ups are premium-priced and bypass the discount.
Worked example
Component price 10.00, usage 150 for the period, formula 10 if usage >= 100
else 0:
| Line item | Amount |
|---|---|
| Component (10.00 × 150) | 1,500.00 |
| Volume discount (10 % of 1,500.00) | −150.00 |
| Net | 1,350.00 |
Logarithmic curve MIN(70, LN(MAX(1, usage)) * 10) applied to the same 10.00
unit price:
| Usage | Discount % | Line charge | Discount | Net |
|---|---|---|---|---|
| 10 | 23.03 % | 100.00 | −23.03 | 76.97 |
| 100 | 46.05 % | 1,000.00 | −460.50 | 539.50 |
| 1,000 | 69.08 % | 10,000.00 | −6,908.00 | 3,092.00 |
| 10,000 | 70.00 % (capped) | 100,000.00 | −70,000.00 | 30,000.00 |
API
Discounts are managed per plan through the existing update_discounts action;
the caller needs the UPDATE_OFFERING_PLAN permission (service provider or
staff).
1 2 3 4 5 6 7 | |
- An empty
discount_formulaclears the discount for that component. - Component types not present in the plan's offering are rejected.
- Invalid formulas are rejected with a 400 and the evaluator's error message.
Plan and plan-component serializers expose discount_formula and a derived
discount_description for display.
Migration from the old threshold/rate discount
This replaces the earlier flat discount_threshold + discount_rate fields on
PlanComponent (which only applied to LIMIT components). Existing configurations
are converted automatically by migration
0243_plancomponent_discount_formula into the equivalent formula:
1 | |
Two behavioural changes come with the unification:
- The discount now applies to all billing types (FIXED, ONE_TIME, LIMIT, USAGE), not just LIMIT.
- On the LIMIT path the discount now scales on the full line charge (duration-multiplied), making it proportional to what is actually billed.