Skip to main content
Version: 4.0 preview

Custom Calc Term Types (for sources that are not a question)

Version
4.0 preview
Updated
View markdown

How to add your own calculation term type to CBX: a new kind of building block an admin can drop into a formula calculation, which the pricing engine evaluates at configure-time. This guide is for terms about something that is not a question (an inventory figure, an exchange rate, an external price). CBX ships the term types that are not about a question (number, operator, function, calculation, customer group) plus ONE generic question term that every question type feeds; this guide shows how to add a non-question type without touching core.

A custom question type that stores a composite value (a width/height/quantity triple, say) does NOT need a term type to be priced: it declares calc fields (§0), and the generic question term reads them. That used to be the main reason to write a term class; it no longer is.

Read com_configbox_customization_overview.md first, and technical/com_configbox_calculation_engine.md for how calculations are stored and evaluated. All paths are relative to the component root docroot/components/com_configbox/; getDirCustomization() is KenedoPlatform::p()->getDirCustomization(). Source references are point-in-time (component 3.8.26); verify against the code.


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

What a formula can read off a question is declared by the question's class: ConfigboxQuestion::getCalcFields() lists the fields a term can use as a number, and getFieldValue($key, $selection, $answerId, $selections) reads one from the GIVEN selection. The generic question term (ConfigboxCalcTermQuestionProperty) does the rest: the chips under the question in the formula editor, the evaluation with the term's fallback value, the delete guards, the id remapping on copy and transfer, and the per-question fields list in cbx_describe_calculations. Rules read getRuleFields() the same way, so one class declares what both editors offer.

Two things a type does with the base declaration:

  • withoutField($fields, 'selected') drops the base selected field when the type has no single entry (a composite). Add your own fields in its place; the base price and custom fields stay.
  • Per-answer fields are declared once per answer with answerId set; the stored term then carries answerId next to field, so copy and transfer remap it without knowing the type.

§5 shows the declaration on a real type. The full contract (the ConfigboxQuestionField kinds, getRuleFields(), "null means nothing there") is in com_configbox_custom_question_types.md, §2.6 "Rules and formulas — the type declares its fields".

If you already ship a term class for such a number​

It keeps working — discovered by the directory scan, evaluated for the formulas stored with its type, listed in the formula editor under its own heading. Retire it the way the rule-conditions playbook describes in its §0: declare the field on the type, rewrite the stored terms of your type into question terms with ConfigboxRulesJsonHelper::walk() from a customization update script, then delete the class. A term item reads {"type":"QuestionProperty","questionId":42,"field":"area","fallbackValue":"0"} — fallbackValue in place of a rule's operator/value; the formulas live in #__configbox_calculation_formulas.calc, so that is the column the script rewrites.

Write a term class only when the number comes from somewhere that is not a question.


1. How a term type resolves to your class​

Unlike question types, term classes are loaded eagerly. ConfigboxCalcTerm::loadTermClasses() (classes/ConfigboxCalcTerm.php:75) include_onces every .php file in core classes/calc_term_types/ and in getDirCustomization()/calc_term_types/. There is no autoloader involvement and no registration step: dropping the file in is the installation.

The type name is derived from the class name by stripping either accepted prefix (getTermTypeNames(), classes/ConfigboxCalcTerm.php:130):

$className = str_replace('ConfigboxCalcTerm', '', $className); // built-ins
$className = str_replace('CustomCalcTerm', '', $className); // yours

So both prefixes work, and the convention is:

classes/calc_term_types/ConfigboxCalcTermNumber.php ← shipped → type "Number"
getDirCustomization()/calc_term_types/CustomCalcTermStock.php ← yours → type "Stock"

That suffix is what gets stored as "type":"Stock" in the formula's JSON, so renaming the class breaks every calculation already using it. Treat the suffix as a published identifier. The type list is built from the FILE names in the two folders (getTermClassNames(), :101), so name the file after the class.


2. The contract: three abstract methods​

ConfigboxCalcTerm is abstract with exactly three methods to implement (classes/ConfigboxCalcTerm.php:169-186). Two of them are admin-side; only one runs on the storefront.

