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 existingusePricingControls 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 asMath.round(1.005 * 100) === 100.
Every numeric input is:
- Converted to an integer at its declared decimal scale by string parsing.
- Multiplied or divided using BigInt — intermediate products can exceed 2^53.
- Rounded once (half-up) at the point the client rounds, producing exact integer cents.
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).
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 thelocationMaterialisationContract.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 predicateisLabourComponent resolves the classification in two steps:
- Recognised
rateGroup(authoritative) — arateGroupoflabourapplies the multiplier; any of the other five recognised tokens (plant_vehicles,quarry_materials,other_materials,subcontractors,disposal) suppresses it. category(legacy fallback) — ifrateGroupisnull, absent, or unrecognised, the predicate sniffs the free-textcategoryfield using the existing keyword heuristic. Rows written beforerateGroupwas introduced therefore price identically to before.
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:
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.
Tonne-gate drift diagnostics
The vitest suitesrc/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
subcontractBidRateIdset. Mirrors are display metadata; the bid is priced through its own channel. - Activity container — a component where
type === "activity"andrateItemIdis absent or null. A container is priced through its children, never on its ownunitCost.
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 bothallocatedQuantity 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:
- 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^10base-unit bound.
Instance child quantity
A component is an instance child when itsparentComponentId 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):
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; anddurationDays— 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 toquantityIsTotal: 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
rateItemIdandactivityIdis a copied recipe child whose quantity is already per-unit-scaled; flipping it would under-price by the recipe multiplier. - Container rows (
type === "activity"andrateItemId == null) — containers price through their children and contribute no cost themselves.
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 withquantityIsTotal: 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:
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), sogetPricingQuantity 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:
type === "activity"wins unconditionally — the component is a container.type === "day_rate"wins unconditionally — the component is a day-rate line and files underday_rates, regardless of itsrateGrouporcategory.- A
rateGroupthat is one of the six rate-group wire tokens maps 1:1 to its heading. - Anything else (NULL, absent, or an unrecognised value) runs the legacy substring sniff over
category,name, andunitfields.
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 ofrateGrouporcategory. A day-rate line is a SELL rate that already includes overheads and margin, so the labour multiplier cannot reach it.- An authoritative
rateGroupof"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
categorycontains"labour"check.
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:
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:
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 usingsrc/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 againstwork_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
rateItemIdand carries nocategoryId(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
NULLincategory_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.
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 onWorkItemTotalsInput 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 intoconversionFactor 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.
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:
- Only measured candidates compete: a current route, or one stale by age alone (
staleReason: "age"). Thecheapest_within_kmpolicy accepts current routes only, and a route whose project or site has moved never counts. - The radius is the project’s saved search distance (
ResolverLine.defaultRadiusKm, from api-globalsearchRadiusKm). With none saved, the resolver steps throughDEFAULT_SITE_RADIUS_STEPS_KM([50, 100, 150]) and uses the first radius that holds a measured candidate. The cheapest candidate within that radius wins. - If no measured candidate is within any radius, the nearest measured candidate wins.
- If no candidate is measured at all, the cheapest candidate wins.
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.
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 withprovenance: "pinned"or absent provenance that are excluded by default. The caller may pass their IDs inconvertToAutoto 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.
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.
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-rateswrite in api-rates): api-rates keeps the outbox row inpendingstate 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, andstep_retired. feature-estimate’sREGISTERED_RATE_EXCEPTION_KINDSlists the first eleven, and its panel labels also cover the two step kinds (Step needed and Step no longer offered).rate_table_row_not_chosenis an error code, not an exception kind: an unchosen rate table is reported asmatrix_secondary_unresolved.sentence— a plain-language description rendered in the panel.money— delta amount and optional per-unit delta.nullwhen 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.
/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
ThepricingSource 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.
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):
costCentsmust be non-negative.marginPercentmust be in the range[0, 95](margins above 95% are rejected). The check is at three decimal places precision.marginPercentmust 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 inrates_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 acondition 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:
bandOverrideKeyandunitPriceOverridecannot be combined.- A
fixedbase cannot carry a quantity minimum (throws at runtime;validatePricingRuleSetrejects at the write boundary). - A
fixedbase cannot carry an additive modifier (same guards). - A
steppedbase rejects aunitPriceOverridewith aRangeError: a project rate cannot replace a picked step. - A
matrixbase accepts aunitPriceOverride, which supersedes the table and prices the line with no row chosen. - A
steppedbase cannot carry a minimum or a modifier.validatePricingRuleSetrejects 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:
{ 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.
minimumbelongs only onminimum_quantityorminimum_amount). - Cross-row rules: no duplicate slot + condition pair;
fixedbase incompatibilities. - Owner-type restrictions (banded rules,
cost_plus,stepped,minimum_quantityslot). - 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).
rules array carries normalised condition tokens (and trimmed step labels). The resolver consumes this output directly.
Stepped base rules
Astepped 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 andstepKeyFromBandKey(bandKey)reads it back. - Unpriced result.
resolvePricingRulethrowsStepNotPickedError(codestep_not_picked) when nothing valid is picked. Callers that can show an unpriced line useresolvePricingRuleOutcome, which returns{ priced: false, amount: null, amountCents: null, unitPrice: null, ... }with the quantities still resolved. Narrow onpricedbefore reading money. requiresManualPick(rules)is true when the base must be picked on the line: asteppedbase, or (since pricing 2.7.0) amatrixbase.- Helpers.
pickableSteps(rule)returns the steps that are not retired, in stored order.steppedAmountRange(rule)returns{ min, max }over those steps, ornullwhen there are none.findSteppedBaseRule(rules)returns the stepped base from a rule set or snapshot envelope, ornull.
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) amatrix 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
bandOverrideKeyofm:<row>,<column>prices the line on its own. With no side value the pin is the line’s pick and raises noband_override; beside a side value it overrides the cell that value selects. - Project rate: a
unitPriceOverridesupersedes 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:
unitandquantitymust be supplied together (both-or-neither).quantitymust be > 0 when present.ratemust be ≥ 0.
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.
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
perUnitBasisRunsfrom 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, atiered (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,fixedandsteppedbases: 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.
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.
