Skip to content

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 plain LOG)

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"] = true and 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 like compensations, and it is already included in price.

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
POST /api/marketplace-plans/<plan-uuid>/update_discounts/
{
  "discounts": {
    "cpu": {"discount_formula": "10 if usage >= 100 else 0"},
    "ram": {"discount_formula": ""}
  }
}
  • An empty discount_formula clears 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
discount_rate=15, discount_threshold=100   ->   "15 if usage >= 100 else 0"

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.