The plan language
A comp plan is a document, not a form. It is version-controlled, it compiles, and it is the thing the arithmetic on every statement comes from.
The shape of a plan
Every plan declares what period it pays over, what currency it pays in, and one or more components. A component is one reason somebody gets paid: new business, renewals, a per-meeting bonus. Most plans have two or three.
id: ae-standard
version: 1
effective_from: 2026-01-01
period: monthly
currency: USD
measures:
quota: payee.quota_monthly
booked: 'sum(allocations where allocation.deal.deal_type == "new_business", allocation.amount)'
attainment: "measure.quota > 0 ? measure.booked / measure.quota : 0"
components:
- id: new_business
label: New Business
applies_to: 'allocation.deal.deal_type == "new_business"'
basis: allocation.amount
rate:
type: tiered
on: measure.attainment
mode: marginal
tiers:
- through: "1.00"
rate: "0.08"
- through: null
rate: "0.12"Components
A component takes a basis (the number being paid on), multiplies it by a rate, and optionally by a multiplier. That product is rounded once, to the currency, and becomes one line on a statement.
applies_to narrows which credited deals the component pays on. A component without one pays on everything credited to that person. If no component matches a deal, the deal is reported as credited-but-unpaid rather than dropped. It is money-shaped, and silence about it is how a wrong statement looks correct.
Measures
A measure is a named number computed once per person per period: attainment, total bookings, months of tenure. Components refer to them as measure.name. They exist so the same figure is not written out three times and allowed to drift.
Measures are computed over this period only. A rate that depends on a year-to-date running total is not expressible yet; use a longer plan period instead.
Rates
There are three kinds.
- flat: one rate, always.
- tiered: a rate that changes as some measure grows.
- table: a rate looked up by a key, for per-product or per-region rates.
Marginal and cliff, which are not the same
A tiered rate must state its mode, and there is deliberately no default. It is the highest-stakes distinction in the language and guessing it is a large silent error.
Marginal means each tier's rate applies only to the part of the measure inside that tier, the way income tax works. Cliff means crossing a threshold re-rates the whole amount at the higher rate.
On a rep at 120% of a $100,000 quota, paying 8% to quota and 12% above it, marginal pays $10,400 and cliff pays $14,400. Both are real plans that real companies run. Neither is a sensible thing to infer from context.
Adjustments
Draws, clawbacks, caps, guarantees and SPIFFs are adjustments rather than components, because they act on what the components produced. A recoverable draw pays a floor and recovers it from later earnings; a clawback reverses commission on a deal that churned inside a window.
Money and rates
Money is written in minor units: 2000000 is $20,000.00. Rates are written as quoted decimal strings ("0.08", not 0.08), so that a rate is never subject to binary floating point. Rounding happens exactly once per line, after basis, rate and multiplier have been applied at full precision.
Deal attributes are text
Any column in your import that is not one of the standard fields is kept as a custom attribute, readable as allocation.deal.attributes.your_column. These are always text, even when the column contains nothing but digits, because that is what a spreadsheet exports.
To do arithmetic on one, convert it with number():
basis: "allocation.amount - number(allocation.deal.attributes.carrier_cost)"The conversion is explicit on purpose. Converting everything that looks numeric would quietly mangle the attributes that are not quantities: an account code of 0042, a postcode of 01234, and those are compared as text elsewhere in the same plan.
Function reference
This list is generated from the calculation engine itself, so it cannot describe a function the product does not have, or omit one it does.
Numbers
| Function | What it does | Example |
|---|---|---|
min(a, b, ...) | The smallest of the values given. Null if any of them is null. | multiplier: "min(measure.attainment, 2.0)" |
max(a, b, ...) | The largest of the values given. Null if any of them is null. | basis: "max(allocation.amount, 0)" |
abs(x) | The value without its sign, so -250 becomes 250. | basis: "abs(allocation.amount)" |
number(text) | Reads text as a number. Needed for any custom deal attribute, because a column imported from a spreadsheet is text even when it holds nothing but digits. Refuses text it cannot read rather than guessing. | basis: "allocation.amount - number(allocation.deal.attributes.cost)" |
round(x, places = 0) | Rounds half away from zero, not to even. Rounding inside a plan is rare: the engine already rounds each line once, after basis x rate x multiplier. | basis: "round(allocation.amount / 1000, 2)" |
floor(x, places = 0) | Towards zero, to the number of decimal places given. | basis: "floor(allocation.amount, 0)" |
ceil(x, places = 0) | Away from zero, to the number of decimal places given. | basis: "ceil(allocation.amount, 0)" |
coalesce(a, b, ...) | The first value that is not null. The usual way to give a missing payee parameter a default instead of failing the whole run. | multiplier: "coalesce(payee.ramp_percent, 1.0)" |
prorate(amount, from, to) | Scales an amount by how much of the period those dates cover. What a mid-month starter or leaver should be paid of a fixed figure. | basis: "prorate(payee.target_variable, payee.hired_on, period.ends_on)" |
Lists
| Function | What it does | Example |
|---|---|---|
sum(list, expression) | Adds the expression up across a list, most often the allocations credited to this payee this period. Narrow it with `where`. | booked: 'sum(allocations where allocation.deal.deal_type == "renewal", allocation.amount)' |
count(list) | How many items are in the list. Used by plans that pay per event rather than per dollar. | meetings: 'count(allocations where allocation.deal.deal_type == "meeting")' |
Text and dates
| Function | What it does | Example |
|---|---|---|
contains(text, part) | True when the text contains that part. Case sensitive. | applies_to: 'contains(allocation.deal.name, "Renewal")' |
days_between(from, to) | Whole days from one date to another, negative if they are the wrong way round. Dates are plain YYYY-MM-DD strings; there is no clock inside a plan. | months_tenure: days_between(payee.hired_on, period.ends_on) / 30.44 |
Tables
| Function | What it does | Example |
|---|---|---|
lookup("table", key, fallback = null) | Reads a value out of a named table declared under `tables:`. The table name is text in quotes, not a bare word. A figure that varies by product or region belongs in a table, not in a chain of conditionals. | multiplier: "lookup(\"region_multiplier\", allocation.deal.attributes.region, 1.0)" |
What the language deliberately cannot do
There is no clock: a plan cannot ask what today is, only what the period is. There is no randomness, and no way to reach outside the snapshot the run was given. This is what makes a period from eighteen months ago recalculable to the penny, and it is a constraint worth the inconvenience it occasionally causes.