The Price Calculation Engine, in Detail
- Version
- 3.x
- Updated
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 inmigration-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 (
A–D) and macros (Total,QuestionPrice(),Calculation(), …). Macros are string-replaced, theneval()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, andcalcmodel_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
datasub-key ({type, data:{...}}). Both the renderer and the evaluator flatten this on read (calculation.php:69and: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):
- Re-reads the saved
type. - Gets the matching sub-model (
getModelForCalcType). getDataFromRequest()→prepareForStorage()→validateData()→store()on the sub-model, forcing$data->idto the parent calc id (so the child shares the PK).- 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, JSassets/javascript/calc-editor.js, loaded viaconfigbox/calcEditor::initCalcEditorEach, seeview.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 viagetTermsPanelHtml()), a#termsdrop area, draggable operators, and function terms with nested parameter drop-zones. On save,calc-editor.js:getCalcItems()(:28) walks the DOM.itemspans, recursing into brackets and into each.parameterof a function, reads each span'sdata-*attributes +.input[data-data-key]values into an object, andJSON.stringifys the result (:19) into the form field. (Mechanically the same serializer asrule-editor.js, just emitting terms instead of conditions.) - Code editor (
views/admincalccode, JScalcCode.js). A textarea/code field for the expression plus four element pickers (A–D). Stored ascalculation_codes.code+question_id_a..d. - Matrix editor (
views/admincalcmatrix, JScalcmatrix.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)
ConfigboxCalculation::calculate($calculationId, $regardingQuestionId, $regardingAnswerId, $selections)
- Memoizes on
serialize([$calculationId, $selections, $regardingQuestionId, $regardingAnswerId])inself::$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 ontype→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, thenConfigboxCalcTerm::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
valueverbatim → injected as a PHP operator. (...Operator.php) - QuestionProperty — returns an attribute of a question (
price,priceRecurring,selectedcomparable value, or anygetField()path), with'regarding'resolving to the host question, and afallbackValue/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)
- Strips newlines, pads with spaces.
- Replaces placeholders
A…Dwith the float of the selection ofquestion_id_a..d. - String-replaces macros (each guarded by a
stristrcheck):Total/TotalRecurring— product price + sum of element prices (cached in$GLOBALS).QuestionSelection(<qid>),QuestionPrice(<qid>),QuestionPriceRecurring(<qid>),QuestionProperty(...),RegardingQuestion()— resolved viapreg_match_all+str_replace.Calculation(<calcId>)— recurses intocalculate().
$code = 'return ('.$rawCode.');'→@eval($code)(:571).- If a host question is set, multiplies the result by the question's
multiplicator(:585).
3.4 matrix → calculateByMatrix() (:189)
- Resolves
inputX(column) andinputY(row): each is either a question selection or, if the input type iscalculation, a recursivecalculate()(with self-reference guards that throw,:196/:224). - Lookup mode from
lookup_value:1→ next-higher (>=),2→ next-lower (<=), else exact (==, with optionalroundsnapping 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 matchedvalueis the result (then subject to the matrix's configured multiplier). Noeval.
3.5 The Function term (ConfigboxCalcTermFunction)
getTermResult (...Function.php:33):
- For each parameter (itself a term-array), build its code with
getTermsCode()and@eval('return '.$code.';')to get the argument value. - Verify the function exists (
function_exists, ormethod_existsforClass::method). 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) andgetFormulaResult()($memoGetFormulaResult), both keyed onserialize([...])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.
Calculationterms and matrices recurse intocalculate(); 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) resolvesConfigboxCalcTerm<Type>first, thenCustomCalcTerm<Type>, caching singletons.loadTermClasses()(:74)include_onces every*.phpinclasses/calc_term_typesand ingetDirCustomization().'/calc_term_types'.getTermTypeNames()derives the type name by stripping theConfigboxCalcTerm/CustomCalcTermprefix (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
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 firstcalculate()of the request (calculation.php:130), wrapped in a try/catch that logs failures. namemay beMyClass::myMethod— theFunctionterm detects::and usesmethod_exists.- Parameters are eval-evaluated before the call; keep functions pure and fast (hot path).
parametersAllowNonNumericper 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-…>. Everydata-*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 duringcalculate()/ 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$allowNonNumericis set; if you need a string to survive into a function call, declare it viaparametersAllowNonNumeric. evalreach: 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
- Author a calculation in the admin (formula drag-and-drop, code expression, or matrix grid),
scoped to a product; the parent
calculationsrow + one typed child row are saved together. - Assign it to a question/answer price, recurring price, weight, or min/max bound via the
calcmodel*fields. - At runtime,
ConfigboxPricescallsConfigboxCalculation::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. ConfigboxPricesthen applies customer-group overrides, currency, and tax to the raw number.- 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) underdata/customization/.