# The Price Calculation Engine, in Detail

> > Scope: how calculations are created, stored, processed, and — most > importantly — extended in the customization layer · Last reviewed: 2026-08-02

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/calculation_engine. Last updated 2026-08-25.

---
> **Scope:** how calculations (pricing/weight logic) are created, stored, processed, and — most
> importantly — **extended in the customization layer** · **Last reviewed:** 2026-08-02

> **Naming:** this page uses the 4.0 (CB4) vocabulary — `QuestionProperty`, `QuestionPrice()`,
> `QuestionSelection()`, `questionId`. CBX 3.x used the legacy element vocabulary
> (`ElementAttribute`, `ElementPrice()`, `ElementEntry()`, `elementId`); the 4.0 migration
> rewrites stored calc JSON and calc-code DSL text automatically — the full before/after map is
> in [`migration-to-cb4/element-question-rename.md`](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/element-question-rename).

All paths are relative to `docroot/components/com_configbox/`. This is the companion to
`com_configbox_rule_engine.md`; the two engines are deliberately parallel in structure (a factory
+ pluggable "type" classes + a customization folder), but the calculation engine differs in one
crucial way: **it injects computed values and operators into the `eval()`'d string** (the rule
engine only evals a boolean skeleton).

---

## 0. Mental model

A **calculation** computes a single number (a price, a recurring price, a weight, or a numeric
min/max bound). It is a row in `#__configbox_calculations` scoped to one product, with a `type` of
**`matrix`**, **`formula`**, or **`code`**. It is *assigned* to a question or answer through the
question/answer's `calcmodel*` fields and is run whenever that price/weight is needed.

- **formula** — a drag-and-drop tree of **terms** (numbers, operators, references to questions,
  references to other calculations, customer-group fields, functions). Evaluated by rendering the
  terms to a PHP arithmetic string and `eval()`ing it.
- **code** — a free-text expression with placeholders (`A`–`D`) and macros (`Total`,
  `QuestionPrice()`, `Calculation()`, …). Macros are string-replaced, then `eval()`ed.
- **matrix** — a 2-D lookup table; two inputs (each a question selection or another calculation)
  index a grid of cells; supports exact / next-higher / next-lower matching and rounding. No
  `eval()`.

Formula **terms** are pluggable classes in `classes/calc_term_types/`
(`ConfigboxCalcTerm<Type>`): `Number`, `Operator`, `QuestionProperty`, `Calculation`,
`CustomerGroup`, `Function`. The `Function` term additionally exposes a **function registry** that
custom code can extend.

---

## 1. Storage — what a calculation actually is

The schema is **class-table inheritance**: one parent row plus one child row per type, sharing the
same primary key.

| Table | Holds |
|-------|-------|
| `#__configbox_calculations` | parent: `id`, `name`, `type` (`matrix`/`formula`/`code`), `product_id` |
| `#__configbox_calculation_formulas` | `id` (= calc id), **`calc`** = the formula as a **JSON term tree** |
| `#__configbox_calculation_codes` | `id`, **`code`** = the expression string, `question_id_a..d` = the questions bound to placeholders A–D (`models/calccodes.php:52`; real FKs to `#__configbox_questions`, `helpers/updates/complete/3.6.3_ddl.sql:782-796`) |
| `#__configbox_calculation_matrices` | `id`, row/column input config (`row_type`/`row_question_id`/`row_calc_id`, same for column: `column_type`/`column_question_id`/`column_calc_id` — `models/calcmatrices.php:40-127`), `lookup_value`, `round`, multiplier |
| `#__configbox_calculation_matrices_data` | the grid cells as rows: `id` (= calc id), `x`, `y`, `value` (composite PK `id,x,y`) |

`models/calculations.php` is the parent model (`ConfigboxModelCalculations`); the type radio is
defined at `:86` (`'choices'=>['matrix','formula','code']`). The child sub-models are
`ConfigboxModelCalcFormulas`, `ConfigboxModelCalcCodes`, `ConfigboxModelCalcMatrices`
(`models/calcformulas.php` / `calccodes.php` / `calcmatrices.php`), resolved by
`getModelForCalcType()` (`calculations.php:235`). Note the asymmetry: the models dropped the
`admin` prefix in the CB4 cleanup, the **controllers kept it** —
`controllers/admincalculations.php`, `admincalccodes.php`, `admincalcmatrices.php`.

