Skip to main content
The stralos-domain pricing module is the single authoritative implementation of work-item cost and price totals. Both the estimate service (write path) and the frontend (live preview) import from it; no money arithmetic happens outside these helpers.

Purpose

Extracting pricing into a shared module means the server and client always produce identical totals for the same input data. The module is a faithful extraction of the existing usePricingControls behaviour in the estimate feature — it replicates, not redesigns.

Money arithmetic

All money is computed in scaled integers (bigint), never floating-point. The approach eliminates float rounding errors such as Math.round(1.005 * 100) === 100. Every numeric input is:
  1. Converted to an integer at its declared decimal scale by string parsing.
  2. Multiplied or divided using BigInt — intermediate products can exceed 2^53.
  3. Rounded once (half-up) at the point the client rounds, producing exact integer cents.
Declared input scales: resourceCount and activityCount are parsed at their natural decimal scale (toScaledExact) rather than a fixed 3 dp, so fractional resource equivalents (for example 2.5 machines) are preserved exactly through to the cents boundary. Their defaults differ:
  • resourceCount: absent or non-finite → 1; zero or negative → 0 (getResourceCount, see Component cost).
  • activityCount: absent or degenerate (zero, negative, NaN, ±Infinity) → treated as absent, so the per-count quantity is not multiplied (activityCountScaled).
All computed money values are integer cents. The maximum representable value is 10^12 cents (numeric(14,2)); values exceeding this throw rather than silently lose precision.

Percent and fraction convention

Markup values travel as decimal fractions throughout the domain — 0.125 means 12.5%, not 12.5. The API and DB store whole-percent values (e.g. 12.5); the estimate service converts once at its boundary. Margin-on-sell inputs (marginPercent, defaultMarginPercent) are the exception inside the domain contract. They are whole percents (19.9 means 19.9%) and are not divided by 100 before the pricing functions receive them. Float noise in derived fractions. When the estimate service computes markupFraction = percent / 100, binary float division can produce values like 0.006999999999999999 for 0.7 / 100. The pricing module accepts this: it parses the float at its exact natural scale then rescales half-up to five decimal places before the integer multiplication. Strict scale validation of the user-entered percentage belongs at the API boundary, not inside applyMarkupCents.

Snapshot contract

The pricing functions price only from persisted estimate data. Several inputs (final rate, density, and material pricing unit) were previously read live from the rates service at render time. Those values must be materialised onto component or pricing-override rows at edit time. A legacy component row without its materialised fields prices at base cost only. Only the frontend replays the locationMaterialisationContract.json fixture now. stralos-feature-estimate runs each case through resolveMaterialisedFields (src/locationMaterialisationContract.test.ts) and pins the fixture’s content hash, so the materialised fields (the unit cost and the cube-to-tonne material conversion) cannot drift without a test failing. It began as a cross-repository parity fixture, but stralos-api-estimate has deleted its mirror (materialiseOverrideFields) and its copy of the fixture, and resolver-parity.yml no longer compares location materialisation. The parity script’s other contract, rateAuthorityContract.json, does not carry the location materialisation cases. The fixture pins the cases it lists; it does not claim to cover every heuristic.

Calculator quantities and pricing

Current calculator configurations contain geometry and physical material snapshots only. Their engine rows expose named quantities such as in-situ volume, loose volume, tonnes, area, and length. Project and library assignment registers bind one of those outputs to a destination line, where the rate item, option, unit cost, rate group, and margin are stored. This separation keeps calculator recomputation from replacing an estimator’s pricing choice. Persisted component and pricing-override fields remain the authoritative money inputs described below. The calculator no longer chooses or locks a rate option on generated lines. Automatic price selection picks the cheapest eligible option at first resolve, but the estimator owns the choice: any pinned option on a calculator-generated line is respected and can be changed through the pricing panel. The durable-pin guard that previously refused a different option on these lines now applies only to fixed-charge companion lines, whose pin is required by a database constraint.

Labour multiplier resolution

The labour multiplier is applied to a component’s base cost when the component is classified as labour. The shared domain predicate isLabourComponent resolves the classification in two steps:
  1. Recognised rateGroup (authoritative) — a rateGroup of labour applies the multiplier; any of the other five recognised tokens (plant_vehicles, quarry_materials, other_materials, subcontractors, disposal) suppresses it.
  2. category (legacy fallback) — if rateGroup is null, absent, or unrecognised, the predicate sniffs the free-text category field using the existing keyword heuristic. Rows written before rateGroup was introduced therefore price identically to before.
A rateGroup token that is not one of the six accepted values is treated as null at the pricing-input boundary, so the fallback sniff applies. This guards against an out-of-band value reaching the runtime without silently suppressing a legitimate multiplier.

Component cost

computeComponentEffectiveCost(component, override, context) returns integer cents for a single component. Base cost is its only term:
Every factor is carried as an exact scaled integer and there is one rounding, half-up, at the cents boundary — the product is never rounded in stages. The labour multiplier is resolved as described above; all non-labour components use 1. context is retained in the signature even though nothing currently reads it, so the shape and every call site stay stable. The quantity passed to this function must already be flattened (multiplied by workItem.quantity). computeWorkItemTotals does this flattening internally. Resource count minimum. getResourceCount resolves a stored resourceCount to max(0, resourceCount) — not max(1, ...). A fractional count such as 0.2 (one-fifth of a crew) is preserved as-is in pricing. An absent or non-finite count defaults to 1. Both the frontend preview (src/estimateShared.tsx) and the shared @stralos-co-nz/pricing package apply the same rule, so the browser preview and the server recompute agree for fractional inputs.
The chargeQuantity divisor is gone, and so is the materialPricingUnit / density tonne conversion that used to be documented here. Both belonged to the retired rate-offering model. A rate option is priced in one unit, and conversion into a consuming line’s explicit target unit uses the option’s own conversionFactor where the option is normalised — not inside the component cost. A reader implementing the old formula would divide by a field no row carries.

Tonne-gate drift diagnostics

The vitest suite src/tonneGateDiagnostic.test.ts checks the frontend and stored tonne-ordering predicates in scripts/tonne-gate.mjs against their expected cases. scripts/parity-check.mjs scans eligible pricing rows, compares the two decisions, and fails when drift is found. The scan uses the selected price, excludes mirror rows, and skips rows whose pricing inputs are ambiguous rather than guessing. Run these diagnostics before changing tonne-ordering eligibility or migrating stored pricing inputs. Keep the frontend and stored predicates aligned until rate items carry the explicit material-pricing fields needed to replace the current heuristic safely. Component exclusion rule. Two and only two shapes are excluded from the internal build-up and from pricedComponentCount:
  • §6.3 subcontract mirror — a component with subcontractBidRateId set. Mirrors are display metadata; the bid is priced through its own channel.
  • Activity container — a component where type === "activity" and rateItemId is absent or null. A container is priced through its children, never on its own unitCost.
Every other row contributes its own unitCost, including a manually entered rate row (type: "rate_item", pricing_source: "manual_override"). The earlier rateItemId == null check caught manually entered rows as well as containers and caused them to contribute $0. The exported predicate isActivityContainerComponent({ type, rateItemId }) encodes the container half of this rule. Use it rather than reimplementing the check, so all callers share one definition. The two exclusions operate at different points in computeWorkItemTotals. A mirror is filtered before the component-cost fold — it contributes nothing at all. A container is filtered after the component-cost fold — it prices through its children only. Folding the two into a single predicate at either site would misplace one of them and move money. Caller responsibility — PricingComponentInput.type. PricingComponentInput now includes an optional type?: string | null field. The domain cannot otherwise distinguish a rate-less container from a rate-less manually entered row. Absent means “not a container”, so the row prices — the safe default for a row a person entered. A caller that omits type on a genuine activity container turns that container into a priced row worth its own unitCost. Both production callers (api-estimate recompute.toPricingComponentInput and feature-estimate toDomainComponentInput) thread this field.

