Calculation rules

TL;DR

How a calculation rule is built, how its filters combine, how its impact is applied, and what each edit recalculates.

A calculation rule turns a transaction into money booked against an account. Users configure rules alongside accounts, channel mappings and other settings. Rules produce the components of contribution margin.

This page explains what a rule is and how it behaves. To build one, follow configure a calculation rule.

What a rule is

One row in cm_calculationrules, with five parts:

PartColumnMeaning
Transaction typecm_transactiontype_idWhich of the seven transaction types this rule looks at
Filterssettings_filters_header, settings_filters_lines, settings_filters_linesnoninvWhich transactions and which lines it applies to
Impactsettings_impactWhat money it books, and how that amount is derived
Activation windowactivated, startdate, enddateWhether it is live, and over which transaction dates
Accountaccount_idWhere the money lands, which determines the account group

A rule with no transaction date to evaluate is skipped.

How filters combine

A filter is a field, an operator, a value and a group.

Conditions inside a group are joined with AND. Groups are joined with OR.

(A AND B) OR (C AND D)

The operators are exactly four: ==, !=, >, <. There is no contains, no range and no pattern match. Comparisons use PHP semantics, so a numeric string compares as a number; this is not a strongly typed expression language and should not be described as one.

A missing or NULL field fails its comparison rather than matching it.

An empty filter set matches everything of that transaction type.

Header filters and line filters do different jobs

For ordinary fixed/percentage rules, header filters gate the whole transaction. If they fail, nothing from the rule applies.

Line filters gate only the line impacts. A line that fails its filter does not cancel a header impact that already matched. Item lines and non-inventory lines have separate filter sets and separate impacts.

This is the most common misreading of a rule: a header charge appearing on a transaction where every line failed its filter is correct behaviour, not a bug.

Where the field list comes from

The available fields are read from a live SELECT * against the relevant table, minus an explicit blacklist. That is why a newly added column appears as a filter option on its own, with a SELECT DISTINCT dropdown of its values behind it.

A field existing in a table does not make it a sensible rule filter. Free text, reference numbers, dates and opaque identifiers belong on the blacklist; flags and low-cardinality dimensions do not.

How an impact is applied

An impact has a type, an object type, a value and a field.

TypeBehaviour
fixedA flat amount
percentagefield_value * percentage / 100
Object typeApplied
headerOnce per matching transaction
linesOnce per matching item line
linesnoninvOnce per matching non-inventory line

A line quantity of three does not multiply a fixed line charge by three. A fixed line impact is per matching line, not per unit.

Amounts are rounded per impact row and then summed, so a total is the sum of rounded parts rather than the rounding of a sum.

Overlapping rules are additive. Two active rules that both match the same transaction both book. There is no first-match priority and no deduplication, so an accidental overlap charges twice.

System GL rules

The system gl_impact branch first applies the date window, then reads item lines, non-inventory lines and discount rows. It does not use the ordinary header/line filter path described above. For each line it requires posting = 1, a nonempty account, a nonzero amount and an account outside getaccounts_blacklist().

It chooses nonempty amount_eur_exvat, otherwise nonempty amount_eur, otherwise zero. It preserves that amount's sign and maps the upstream internal GL id through accounts.gl_account to a CCC account. A qualifying line with no mapping stops calculation. The mapped account, rather than a single fixed account on the rule, determines where each impact is stored.

Preview cases for an ordinary rule

Synthetic example: the header filter is country NL, one impact is fixed +2.00 on the header, and another is fixed +0.50 on item lines with quantity greater than 1.

InputExpected impact
NL header, one line with quantity 3+2.50, not +3.50
NL header, every line has quantity 1+2.00; the header still matches
BE header, one line with quantity 3None; header filters gate the ordinary rule
NL header, line quantity is missing or NULL+2.00; the line comparison fails
Matching transaction exactly on either window boundaryIncluded
Matching transaction one day outside the windowNone

If another active rule matches, its result is added too. Preview boundary dates and negative bases as well as the normal case before activating.

Signed amounts, worked through

The engine preserves signs; it does not convert costs to negative amounts. Read dates, currency and signs before choosing a rate.

Synthetic example with the refund seed's shape:

credit memo amount_eur_invat  = -100.00
rule percentage               =   -0.67 %
percentage impact             = -100.00 * -0.67 / 100 = +0.67
plus a fixed component        =                        +0.06
stored impact                                          +0.73

Both components are costs under this posting convention. A fixed +4.73 shipping rule similarly books +4.73 once per matching header. Neither amount is negated by its account group. These examples explain arithmetic, not which rate to use for a new commercial agreement.

Activation and what recalculation refreshes

activated decides whether the rule runs at all. startdate and enddate bound the transaction dates it applies to, inclusive.

Changing the window re-queues one calculate batch per day inside it. Widening a window on a busy rule is therefore not a free edit.

The five editable sections do not all have the same effect on stored figures:

ActionWhat happens
Basic edit (name, transaction type, account)Deactivates the rule and deletes its stored impacts
Saving activationQueues one batch per day in the submitted window, capped at today, then deletes every impact the rule ever booked
Filter or impact editNeither queues nor deletes

Activating, deactivating and changing the window are all the same action. They are one save on one screen and one code path, so each of them deletes the rule's impacts everywhere and queues only the window submitted. There is no separate shrink or deactivate behaviour to reason about.

That pairing carries a consequence worth stating plainly: the deletion covers every impact the rule ever booked, while the queue covers only the submitted window. So the rule's impacts are gone everywhere, and the per-order totals outside the queued window still include them, because nothing re-aggregated those orders. See recalculate margin for the repair.

A preview evaluates an inactive rule and still enforces its dates. It proves the rule matches the transactions you previewed. It does not prove correctness across the whole population, and it does not refresh any stored report.

Where the output goes

Into cm_impact_<accountgroup>, chosen by the rule's account. From there calculate/impactsalesorders.php aggregates per order into cm_impact_salesorders, whose processed = 0 flag is its own queue, separate from the batches table.

Evidence

Rule shape from the cm_calculationrules schema in app/jobs/install/install.php. Operators from calculationrules.php::getoptions_operators(), impact and object types from getoptions_impacttypes() and getoptions_objecttypes(), field discovery from getoptions_fields_header(), _lines(), _linesnoninv() and getoptions_fields_blacklist(). Group combination and header-versus-line scope from calculate_buildimpact(). Per-row rounding and the fixed-per-line behaviour read from the round() calls in calculate_buildimpact(), which apply the rule value without multiplying by quantity. Edit lifecycle read from the edit_basic_save, edit_activation_save, edit_filters_*_save and edit_impact_save cases in app/http/contributionmargin/calculationrules.php: the first deactivates and deletes impacts, the second queues and deletes, and the filter and impact cases do neither. Seeded rule shapes from config/seed/cm_calculationrules/. Basis: code-checked against the current implementation.