### How a calculation is attached to a price/weight
Questions and answers carry calc-model foreign keys (Kenedo property type `calculation`):
- On a **question** (`elements`): `calcmodel` (price), `calcmodel_recurring`, `calcmodel_weight`,
  and `calcmodel_id_min_val` / `calcmodel_id_max_val` (calculated numeric validation bounds).
- On an **answer** (`xref_element_option`): `calcmodel`, `calcmodel_recurring`, `calcmodel_weight`.

At runtime `ConfigboxPrices` reads these (via the product cache) and calls the engine — e.g.
`getQuestionPrice()` (`helpers/prices.php:419`) → `ConfigboxCalculation::calculate($calcId, $questionId, NULL, $selections)`
(`:432`); answers go through `getXrefPrice()` (`:562`), weights through `getQuestionWeight()` (`:750`).
A static price is used when no calc model is assigned. **Currency, customer-group overrides and tax
are applied by `ConfigboxPrices` *after* the calculation returns** — the engine itself only produces
a raw number.

### The formula JSON shape (`calculation_formulas.calc`)
A JSON **array of terms**. Each term is `{ "type": "...", ...data }`, except brackets which are a
**nested array** (no `type`). Example — `(question 7's price + 12) * 2`:
```json
[
  [
    { "type": "QuestionProperty", "questionId": 7, "fieldPath": "price" },
    { "type": "operator", "value": "+" },
    { "type": "number", "value": "12" }
  ],
  { "type": "operator", "value": "*" },
  { "type": "number", "value": "2" }
]
```
Function terms carry their arguments as `parameters` — an array of sub-term-arrays:
```json
{ "type": "function", "name": "round",
  "parameters": [ [ {"type":"Calculation","value":42} ], [ {"type":"number","value":"2"} ] ] }
```
> **Legacy format:** older terms stored their fields under a `data` sub-key
> (`{type, data:{...}}`). Both the renderer and the evaluator flatten this on read
> (`calculation.php:69` and `:688`), so both shapes work.

---

## 2. Creating a calculation in the backend

### 2.1 The parent form + type routing
A calculation is created/edited via the `admincalculation` form (controller
`controllers/admincalculations.php`, model `models/calculations.php`). The form has just
name, product, and the **type** radio. The type-specific data is saved in `afterStore()`
(`calculations.php:136`):
1. Re-reads the saved `type`.
2. Gets the matching sub-model (`getModelForCalcType`).
3. `getDataFromRequest()` → `prepareForStorage()` → `validateData()` → `store()` on the sub-model,
   forcing `$data->id` to the **parent calc id** (so the child shares the PK).
4. **Deletes** the child rows of the *other* two types (so switching code→formula cleans up).

So one logical calculation = one parent row + exactly one child row, written transactionally.

### 2.2 The three editors (each is a different UI)

- **Formula editor** (`views/admincalcformula`, JS `assets/javascript/calc-editor.js`, loaded via
  `configbox/calcEditor::initCalcEditorEach`, see `view.html.php:51`). A drag-and-drop surface
  identical in spirit to the rule editor: tabs per **term type** (each term class supplies a panel
  via `getTermsPanelHtml()`), a `#terms` drop area, draggable operators, and **function terms with
  nested parameter drop-zones**. On save, `calc-editor.js:getCalcItems()` (`:28`) walks the DOM
  `.item` spans, recursing into brackets and into each `.parameter` of a function, reads each
  span's `data-*` attributes + `.input[data-data-key]` values into an object, and
  `JSON.stringify`s the result (`:19`) into the form field. (Mechanically the same serializer as
  `rule-editor.js`, just emitting terms instead of conditions.)
- **Code editor** (`views/admincalccode`, JS `calcCode.js`). A textarea/code field for the
  expression plus four element pickers (A–D). Stored as `calculation_codes.code` +
  `question_id_a..d`.