Activity instance pricing

@stralos-co-nz/domain 0.13.0 added instance-derived pricing for components that belong to a promoted activity instance.

Instance mode and validation

A component is an instance container when both allocatedQuantity and productionRate are non-null. The exported isInstanceContainerComponent(component) predicate checks the presence of this pair, not whether its values are valid. This distinction is deliberate. A non-null pair containing zero, a negative number, or a non-finite number remains an instance and fails loudly during pricing; it is not silently reclassified as a legacy container. Call validateInstancePair(allocatedQuantity, productionRate) before persistence to validate the pair and derive its scaled base duration:
Validation rejects:
  • an operand that is not a positive finite number;
  • a derived duration that rounds to zero at DURATION_SCALE (six decimal places); and
  • a derived duration above the representable 10^10 base-unit bound.
Operand size alone is not a rejection cause. The duration uses the raw floating-point quotient and only the result is bounded and rounded half-up:
A container with either field absent or null remains on the legacy pricing path.

Instance child quantity

A component is an instance child when its parentComponentId points to an instance-mode container. Its quantityBasis selects how the stored quantity is charged: An unknown token narrows to null, retaining the legacy basis. In that legacy arm, the stored quantity is per base unit of the activity (for example an hour, day, week, or month):
None of these arms multiplies by workItem.quantity; the container’s allocation already records how much of the work item the instance handles. resourceCount still applies downstream, and stored component-quantity overrides do not replace an instance child’s resolved quantity. durationDays remains the container’s calendar duration and is not multiplied by the count. A line with no instance parent, or whose parent is a legacy container, ignores quantityBasis and follows the normal non-instance rules, including quantity overrides and quantityIsTotal where applicable. If an instance child carries quantityIsTotal: true, that flag takes precedence over quantityBasis and all instance-child arms. The row’s stored quantity is used once without any duration or count multiplication. See Standalone total-quantity rows for full rules and the defect history. activityCount is an optional positive tally on the instance container, such as the number of manholes produced. Its only pricing consumer is a direct child using "per_count". A missing or degenerate count (zero, negative, non-finite) is treated as absent, so the multiplier is 1. Conversion and bounds checking occur only when a "per_count" child consumes the value.

Shared quantity basis

resolveComponentQuantityBasis(components, workItem, pricingRow, settings) returns a Map<string, ComponentQuantityBasis> keyed by component ID. Each entry contains:
  • quantityScaled — the exact scaled-integer quantity used for pricing; and
  • durationDays — the calendar duration the line carries, when it is duration-driven. It is a display and scheduling output; nothing in this module prices from it.
computeWorkItemTotals consumes this resolver for both legacy and instance lines. Frontend pricing previews should use the same resolver rather than reimplementing the flattening rules.

Top-level whole-item charge

On the non-instance path, a parentless non-container row is charged once for the whole work item by default. The estimate service maps this to quantityIsTotal: true in the domain pricing input via isWholeItemPricedRow. The entered quantity is the job total and is never multiplied by workItem.quantity. The opt-out is quantityBasis: 'per_unit' on the row. A row with this value treats its quantity as a per-work-item-unit figure expanded by workItem.quantity. Two row shapes are excluded from the whole-item default even when quantityBasis is null:
  • Recipe-derived rows — a parentless row that carries both rateItemId and activityId is a copied recipe child whose quantity is already per-unit-scaled; flipping it would under-price by the recipe multiplier.
  • Container rows (type === "activity" and rateItemId == null) — containers price through their children and contribute no cost themselves.
A third exclusion, duration-derived rows (an hour or day line whose quantity came from the work item’s units/day), went with estimate_work_items.productivity on 2026-09-18: a work item carries no production rate, so no row’s quantity derives from one. isWholeItemPricedRow and wholeItemBasisContext in pricing/wholeItemBasis.ts are the single source of truth for this decision. Both the authoritative recompute and the counterfactual measured-quantity pass (re-resolve and rate exceptions) read from that module so neither can drift.

Standalone total-quantity rows

A component with quantityIsTotal: true carries a quantity that is already at whole-work-item altitude. A quantity override is checked first. Otherwise the stored quantity is used once and is never multiplied by workItem.quantity, an instance container duration, or activityCount. The quantityIsTotal check runs before the instance-child arms. This matters for frozen pricing selections, which can be instance children while already carrying a measured total. The row still retains the container calendar duration for display and scheduling. A top-level row with quantityBasis: "per_unit" opts out of the whole-item default and is expanded by workItem.quantity. Hours and days on a total-quantity row remain absolute entered quantities; production-driven duration belongs inside an activity. The optional field defaults to false for legacy rows.

Duration summary

instanceDurationSummary(components, hoursPerDay) returns the per-instance calendar durations, grouped crew chains, and item duration:
Chain labels are trimmed and grouped case-insensitively; the first-seen capitalisation is retained for display. Instances with the same non-empty label run sequentially, so their days are summed. Each unlabelled instance forms its own chain. The item’s duration is the maximum chain duration because different chains run concurrently. For an hour-based activity (hr or hour), the summary converts base hours to calendar days:
effectiveHoursPerDay resolves from the container override, then the estimate setting supplied to the summary, then the default of 8. Calendar days are rounded to DURATION_SCALE and are used for display only; pricing continues to use the base hours.

Commercial rules (REMOVED)

The three commercial-rule fields — minChargeDuration, roundingIncrement, and mobilisationAmount — were removed as a feature on 2026-09-20 (api-estimate migration 0060). They no longer exist on PricingComponentInput or on stored work item component rows. validateInstanceCommercialRules is retired; only validateInstancePair is called on the write path. The charged duration is now always the raw allocatedQuantity ÷ productionRate quotient — no minimum-charge floor and no rounding increment are applied. There is no mobilisation amount. InstanceDurationInstance.durationDays is the calendar duration derived from the raw quotient. calculatedDurationDays was removed alongside the commercial rules.

Instance pricing fields

Duration-driven quantities

Duration-driven pricing was introduced in @stralos-co-nz/domain 0.7.0 and remains part of the current contract. Productivity is an input on the work item:
workItemDurationDays returns null when either input is non-finite, quantity is negative, or unitsPerDay is missing, zero, or negative. A zero work-item quantity is valid and produces a zero-day duration. The result is not rounded; presentation code decides how to display it. Only genuine hour- and day-duration units are derived. Units containing / or the word per are rate units and classify as other before the module tests for hr / hour and then day. Other units keep their entered quantity. For a duration-driven component, the pricing path substitutes a derived per-work-item-unit, per-resource quantity for the entered component quantity: effectiveHoursPerDay resolves from the component override, then the estimate default, then 8. The derived per-unit quantity is consumed live by pricing; it is not persisted into component.quantity. Existing downstream multiplication by work-item quantity and resource count reconstructs the whole-job charge without applying either factor twice. For display and programme calculations, the corresponding whole-job quantities are durationDays × effectiveHoursPerDay for hour lines and durationDays for day lines, both per resource. These whole-job values must not be fed back into pricing. Rows marked quantityFromRecipe retain their authoritative generated quantity instead of being duration-derived. Current producers include activity recipes. A manual component-quantity override supersedes duration derivation for that line. The effective display and domain calculation omit the derived duration; there is no persisted durationDays field in the quantity override itself.

