Skip to main content
Version: 4.0 preview

The Price Calculation Engine, in Detail

Version
4.0 preview
Updated
View markdown

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.

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 (AD) 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.

TableHolds
#__configbox_calculationsparent: id, name, type (matrix/formula/code), product_id
#__configbox_calculation_formulasid (= calc id), calc = the formula as a JSON term tree
#__configbox_calculation_codesid, 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_matricesid, row/column input config (row_type/row_question_id/row_calc_id, same for column: column_type/column_question_id/column_calc_idmodels/calcmatrices.php:40-127), lookup_value, round, multiplier
#__configbox_calculation_matrices_datathe 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 itcontrollers/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:

[
[
{ "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:

{ "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.stringifys 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/.xlsxcontrollers/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)

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 typecalculateByMatrix / 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:

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 AD 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, maxand 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 evals 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_onces 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:

MethodPurpose
getTermResult($termData, $selections, $regardingQuestionId, $regardingAnswerId, $allowNonNumeric): mixedcompute the term's value (number, or operator string). Concatenated into the eval'd formula.
getTermsPanelHtml($calculationId, $productId): stringthe formula editor tab listing draggable term blueprints
getTermHtml($termData, $forEditing = true): stringmarkup 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.

MechanismUse whenWhere
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 semanticsdata/customization/calc_term_types/CustomCalcTerm<Type>.php

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
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_onced 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
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_onced 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/.