- **Matrix editor** (`views/admincalcmatrix`, JS `calcmatrix.js`). A spreadsheet-like grid plus
  row/column input config. It can **import cells from an uploaded `.xls`/`.xlsx`** —
  `controllers/admincalcmatrices.php:getMatrixDataFromSpreadsheet()` (`:39`) accepts the upload
  (extension-checked at `:68`) and returns a 2-D array of cells (via PhpSpreadsheet).

---

## 3. Processing — how a calculation is evaluated

Engine: `ConfigboxCalculation`, which ships **ionCube-encoded** (`helpers/encoded/`). The plaintext is
not part of the distribution, which is why this section documents the evaluation in the detail it does
— you cannot read it out of the tree. Line references below are to `calculation.php`.

### 3.1 Entry & dispatch: `calculate()` (`calculation.php:115`)
```php
ConfigboxCalculation::calculate($calculationId, $regardingQuestionId, $regardingAnswerId, $selections)
```
- Memoizes on `serialize([$calculationId, $selections, $regardingQuestionId, $regardingAnswerId])`
  in `self::$memoCalculate`.
- On first call, runs `ConfigboxRulesHelper::checkLicense('calculations')` and **loads the custom
  function file once** (§5.2): `getDirCustomization()/calculation_functions/calculation_functions.php`
  (`:130`).
- Loads the calc row from the product cache (`ConfigboxCacheHelper::getCalculation`) and switches
  on `type` → `calculateByMatrix` / `calculateByFormula` / `calculateByCode` (`:148`).

`$regardingQuestionId` / `$regardingAnswerId` carry the context of *what the calc is attached to*,
so terms like `QuestionProperty` with `questionId == 'regarding'` resolve to the host question, and
the question's `multiplicator` can be applied.

### 3.2 formula → `getFormulaResult()` (`:627`) → `getTermsCode()` (`:678`)
`getTermsCode()` walks the term array and builds an arithmetic **string**:
- **bracket** (no `type`) ⇒ recurse, wrapped in ` ( … ) `.
- **anything else** ⇒ flatten legacy `data`, then
  `ConfigboxCalcTerm::getTerm($type)->getTermResult($termData, $selections, $regardingQ, $regardingA, $allowNonNumeric)`
  and **append the returned value inline**: ` $result `.

The crucial difference from the rule engine: each term's `getTermResult` returns a **value** that
is concatenated into the code, and the **`Operator` term returns its raw `value`** (`+ - * / ( )`).
So the generated string for §1's example is literally:
```php
return  (  3.5  +  12  )  *  2 ;
```
which is then `@eval`'d (`getFormulaResult`, `:648`) inside a `ParseError` catch that logs the
offending code + JSON.

Term `getTermResult` semantics:
- **Number** — returns its literal `value` (cast to float unless non-numeric allowed). (`...Number.php`)
- **Operator** — returns its `value` verbatim → injected as a PHP operator. (`...Operator.php`)
- **QuestionProperty** — returns an attribute of a question (`price`, `priceRecurring`, `selected`
  comparable value, or any `getField()` path), with `'regarding'` resolving to the host question,
  and a `fallbackValue`/0 default. (`...QuestionProperty.php:30`)
- **Calculation** — recurses into `ConfigboxCalculation::calculate($value, …)`. (`...Calculation.php`)
- **CustomerGroup** — returns a field of the current customer group (`custom_1..4`), else 0.
- **Function** — see §3.5.

### 3.3 code → `calculateByCode()` (`:359`)
1. Strips newlines, pads with spaces.
2. Replaces placeholders ` A `…` D ` with the float of the selection of `question_id_a..d`.
3. String-replaces macros (each guarded by a `stristr` check):
   - `Total` / `TotalRecurring` — product price + sum of element prices (cached in `$GLOBALS`).
   - `QuestionSelection(<qid>)`, `QuestionPrice(<qid>)`, `QuestionPriceRecurring(<qid>)`,
     `QuestionProperty(...)`, `RegardingQuestion()` — resolved via `preg_match_all` + `str_replace`.
   - `Calculation(<calcId>)` — recurses into `calculate()`.