Derived quantity scale boundary

A duration-driven row or activity-instance child carries a derived quantity with no natural decimal scale of its own. The domain resolver is strict: it rejects any quantity that exceeds 3 decimal places. A derived quantity can have up to 12 decimal places (the domain’s internal cap), so getPricingQuantity in the estimate frontend snaps the ordered quantity to 3dp (half-up) before passing it to the resolver. This snap mirrors the one api-estimate’s recompute.ts applies at its own pricing boundary (quantityScale.ts, audit F2), so both sides produce the same resolver input. Without this snap, a derived quantity such as 0.21323988 (eight decimal places) caused the resolver to throw, and the error boundary took down the entire pricing panel — all components on all rows — not just the affected row.

Build-up display groups

BUILD_UP_GROUPS defines the shared display order for the nine estimate build-up sub-headings: The six rate-group headings resolve through the organisation’s rate_group_labels setting list. A missing, empty, or failed load falls back to the domain default; activity and other remain structural defaults. Labels are display-only and never enter grouping, totals, or persistence. classifyBuildUpGroup(component) classifies a component into one of these nine keys. It follows an enum-first, legacy-fallthrough precedence:
  1. type === "activity" wins unconditionally — the component is a container.
  2. type === "day_rate" wins unconditionally — the component is a day-rate line and files under day_rates, regardless of its rateGroup or category.
  3. A rateGroup that is one of the six rate-group wire tokens maps 1:1 to its heading.
  4. Anything else (NULL, absent, or an unrecognised value) runs the legacy substring sniff over category, name, and unit fields.
The legacy sniff preserves the classification every pre-PRD row already had. The only visible change for NULL-group rows is that the former single “material” heading splits into quarry_materials (when the “quarry” term appears) and other_materials (everything else in the material vocabulary). The estimate work-items UI iterates BUILD_UP_GROUPS from stralos-domain and resolves each rate-group label for the current organisation. Exports retain the wire tokens. Consumers must not hard-code the group list or order locally. The three structural groups that are not rate groups have fixed headings in STRUCTURAL_BUILD_UP_GROUP_LABELS (exported from @stralos-co-nz/base): activity → "Activities", day_rates → "Day rates", other → "Other". These headings are not customisable and do not come from the organisation’s rate_group_labels setting. Every surface that renders build-up headings must spread this map alongside the rate-group labels rather than hard-coding the strings.

Labour predicate

isLabourComponent(component) is the single shared predicate that determines whether a component carries the labour multiplier. It uses the same enum-first precedence as the classifier:
  • type === "day_rate" → never labour, regardless of rateGroup or category. A day-rate line is a SELL rate that already includes overheads and margin, so the labour multiplier cannot reach it.
  • An authoritative rateGroup of "labour" → labour (multiplier applies).
  • Any other recognised rate-group token → not labour (the group overrides the free text).
  • NULL, absent, or an unrecognised group → falls through to the legacy category contains "labour" check.
This equivalence invariant (§7.1) means no pre-PRD row’s money changes at cutover. getLabourMultiplier in the pricing module consumes isLabourComponent; the four estimate-UI call sites adopt it in M6.

Work-item totals

computeWorkItemTotals(workItem, components, pricingRow, bidRates, settings) returns:
Per-kind formula (§5.4): Day-rate markup split. For measured, provisional_quantity, and lump_sum items, the markup or margin applies only to the cost that is not day-rate lines. The day-rate cost is added after it, unmarked, because a day rate already includes overheads and margin:
For example, $1,000 in marked-up cost at 15% plus $2,000 of day-rate cost gives $1,150 + $2,000 = $3,150. With no day-rate lines, dayRateCostCents is zero and every existing item prices exactly as before. When a subcontract bid replaces the build-up, dayRateCostCents is zero — the bid is one cost line marked up as a whole. Margin and markup are alternative bases. For measured, provisional_quantity, and lump_sum items, the engine resolves marginPercent via workItem.marginPercent ?? settings.defaultMarginPercent ?? null. When that resolves to a non-null value, computeMarginSell is called on the non-day-rate portion and markup is not applied. When both rungs are absent or null, the markup arm runs byte-for-byte as before — the field is inert for every estimate that predates the pricing ladder. Only provisional_sum items take neither basis: cost = price = allowance, margin-neutral by definition.

Estimate API component margin ladder

The Estimate API resolves margin separately for each component using src/pricing/marginLadder.ts. The order is component marginPctOverride, category, rate group, tender defaultMarginPct, then frozen organisation default. At a category or group, a tender row replaces the organisation row at that same scope. A frozen category therefore outranks a tender group or tender default. The first non-null value wins. Explicit 0% means sell at cost; when nothing resolves, the component prices at cost and its applied margin/source remain null. Published package subtrees and provisional_sum items bypass the ladder. The API sums each component’s margin sell. For example, $100 cost at 10% margin and $100 at 20% margin yield $111.11 + $125.00 = $236.11. This is separate from the flat domain input described below. pricingBasis is server-owned and frozen on estimate creation. Organisation settings, group margins, and category margins are fetched only during creation, with a five-second timeout per request. Unavailable or invalid organisation settings fall back to markup without a ladder. Failed group/category fetches retain the margin basis and freeze the available rungs. Recompute reads the estimate’s stored ladder without calling Global. The margin-ladder API edits tender group/category rungs under the estimate-totals lock and recomputes before returning. The tender default uses PUT /estimates/:id; component overrides use marginPctOverride. There is no work-item rung: estimate_work_items.markup_pct was removed on 2026-09-18, so the fourth rung is the tender default alone on both bases.

Category stamp on rate lines

The category rung resolves against work_item_components.category_id. The server stamps this column from the rates service — never from the client — on two occasions:
  • New rate line: any component write that names a rateItemId and carries no categoryId (quick-add, build-up add, activity child, relink) stamps the category before the write commits. The lookup is made to api-rates using the work item’s project scope so project-only rate items resolve correctly. The stamp runs outside the write transaction; only the UPDATE runs under the estimate lock.
  • Ladder mutation: a tender rung PUT or DELETE heals every rate line in the estimate that still carries NULL in category_id, then recomputes. This ensures estimates built before the stamp was introduced price the category rung correctly the first time a rung is set or reset.
A category a client sent is never overwritten, and a row that already carries a category is never restamped. The column is a frozen snapshot: a category retired or renamed in the rates library must not shift a submitted tender. The stamp is fail-soft. If api-rates is unavailable or answers a shape the service cannot parse, the row keeps NULL and the next rung (rate group) applies. Refusing the write in that case would block estimating because a margin classification was unavailable. Component override validation (marginPctOverride). The body schema on the four write endpoints that carry this field (POST /work-item-components, PUT /work-item-components/:id, POST /work-item-components/:id/children, and POST /work-items/:id/time-resource-activity) accepts the union of the two basis bands (−100 to 999.999) because the schema is parsed before the estimate is read. The actual basis-band check runs inside the write transaction using assertComponentOverrideInBasisBand, which reads the estimate’s frozen pricing_basis and returns a 400 if the value is outside that band. The valid bands are defined in LADDER_BANDS (margin: 0–95; markup: −100–999.999) and are the single authoritative definition shared by the schema, the tender-rung routes, and the DB CHECK. Resolve-time drop. resolveComponentMargin drops a stored override that is outside the band for the estimate’s basis rather than pricing it. This mirrors the behaviour of organisationLadderRows on the other four rungs and makes an out-of-band override impossible by construction regardless of how the row arrived — including via copy-build-up from a markup tender into a margin one. A dropped override falls through to the next rung; it does not cause a recompute error.