MethodRunsJob
getTermsPanelHtml($calculationId, $productId)Formula editorThe panel shown when your entry under "Other terms" (below the product's questions and the Regarding Question) is clicked: a <ul class="cb-calc-terms-list"> of <li> chips the admin drags into the formula
getTermHtml($termData, $forEditing = true)Formula editorRender one term as a chip: span.item.term with data-type and your data-* keys, a cb-calc-term-name span, and an <input class="input" data-data-key="…"> for anything the admin types (a read-only cb-calc-term-value span when $forEditing is false)
getTermResult($termData, $selections, $regardingQuestionId = null, $regardingAnswerId = null, $allowNonNumeric = false)Every price calculationThe value the term contributes

$termData is your own shape: every data-* attribute of the chip (kebab-case to camelCase) plus every .input value under its data-data-key, exactly as calc-editor.js harvests them; the editor has no per-type code. Keep it small and stable; it is persisted. Optional hooks: getTypeTitle() (your entry's text), containsQuestionId/AnswerId/CalculationId() (delete guards, if you hold ids) and getCopiedTermData() (id remapping on product copy; the transfer import remaps the keys questionId, answerId and calcId of every type generically).


3. getTermResult() runs a lot​

A formula is evaluated on every selection change, for every position in the cart, and again on cart and checkout pages. getTermResult() therefore sits in the hottest path CBX has.

  • No queries per call where a cached read will do. Question data is already cached; reach for ConfigboxQuestion::getQuestion() rather than SQL.
  • Return a number. Unless $allowNonNumeric is true, the engine is composing an arithmetic expression; returning a string or null propagates into the whole formula.
  • $selections is the configuration the engine is evaluating, which may be a simulated one. Read from it rather than re-fetching state.

4. The editor methods have a failure mode worth knowing​

getTermHtml() renders terms an admin saved earlier, including terms that point at a question which has since been deleted. If your implementation resolves a question id without checking, the lookup throws, and the whole formula editor fails to render: the admin cannot open the calculation, and cannot delete the stale term either, because they cannot reach it.

Guard it:

if (!ConfigboxQuestion::questionExists($questionId)) {
return '<span class="item term broken">' . KText::_('Deleted question') . '</span>';
}

ConfigboxQuestion::questionExists() (classes/ConfigboxQuestion.php:131) exists for exactly this. A placeholder keeps the editor usable and lets the admin remove the term. (The generic question term does the same: a deleted question renders as [deleted question #id].)


5. Worked shape: pricing a composite question (a field, not a term class)​

The quantities type (quantityanswers: one quantity per answer, selection {"57":"3","58":"1"}) prices itself with two calc fields. There is no term class; this is the whole pricing path:

class ConfigboxQuestionQuantityanswers extends ConfigboxQuestion {

/** Formulas get the totals on top of the quantity fields. The base `selected` goes: a
* quantity map is not a number. */
public function getCalcFields() {

$fields = array_merge($this->getQuantityFields(), $this->withoutField(parent::getCalcFields(), 'selected'));
$pricing = KText::_('Related to pricing'); // the group the editor lists them under

$fields[] = new ConfigboxQuestionField('pricedtotal', ConfigboxQuestionField::compose(KText::_('%s of %s'), KText::_('Price × quantity total')), ConfigboxQuestionField::KIND_NUMBER, array(
'group' => $pricing,
'description' => 'Sum over the answers of price × quantity', // for the API / AI
));
$fields[] = new ConfigboxQuestionField('weighttotal', ConfigboxQuestionField::compose(KText::_('%s of %s'), KText::_('Weight × quantity total')), ConfigboxQuestionField::KIND_NUMBER, array(
'group' => $pricing,
'description' => 'Sum over the answers of weight × quantity',
));

return $fields;
}

public function getFieldValue($key, $selection, $answerId = null, $selections = null) {

if ($key === 'pricedtotal' || $key === 'weighttotal') {

if ($selection === null || $selection === '' || empty($this->answers)) {
return null; // nothing entered: the term uses its fallback
}

$column = ($key === 'pricedtotal') ? 'price' : 'weight';
$sum = 0.0;

foreach ($this->answers as $id => $answer) {
$qty = self::getSubValue($selection, (string) $id); // the type's own decoding
if ($qty === null || $qty <= 0) {
continue;
}
$sum += (isset($answer->$column) ? floatval($answer->$column) : 0.0) * $qty;
}

return $sum;
}

return parent::getFieldValue($key, $selection, $answerId, $selections);
}

}

The admin assigns a formula [Price × quantity total of Accessories] to the question's calcmodel and the store prices it on every path (display, cart, order freeze). The stored term is {"type":"QuestionProperty","questionId":42,"field":"pricedtotal","fallbackValue":"0"}, the same shape as "Price of …". The same class declares quantity once per answer ('answerId' => $answerId in its getQuantityFields()), which is how a formula reads one answer's quantity: {…,"field":"quantity","answerId":57,…}.

The point is getFieldValue(): the selection is JSON, so nothing generic could read a number out of it. Decoding it is the type's job, and declaring the field is what puts the result in the editor, in the API and in front of an assistant.


6. Deployment checklist​

  1. Check the number does not come from a question. If it does, declare a calc field (§0, §5); no term class.
  2. getDirCustomization()/calc_term_types/CustomCalcTerm<Type>.php: implement all three methods.
  3. Clear the CBX cache; on some platforms CBX keeps its own file cache the host's cache-clear does not touch.
  4. Open a formula calculation in the admin: your entry should be listed under "Other terms", after the product's questions and the Regarding Question. If it is absent, the file is not in the folder the platform resolves (§1).
  5. Insert the term, save, and check a price on the storefront. getTermsPanelHtml() working does not prove getTermResult() does.

7. Gotchas​

  • A term about a question is a calc field, not a class (§0). The old per-type term classes (a CustomCalcTermDimensions reading a subValue) are gone; the stored terms were rewritten to QuestionProperty with a field.
  • The class-name suffix is a stored identifier. Renaming the class orphans every formula using it (§1).
  • Both prefixes are stripped, so ConfigboxCalcTermFoo and CustomCalcTermFoo both yield type Foo: do not ship both.
  • Guard question lookups in getTermHtml() or a deleted question takes the formula editor down with it (§4).
  • Loading is eager and unconditional: a parse error in your file breaks calculation loading everywhere, not just where the term is used.
  • getTermResult() is hot; see §3.

See also​

  • com_configbox_custom_question_types.md: §2.6 "Rules and formulas — the type declares its fields", the field declaration a composite value is priced through
  • classes/calc_term_types/ConfigboxCalcTermQuestionProperty.php: the generic question term that reads those fields; …Calculation.php and …CustomerGroup.php: the shipped non-question types to copy patterns from
  • com_configbox_customization_overview.md: the layer, and where it lives per platform
  • technical/com_configbox_calculation_engine.md: storage, the three calculation types, evaluation
  • com_configbox_custom_rule_conditions.md: the same split for the rule engine