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-09-06

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 (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. QuestionProperty is the one term about a question and is generic over question types: what a question offers a formula is declared by the question's class (ConfigboxQuestion::getCalcFields(), §4.1). 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_id — models/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 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 (#__configbox_questions): calcmodel (price), calcmodel_recurring, calcmodel_weight, and calcmodel_id_min_val / calcmodel_id_max_val (calculated numeric validation bounds).
  • On an answer (#__configbox_answers): calcmodel, calcmodel_recurring, calcmodel_weight - the first two apply only while the answer's price_calculated / price_recurring_calculated switch is on (the pricing cache leaves the other set out).

At runtime ConfigboxPrices reads these (via the product cache) and calls the engine — e.g. getQuestionPrice() → ConfigboxCalculation::calculate($calcId, $questionId, NULL, $selections); answers go through getAnswerPrice(), weights through getQuestionWeight() / getAnswerWeight(). Every one of these helpers REQUIRES the selection set ($selections, keyed by question id, right after the ids) and hands it to the engine, so a price read while a hypothetical set is evaluated - a rule condition on a preview, Total or QuestionPrice(n) in a code expression - prices that set, not whatever the session holds; a forgotten set is an ArgumentCountError, not a wrong price. The live configuration's set is supplied by ConfigboxQuestion::getPrice() / ConfigboxAnswer::getPrice() (and the deprecated getElement* aliases), nowhere else. Because the encoded engine calls these helpers, a release that changes their signature ships a re-encoded engine with it. 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, "field": "price", "fallbackValue": "0" },
{ "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:{...}}) and named the question term's field fieldPath. Both the renderer and the evaluator flatten data on read (calculation.php:69 and :688), and migration 3.8.26 rewrote every stored formula to the flat shape with field (ConfigboxRulesJsonHelper::normalize(), helpers/rulesjson.php), the same vocabulary rules use. A question term carries questionId (an int, or "regarding" for the question the calculation is assigned to), field, fallbackValue, and answerId for a field declared once per answer.


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:85; CSS assets/css/editor-picker.css + calc-editor.css). A drag-and-drop surface built like the rule editor: a #terms drop area, draggable operators and a Number blueprint above a question-first picker. prepareQuestions() (view.html.php:129) lists the product's questions and asks the generic question term for each one's chips (ConfigboxCalcTermQuestionProperty::getQuestionTermsHtml(), grouped by the field groups the question's type declares), plus the "Regarding Question" chips (getRegardingTermsHtml(), the base fields every type has); prepareOtherTerms() (:179) lists the term types that are not about a question (Function, Calculation, CustomerGroup, then a site's own) under "Other terms", each with its panel (getTermsPanelHtml()). Clicking an entry on the left shows its group (calc-editor.js:326). Function terms have 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 (getItemMetadata(), :82), and JSON.stringifys the result (:19) into the form field. (Mechanically the same serializer as rule-editor.js, just emitting terms instead of conditions; no per-type code.)
  • 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)​

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:

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 a declared calc field of a question (ConfigboxQuestion::getCalcFields(), read through getFieldValue() from the selections the engine passes), with 'regarding' resolving to the host question and answer, and fallbackValue/0 when the field yields nothing (null from getFieldValue() means "nothing there"). (...QuestionProperty.php:41)
  • 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 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 (the entries under "Other terms" in the editor).

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 type's panel: a <ul class="cb-calc-terms-list"> of draggable term blueprints, shown when the type's entry under "Other terms" is clicked (an empty string means no entry)
getTermHtml($termData, $forEditing = true): stringmarkup for one term — the data contract the JS serializer reads

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

4.1 The QuestionProperty term (...CalcTermQuestionProperty.php)​

The one term about a question, generic over question types. Stored shape: {"type":"QuestionProperty","questionId":42,"field":"area","fallbackValue":"0"}; questionId may be "regarding" (the question, and answer, the calculation is assigned to, so one calculation serves many answers); answerId is present for a field declared once per answer ({…,"field":"quantity","answerId":57,…}). field names one of the question type's getCalcFields(): base selected (a question without answers only; an answer id is not a number), price, priceRecurring, weight, selectedAnswer.price / selectedAnswer.weight (with answers), the custom fields, plus whatever the type adds (area, pricedtotal, …). getTermResult() (:41) reads it with getFieldValue($key, $selection, $answerId, $selections) and returns the value, else fallbackValue, else 0. It has no panel: getQuestionTermsHtml() (:170) renders a question's chips grouped by field group for the editor's question list, getRegardingTermsHtml() (:203) the "Regarding Question" chips (the base fields, since the regarding question is only known when the calculation runs). remapReferences() (:331) re-aims questionId and answerId for product copy and transfer; "regarding" is not an id and stays. The old fieldPath key and the per-type sub-view views/admincalcformula_questionproperty/ are gone (migration 3.8.26); a term that still carries fieldPath or the data wrapper is normalized on read (ConfigboxCalcTermQuestionProperty::normalized()).


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

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

MechanismUse whenWhere
Calc fields on a question typea formula should read something off a question of a custom type (a composite's area, a per-answer quantity, a price total)data/customization/question_types/ConfigboxQuestion<Type>.php, getCalcFields() + getFieldValue() (§5.0)
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 about something that is not a question, with its own panel and value semanticsdata/customization/calc_term_types/CustomCalcTerm<Type>.php

5.0 A term about a question is a calc field on the question type​

A custom question type does not need a term class to be priced. It declares what a formula can read in getCalcFields() (a ConfigboxQuestionField per field, kind number), reads it in getFieldValue($key, $selection, $answerId, $selections), and the generic QuestionProperty term (§4.1) does the rest: the chips under the question in the editor, the evaluation with the fallback, the delete guards, the id remapping, and the per-question fields list the MCP tools hand to an assistant. A composite drops the base selected field (withoutField()) and declares its members; a per-answer figure is declared once per answer with answerId. Return null for "nothing there" so the term's fallback applies. The worked example (a quantities type pricing itself with a pricedtotal field) is in customization/com_configbox_custom_calc_term_types.md §5; the contract is in customization/com_configbox_custom_question_types.md, §2.6 "Rules and formulas — the type declares its fields".

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 under Functions in the formula editor's "Other terms", 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, for a source that is not a question)​

Create data/customization/calc_term_types/CustomCalcTerm<Type>.php with class CustomCalcTerm<Type> extends ConfigboxCalcTerm. <Type> becomes the JSON type, and the entry under "Other terms" shows getTypeTitle(); you cannot shadow a built-in (ConfigboxCalcTerm<Type> wins in getTerm()), so use a new name. A term that reads a question belongs in §5.0, not here.

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 panel under "Other terms": one draggable blueprint
function getTermsPanelHtml($calculationId, $productId) {
return '<ul class="cb-calc-terms-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="cb-calc-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="cb-calc-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" entry appears under "Other terms" 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 three​

  • Need a number off a question of your own type (a composite's member, a per-answer quantity, a price total) → a calc field on the question type; no term class.
  • 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 about something that is not a question, with its own 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. And the same split: a rule condition about a question is a rule field on the question type (getRuleFields()); a condition class is for other sources.

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: declare calc fields on a custom question type (question_types/ConfigboxQuestion<Type>.php, getCalcFields()), add a custom function (calculation_functions/calculation_functions.php + getAdditionalCalcFunctions()), or add a custom term type for a non-question source (calc_term_types/CustomCalcTerm<Type>.php), all under data/customization/.