Shared Costs and sell

Recompute sums the Shared Costs pool’s cost and already-calculated sell independently, then apportions each total in cents using largest remainder. Both allocations use eligible recipients’ non-fixed cost. Fixed package portions and provisional sums contribute no eligible base, and only top-level recipient rows receive shares. When there is no positive eligible base, the pool remains an ordinary section. The pool retains its own commercial result; the recipient’s margin is not reapplied to its allocated share. A zero-margin pool need not increase the tender price.

Frontend pricing boundaries

Tender Submission → Margins saves the field for the estimate’s frozen basis. The legacy estimate Settings percentage and work-item inline percentage still write markup fields even when labelled for margin. The View rate-margin dialog uses local preview state and does not persist component overrides. The priced schedule’s money uses server totals. Its percentage summary uses component calculations without allocated spread shares and receives the live organisation default as a fallback for the default-percentage rung. That preview is not proof that the server’s frozen ladder or saved totals changed.

Pricing basis wiring (stralos-feature-estimate)

EstimatePage publishes the estimate’s pricing basis through PricingBasisContext. Every panel that names a percentage reads the basis from that context via usePricingBasis. The basis is resolved by normalisePricingBasis(estimate?.pricingBasis). An absent or unrecognised token reads as 'markup' — the constant a stored percentage has always meant, and the only value that cannot change under a live tender. The pre-load window (while estimate is null) therefore also presents 'markup', which is unobservable because the page’s loading gate returns an empty state above every reader of the basis. useOrganisationPricingBasis returns only the organisation’s default percentage (organisationDefault: number | null). It no longer returns a basis token. Reading the organisation’s basis to decide how an estimate’s numbers are labelled was a bug: a stored 20.000 is $120,000 on $100,000 of cost as markup and $125,000 as margin, so an organisation switching basis must not silently reinterpret a tender already quoted. api-estimate enforces the same invariant from the server side: pricingBasis is refused on the estimate PUT with pricing_basis_server_owned. The PricingBasisProvider wraps the estimate workspace and is the only permitted source of the basis word for any child component. Do not pass the organisation’s basis into this context or use useOrganisationPricingBasis to derive it.

Margin input fields

Two optional fields on WorkItemTotalsInput and PricingSettings carry the margin basis: These fields are additive and optional. Callers that supply neither field continue through the existing markup calculation unchanged. Package sell and allowance kinds. A frozen package sell (a subcontractor package container’s packageSellUnitPrice) is added to the total only for measured, provisional_quantity, and lump_sum items, where the price derives from the internal build-up. For provisional_sum items, the package sell is excluded from the price calculation because those items are margin-neutral and priced by their allowance alone. A package container on a provisional sum is informational only and does not add to the price.

Pair-aware frozen conversion factors

The rates library stamps bulk-material density into conversionFactor on every rate option, including options priced in the same unit the estimate row is measured in. Applying that factor without considering the unit pair would overcharge: a row measured in m³ and priced in m³ at $28/m³ with a stored density of 1.72 t/m³ would be priced as 1.72 m³, producing $48.16 instead of $28. pairAwareConversionFactor in src/pricing/unitPairConversion.ts determines the effective factor from the measured-unit / priced-unit pair before either resolver arm receives it. The stored factor is validated as written; what the resolvers consume is the pair-aware effective value. The rule (identical on the frontend’s pricingEngine.getPricingQuantity): resolveFrozenRulesForWorkItem passes the pair-aware effective factor into both the simple and ruled resolver arms. The live option path (resolveLineAmount → normalisedCostRate) is also pair-aware, so preview, apply, and the authoritative recompute agree on a same-unit quarry pick. No schema change and no data migration are required. Options that are priced in the same unit as the estimate row continue to carry the density value in the database; the factor simply has no effect when the pair rule suppresses it.

First-render location resolution

When a pricing panel is opened for the first time against a component that has no saved override entry, Stralos automatically selects a default rate option using a priority ladder. This runs once per component per estimate — later panel loads are no-ops, and any component that already has a persisted snapshot, a session selection, or a manual override is left untouched.

Resolution order

resolveDefaultRateOption first considers a project-scoped override. It then walks the project’s shared ordered list of rate rules and the pricing location, sorted by position. The first entry that covers the line and yields an eligible option wins. Position overrides specificity: a rate-group rule can beat a later item rule, and the pricing location can beat a later rule. An entry with no eligible option is skipped. If the list yields none, the resolver checks the project’s own office location and finally the distance-aware default tier, described under Default tier (Search distance) below. An explicit pin is excluded by construction: first-render resolution runs only when the component has no saved override entry. Project-option scope guard. An option whose projectId does not match the estimate’s project is silently excluded from the candidate list. The Rates API never serves a foreign-project option to a correctly scoped caller; the guard is defence in depth. projectRateOverrideId for project prices. When a project-scoped override wins, the persisted snapshot records the winning project option’s id. Converted prices retained the old override id, so existing snapshots remain valid. The resolver no longer accepts a scalar overrideFinalRate or a candidate projectRateOverrideId input. Scope guard. A rate option whose projectId is set to a project other than the current estimate’s project is excluded from the candidate set before the resolution order runs. Only Rates Library options (no project) and this project’s own options are eligible candidates. S6b removes the legacy override-list read and transition tripwire. Pricing resolves directly from project-scoped options.
This ladder was rebuilt and both resolveDefaultRatePrice and normalizeLocationLabel are gone. Tiers 3 and 4 used to compare normalised location label strings — the project’s geoTag.region and organisation_settings.default_location_label against the selected price row’s free-text location label. They now compare location ids: a project declares its rate context as a real company location (see Rates API — project rate context), and the region string-match and the quarry short-circuit were both retired. The rule tier between them is new. The organisation-wide default_location_id column (organisation_settings) is retired and its database column dropped (migration 0082); the office tier now resolves from each project’s office location. A user’s default office can initialise a project; it is not the estimate pricing fallback.
A rule that covers the line but matches no candidate does not fall through silently: the decision records resolvedRuleOutcome as unsatisfiable (a source target with no matching option) or policy_unmet (a cheapest within N km rule with nothing in radius), and resolution continues to the next tier carrying that outcome. Within a tier the winner is the candidate with the lowest resolved line amount at the line’s quantity, tie-broken by option id ascending — not the lowest display rate. Where pricing rules apply the two differ: a supplier whose representative rate looks cheapest can cost more once its bands, matrix, minimums and loadings resolve at the quantity being priced. Line-unit pricing pivot. Every candidate’s cost is normalised into the line’s own unit before comparison (resolveLineAmount in pricing/ruleResolver.ts). The pivot is the component row’s unit, the value persisted on estimate_work_item_components.unit, because rate items no longer carry a unit. Both repos read it through resolverLineUnit (pricing/repriceLines.ts in stralos-api-estimate, pricingEngine.tsx in stralos-feature-estimate), which returns the stored unit except on a project calculator row (generatedKey starting calc:). A calculator row stores the calculator’s short spelling (m3, t, m2, m) while rate options are priced in catalogue codes (cubic_metre, tonne), so resolverLineUnit passes canonicaliseUnit(unit) for those rows only. Without it every option was excluded and a calculator line could take no default. A candidate that cannot convert to the line unit is excluded with conversion_missing. An absent or blank line unit is also excluded rather than allowing a mixed-denomination comparison. Legacy itemUnit tolerance. ResolverLine.itemUnit and ResolvableOption.itemUnit remain optional and nullable for compatibility. Their absence is valid and does not make fetchResolverRateItem return HTTP 502. Companion orientation fields. conversionNumeratorUnit and conversionDenominatorUnit are optional and nullable on the companion option block (rateOptionCompanions). Both present fields define the conversion direction; both absent fields use rate-group and unit-pair inference. Component unit stored verbatim. The unit canonicalisation alias table (unitCanonical.ts) explicitly excludes estimate_work_item_components.unit. Component units are stored as supplied — ea is not rewritten to each, hr to hour, and so on. The pivot comparison is exact, so canonicalising the stored unit would merge rows that resolve to different amounts. The calc: read in resolverLineUnit is the only exception, and it never rewrites the stored column. source rules — All-locations targets match only location-less prices. A source rule with locationId: null (an All-locations target) matches only the owner’s options where locationId is null — prices that genuinely apply everywhere. It never matches one of the owner’s place-specific prices, even a cheaper one. If the owner has no all-locations price for the item, the rule is unsatisfiable and the next entry in the ordered list tries. A source rule with a specific locationId (site X) matches that site’s own prices and the supplier’s all-locations price, because an all-locations price is sold at every site of the supplier. The supplier is the rule’s partyId, or else the supplier whose siteRoutes include X. The cheapest line amount wins; lowestAtPlace breaks an exact tie in favour of the site’s own price. ruleSiteLabel records X as resolvedLocationLabel when the all-locations price wins. Default tier (Search distance). When no project price, preference or office option wins, defaultCandidate picks the default:
  1. Only measured candidates compete: a current route, or one stale by age alone (staleReason: "age"). The cheapest_within_km policy accepts current routes only, and a route whose project or site has moved never counts.
  2. The radius is the project’s saved search distance (ResolverLine.defaultRadiusKm, from api-global searchRadiusKm). With none saved, the resolver steps through DEFAULT_SITE_RADIUS_STEPS_KM ([50, 100, 150]) and uses the first radius that holds a measured candidate. The cheapest candidate within that radius wins.
  3. If no measured candidate is within any radius, the nearest measured candidate wins.
  4. If no candidate is measured at all, the cheapest candidate wins.