4. `$code = 'return ('.$rawCode.');'` → `@eval($code)` (`:571`).
5. If a host question is set, multiplies the result by the question's `multiplicator` (`:585`).

### 3.4 matrix → `calculateByMatrix()` (`:189`)
- Resolves `inputX` (column) and `inputY` (row): each is either a question selection or, if the
  input type is `calculation`, a recursive `calculate()` (with self-reference guards that throw,
  `:196`/`:224`).
- Lookup mode from `lookup_value`: `1` → next-higher (`>=`), `2` → next-lower (`<=`), else exact
  (`==`, with optional `round` snapping of the inputs, `:262`).
- Loads cells (`ConfigboxCacheHelper::getCalcMatrixData`), groups them `[$y][$x] = value`, sorts
  ascending (or descending for next-lower), and scans for the first row then cell satisfying the
  operator (`:290`). The matched `value` is the result (then subject to the matrix's configured
  multiplier). No `eval`.

### 3.5 The `Function` term (`ConfigboxCalcTermFunction`)
`getTermResult` (`...Function.php:33`):
1. For each parameter (itself a term-array), build its code with `getTermsCode()` and
   **`@eval('return '.$code.';')`** to get the argument value.
2. Verify the function exists (`function_exists`, or `method_exists` for `Class::method`).
3. `call_user_func_array($termData['name'], $parameters)` and return (coerced to float unless the
   function's parameter metadata allows non-numeric).

Built-in functions come from `getAllowedFunctions()` (`...Function.php:185`): `round`, `min`, `max`
— **and any returned by a global `getAdditionalCalcFunctions()`** (merged in at `:230`). That merge
is the function extension point (§5.2).

### 3.6 Caching & performance
- Per-request memoization at two levels: `calculate()` (`$memoCalculate`) and `getFormulaResult()`
  (`$memoGetFormulaResult`), both keyed on `serialize([...])` of inputs incl. the whole selections
  array.
- Cross-request: calc rows, matrix cells and per-product pricing are bulk-loaded via
  `ConfigboxCacheHelper` (APCu/file cache).
- **Hot path:** prices recompute on every selection change, and the rule engine's consistency loop
  re-prices repeatedly. `Calculation` terms and matrices recurse into `calculate()`; deep calc
  graphs multiply cost. Custom terms/functions must cache external lookups.

### 3.7 Security note
The calculation engine has a **larger `eval` surface than the rule engine**: term *results* and the
`Operator` *value* are concatenated into the evaluated string, the `code` type string-replaces
macros into an evaluated string, and the `Function` term evals each parameter. All of this input is
admin-authored (built in the editors), but it means a malformed/hostile calculation row is
arbitrary PHP. Any future hardening should replace these `eval`s with a real expression evaluator
(see the refactor plan).

### 3.8 Server-side only — how results reach the browser

Calculations are evaluated **exclusively on the server**. There is no client-side calc engine, no
formula JSON in the page, and no way for frontend JS to run a calculation directly: the browser
only ever sees **results**, delivered as the `pricing` payload of the `makeSelection` response
(and the initial page render). Every selection change round-trips to
`configuratorpage/makeSelection`, the server re-prices the whole configuration through
`ConfigboxCalculation::calculate()`, and the client's `cbPricingChange` handlers repaint totals and
per-answer prices from the returned numbers. So a custom term or function never needs a JS
counterpart — and, conversely, nothing you do client-side can influence a computed price. The full
round-trip is in `com_configbox_configurator_questions.md` §5–§6.

---

## 4. The term-type contract (`classes/ConfigboxCalcTerm.php`)

Every term type subclasses the abstract `ConfigboxCalcTerm`. Discovery and resolution mirror the
rule engine exactly:

- `getTerm($type)` (`ConfigboxCalcTerm.php:35`) resolves **`ConfigboxCalcTerm<Type>` first, then
  `CustomCalcTerm<Type>`**, caching singletons.
- `loadTermClasses()` (`:74`) `include_once`s every `*.php` in `classes/calc_term_types` **and** in
  `getDirCustomization().'/calc_term_types'`.
- `getTermTypeNames()` derives the type name by stripping the `ConfigboxCalcTerm`/`CustomCalcTerm`
  prefix (used to build editor tabs).

**Abstract methods:**
| Method | Purpose |
|--------|---------|
| `getTermResult($termData, $selections, $regardingQuestionId, $regardingAnswerId, $allowNonNumeric): mixed` | compute the term's value (number, or operator string). Concatenated into the eval'd formula. |
| `getTermsPanelHtml($calculationId, $productId): string` | the formula editor tab listing draggable term blueprints |
| `getTermHtml($termData, $forEditing = true): string` | markup for one term — **the data contract the JS serializer reads** |

**Overridable hooks:** `getTypeTitle()` (tab label), `containsQuestionId/AnswerId/CalculationId()`
(so referenced entities can't be deleted), `getCopiedTermData($termData, $copyIds)` (ID remap on
product copy).

---

## 5. Extending the calculation engine in the customization layer  ← the important part

There are **two** distinct extension mechanisms, both living under `data/customization/` (gitignored,
upgrade-safe). On Joomla `getDirCustomization()` = `…/components/com_configbox/data/customization`.

| Mechanism | Use when | Where |
|-----------|----------|-------|
| **Custom function** (easier, most common) | you want a new function usable inside *formula* calcs (e.g. `ceilTo`, a tier lookup, an external price) | `data/customization/calculation_functions/calculation_functions.php` |
| **Custom term type** (more powerful) | you want a whole new draggable term with its own editor panel and value semantics | `data/customization/calc_term_types/CustomCalcTerm<Type>.php` |

### 5.1 — Add a custom function (recommended for most needs)

Create `data/customization/calculation_functions/calculation_functions.php`. It must define a
global `getAdditionalCalcFunctions()` returning a metadata map, **and** define the actual callable
function(s). The metadata schema matches the built-ins (`...Function.php:187`):

```php
<?php
defined('CB_VALID_ENTRY') or die();

// 1) Describe the function(s) for the editor + evaluator
function getAdditionalCalcFunctions() {
    return array(
        // key = the actual PHP function name (or 'Class::method')
        'cb_ceil_to' => array(
            'title'                     => KText::_('Round up to step'),
            'parametersRequired'        => 2,
            'parametersOptional'        => 0,
            'parameterNames'            => array(KText::_('Value'), KText::_('Step')),
            'parametersAllowNonNumeric' => array(false, false),
        ),
    );
}

// 2) Implement the callable. Parameters arrive already evaluated (numbers here).
function cb_ceil_to($value, $step) {
    if ($step <= 0) return $value;
    return ceil($value / $step) * $step;
}
```

It then appears in the formula editor's **Functions** tab, can be dragged in with the right number
of parameter drop-zones, serializes as `{"type":"function","name":"cb_ceil_to","parameters":[[…],[…]]}`,
and at runtime each parameter is evaluated and passed to `cb_ceil_to(...)` via `call_user_func_array`.

Notes:
- The file is `include_once`d once on the first `calculate()` of the request (`calculation.php:130`),
  wrapped in a try/catch that logs failures.
- `name` may be `MyClass::myMethod` — the `Function` term detects `::` and uses `method_exists`.
- Parameters are **eval-evaluated** before the call; keep functions pure and fast (hot path).
- `parametersAllowNonNumeric` per index lets a parameter stay a string (e.g. a SKU) instead of
  being floated.

### 5.2 — Add a custom term type (full control)

Create `data/customization/calc_term_types/CustomCalcTerm<Type>.php` with class
`CustomCalcTerm<Type> extends ConfigboxCalcTerm`. `<Type>` becomes the JSON `type` and the editor
tab; **you cannot shadow a built-in** (`ConfigboxCalcTerm<Type>` wins in `getTerm()`), so use a new
name.

Implement the three abstract methods. The **HTML/data contract** the serializer (`calc-editor.js`)
relies on:
- Root: `<span class="item term <yourtype>" data-type="<Type>" data-…>`. Every `data-*` becomes a
  JSON key (kebab→camelCase).
- For user input, put `<input class="input" data-data-key="someKey">` as a child → lands in
  `$termData['someKey']` (numbers auto-normalized for the locale decimal symbol).
- Honor `$forEditing` (editable input vs read-only display).
- `getTermResult(...)` must return a **number** (or the engine floats it) that will be inlined into
  the formula's eval string.

Minimal example — a term returning live stock for a SKU typed into the term:
```php
<?php
defined('CB_VALID_ENTRY') or die();

class CustomCalcTermStock extends ConfigboxCalcTerm {

    function getTypeTitle() { return KText::_('Inventory'); }

    // The editor tab: one draggable blueprint
    function getTermsPanelHtml($calculationId, $productId) {
        return '<ul class="conditions-list"><li>'
             . $this->getTermHtml(array('type'=>'Stock', 'sku'=>'')) . '</li></ul>';
    }

    function getTermHtml($termData, $forEditing = true) {
        ob_start(); ?>
        <span class="item term stock" data-type="Stock" data-sku="<?php echo hsc($termData['sku']); ?>">
            <span class="term-name"><?php echo KText::_('Stock for'); ?></span>
            <?php if ($forEditing) { ?>
                <input class="input" data-data-key="sku" type="text" value="<?php echo hsc($termData['sku']); ?>" />
            <?php } else { ?>
                <span class="term-value"><?php echo hsc($termData['sku']); ?></span>
            <?php } ?>
        </span>
        <?php return ob_get_clean();
    }

    function getTermResult($termData, $selections, $regardingQuestionId = NULL, $regardingAnswerId = NULL, $allowNonNumeric = false) {
        $stock = MyInventory::getStock($termData['sku']);   // cache this — hot path
        return (float) $stock;
    }
}
```
Drop the file in, reload the formula editor, and an **"Inventory"** tab appears with a draggable
"Stock for [sku]" term; it serializes into `calculation_formulas.calc` and its numeric result is
inlined into the evaluated formula like any built-in term.

### 5.3 Choosing between the two
- Need a **value/operation inside arithmetic** (most cases) → a **custom function** is simplest and
  composes with operators and other terms.
- Need a **new first-class building block** with its own editor panel, picker UI, or data binding
  (e.g. "pick an external product", "pick a tier table") → a **custom term type**.

### 5.4 Gotchas
- A parse/fatal error in either customization file can break the admin (files are `include_once`d
  during `calculate()` / term loading) — develop defensively; failures in the function file are
  caught & logged, term files are not.
- **Performance:** everything here runs inside the per-selection pricing loop; memoize all I/O and
  external calls.
- **Numeric coercion:** results are `floatval`'d unless `$allowNonNumeric` is set; if you need a
  string to survive into a function call, declare it via `parametersAllowNonNumeric`.
- **`eval` reach:** because term results and operators are inlined into the eval'd string, never
  build a term/function that returns attacker-influenced raw expression text — return *values*, not
  code.
- This is the same "drop a prefixed class/file into `data/customization`" convention used by custom
  **rule conditions** (`rule_condition_types/CustomCondition<Type>`) and custom **question types** —
  consistent across the product.

---

## 6. End-to-end summary

1. **Author** a calculation in the admin (formula drag-and-drop, code expression, or matrix grid),
   scoped to a product; the parent `calculations` row + one typed child row are saved together.
2. **Assign** it to a question/answer price, recurring price, weight, or min/max bound via the
   `calcmodel*` fields.
3. At runtime, `ConfigboxPrices` calls `ConfigboxCalculation::calculate()`, which dispatches by type:
   **formula** renders terms → arithmetic string → `eval`; **code** substitutes placeholders/macros
   → `eval`; **matrix** does a 2-D lookup. Results are memoized and recurse for nested calcs.
4. `ConfigboxPrices` then applies **customer-group overrides, currency, and tax** to the raw number.
5. **Extend** the engine without touching shipped/encoded code by adding a custom **function**
   (`calculation_functions/calculation_functions.php` + `getAdditionalCalcFunctions()`) or a custom
   **term type** (`calc_term_types/CustomCalcTerm<Type>.php`) under `data/customization/`.