An all-locations supplier option carries siteRoutes, one route per active site of the supplier. siteDistance measures it at its nearest usable site, and the winning site’s name is recorded in resolvedLocationLabel (no schema change). A site with no address, and a location-less price with no sites, is never the default while any candidate is measured. distanceAwareDefault(line) scopes the tier and is true for every line. The server never measures routes: api-estimate reads cached routes from api-global (GET /api/global/projects/:id/location-routes with staleReason=true and partyIds), while the browser measures them when a project is set up and an estimate opens. The resolver module is a byte-for-byte transcription between stralos-feature-estimate and stralos-api-estimate, backed by a cross-repo fixture. A tier that lands on one side only is a divergence that makes re-resolution reprice lines nobody touched. Resolution is gated on the current estimate’s pricing overrides and rate data loading successfully. The project office can be null; it must not delay resolution. A stale success state from a previous estimate cannot authorise resolution mid-switch.

Provenance snapshots

Every automatically resolved component is stamped with a provenance snapshot:
  • provenance: "auto" — written by first-render resolution or the explicit re-resolve action.
  • provenance: "pinned" — written when the user explicitly picks an alternative or edits the component.
  • Absent (legacy rows written before this feature) — treated as pinned; re-resolution does not move them unless the user explicitly includes them through convertToAuto.
The snapshot also stores resolvedTier and resolvedLocationLabel (the display label captured for "project" and "office" tiers; null for "override", "rule", and "default"). These fields are carried verbatim on every subsequent materialise — a re-render of an already-persisted component reproduces its snapshot byte-for-byte so autosave diffs are no-ops. libraryUnitCost is a presence-based audit field containing the library option’s unit cost at the moment represented by the snapshot, in the priced unit. The pricing-rule endpoint can capture it for a pinned selection. Re-resolve preserves an absent key, but when an auto selection already carries the key it refreshes the value to the newly resolved library cost. The broad work-item pricing route persists the incoming number, null, or absence and can therefore add, change, or remove it regardless of provenance. The recompute engine never prices from this field.

Atomic unit move via pricing-rule selection (pricingRuleSelectionSchema.unit)

pricingRuleSelectionSchema carries an optional unit field. When present, PUT /work-item-components/:id/pricing-rule writes the component’s own unit, the pricing selection, and the recomputed unitCost denominated in it in a single statement followed by one recompute. This field lets the UI move a frozen row directly to another option priced in the target unit. A plain component PUT can also change the unit, but it unfreezes the selection and leaves the numeric rate for the estimator to correct. The atomic selection path changes the option, unit, and recomputed money together instead. Safety invariant. unit must equal the canonical pricedUnit. The route enforces this with a 422. The equality restricts the move to same-unit selections by construction: an option that requires a conversion has a pricedUnit the component is not moving to and cannot satisfy the check, so conversion-based rows are unaffected. Not implied by pricedUnit. A row measured in m³ priced from a per-tonne option legitimately keeps m³ and carries a conversion factor. Copying pricedUnit onto every selection would silently re-unit those rows. The unit key is therefore opt-in and must be sent explicitly by the caller.

Re-resolve pricing review

The re-resolve action (POST /api/estimate/estimates/:id/re-resolve-prices) re-runs the resolution order for every component whose provenance is "auto". It is exposed in the frontend as the ReResolvePricingDialog component, accessible from the Project Rates panel. Dry-run preview (dryRun: true) — returns a ReResolvePricingPreview without writing anything. The preview includes:
  • items — each component that would change, with current and resolved option labels, rates, and amounts.
  • managedTickRemovals — managed fixed-charge companion components that the parent option change will remove.
  • companionReviewParentIds — parent component IDs whose fixed charges need a companion review after the apply step.
  • summary — total current and resolved amounts, delta, count by tier, and total changed count.
  • pinned / legacyPinned — components with provenance: "pinned" or absent provenance that are excluded by default. The caller may pass their IDs in convertToAuto to include them.
  • skippedChanged — components omitted because the resolver could not represent their money safely (shown as a warning).
  • previewHash / resolutionInstant — authority tokens that must be echoed back in the confirm call.
Confirm (dryRun: false) — applies the preview. Must include previewHash and resolutionInstant. If the estimate’s pricing changed since the preview was computed, the server returns HTTP 409 stale_re_resolve_preview with a fresh preview embedded in the error body; the dialog parses this and prompts the user to review and confirm again. After a successful apply, the frontend navigates to the work-items panel with a reviewCompanions query parameter when companionReviewParentIds is non-empty. The WorkItemsPricingPanel detects this handoff on mount and opens the CompanionReviewModal automatically. Mirror companion rule. When a re-resolve moves a line onto the mirror of its current option — meaning the new winner is a project option that mirrors the library option already stored, verified by mirrorsOptionId or by matching source, yard, and condition — the move is not treated as a parent change. Ticked fixed-charge companions are preserved. A move to a different supplier’s mirror is a parent change and ticked companions are removed as normal.

Ticked-companion removal rule

A ticked companion is a fixed-charge component owned by its parent’s rate option — it is desired state that Stralos manages, not a line the estimator entered. The same rule runs in both the re-resolve path and the automatic re-price path so the two writers can never answer the question “does this companion still belong to its parent?” differently. A ticked companion is removed when the writer moves its parent onto a different option and either of the following applies:
  • Rule 1 (identity change): the parent moves onto a different option that is not the line’s own mirror option. Decided by id comparison with no network read. This covers a move onto another supplier’s option as well as a clear of the current selection.
  • Rule 2 (in-place supplier change): the parent’s new option is a project price and the companion’s own option is no longer among the companions that new option serves now. Decided by reading /rate-options/<newOptionId>/companions?projectId= through the rates API. This preserves the membership check when a project option keeps its id but its served companions change.
Rule 2 applies only when the parent’s new option is a project price. A library option cannot be re-pointed in this way, so a library-only estimate never triggers a companion read. companionReadsNeeded and tickedCompanionRemovals implement the two passes. Pass 1 names the distinct option ids that need a companions read: only moved parents that (a) survive rule 1, (b) have at least one ticked companion, and (c) move onto a project price. Pass 2 applies both rules to the answers. The two functions are pure; the caller performs the reads and the writes. Companion read failure (companions_unavailable). If a read needed by pass 2 is absent from the answers — either because the fetch failed or because the estimate’s lines moved between the pre-lock plan and the locked plan — the whole operation fails closed with HTTP 502 companions_unavailable and writes nothing. The error message names the work item number, work item name, and the line.
  • Re-price path (a /project-rates write in api-rates): api-rates keeps the outbox row in pending state and re-delivers. The legacy override routes are removed in S6b; write through /project-rates. The re-price is computed from current state each time, so retrying is safe. Pinning the affected line does not help because the re-price refreshes pins. The remedy is to delete the stale ticked companion charge from the build-up.
  • Re-resolve path (POST /estimates/:id/re-resolve-prices): the preview and confirm steps both compute before any transaction, so an unread answer never reaches a write. The remedy is to delete the stale ticked companion charge, or pin the line so re-resolve leaves it.

Import rate refresh fan-out key

importRateRefresh resolves rate options for every priced component row when new option pricing is imported. Components with the same (rateItemId, code, name, rateGroup, quantity, unit) identity resolve identically, so the fan-out de-duplicates on a composite key (rateLineKey) and issues one upstream lookup per distinct identity. unit is part of the key. Two rows with the same rate-item identity but different units may resolve to different scalars — one may use a cross-unit conversion while the other prices direct — so they must not share one resolution. The key encodes line.unit verbatim (not normalised), because the pivot comparison is exact.

Rate exceptions

RateExceptionsPanel (/api/estimate/estimates/:id/rate-exceptions) computes and displays lines whose current pricing selection or frozen pricing-rule outcome needs review. Each RateExceptionRow carries:
  • kind: one of the registered exception tokens. api-estimate registers thirteen: pin_beats_rule, override_beats_rule, rule_unsatisfiable, policy_unmet, rule_changed, pin_unresolvable, no_eligible_option, band_override, matrix_secondary_unresolved, condition_token_miss, minimum_over_override, step_not_picked, and step_retired. feature-estimate’s REGISTERED_RATE_EXCEPTION_KINDS lists the first eleven, and its panel labels also cover the two step kinds (Step needed and Step no longer offered). rate_table_row_not_chosen is an error code, not an exception kind: an unchosen rate table is reported as matrix_secondary_unresolved.
  • sentence — a plain-language description rendered in the panel.
  • money — delta amount and optional per-unit delta. null when no price difference is computable.
  • actions — one or more action buttons the user can invoke (keep_pin, apply_rule, accept, widen_policy, choose_option, follow_rule, clear_band_override, supply_input).
  • fingerprint — a SHA-256 content hash of the exception’s inputs. If the exception changes between load and action, the server returns HTTP 409 and the panel reloads.
The count endpoint (/rate-exceptions/count) returns a lightweight { count, stale, computedAt } used to badge the Project Rates panel header without loading the full list.

Grouped dismiss (similarExceptionGroups)

similarExceptionGroups(rows) in src/RateExceptionsPanel.tsx identifies exception cards that share the same kind, action.token (a dismissing token only — never a fixing action), and lineRef.componentName. When two or more cards match on all three keys, the panel renders a grouped row above the individual cards. The grouped row shows the count, component name, and kind, and its button applies the common dismissal action to every card in the group in a single keepAllSimilar call. A DISMISSING_TOKENS set defines which action tokens qualify: these are the tokens whose action resolves without opening a picker or sheet. Fixing tokens (choose_option, apply_rule, follow_rule, clear_band_override, supply_input) are never grouped because each fixing action requires individual review.

Provenance captions

formatResolutionCaption was removed from stralos-feature-estimate because nothing rendered it, so the pricing panel shows no “resolved when priced” caption. The persisted snapshot (provenance, resolvedTier, resolvedLocationLabel, the frozen rule) still records the conditions that applied when the price was resolved, and is never recomputed from current settings. What the estimator sees comes from WorkItemDetailPane.tsx: an option whose resolution.provenance is auto appears in the picker as Default — option label, and a pinned line names its option. A line flagged for a price choice shows Choose a price… instead.

Location label normalisation — retired

normalizeLocationLabel and its content-hash-pinned locationNormalizationContract.json fixture no longer exist in either repo. They existed because the project and office tiers matched a selected price row’s free-text locationLabel against a project region string, so the two sides had to agree on a normal form down to Unicode composition. Those tiers now compare location ids — a project’s rate context is a declared company location, not a name — so there is nothing left to normalise and no way for the two sides to disagree about casing. A resolved location’s label still travels with the decision (resolvedLocationLabel), but only as display provenance; it is never a match key.

Pricing rule resolution exceptions

resolvePricingRule returns a RuleResolution whose exceptions array carries zero or more PricingRuleResolutionException entries. Each entry has a discriminant kind field. Consumers should surface these to the user as a review prompt, not as a hard error. The resolution still produces a money amount, except for step_not_picked and matrix_secondary_unresolved, which come back unpriced (see Stepped base rules and Rate table must-pick).

condition_token_miss

Fires when a non-standard condition token is charged against a schedule scope that holds at least one loading rule, but none of those loading rules is keyed to the charged token (D12).
heldTokens may be an empty array. A schedule that only holds a standard-conditioned loading has no non-standard token to name, but the exception still fires because the owner holds a loading and the line’s token was not recognised. An empty heldTokens means “this schedule holds a catch-all loading and no specific loadings — the condition on the line is unrecognised”. Display such entries as “unrecognised condition, no alternatives” rather than suppressing them. This exception is a schedule concept only — condition is part of a rate option’s identity, so a rate-option charge never raises it. condition_token_miss was tightened in domain 0.25.1. Before that release, the exception was gated on heldTokens.length > 0, which meant a typo against a standard-only loading (or a schedule holding only a standard fallback) produced no exception and priced silently. Consumers compiled against an older domain will not see the empty-heldTokens variant.

Other exception kinds

Pricing source resolution

The pricingSource token on the pricing row selects either internal build-up or a specific subcontract bid. Token format: "internal" or "subcontract:<bidRateId>". The token’s only persistent home is estimate_work_item_pricing.pricing_source. The estimate_work_items table carried a pricing_source column as a transition store during the M2 migration; that column is retired (snapshot v31) and no writer remains. Migration 0086 drops that transition column. POST /work-items and PUT /work-items/:id still accept pricingSource in the request body for compatibility, but the value is written only to the pricing row. When no pricing row exists, or when the pricing row has a null or empty pricingSource, the no-row default rule (R2.a) applies: the item prices from the internal build-up. A subcontract bid is never auto-selected. A bid applies only when an estimator explicitly sets pricingSource to "subcontract:<bidRateId>" on the pricing row. Every bid is still validated when resolveNoRowPricingSource is called, so a malformed bid throws as before, but the cheapest-bid auto-select behaviour that previously copied EstimatePage was retired in pricing 1.1.0. The rationale: a contractor may price on the assumption of winning all sections, so all returned rates must be reviewed before any one rate is chosen. Cherry-picking the cheapest bid silently is not permitted. If a selected subcontract bid no longer exists, the source falls back to internal automatically. Package containers always price internal. When a work item has a published package (a component with a packageSellUnitPrice), the item’s cost and price are computed from the internal build-up regardless of any stored pricingSource token. The package sell is applied on top of the internal cost. A subcontract:<bidRateId> selection on a package container has no effect on the money recompute. Read-path accuracy. GET /work-items/:id and GET /estimates/:id/work-items report the pricingSource value that the money recompute actually used, not the raw stored token. The effective source is "internal" when:
  • no pricing row exists, or the stored token is null or empty;
  • the stored token names a bid that does not belong to this work item;
  • the stored token names a bid that belongs to this item, but the item has a published package.
In all other cases the stored token is returned as-is.

Package sell and margin sell calculations

computePackageSell(costCents, marginPercent) converts a package cost in integer cents and a whole-percent margin on sell into an integer sell price in cents. It is exported from the @stralos-co-nz/domain barrel and used wherever a published sell amount must be derived from cost and margin.
computeMarginSell(costCents, marginPercent) is the corresponding function for pricing a work item line on the margin basis. It is called by computeWorkItemTotals when marginPercent resolves to a non-null value. Both functions share one private implementation so the formula exists in exactly one place.
marginPercent is a whole percent (19.9 means 19.9%), matching the numeric(6,3) activities contract. This is the opposite convention to the domain’s legacy markup fraction (0.199). Both functions stay on scaled integers throughout and perform one half-up rounding at the final cent boundary:
computeMarginSell exists as a separate export rather than converting the margin to an equivalent markup fraction and reusing applyMarkupCents. That conversion is lossy at the engine’s five decimal-place fraction scale — measured at 59 cents short on a $1,000,000 cost line at 15%. Tender revisions are hashed, so they must reproduce to the cent. Validation rules (each throws RangeError on violation, for both functions):
  • costCents must be non-negative.
  • marginPercent must be in the range [0, 95] (margins above 95% are rejected). The check is at three decimal places precision.
  • marginPercent must be a finite number (NaN throws).

Pricing rules (D14)

@stralos-co-nz/domain 0.22.0 adds a shared pricing-rule resolver and write-boundary validator. These are the domain contracts for D14 flexible pricing.

Rule types

Each rule is a JSON payload stored in rates_pricing_rules.rule. The PricingRule type is a discriminated union keyed on ruleKind:

Owner types

Rules belong to one of three owner types (PricingRuleOwnerType):

Conditions

Every rule in a set is paired with a condition token. A condition identifies the pricing context — for example a time-of-day loading or a rate variation. base rules must always use "standard". Non-base rules can carry non-standard tokens to apply loadings only when a matching condition is active. normalizePricingRuleCondition(value) normalises a raw token: trims whitespace and lowercases. Stored condition values must match the pattern [a-z0-9]+([ _-][a-z0-9]+)* and must be at most 40 characters.

resolvePricingRule

resolvePricingRule(input) is a pure function that computes a charge from a context and a set of rule scopes. It never reads or writes any external state. Resolution order (locked): conversion → quantity floor → base → additive modifier → multiplicative modifier → money floor.
ChargeContext supplies the measured quantity, unit price, optional conversion factor (e.g. t/m³), and the rate table side for matrix rules. An optional condition token (defaults to "standard") selects modifier loadings. unitPrice is nullable: only a stepped base may leave it null, and every other base refuses a null with a RangeError rather than pricing it as $0. A rate table’s side is supplied by kind: A missing or unknown key leaves the line unpriced and raises matrix_secondary_unresolved; there is no default row and the resolver never guesses. A stored value of the other kind is treated as no value. PricingRuleScope groups one owner’s rules with its ownerType and ownerId. The resolver canonicalises scope order internally — for a schedule charge, line-scope rules win over revision-scope rules; for a rate-option charge, exactly one rate-option scope is required. The returned RuleResolution carries: Exceptions. RuleResolution.exceptions is an array of PricingRuleResolutionException items. Each has a kind: Constraints:
  • bandOverrideKey and unitPriceOverride cannot be combined.
  • A fixed base cannot carry a quantity minimum (throws at runtime; validatePricingRuleSet rejects at the write boundary).
  • A fixed base cannot carry an additive modifier (same guards).
  • A stepped base rejects a unitPriceOverride with a RangeError: a project rate cannot replace a picked step.
  • A matrix base accepts a unitPriceOverride, which supersedes the table and prices the line with no row chosen.
  • A stepped base cannot carry a minimum or a modifier. validatePricingRuleSet rejects the set (“A rate priced by steps can’t also have a minimum or a loading. Please remove it.”).

validatePricingRuleSet

validatePricingRuleSet(ownerType, input) validates and normalises an owner’s complete rule set at the write boundary. It returns PricingRuleSetValidation:
A failed result returns every error in one response ({ path, message }[]), so the API can report all bad fields in a single 422 rather than one at a time. Validation covers:
  • Per-rule field shapes and value constraints (magnitude, decimal scale, enum membership).
  • Slot/kind compatibility (e.g. minimum belongs only on minimum_quantity or minimum_amount).
  • Cross-row rules: no duplicate slot + condition pair; fixed base incompatibilities.
  • Owner-type restrictions (banded rules, cost_plus, stepped, minimum_quantity slot).
  • Rate table and stepped shapes, with estimator-worded messages (for example “Please add at least one step, like 0–20 km.”).
  • Condition token format (normalised after trim + lowercase).
On success the returned rules array carries normalised condition tokens (and trimmed step labels). The resolver consumes this output directly.

Stepped base rules

A stepped base (pricing 2.6.0, domain 3.13.0) holds user-named steps, each a lump sum charged once whatever the quantity. The estimator picks one step on the line. There is no default step, and the option’s regular cost never applies. A rule holds at most 50 steps, and at least one must not be retired.
  • Pick format. A line stores its pick as bandOverrideKey: "s:<key>". stepBandKey(key) builds it and stepKeyFromBandKey(bandKey) reads it back.
  • Unpriced result. resolvePricingRule throws StepNotPickedError (code step_not_picked) when nothing valid is picked. Callers that can show an unpriced line use resolvePricingRuleOutcome, which returns { priced: false, amount: null, amountCents: null, unitPrice: null, ... } with the quantities still resolved. Narrow on priced before reading money.
  • requiresManualPick(rules) is true when the base must be picked on the line: a stepped base, or (since pricing 2.7.0) a matrix base.
  • Helpers. pickableSteps(rule) returns the steps that are not retired, in stored order. steppedAmountRange(rule) returns { min, max } over those steps, or null when there are none. findSteppedBaseRule(rules) returns the stepped base from a rule set or snapshot envelope, or null.
Adoption. api-rates, api-estimate, feature-rates and feature-estimate are all on pricing 2.7.0. api-rates stores stepped rules on rate options (migration 0064; see Pricing rules service). api-estimate prices a stepped line from its pick, never selects a stepped option automatically, and writes Step needed in the export’s money cells for an unpicked line. feature-estimate shows a Step picker on the line. feature-rates offers Priced by step in the rate option editor only while the stepped-rates PostHog flag is on (default off); an option that is already stepped stays editable with the flag off.

Rate table must-pick

Since pricing 2.7.0 (domain 3.14.0) a matrix base is must-pick, like a stepped base. It has no default row, the option’s regular cost never applies, and a line is unpriced until the table has a cell:
  • Ranges side: priced once secondaryInputs[secondary.key] carries a number.
  • Named side: priced once secondaryChoices[secondary.key] names a row key. An unknown key counts as no choice.
  • Cell pin: a bandOverrideKey of m:<row>,<column> prices the line on its own. With no side value the pin is the line’s pick and raises no band_override; beside a side value it overrides the cell that value selects.
  • Project rate: a unitPriceOverride supersedes the table and prices the line whatever the side.
resolvePricingRule throws RateTableRowNotChosenError (code rate_table_row_not_chosen) when no cell applies; its message reads “Please choose the concrete type for … so it can be priced.” (or “enter” for a ranges side). resolvePricingRuleOutcome returns the case as { priced: false, amount: null, ... } with a matrix_secondary_unresolved exception. Narrow on priced before reading money. matrixPriceRange(rule) returns { min, max } over every cell price, or null for a table with no cells. It is what lists show for a rate table option now that the representative cost from the default row is retired.

RuleSetEnvelope

RuleSetEnvelope is the snapshot stored alongside a price when rules are captured for offline resolution. Its envelopeVersion discriminator is currently 1. Each row in rules is a PricingRuleEnvelopeRow, which duplicates the slot, condition, and ruleKind discriminators at the outer level alongside the inner rule object — the outer and inner discriminators are guaranteed by the type system to agree at compile time.

Band label derivation

deriveBandLabel(minQuantity, nextMinQuantity?, unit?, anyNoun?) derives a plain-language label from stored inclusive lower bounds. Labels are never persisted; they are produced at display time. Since pricing 2.5.0 a band reads by where it starts and where the next band starts: The label never closes the band at an inclusive upper value such as 0–9, because 9.5 belongs to the first band. deriveTierBandLabel(rule, bandIndex, unit?) reads adjacent bands from a TieredPricingRule. describeBandRange(bands, index, { unit, anyNoun }) does the same for any array of lower bounds and throws RangeError for an index the axis does not have.

Rate table sides and helpers

MatrixSecondary is a union. A ranges side (MatrixSecondaryRanges) has key, label, unit and bands (inclusive lower bounds from 0); its kind is optional because rules stored before named rows omit it. A named side (MatrixSecondaryNamed) has kind: "named", key, label and rows ({ key, label }[]). Both keep an optional defaultBandIndex so older rules still parse; the validator range-checks a stored value, but pricing ignores it. api-rates accepts 1 to 12 rows, columns and bands. A named row’s key is generated once from its label and kept through renames, because estimate lines store the chosen key. The cell pin key is m:<rowIndex>,<columnIndex> for both side kinds.

Subcontract bid amounts

bidEffectiveAmountCents(bid) computes round(quantity × rate) when both unit and quantity are present; otherwise rate is treated as a lump sum. Validation rules:
  • unit and quantity must be supplied together (both-or-neither).
  • quantity must be > 0 when present.
  • rate must be ≥ 0.
A one-sided row (unit without quantity, or quantity without unit) throws rather than silently guessing a lump-sum interpretation.

Whole-unit stock ordering per run

Lines priced in a stock unit — length, roll_length, roll_area, or sheet — order whole units separately for each run rather than rounding the total quantity. Each run represents a discrete start-to-end stretch. An offcut at the end of one run cannot begin the next, so every run is rounded up to the nearest whole unit independently. Example: two 12.5 m runs of 6 m/length pipe order ⌈12.5 / 6⌉ + ⌈12.5 / 6⌉ = 3 + 3 = 6 lengths. Rounding the combined total would give ⌈25 / 6⌉ = 5 lengths and undercount by one. The runs a quantity is split into are stored beside the quantity in quantity_runs on estimate_work_items and estimate_work_item_components (migration 0078). The column records the quantity it split alongside the run breakdown. A path that writes a new quantity without knowing about runs — a typed edit, an import — degrades the row to one run of the new quantity rather than splitting it by old runs. Run sources:
  • A calculator pipe row with N parallel pipes over a trench of L contributes N runs of L.
  • A takeoff, area, or volume source contributes one run.
  • Each counted rate-item row contributes one run.
  • The link multiplier scales every run. A positive link offset contributes one additional run.
  • A typed or overridden quantity is one run.
Pricing scope. wholeUnitStockOrdering in src/pricing/unitPairConversion.ts is called before pairAwareConversionFactor on every pricing path — frozen recompute, option comparator, re-resolve, reprice, exceptions, insert defaults, rule change, import refresh. It returns either a { orderedQuantity, charge } result that replaces the scalar quantity in the resolver, { excluded: "conversion_missing" } when the line cannot be priced in the option (for example a count measured against a per-length price), or null when the option is not a stock unit. The unit list for stock units is provided by @stralos-co-nz/api-common/units. src/lib/unitCanonical.ts re-exports canonicaliseUnit from that package and adds write-side policies on top. measuredRunsForComponent in src/pricing/quantityRuns.ts computes the runs for a single build-up line, following the same arms as resolveComponentQuantityBasis:
  • A pricing-override quantity is one run.
  • A whole-item line uses its own stored runs.
  • A per-unit line uses perUnitBasisRuns from the domain, spreading the component runs over the work item’s runs.
  • A resource count k repeats the runs k times via runsForResourceCount.
feature-estimate mirrors this function as measuredRunsForComponent for the live pricing preview.

Stored unit cost on banded lines

For a build-up line whose frozen base carries its own prices, a tiered (quantity breaks) or matrix (rate table) base, the stored unit_cost on the component is the unit price of the band actually applied, not the option’s cost_rate or band 0’s price. baseCarriesOwnPrices in src/pricing/pricingRuleSnapshot.ts decides which lines qualify. The recompute in src/pricing/recompute.ts re-syncs unit_cost to the frozen resolution’s unitPrice, so every write that can move the band lands the right figure: a quantity change, a pick, a re-selection or a reprice. It writes only on change. A rate table with no cell resolved stores 0. Two shapes are left alone:
  • Override-tier lines (resolvedTier === "override"): the scalar is the negotiated project rate, which supersedes the bands.
  • simple, fixed and stepped bases: a simple base’s scalar is the resolver’s input, so syncing the output back would compound it on every pass; a step is a lump sum with no unit price and stores 0.
For a progressive tiered rule, unitPrice is the blended rate (amount divided by charged quantity), so unit cost times quantity still equals the line amount. buildSelection and refreshPinnedSelection in src/pricing/repriceLines.ts store the same figure through storedBaseScalar, so a re-resolve preview of a line whose band has not moved shows no change. feature-estimate’s selection source sends the applied band’s price as the selection’s base unit cost for the same reason.