Rewritten for the rates reimage. The three-table stack — rate items → rate
offerings → rate prices — no longer exists. A rate option replaces both
inner layers: one row carrying scope, supplier, location, supplier code,
condition, basis, unit, conversion factor and cost. The eleven
/rate-offerings/* and /rate-prices/* routes documented here previously have
been removed and now return 404.Migration 0045 — rate source ownership (breaking). The
rate_sources and rate_source_locations tables in api-global have been removed. Rate options no longer carry rateSourceId or rateSourceLocationId. They now carry scope (organisation or supplier), supplierId (a parties.id, null for organisation scope), and locationId (a locations.id, null means all locations). The sourceIds= filter on GET /rate-items and GET /rate-summaries has been renamed sources=; its values are supplier party UUIDs or the literal token organisation. The source_location rule kind was retired in that migration; the later unified source-rule contract also retired supplier and location kinds. These are wire-breaking changes; see the sections below for details.Base prefix
Endpoint groups
Removed routes
These eleven routes were removed with the rate-option model. They are absent from the OpenAPI document and return404. No compatibility shim exists.
RateOffering and
RatePrice are not types any longer, and neither are locationScope,
quotedUnit, chargeQuantity, chargeUnit, materialPricingUnit,
overheadPercent, markupPercent, finalCost, finalRate, priceCount,
defaultMarkupPercent, labourMultipliers, supplierId, locationLabel,
sourceName, or the item-level swellFactor, payloadTonnes, binVolumeM3,
density, isMaterial, isDisposal, isPipe and pipeDiameter. Read the
sections below rather than mapping old field names onto new ones — several
concepts moved rather than being renamed.
Retired override routes
S6b removes the legacy override API and its backing table. These routes are absent from OpenAPI and return404 Not Found:
- Read
GET /project-rates?projectId=<id>&status=current. - Create with
POST /project-rates/replacementsorPOST /project-rates/project-only. - Edit with
PUT /project-rates/:optionId. - Retire with
DELETE /project-rates/:optionId.
Retired project rate rule routes
Migration 0047 — ordered project rate rules (breaking). The per-rule write routes have been removed. They are absent from OpenAPI and return404:
PUT /project-rate-settings/rule-set to create, update, reorder, and remove rules and the pricing location in one atomic call. See Project rate context and rules for the full contract.
Version endpoint
GET /api/rates/version returns the semver identity and commit SHA of the running deployment.
Cache-Control: no-store.
SERVICE_VERSION is stamped by the deployment process from the promotion manifest’s appVersion. SERVICE_COMMIT is stamped from the Git commit SHA of the deployed artifact. Release coordinators compare version with the manifest’s minimum version before promotion, and can use commit to confirm which exact build is serving.
Data isolation
Rate items, rate options, pricing rules, day rate schedules, project rate rules and project rates are tenant data. Each record belongs to one organisation. Routes execute tenant queries in an organisation-scoped transaction, use explicit organisation predicates where response semantics require them, and rely on FORCE RLS as the database backstop. For an authenticated organisation caller, a direct lookup or mutation of another organisation’s record returns404; a filtered collection read returns an empty
result. Neither response confirms that another organisation’s record exists.
Composite foreign keys prevent an option from binding to a rate item in a different organisation.
Authentication requirements
“Org member or admin” means a signed-in person whose role in the organisation is
member or admin. A
service token never passes that gate. A platform or service token with no organisation receives 403
on tenant business endpoints. The health, version, and OpenAPI infrastructure routes are auth-exempt.
A member token receives 403 on the admin-gated endpoints above. An unauthenticated request to a write
endpoint receives 401.
When billing enforcement is enabled for the environment, an authenticated user write that cannot be
matched to an active paid subscription for the organisation also receives 403.
OpenAPI document
- Unauthenticated: no bearer token is required.
- Cached bytes: the document is serialised once at boot and returned unchanged on every request.
Cache-Control: no-storeis set so intermediate caches do not hold stale copies. - Contract version:
info.versionis the descriptor-contract version, not the deployed service version. - Drift-gated: a checked-in snapshot is compared against the generated document in both directions in CI, so a route schema cannot change without the document changing with it. Treat the document as authoritative over this page.
Response bodies and phasing
Request schemas come from the same Zod objects used by the handlers. Response coverage is being added in phases:- A route with
x-stralos-response-status: undocumentedin its response object does not yet document its success body. The response object is present and describes the record type; the body schema will be added in a later pass. - List routes document the shared
ListEnvelope({ items, totalCount, start, length }) but leave the item element schema undocumented until row schemas are lifted. - The shared
ErrorEnvelope({ message, issues?, code? }) is referenced by every error response. - Unclassified errors — those not handled by a route — use a distinct shape:
{ code, reference }. A saturated database returns503withRetry-After: 5andcode: "service_unavailable"; any other unhandled error returns500withcode: "internal_error". In both cases,referenceequals thex-request-idresponse header value and can be used to correlate the incident in service logs. See Safe error handler for operational details.
Security
The OpenAPI document marks every included operation with the document-levelbearerAuth
requirement and a 401 response. It contains no public business operations. The auth-exempt
version and OpenAPI infrastructure routes are intentionally excluded from the descriptor
operations. Every included operation also documents 403 Forbidden because its tenant-scoped
transaction rejects an authenticated caller with no resolved organisation context.
Rate items
A rate item is what the thing is: name, category, status, and the physical attributes its category licenses. Everything about what it costs, from whom, where, and under what conditions belongs to its options.code and unit are no longer item fields. Migration 0036 dropped both columns from rates_rate_items. A supplier’s own code for a line now lives on the rate option as sourceItemCode. modeUnit, derived from the option set and returned on rate summaries, is the summary display, filter, and initial-seed axis; it is never written on the item itself. Estimate pricing instead normalises candidates into each component line’s stored unit.isDisposal is a retired field. The server accepts isDisposal in a POST /rate-items or PUT /rate-items/:id body but always ignores it and forces the stored value to false. Disposal classification is determined solely by the item’s rateGroup — an item whose selected category has the disposal parent group receives rateGroup: "disposal" on every write. The field remains on the response body and is always false.
Rate items are org-scoped. Any signed-in member or admin of the organisation can create or update one. Deleting an item, or bringing a deleted item back, requires the
admin role; a member receives 403.
GET /rate-items supports search, includeDeleted, categoryId, projectId, projectScope, and sources. search is treated as literal text — %, _, and \\ match only themselves — and totalCount always reflects the same predicate as the returned rows. Outside uuid mode, it matches the item name, category name, and sourceItemCode values on the item’s options, including superseded options.
sources is a comma-separated list whose members are either the literal token organisation (for internally owned prices) or a supplier’s party UUID. When supplied, only items that have at least one live (active, no validTo) option owned by one of the named sources are returned. An item with no live options at those sources is excluded even if it has historical options there. The filter applies the same query shape as the sources filter on GET /rate-summaries. A malformed value returns 400; a well-formed token that matches no source returns an empty result rather than 404.
Project scope on item and option reads. Without projectId, every read returns only Rates Library rows — items and options whose projectId is null. A project-only item is excluded and a project’s option never appears in a code search.
Supplying projectId (a GUID) widens the listing to include the Rates Library plus that one project’s items and options. Another project’s rows are never included.
projectScope=all is a special value for GET /rate-items that returns every project’s items alongside the library, for broad catalogue sweeps such as category-usage reporting. Sending projectScope=all together with projectId is a 400. A malformed projectId is a 400.
The projectId filter also applies to individual item and option reads:
GET /rate-items/:id— a project-only item is a404unless?projectIdnames its project.GET /rate-items/:id/options— withoutprojectIdonly Rates Library options are listed and a project-only item is a404; with it, the library’s options plus that project’s options are returned (each option carriesprojectId).GET /rate-options/:id— a project-scoped option is a404unless?projectIdnames its project.GET /rate-options/:id/companions— a project option as the parent is a404unless?projectIdnames its project, and companions are drawn from the library plus that project only.
Batch item and option read
GET /rate-items/:id plus GET /rate-items/:id/options and returns the same item and option shapes, so a per-item parser works unchanged.
The body is { "ids": ["<uuid>", ...] }, with 1 to 100 UUIDs. Duplicates are answered once, and items keeps the order of the requested ids.
The response is:
missingIds: an unknown id, another organisation’s item, a deleted item without includeDeleted=true, or a project-only item whose project is not named. The endpoint never returns 404; its error responses are 400 and 502. Options are ordered as GET /rate-items/:id/options orders them.
The estimate-reprice service login may call this endpoint as one of its read-only routes.
Paging a category safely
categoryId is not only a filter. Supplying it switches the sort from category, name, id to id alone, and that matters for correctness rather than performance: the default sort reads a denormalised category string that this endpoint’s own reconcile can rewrite between two pages of an offset sweep, which silently drops or repeats rows. The uuid column and an id sort are both immune.
Page one category at a time whenever completeness matters — for example, when deciding whether a category still holds anything. A malformed categoryId is a 400, never an ignored filter.
In uuid mode, search no longer matches the category name. That column is rewritable and redundant once the category is pinned, so search covers only name and option sourceItemCode and sourceItemName values.
Duplicate-rate warning
name, most alike first. The Rates page and the rate editor use it to warn that a rate may already exist while a new one is being named. It is a warning only and never refuses a write; the exact-name refusal on writes (409 name_conflict) is unchanged.
A match needs a name similarity of 0.5 or more, and the sizes in the two names must agree in at least one direction, so AP20 never warns about AP40. Ties are ordered by name.
Deleted items are left out. Malformed query parameters return
400.
Category licensing
categoryId is the one user-authored classification input. The server stamps the item’s rateGroup and category name from the referenced category on every write; neither is a request field.
An id that is unknown or belongs to another organisation returns
422; if the catalogue service is unreachable the write returns 502 and nothing is stored.
The category also licenses the physical fields. Supplying bulkingPercent or compactionPercent under a category whose parent is not quarry_materials, or maxTonnes or maxCubicMetres under a non-cartage category, is a 422 — a direct API call cannot leave physics on a labour item.
A category change clears rather than refuses. When a write moves the item to a different category, any physics field the new category no longer licenses is set to null — including one the same request supplied explicitly — and the response lists what was cleared in clearedFields. clearedFields covers the physics fields only; code and unit are gone from the item and are not reported. Restoring an item from deleted re-applies the current licence the same way, because a deleted row can outlive a re-parent or a cartage-flag change.
Deleting an item
status flips to deleted and its open options are set inactive in the same transaction. Nothing is erased, because a historical estimate resolves its line’s name and category through this row.
A deleted item leaves every listing, picker, entry row, summary result and import target by default. It stays readable by explicit id (GET /rate-items/:id still answers 200 with the row and its status) and by a caller passing includeDeleted=true.
Restoring is a PUT moving status off deleted. Uniqueness is enforced on name across non-deleted rows. Restoring an item whose name is already held by another active or inactive item returns 409.
Audit history
Tracked fields on a rate item:
name, categoryId, category, rateGroup, status, bulkingPercent, compactionPercent, maxTonnes, maxCubicMetres.
Tracked fields on a rate option: scope, supplierId, locationId, sourceItemCode, condition, basis, basisMultiplier, unit, conversionFactor, costRate, provenance, validFrom, validTo, status, needsReview.
A write that changes nothing in the tracked field set writes no audit row. The history therefore contains only meaningful changes and not every database touch.
History rows for a rate option outlive the option itself. A deleted option is hard-removed, but its audit rows remain and remain reachable through the owning item’s history endpoint.
The endpoint accepts the same projectId query parameter as GET /rate-items/:id. A project-only item is a 404 unless ?projectId names its project. Standard start and length pagination query parameters apply.
The optional actor query parameter filters by who made the change. actor=own keeps changes by the company’s own people (actorOrgId is null). actor=<contractor company id> keeps that contractor company’s changes. Leave it out for everyone. Any other value returns 400. The library-wide GET /history accepts the same parameter.
Rate options
A rate option is one purchasable line: this item, from this source, at this location, under this condition, in this unit, at this cost.
An organisation’s own price has no supplier, so it carries no supplier item code or name. A library or project option write (
POST /rate-items/:id/options, PUT /rate-options/:id, POST /project-rates/replacements, POST /project-rates/project-only, PUT /project-rates/:optionId) that sends a non-null sourceItemCode or sourceItemName on an organisation-scope option returns 422 with code: "supplier_fields_on_internal_rate". The issues array names each offending field. A database check constraint enforces the same rule, and restoring a history snapshot clears both fields on an organisation-scope option.
Factor orientation
conversionNumeratorUnit and conversionDenominatorUnit record the direction of a conversionFactor. A factor of 6 on a pipe option priced per length means 6 metres per length — the orientation makes that explicit.
The two fields are a pair: both are present or both are null. A half-present pair is treated as corrupt by the pricing domain and the option is excluded from cost calculations entirely. The service enforces this with a database constraint, so the pair is always consistent.
The orientation fields are read-only on the API. You cannot supply them in a request body. On create or update, the service derives and persists the orientation for these families:
For options that do not fall into one of these families — including
disposal options, where a factor is not valid — both fields remain null. A factor whose direction cannot be determined is excluded from pricing rather than priced incorrectly.
The metre/each conversion pair is not a supported family. A pipe or conduit priced by the stick should use the length unit; the service derives metre per length for it automatically.
Pricing reads a factor with no stored orientation from the option’s unit. Only these conversions exist:
A
disposal option never converts; its conversion comes from the substance. A metre ↔ each factor is no longer a conversion in either orientation, including a stored one, so a pipe option priced per each with a metres factor is excluded as conversion_missing. Re-price it per length with metres per length. A consuming line in metres or square metres priced from a stock unit orders whole units, rounded up per run.
Working days on weekly and monthly rates
An option priced perweek or month, in any group except quarry_materials and disposal, can carry a conversionFactor that states the working days in that period, for example 5 days per week. The service stores it as day per week or day per month.
Leave conversionFactor null to follow the project’s default working days, then the company’s. A supplied value must be greater than 0 and no more than 7 for week or 31 for month, with up to 2 decimal places. A create or update outside those bounds returns 422, for example “Days per week must be greater than 0 and no more than 7, with up to 2 decimals.” A monthly option gets the same message with month and 31. An import commit row outside them returns 422 naming the row.
The multiplier basis
Amultiplier option tracks the Standard option for the same item, scope, supplier and location — Night at ×1.25 of Standard, for example. It stores no cost of its own; its money is standard.costRate × basisMultiplier, derived on read. A 0 placeholder is not permitted, because a real zero and a placeholder would be indistinguishable to every downstream consumer.
The invariant is enforced in the service, not by a database CHECK, because a row-level constraint cannot see a sibling row:
- Creating a multiplier with no Standard for that item, scope, supplier and location is
422. PUT-ing a fixed option tomultiplierwith no Standard present is422.- Moving a multiplier to another scope, supplier or location re-validates against the Standard there.
- A Standard option can never itself be a multiplier —
condition = 'standard'requiresbasis = 'fixed'.
validTo, deleting it, and PUT-ing its scope, supplier or location all first rewrite each dependant to fixed at its last derived value. The dependants keep pricing at the number they were already showing.
Changing a condition is a re-key, not a rename
A condition token discriminates the one-live-option index and, from the pricing rules engine, the day-rate loading unique key. Editing it moves the row to a different index slot and orphans anything keyed on the old token.PUT /rate-options/:id therefore refuses a condition change with 422, naming the endpoint that does it properly:
current or superseded revisions), which keep the old token because rewriting them would edit a signed document. It also returns a collision report: a target token that would leave an owner holding two rows for the same slot and condition is 422, as is re-keying away from standard on an option whose siblings depend on it (reserved_standard).
The commit is 409 if a dependant appeared or moved since the preview — a re-key committed against a stale plan would silently skip an owner the user never saw.
Ordering
GET /rate-items/:id/options returns Standard first, then the remaining condition tokens alphabetically, then source and id. That coupling is deliberate: multiplier options derive their cost from Standard, so it leads rather than sorting among the tokens it anchors. includeInactive=true opts into history.
Companions
Retiring an option
Options are retired, never deleted, so their history stays intact. To withdraw an option from sale, setstatus to inactive with PUT /rate-options/:id. Retiring is reversible, and it is also what item deletion does to an item’s options. There is no DELETE /rate-options/:id route; a request to it returns 404.
Rate summaries
modeUnit (or, under a sources or units filter, to the matching options’ mode unit).
modeUnit is the most common unit among the item’s live options; it can be a plurality rather than an absolute majority. Standard-option count breaks ties, then earliest validFrom, then alphabetical unit code. It is null for an item with no live options. It is derived and never written on the item.
suppliers lists the distinct option owners across the item’s active options, Standard first. Each entry has id (the supplier’s party UUID, or null for the organisation’s own prices) and name (the resolved display name, or null when the id could not be resolved). locations lists the distinct locations across the same options in the same Standard-first order. Each entry has id (a locations.id) and name (the resolved display name, or null when unresolved). Both lists are built from the same ordered rows so they agree about which options they describe.
An option with no location applies to all locations and contributes nothing to locations. An item priced only through all-locations options returns an empty locations array. hasAllLocations is true when any live option has a null locationId. Read it alongside locations: an item priced at all locations and at a named place lists only the named place in locations, so hasAllLocations is the only signal that the all-locations price exists.
costRate and excludedOptionCount are computed from the options that match the active sources and units filters. With neither filter, every live option contributes. Options outside the filter are still returned in options, but they do not count towards the range or the excluded count.
costRate.unit is the mode unit of the options that contribute to the range: modeUnit with no filter, or the matching options’ own mode unit under a filter. It is authoritative, so render it rather than inferring the unit from modeUnit or the filter inputs.
stepAmountRange is { min, max } over the pickable step amounts of the item’s matching options that are priced by steps, or null when none are. Steps are lump sums, not prices per unit, so a stepped option never contributes to costRate and is never counted in excludedOptionCount. When summaries are sorted by costRate, an item priced only by steps sorts last in both directions.
Options array
Each entry inoptions carries the complete option row — the same fields that GET /rate-items/:id/options returns for that option — alongside the pricing rule metadata. The two key sets are disjoint, so neither overwrites the other.
The option row has the same field set as its entry in GET /rate-items/:id/options, including source, scope, provenance, pricing, quote, audit, and resolved supplier/location fields. validFrom and validTo are serialised as ISO 8601 strings. See Rate options for the per-option contract.
Each option keeps its nested pricing block from the full option response. The summary also includes the same metadata as flat fields: version, sourceFingerprint, ruleKind, ruleShape, fromUnitPrice, stepCount, stepAmountRange, matrixPriceRange, minimumQuantity, minimumAmount, modifiers, and rules. See Pricing rules for the rule shape.
Options are ordered Standard-first within each summary row, then alphabetically by condition token, then supplier (organisation-owned first), then by id. This is the same order GET /rate-items/:id/options uses.
Bulk material density range
For items in thequarry_materials group, each summary row includes a loose-density range derived from its live priced options.
The service reads
conversionFactor as t/m³ when the stored orientation is tonne per cubic metre, uses its reciprocal for cubic metre per tonne, and treats a factor with only half its orientation as unreadable. A legacy factor with neither orientation field is read as t/m³.
A range keeps differing supplier densities available to the calculator. The estimator chooses the supplier and exact density later through the rate option on the work-item line.
Query parameters
Supportssearch, category, categoryId, categoryIds, rateGroup, units, sources, sortBy (name, category, costRate, companyLocation; defaults to category), sortDir (asc, desc), and standard pagination.
categoryId takes precedence over category and is the one to use: category matches the denormalised name that the reconcile can rewrite. A malformed categoryId is a 400. In uuid mode, search matches the item name and option sourceItemCode and sourceItemName values only.
categoryIds is a comma-separated list of category UUIDs. It takes precedence over both categoryId and category. A malformed value is a 400.
rateGroup accepts any of the six group tokens, or null for the unresolved review queue — items whose stored category no longer resolves. An unrecognised or absent value applies no filter and never returns 400.
units is a comma-separated list of unit codes (matching ^[A-Za-z0-9_]+$, up to 50 tokens). Only items whose modeUnit (the most common unit among the item’s live rate options) matches one of the supplied codes are returned. An item with no live options has no modeUnit and matches no unit filter. The filter is applied before totalCount and the page slice, so the count always describes the filtered set. An empty string is treated as absent. A malformed value is a 400. When active, only options priced in one of the supplied units count towards costRate.
sources is a comma-separated list whose members are either the literal token organisation (for internally owned prices) or a supplier’s party UUID. Only items that have at least one live option owned by one of the supplied sources are returned. An unpriced item has no options and therefore never matches. The filter is applied before totalCount and the page slice. An empty string is treated as absent. A well-formed token that does not match any option returns an empty page rather than a 404. A malformed value is a 400. When active, only options owned by one of the supplied sources count towards costRate.
Both units and sources are optional and additive. They AND with every other active filter.
In every sort mode, ties fall back to item name and then id, ascending, so the order is total. Ties on costRate.min, category or company and location are common, and without the tie-break offset paging over them is nondeterministic.
companyLocation sorts by the first owner and first place shown in each item’s Company — Location cell. The organisation’s own prices sort first, then suppliers by name, then suppliers whose id did not resolve. Within an owner, an all-locations price (hasAllLocations) leads named places. Unpriced items sort last in both directions.
Unresolved count
unresolvedCategoryCount is computed independently of any active rateGroup, category, categoryId or search filter, so it stays stable as a user changes filters. It suits a persistent review indicator.
Rate sites
{ items: [{ supplierId, locationId }] }. The project setup step uses it to measure each site’s road distance from the project and count the suppliers within the chosen search distance.
A row comes from a current option (status active, open validTo) with scope = 'supplier', on an active Rates Library item. rateGroup is optional: one of the six rate group tokens, absent for every group, and an unknown value returns 400. The company’s own prices and every project price are left out.
locationId: null is a supplier price for all of that supplier’s sites. It is returned as one row per supplier, not expanded. Pass those suppliers as partyIds to api-global’s location-routes resolve to expand them. Rows are ordered by supplier, with each supplier’s all-locations row first.
Any authenticated caller with an organisation can read it, the same as GET /rate-summaries.
Pricing rules
A rate option with a plain price needs no pricing rule; no row means simple. A rule set makes an option’s price depend on quantity or condition: quantity bands, a two-axis matrix, a fixed charge, a cost-plus percentage, additive and multiplicative loadings, and minimum quantity or minimum amount floors.PUT replaces an owner’s complete set and carries the owner’s canonical version token; a mismatch is 409, so two editors cannot interleave partial saves. Slots — base, minimum_quantity, minimum_amount, modifier_additive, modifier_multiplicative — are independent rows and resolve independently, most specific first, so a revision-level cost-plus and a line-level minimum both apply to the same charge.
GET /pricing-rules/condition-tokens returns the organisation’s condition vocabulary for suggestion in an editor. Tokens are free text by design; the vocabulary is a suggestion, not an allow-list.
Owner mutability is enforced here, and only here. A rule write against a current or superseded day rate revision is 409 published revision is immutable — create a new revision. A rule write whose rate option is not live — inactive, or carrying a validTo — is 409 Inactive rate option pricing rules are immutable, because a past tender reads those rules. Since deleting a rate item inactivates its options, that item’s option rules become uneditable until the item is restored; restore first, then edit.
Project rate context and rules
ruleKind: "source" to select an organisation or supplier price at a named location or across all locations, or ruleKind: "policy" for a radius-based choice. Earlier supplier, location, and source_location rule kinds are retired from this Rates API contract. A source that later becomes inactive or unreadable remains visible in the rule-set read with source.unavailable: true and matches no option.
An All-locations rule (
locationId: null) matches only the owner’s prices that carry no locationId — prices that genuinely apply everywhere. It never matches a place-specific price from that owner, 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 is tried. A place rule (locationId set) is unchanged: it matches only that named location.
Migration 0048 rewrote stored supplier and location rules as source rules and retired providerKind; migration 0054 dropped its column. The current read returns a hydrated source object instead of the retired pointer fields. A location target’s owner is checked against partyId on write and read from api-global again on later reads.
Ordered rule set
Rules and the pricing location share one ordered list. Each entry carries a 1-basedposition from its place in the submitted array. The estimate prices a line against the first matching entry in that order.
GET /project-rate-settings returns exists: false at HTTP 200 for a virgin project. It returns rules (each with a position field) and a context object that includes the pricing location’s own position in the shared list.
PUT /project-rate-settings/rule-set is the only route that writes rules or the pricing location. It replaces the whole ordered list — rules and the location — in one atomic call. Entry order in the entries array determines position values 1..n.
The request body requires:
Each
location entry has:
Each
rule entry has:
Two
rule entries with the same scopeKind and scopeRef return 422 duplicate_rule_scope.
The response on success is the same payload as GET /project-rate-settings. Source rules are returned with source: { partyId, partyName, locationId, locationName, unavailable }; policy rules have source: null.
Each source rule also carries an evidence object (or null when no cached route is available) with the driving distance from the project to the rule’s source location:
For a source rule with a named location (
locationId set), evidence reflects that specific place’s route when cached. An All-locations source rule (locationId: null) and every policy rule always return evidence: null: neither names a single source place whose distance could be displayed.
PUT /project-rate-settings/rule-set warms routes for named-place source rules only, bounded to 25 places per save and prioritised by straight-line distance, before writing the transaction. A rule set containing only All-locations source rules warms no source routes. A warming failure is logged but does not block the save; affected named-place rules show evidence: null until those routes are warmed later.
Derive context
POST /project-rate-settings/derive-context previews what an auto location entry would derive to — the nearest routed active internal location, or a named unresolved reason — without writing anything. Route-cache warming in api-global still happens. dryRun must be true.
When candidates exist but none is geocoded the response records unresolvedReason: "no_coordinates" rather than a bare null. When the project exists but the caller does not have access to it, this route returns 403.
Conflict responses
PUT /project-rate-settings/rule-set returns 409 in these cases:
Project rates
Project rates hold fixed prices for one project. Areplacement prices an existing Rates Library item for that project. A project_only item and its prices exist only within that project until a member or admin promotes the item.
List project rates
GET /project-rates requires projectId. It accepts estimateId (optional, see below), scope (organisation or supplier), supplierId (a party UUID), status (current by default or all), search, start, and length. Search matches the item name, supplier code, supplier item name, and quote reference within the named project. scope and supplierId are independent filters: use scope=organisation to list internally priced items, or supplierId=<uuid> to list a specific supplier’s items.
Estimate-scoped reads. When a project has more than one estimate, each estimate holds its own copy of that project’s rates. Pass estimateId to read the rates for a specific estimate. Omitting estimateId returns the project’s default (primary) estimate rates, which is the only behaviour available until a project has been backfilled and a copy exists.
Each row identifies its kind, item, source and location, condition, unit, conversion factor, cost, quote details, provenance, notes, status, validity dates, and audit fields. sourceItemName stores the supplier’s wording for display and reconciliation; it does not affect identity, matching, or pricing. A replacement also carries library, the current library price range for the same condition.
An imported row also carries sourcePosition and sourceImportId, which keep the supplier quote’s own line order. sourcePosition is the row’s 1-based place in the import commit’s rates[], the order the document was read in. sourceImportId is shared by every option one import commit writes; positions are only comparable within one sourceImportId. Both are null together, on rows not written by an import and on rows imported before these fields existed. Importing a newer quote over an existing price re-stamps both.
The response includes pendingRepriceCount and:
Create a replacement rate
POST /project-rates/replacements records a project price for an existing library item. It requires projectId, rateItemId, scope, supplierId (required when scope is supplier, null when organisation), locationId, unit, costRate, and provenance. Optional quote, conversion, supplier-item, and notes fields describe the price.
A duplicate item, scope, supplier, location, and condition slot returns 409 current_exists with the existing option id.
Create a project-only rate
POST /project-rates/project-only creates a project-owned item and its first price in one transaction. It requires the replacement fields plus name and categoryId.
A matching library item returns 409 library_item_exists. A duplicate project-only name returns 409 project_name_taken.
Update a project rate
PUT /project-rates/:optionId?projectId=<uuid> updates a current rate. You can change price, unit, factor, supplier code and name (supplier prices only), quote fields, provenance, and notes. A project-only rate can also change its item name and category. Scope, supplier, location, condition, project, and item remain fixed.
Omitting a nullable field leaves it unchanged. Sending null, or a blank supplier item name, clears it. A retired rate returns 409 rate_retired.
A project rate priced by steps has no cost of its own. Changing its costRate returns 422 stepped_rate_cost_edit; its steps are edited on the rate option itself. A cost sent for a project rate priced by a rate table supersedes the table: the cost becomes the price and the table is removed in the same transaction. An update that sends no costRate keeps the table.
Delete a project rate
DELETE /project-rates/:optionId?projectId=<uuid> retires the option. It remains readable with status=all, so existing estimate references stay resolvable. Deleting the last current price on a project-only item also marks that item deleted.
Promote a project-only rate
POST /project-rates/:optionId/promote?projectId=<uuid> moves the item into the Rates Library while retaining its item id. It creates a library option from the project price. The project option remains and becomes a replacement; estimate lines do not move and this operation does not trigger re-pricing.
Promotion requires a signed-in member or admin. not_project_only, name_conflict, and rate_retired report invalid promotion states. Promoting a rate priced by steps copies its steps onto the new library option.
Automatic re-price
Each create, update, and delete commits a re-price outbox row with the rate change, then asks the Estimate API to re-price the project’s affected work-item lines. The write succeeds even if delivery fails. The write response’sreprice.status is:
When a parent rate moves to another supplier,
companionsRemoved lists fixed-charge companion lines removed during re-pricing.
Deliver pending re-prices
POST /project-rates/reprice-pending?projectId=<uuid> retries up to 500 pending rows, reduced to the latest change for each item and option. A frozen or missing estimate marks its rows delivered and skipped.
A successful response includes delivered, pending, companionsRemoved, and pendingReprice. If the Estimate API cannot be reached or refuses the operation, the endpoint returns 502, records the attempt, and leaves the rows pending. A companions_unavailable error names the blocking work item and line in pendingReprice.lastError.
Writes to a frozen estimate
Project rates lock per estimate. Before a user-facing project-rate write, the service reads the target estimate’s state from the Estimate API with the caller’s token. A write to a frozen estimate is refused and nothing is stored. These routes apply the check:POST /project-rates/replacementsPOST /project-rates/project-onlyPUT /project-rates/:optionIdDELETE /project-rates/:optionIdPOST /project-rates/:optionId/promotePUT /project-rate-settings/rule-setPOST /project-rate-imports
draft or review that have not been hidden as not awarded, and Delivery estimates in draft, are editable. Any other estimate returns:
The
message names the state and the way out, for example “This tender has been submitted, so its rates are read-only. Please use an alternative estimate to make changes.”
If the Estimate API cannot be reached or returns an unreadable answer, the write is refused with 502 and nothing is stored. An estimate the Estimate API does not know returns 404.
The estimate-rates service-login routes and re-price delivery are not checked: they are how a frozen estimate legitimately receives its rates.
Authentication
Any authenticated organisation member can list, create, update, retire, and retry project rates. Promotion requires a signed-in member or admin; a service token receives403.
Estimate rates
These routes are service-only. They are called by api-estimate under itsestimate-rates service login and are not accessible to user tokens or to other service logins.
Backfill project estimates
POST /estimate-rates/backfill stamps each named project’s existing estimate-less rate rows with the supplied estimate id and records that estimate as the project’s default. The default is what GET /project-rates?projectId=<id> (without estimateId) returns.
The request body contains a projects array of up to 200 { projectId, estimateId } pairs. Each project may appear once.
Each project in the response carries:
The route is idempotent. Calling it again for a project that is already stamped returns
stamped with zero counts.
Update the default estimate
PUT /estimate-rates/defaults updates the project’s default estimate — the estimate that GET /project-rates?projectId=<id> (without estimateId) returns and that the stamp trigger targets on estimate-less writes. api-estimate calls this after promoting an estimate to primary (make-primary or award) so that bare reads and writes follow the new primary.
The request body contains a projects array of up to 200 { projectId, estimateId } pairs. Each project may appear once. The whole call runs in one transaction; a project belonging to another organisation refuses the entire call with 409 project_not_in_organisation and nothing is written.
Each project in the response carries:
The call is idempotent. A repeat with the same estimate id returns
unchanged. Moving the default re-attributes no rows (every project row already carries its own estimate_id after migration 0059’s validated contract), triggers no re-price, and bumps no rate-set version.
Copy estimate rates
POST /estimate-rates/copy copies all project rates from a source estimate onto a target estimate within the same project. This is used when duplicating an estimate to give the copy its own independent rate set.
The request body requires sourceEstimateId, targetEstimateId, projectId, and correlationId (api-estimate’s copy job id, up to 128 characters). Source and target must differ.
The response carries:
The copy is refused with
409 copy_target_not_empty if the target estimate already holds project rates of its own. A replay with the same correlationId returns 200 and the stored id maps without writing anything. Any other request for a target already copied into returns 409 copy_target_taken.
Discard an abandoned copy
POST /estimate-rates/discard soft-deletes the project rates belonging to a copy whose target estimate was never finished. api-estimate’s sweeper calls this when it abandons a copy job that has been pending for more than 24 hours.
The request body requires projectId, targetEstimateId (the abandoned copy’s target), and correlationId (the same job id used in the original copy call). The route acts only on a target that this service copied into under that correlationId; any other value returns 404 copy_not_found. Discarding the project’s current default estimate returns 409 default_estimate.
The response carries:
The call is idempotent. A replay returns
200 with zero counts. A later copy attempt into a discarded target still returns 409 copy_target_taken.
Restore estimate rates
POST /estimate-rates/restore replaces the working estimate’s project rates with those from a saved version. In one transaction, every live project row on the target estimate is soft-deleted and the source estimate’s rows are copied in with new ids. The target’s pricing-location settings are set to match the source’s.
If the source has no live settings row (meaning the saved version had no explicit pricing location — it was deriving automatically), the target’s settings row is soft-deleted rather than hard-deleted. The target then reads as having no saved settings, the same as the source.
The request body requires sourceEstimateId, targetEstimateId, projectId, and correlationId (api-estimate’s restore job id, up to 128 characters). Source and target must differ. Unlike a copy, the same target estimate can be restored into multiple times using different correlation ids.
The response carries:
A first restore returns
201. A replay of the same (targetEstimateId, correlationId) pair returns 200 with the stored id maps and writes nothing. Replaying the same correlation id with a different source or project returns 409 restore_correlation_reused.
The restore does not enqueue a ProjectRatesChanged event and does not write a re-price outbox row.
Error codes:
Authentication
All routes in this group require theestimate-rates service login minted by api-estimate. Any other caller receives 403 estimate_rates_caller_required. Any other route accessed with this login receives 403 estimate_rates_only.
Day rate schedules
A day rate schedule is a contractual sell document: a schedule holds revisions, a revision holds lines, and a line carries a code, description, unit and day rate. Schedules can inherit from another schedule’s pinned revision at a ±% adjustment, and a rebase reviews what the base changed. Key semantics:- A revision freezes at publish.
currentandsupersededrevisions are immutable: linePOST,PUTandDELETEall return409. The one carve-out isPUT /day-rate-schedule-lines/:id/mapping, which records the internal rate-item mapping — analysis, not contract text — and is permitted on a published revision. - One draft at a time.
POST /day-rate-schedules/:id/revisionsrequiressourceRevisionIdexplicitly and is409if a draft already exists. - Publishing requires a later effective date. A revision cut from another copies its source’s
effectiveFrom; set the draft’s own date throughPUT /day-rate-schedule-revisions/:idbefore publishing, or the publish is422. DELETE /day-rate-schedules/:idarchives. It answers204, is idempotent, and returns409from no arm. Every revision, line, mapping, pricing rule and existing pin is preserved; the schedule leaves default listings, stays readable by id and through an explicit archived filter, and all writes against it are then409. The archived name stops reserving an active identity, so a new schedule may take it. UsePOST /day-rate-schedules/:id/restoreto make an archived schedule active again; restore returns409if another active schedule has since taken its name.- An archived base still serves rebases. An active dependant can still read a retired base’s published history and rebase from it; starting new inheritance from an archived base is blocked.
- A
cost_plusline has no number. It carries a percentage over internal cost and renders ascost + 25%; the schedule stores no money for it.
Schedule fields
List and get schedules
items, totalCount, start, and length.
Each item contains its revision history in descending revision-number order,
currentRevision, draftRevision, supersededRevisionCount, derivation, and
needsBaseReview.
Results are ordered by name, then id. Collection filters do not affect direct
lookups, so an archived schedule remains readable by id. A direct lookup returns
404 when the schedule does not exist or belongs to another organisation.
Create a schedule
name, lineAuthority, and effectiveFrom.
clientId, contractRef, termsText, and an inheritance object are optional.
The service creates revision 1 as a draft and snapshots the schedule, client,
and organisation identity. If identity resolution is unavailable, the request
returns 502 without creating the schedule.
To start from another schedule, supply:
adjustmentPct must be greater than -100, no greater than 1000, and have at
most three decimal places. Flat and fixed amounts are adjusted and rounded
half-up to cents. Cost-plus lines keep a null amount; their rules, along with
the other revision and line pricing rules, are copied unchanged.
The pin chain cannot contain a cycle or exceed depth 10. The complete schedule,
revision, adjusted lines, and pricing-rule copy is one transaction. Inherited
lines start without internal rate-option or activity mappings.
Duplicate a schedule
:id.
copy creates an independent schedule with no base pin. It preserves line
mappings and applies the requested adjustment to flat and fixed amounts.
inherit pins the selected revision, records the adjustment, and leaves copied
lines linked to their base provenance for later review and rebase. Both modes
copy the published source’s dates, terms, line authority, contract reference,
lines, and pricing rules, but snapshot the target name, client, and current
organisation identity. It returns 201 Created.
Update, archive, and restore a schedule
PUT body may change name, clientId, contractRef, or lineAuthority;
it must contain at least one field. Existing revision snapshots do not change.
New revisions capture the updated identity.
DELETE archives rather than physically deleting. Archived schedules remain
readable but reject writes, and repeated deletes still return 204. Restore is
also idempotent: calling it on an active schedule returns 200 without changing
the row. Restore requires the admin role; a member receives 403. Restoring
does not change contractual revisions, lines, mappings, or
pricing rules. A successful response contains the schedule identity and a
dependants array of active current or draft revisions directly pinned to the
schedule. These dependants are informative and do not block restoration.
Each dependant contains:
A restore can return two
409 responses. Branch on code, not the message:
existingId and existingStatus are absent from the unstable response because
there is no single blocking schedule to identify.
Revision fields
Create and edit a revision
sourceRevisionId. The source
must be a current or superseded revision of the same schedule. The service
copies its dates, terms, inheritance state, lines, mappings, and pricing rules,
but takes fresh identity snapshots. A draft source returns 422; an existing
draft returns 409. A concurrent identity change also returns 409, and the
caller should retry.
The revision PUT may change effectiveFrom, effectiveTo, or termsText and
must contain at least one field. Only a draft is editable; a published revision
returns 409. An invalid effective-date range returns 422.
Publish a revision
current and supersedes the previous current
revision. The new effectiveFrom must be later than the current revision’s, and
its range must not overlap another published revision except the current one it
supersedes. The service closes an open-ended current revision on the day before
the new revision begins. It also validates that each line’s rate kind agrees
with its pricing rules. Lifecycle or rule-agreement conflicts return 409;
invalid dates return 422.
Published revisions are immutable at the database boundary. Create another
draft to change their dates, terms, contractual lines, or pricing rules.
Schedule authentication
Derivation labels
Thederivation field describes the displayed revision:
Client-defined linesmeanslineAuthoritySnapshotisclient_defined.Independent · from YYYY-MM-DDmeans the revision has no base revision.- An inherited revision names the base schedule, its signed adjustment, and the pinned revision number, for example
Civil Rates +5% · pinned Rev 3.
Revision lines
A line is one contractual sell item owned by a revision.flat and fixed both carry an explicit contractual amount. A cost_plus
line carries no dayRate; its sell is resolved from a standard base pricing
rule on the line, falling back to the revision’s standard base rule.
POST creates a line and returns
201; PUT requires at least one writable line field; DELETE returns 204
and also removes the line’s pricing-rule state. The revision id and line id must
belong together or the API returns 404.
Published contractual content is immutable at the database boundary. Mapping
is the deliberate exception because it records internal cost analysis rather
than changing the issued document.
Line mapping
422 stepped_rate_not_mappable or
422 rate_table_not_mappable.
Mappings remain writable after publication. Deleting a mapped rate option
clears its pointer through the database foreign key; activity mappings are soft
references and can become unavailable in place.
Revert an inherited line
rateKind and its dayRate
after applying the revision’s adjustmentPct, then clears rateOverridden.
The line must carry derivedFromLineId; otherwise the API returns 422.
Review and rebase inheritance
An inherited non-superseded revision reportsneedsBaseReview: true when the
pinned schedule has a newer current revision that is not recorded in
baseAckRevisionId. You can acknowledge that base without changing contract
content, or preview and apply a three-way rebase into a new draft.
Preview a rebase
scheduleId, sourceRevisionId, oldBaseRevisionId,
targetBaseRevisionId, adjustmentPct, line buckets, and pricing-rule
ruleBuckets.
Every relevant line appears in exactly one bucket:
Line outcomes include the stable
lineKey, display fields, old-base,
new-base, current, and resulting amounts, the override marker, and a typed
outcome. Mappings on retained lines remain the dependant’s mappings.
Pricing rules are merged separately at revision and line scope. Their buckets
are adopted, retainedOverrides, local, removed, and unchanged. Each
entry identifies its scope, line key when applicable, slot, condition, the four
old/new/current/result rule values, and the outcome. Rule provenance is used to
distinguish a local override from a rule that merely equals its old base.
Preview requires an inherited current revision and a current revision on the
base schedule. It remains available when that base schedule has since been
archived, because existing pins retain access to published history.
Apply a rebase
The operation repeats the merge under schedule locks and creates a new draft
with the resulting lines and pricing rules. It returns
201 with the same
buckets plus revision. An existing draft returns 409. An unknown removal
key returns 422; duplicate removal keys fail schema validation. A duplicate
line code in the proposed result also returns 422 without creating a draft.
The supplied target revision is a concurrency token. If the dependant current
revision, source identity, or base current revision moved after preview, the
request returns 409; preview again rather than applying stale buckets.
Acknowledge a base
The route updates only
baseAckRevisionId and returns the refreshed revision
summary. It is valid on published contract content because acknowledgement is
review metadata, not a contractual edit. Repeating the same acknowledgement is
idempotent. A draft base or a revision from a different base schedule returns
422.
Live margins
sortOrder, then id order, with live analysis:
The response also contains
revisionId, mappedLines, and totalLines.
Nothing from this calculation is persisted.
For a rate-option mapping, the selected option identifies the item, source, and
location dimension. Cost comes from its live Standard option. The service
rejects inactive or expired options and items, rules that require inputs,
currency mismatches, missing effective costs, and unit conversions it cannot
perform. It normalises supported costs with the domain library and rounds money
half-up to cents.
For an activity mapping, the service reads up to eight activities concurrently.
The activity must be active and its unit must equal the line unit. A missing
activity makes only that line unavailable; it does not fail the revision read.
For cost_plus, the line’s standard base cost_plus rule takes precedence over
the revision rule. The service computes the sell from expected cost and the rule
percentage, then computes margin. A zero sell has no defined margin.
Export
Content comes from the revision’s snapshots, never from live lookups, and the payload type carries no cost field — one server-side builder is what makes that a structural guarantee rather than a rendering convention.
Rate imports
Rate imports expose a direct analyse-and-commit API as well as the asynchronous workflows used by the Rates page. The selection-first workflow uploads and parses a spreadsheet, analyses either confirmed blocks or one selected region, then starts extraction before commit. The whole-document survey workflow uploads and surveys the document before starting extraction and commit. Every endpoint requires a signed-in member or admin of the organisation.Analyse a workbook
The request body is limited to 5 MB, and the route accepts 10 requests per minute. A successful response contains the original file name, up to 20 warnings, up to 2,000 detected rate rows, an
unresolvedCategories array, and an unresolvedUnits array. The service can annotate a row with existingRateItemId and existingRateName as a review suggestion. The reviewer can accept that exact item or require a new one; the suggestion is not an irreversible match.
unresolvedCategories lists distinct category names that are absent or ambiguous in the catalogue. unresolvedUnits keeps unit problems separate. Each unit entry contains unit, category, reason (unknown or not_allowed), and the category’s allowed units. Map an unresolved category when a row will create an item; correct or choose a permitted unit for an unresolved unit. Mapping the same category does not make an incompatible unit valid.
For rows whose document category does not match any catalogue category and whose unit allows classification, the response may include suggestedCategory — a catalogue category name the model considers likely. The suggestion is advisory: the reviewer can accept it, choose a different category, or leave the row for manual resolution. It is absent for rows the model could not classify, for rows already bound to an existing rate item, and for rows whose document category already resolved.
For rows in the quarry_materials group with missing bulkingPercent or compactionPercent, the response may also include suggestedMaterialProperties with an optional bulkingPercent, optional compactionPercent, and a required basis that explains the estimate. The service returns these suggestions only when the corresponding ordinary field on the row is absent, and only after verifying the resolved category belongs to the quarry_materials group.
A provider failure or unusable AI response returns 502 without changing catalogue data.
Commit reviewed rates
POST /api/rates/rate-imports to save imported prices to the Rates Library. Use POST /api/rates/project-rate-imports to save them as project-scoped prices.
The body is the same for both routes. The optional projectId field (a GUID) selects which project the prices belong to:
POST /api/rates/rate-importsmust be called withoutprojectId(or with it set tonull). Supplying aprojectIdon this route returns422.POST /api/rates/project-rate-importsmust be called with a non-nullprojectId. OmittingprojectIdon this route returns422.
/rate-imports never carry projectId and reach only the library, while a client that supplies projectId must use the /project-rate-imports route to confirm the intent explicitly.
When projectId is present, the project must be accessible to the caller. An unknown or unavailable project returns 404. Imported options are written with that projectId and become project-scoped prices. They are visible in GET /project-rates and do not appear in the Rates Library listing. An item whose existingRateItemId belongs to a different project is refused with 422 and the whole commit is rolled back.
After a successful project import, the response includes the standard counts plus a reprice block (the same shape returned by project-rate writes):
reprice is absent on library imports.
A project import response also carries replacedRateCount: the number of placeholder prices the commit retired (see Placeholder replacement).
The commit contains the original fileName and 1–2,000 reviewed rows. Each row requires name, category, unit, costRate, confidence, sourceSheet, sourceRow, scope, supplierId (required when scope is supplier, must be absent or null when organisation), and locationId (a location UUID, or null for all locations). The locationId key must be present: a row that omits it is refused with 422 location_required, naming the first such row, and nothing is written. Optional row fields are code, sourceItemName, supplier, condition, conversionFactor, existingRateItemId, bulkingPercent, compactionPercent, maxTonnes, maxCubicMetres, banding, suggestedCategory, suggestedMaterialProperties, replacesRateOptionId (project imports only; see Placeholder replacement), and rateGroup (accepted but ignored; classification is derived from the catalogue).
Two optional top-level fields link the written options to the imported file:
importFileIdis the kept copy of the file, as the completed parse or survey poll returned it. It must be a live file of the caller’s organisation, otherwise the commit returns422 import_file_not_found. Every option the commit writes stores it assource_import_file_id, on both routes.sourceDocumentIdis the api-documents project document holding the file. Project imports only: a Rates Library import that sends it returns422 source_document_requires_project. Every option the commit writes stores it assource_document_id, so the project’s Tender check can read the quote the prices came from.
suggestedCategory and suggestedMaterialProperties are extraction metadata accepted by the shared row schema. They do not provide server-side fallback values during commit. The review screen copies accepted suggestions into the ordinary category, bulkingPercent and compactionPercent fields before submission. API clients must also submit their reviewed decisions in those ordinary fields; a suggestion does not bypass catalogue or unit validation.
suggestedMaterialProperties carries AI-suggested values for missing bulk-material physics on rows whose resolved or suggested category belongs to the quarry_materials group. It is an object with three fields:
The review screen pre-fills suggested percentages only for new
quarry_materials items and only where the ordinary field is absent or null. A value explicitly supplied by the file — including zero — takes precedence. At commit, the server validates the selected category’s permitted physical properties and stores the ordinary percentage fields for new items. Imports updating an existing item retain its stored physical properties. The basis remains review metadata and is not stored on the rate item. The top-level uploadType is optional and defaults to price_list; it also accepts project_quote and invoice. existingRateItemId has three distinct states: a UUID binds the option to that exact organisation-owned item, explicit null forces creation, and omission uses the service’s default matching.
banding represents a quantity-based price schedule. It contains tierBehaviour (whole_quantity or progressive) and 2–40 bands; each band contains a non-negative minQuantity with at most 3 decimal places, a non-negative unitPrice with at most 2 decimal places, and the document’s 1–40 character label. The first threshold must be 0 and later thresholds must be strictly ascending. When present, the commit uses the first band’s unit price as the option’s representative cost and writes the schedule as a tiered pricing rule. The document labels are review evidence and are not stored on that rule. Extraction assembles banding for both whole-document surveys (including PDFs) and selection-first spreadsheet paths when the reader returns qualifying quantity-band headings.
The top-level analysisId field is optional. When it identifies a confirmed-block analysis, the service saves that selection geometry against this document’s shape so it can be offered as a suggestion on future parses of the same layout. A region analysis is not saved as a template. The confirmed-block save runs after the transaction and does not fail the commit if it cannot write. Survey-path commits that carry no analysisId do not trigger a save.
The row’s code field becomes the option’s sourceItemCode: the supplier’s own code for that line. It is not stored on the rate item. On an organisation-scope row, the code still helps match the row to its item but is not stored.
The optional sourceItemName field becomes the option’s sourceItemName: the supplier’s own name for the item exactly as it appears on their document. On upsert, the document’s value wins only when it is present; an absent sourceItemName in the commit row leaves any previously stored name unchanged rather than clearing it. An organisation-scope row stores no sourceItemName; the importer drops it without an error.
Every imported option lands against a real owner. The importer maps reviewed rows to a real scope and, when scope is supplier, a real supplierId (a parties.id in the Global API). locationId is either a real locations.id or null for the all-locations target. It never invents a synthetic owner to hang a price on. Imported options are always fixed basis.
The optional categoryMappings array resolves category names to catalogue categories at commit time. Each entry provides a from (the name as it appears in the rows) and a toCategoryId. Rows with no matching entry fall back to exact category-name resolution. Duplicate from entries return 422. An unknown mapped id is a resolution failure and blocks the import only when that row would create an item; the response names every unresolved creating row and nothing is committed.
Before any mutation, the service checks rows that resolve to the same item for duplicate price slots. Two rows share a price slot when they agree on the resolved item, scope, supplier (supplierId), location (locationId), and condition. Rows in the same slot would compete to write the same database row; the service returns 422 naming the clashing rows rather than silently keeping one. To resolve the conflict, change the condition or location, point one row at a different rate item, or remove the duplicate row.
Rows that differ by condition (for example standard and night) or by location occupy different price slots and are valid.
The operation is atomic. For each row:
- A UUID in
existingRateItemIdadds the option to that exact item. If the row’s category resolves uniquely (or throughcategoryMappings), that answer also moves the item to the chosen category; an unknown or ambiguous category falls back to the item’s stored category. The move is refused atomically if it would leave incompatible physics fields or live prices behind. Prices replaced by this import, and multiplier prices that inherit from a replaced Standard price, are assessed using their incoming values rather than their old ones. Explicitnullcreates a new item even when analysis suggested a match. Sendingnullfor a name already held by an active or inactive item returns422— a rate item’s name is its identity and two non-deleted items cannot share one. - When
existingRateItemIdis omitted, the service first tries to match on(scope, supplierId, sourceItemCode, normalised name)by looking through the current options for that owner. If that finds nothing (for example the row is code-less, or no existing option carries that code at that source), the service falls back to a normalised name match against active or inactive items directly. Because the name uniqueness index guarantees at most one non-deleted item per normalised name, both arms always agree when they both find a result. - A row that does not reuse an item creates a new active item and an option, so its category must resolve and the imported unit must be permitted by it.
- Repeated default identities in one request reuse the item created by the first row.
201 Created with importedCount, createdItemCount and matchedItemCount.
Placeholder replacement
A placeholder is a current project price typed in by hand (provenance: manual). A supplier quote imported into the project replaces the placeholders on the item it is priced onto.
Poll. On a project poll (GET /api/rates/rate-imports/jobs/:jobId?projectId=...), a row that matched nothing by name or by supplier wording is checked against the project estimate’s own items before the AI suggestion. An item with a name similarity of 0.5 or more and a category that allows the row’s unit becomes the suggestion with matchSource: "project-rate". Its confidence is capped at 0.8, so it always needs review. On a project poll, every matched row also carries placeholderRates: the placeholders on the matched item in this estimate.
targetRateOptionId (a uuid) on the poll aims one row at a specific project price, for Update from import. It requires projectId; on a library poll it returns 400. The row already matched to that price’s item is chosen, otherwise the row whose name reads most like it (never a row bound to another item by exact name). That row returns replacesRateOptionId.
Commit. On POST /api/rates/project-rate-imports, a row priced onto an existing item retires that item’s placeholders in this estimate that have the row’s condition and either no supplier or the row’s supplier, plus the price replacesRateOptionId names. Each is retired as DELETE /project-rates/:optionId retires a price, with a deleted re-price row, in the same transaction. The response carries replacedRateCount.
Whole-document survey import flow
The current Rates page sends every PDF through the whole-document survey flow before any parse request is made. A spreadsheet always uses the selection-first parse and region-analysis flow. Both paths move extraction outside the HTTP request window. All endpoints require a signed-in member or admin.1. Upload the file
fileName (1–300 characters) and an optional contentType (defaults to application/octet-stream). The response contains uploadId, a presigned url, and expiresIn; upload the bytes directly to that URL. The organisation comes from the token and forms the storage key prefix.
2. Start and poll the survey
uploadId and fileName. The POST stores a queued survey and returns 202 { surveyId }; the worker reads and deletes the uploaded object, then analyses its structure. Legacy callers may send bounded sheets with fileName instead of an upload id.
The POST returns 422 when no suitable AI provider is configured, 429 after ten requests in one minute, and 502 if the work cannot be queued.
Poll until the survey status becomes terminal:
A complete response always includes
status: "complete" and importFileId (the kept copy of the file to pass to the commit, or null when none was kept), along with the persisted sheets, detected blocks, supplier/location/category candidates, unclassified headings, and warnings. Candidate matches are resolved against the caller’s live catalogue on each complete poll because the worker has no user token. If that lookup is unavailable, candidates remain unresolved and the response includes a warning instead of failing the survey.
3. Start and poll extraction
surveyId, fileName, 1–20 sheets, and optional vocabulary. Exactly one of the following must be present and non-empty:
blocks— up to 60 uniquely identified bounds-only blocks (the legacy path, re-typed by the model).confirmedBlocks— up to 60 uniquely identified confirmed blocks that the canvas has already assigned full column roles. Used exactly as given: no re-typing, no confidence test, and no model call during extraction.
400.
Each confirmed block carries the following fields:
headerGroups is valid only for a crosstab. Its row must exist in the sheet and come before firstDataRow. The group meaning must not collide with pivotMeaning or an assigned column role. Every span must stay inside the sheet, spans must not overlap, and together they must cover every pivot column. Violations return 422 with header_group_not_applicable, header_row_out_of_range, meaning_collision, span_out_of_range, span_overlap, or span_gap as applicable.
dismissedCells is optional. It lets the caller record priced cells the reviewer has decided are not rates. Each entry carries sheet, row (1-based), column (0-based), and an optional reason. Up to 500 cells may be dismissed. When confirmedBlocks is present, the service validates the selection and dismissals before queuing any block, returning 422 with a machine-readable code when a rule is violated.
The request body is limited to 5 MB and the route allows ten requests per minute. It returns 202 { jobId }.
The job poll reports queued, running, complete, failed, or cancelled, plus each block’s pending, running, done, failed, or empty state, attempt count, rate count, and any error. rates is withheld until the job is terminal so a partial extraction cannot be mistaken for final output. The terminal response also includes dismissedCells — the canonical list of cells dismissed in this job, in sheet/row/column order.
Project-scoped review. Pass ?projectId=<uuid> on the job GET to widen item matching during review. Without projectId, the reviewer sees only Rates Library items as match candidates. With it, the response also suggests items belonging to that project and excludes items that belong to other projects. The projectId query parameter affects only the suggestions on each rate row (existingRateItemId, and the placeholder fields); it does not change the extracted data.
Before queuing any blocks, this survey/legacy path validates the confirmed selection against the sheets submitted in the same request. A validation failure returns 422 immediately with a machine-readable code in the response body rather than accepting the job and surfacing the problem later as a failed block. One such code is header_row_missing: a crosstab block must have a headerRow because its pivot labels come from that row; submitting one without a header row is refused at this point rather than after extraction runs. The selection-first flow below has the stronger boundary: it validates against the server-held parse record and does not accept a client-supplied grid.
Retry one failed block without repeating its successful siblings:
202 { jobId }; a block not in failed state returns 409.
Optional: settle catalogue records and re-read
surveyId for a survey or analysisId for a selection analysis — plus an idempotencyKey, the candidates being decided, and the decisions. Each array accepts up to 200 entries.
For a create decision,
fields depends on the candidate kind:
- A source requires
name.sourceKindissupplierby default and also acceptsinternal;addressLine1,city,region, and two-lettercountryCodeare optional. - A location requires
nameandkind(office,yard,depot,branch,quarry, orother); its two-lettercountryCodeis optional. Its candidate must carryparentCandidateIdreferencing a source candidate whose decision successfully maps or creates the parent source. - A category requires
name,parentGroup, and 1–40allowedUnitCodes.
resolved and failed entries. Each failed entry is { candidateId, kind, name, code, message }. Two codes ask the person to act:
A completed key replays its stored result; a fresh in-flight key returns
409 with Retry-After: 2; a key whose heartbeat is older than 60 seconds is resumed.
4. Commit
Send the reviewed terminal rows toPOST /api/rates/rate-imports to save to the Rates Library, or to POST /api/rates/project-rate-imports with projectId to save as project prices. See the commit contract for the full body reference. The complete selected set is still one atomic transaction.
Selection-first flow
The selection-first API parses document structure deterministically, then accepts either confirmed blocks or one plain region for analysis. The current Rates page uses the region arm. Confirmed-block clients can reuse saved geometry from a previous import; the region canvas does not display or apply those suggestions.Start and poll a parse
fileName and uploadId and returns 202 { parseId }.
Poll until the parse status is terminal:
A
complete response includes:
sheets— the parsed grid.importFileId: the kept copy of the file to pass to the commit, ornullwhen none was kept.blocks— the detector’s proposed rate blocks.uncovered— priced cells no proposed block covers.gridUsability— whether the grid is selectable.suggestedSelection— a previously confirmed selection for this document shape, ornullif none is stored. When present, it containsselectionId, nullablelabel,lastUsedAt, nullablesourceScope(organisationorsupplier), nullablesupplierId(a party UUID, null for organisation scope or when the source was never learned),blocks, anddismissedCells. The two identity fields (sourceScopeandsupplierId) converge independently. A commit naming multiple suppliers yieldssourceScope: nullfor that template but does not make it permanently ambiguous; permanence begins only when a stored single-supplier ID later collides with a different incoming single-supplier ID. Different non-null file labels permanently clearlabel. A shared filename may therefore remain after source ambiguity, and a single source may remain after label ambiguity. A confirmed-block client may apply the saved blocks, but must submit them throughPOST /rate-imports/analysessovalidateConfirmedSelectionchecks them against the current grid. A moved column returnscolumn_out_of_range; a document that gained rows returnsuncovered_cell. The current region canvas leaves this field unused because confirmed blocks do not define one rectangular column range.
Start and poll an analysis
parseId and exactly one selection shape:
blockscontains 1–60 confirmed blocks and may be accompanied bydismissedCells.regioncontains one rectangle:sheet, 1-based inclusivefirstRowandlastRow, and 0-based inclusivefirstColumnandlastColumn.
202 { analysisId }. Confirmed blocks that fail coverage or column-range validation return 422 with a machine-readable code. An out-of-bounds region also returns 422; a region over 20,000 cells or 450,000 prompt characters returns 422 with region_too_large. Sending dismissedCells with region, or sending both selection shapes or neither, returns 400.
Poll until the analysis status is terminal. A complete response contains the kept blocks, entity candidates, undecided headings, and blocks excluded with their priced-cell counts.
Confirm catalogue records for confirmed blocks
For a confirmed-block analysis, send the candidates and decisions toPOST /api/rates/rate-imports/entities using the optional settlement and re-read contract. Name the run with analysisId, not surveyId, and include each undecided heading in unclassifiedIds with its corresponding classification. The current region path skips this endpoint and proceeds directly to job creation; its catalogue settlement happens on the review screen.
Create a job from an analysis
PassanalysisId and parseId to POST /api/rates/rate-imports/jobs. The server reads the grid from the parse record and the selection from the analysis record; the caller sends no grid. A confirmed-block job extracts the kept blocks. A region analysis has already made its single extraction call, so job creation stores those rates in an immediately complete block without queueing another worker. It returns 422 if the stored region or rates cannot be read, or if the analysis found no rates in the selected area.
Commit and save the selection
Pass the reviewed rows toPOST /api/rates/rate-imports (Rates Library) or POST /api/rates/project-rate-imports (project prices), with analysisId included in either case. After a successful confirmed-block commit, the service saves the selection against this document’s shape so future parses of the same layout receive a suggestedSelection. Region selections are not saved as templates. The confirmed-block save is per-organisation and keyed by document shape, not by supplier; it runs after the transaction and never fails the commit.
Kept import files
Look up files by option
The body is{ optionIds }, 1 to 2,000 rate option ids. The response is { items: [{ importFileId, fileName, contentType, optionIds }] }: one entry per live kept file, sorted by file name, listing which of the requested options were priced from it. Options with no kept file, or whose file is deleted, are left out. An invalid body returns 400.
Read a file’s text
The response is{ importFileId, fileName, pages: [{ label, text }], truncated }. The text comes from the newest parse of the file, or the newest survey when there is no parse. It is the content that parse stored, not the original bytes: one line per row, with non-empty cells joined by |. Each page or sheet is one entry, labelled with its sheet name or Sheet N, and empty pages are left out.
The text is capped at 300,000 characters and 500 pages. When the cap is reached, the response holds the start of the file and truncated is true. A file that does not exist, belongs to another organisation or has been deleted returns 404. An invalid id returns 400.
Companion option response details
A fixed companion option belongs to its own catalogue item and shares the same scope, supplier and location as its parent option. Itsfixed base pricing rule references the parent by companionOfOptionId, allowing an estimator to materialise a supplementary charge such as a call-out fee alongside a primary selection.
:id. The response includes each companion’s item detail, option detail, and a captured pricing envelope.
The endpoint is scoped to the caller’s organisation. A 404 is returned when the parent option does not exist or belongs to another organisation.
When the parent option is inactive or has a validTo date set, the response returns an empty companions array. The parent is no longer a selectable catalogue option, so no new companions are offered. Companions that have already been materialised onto an estimate are preserved and managed by the estimate service — they are not removed by this endpoint.
A companion is included in the response only when:
- the companion option is active and has no
validTodate; - the companion’s scope, supplier and location match the parent option’s exactly; and
- the companion’s supplier and location are both resolvable and active in the Global API.
Response shape
pricing.envelope is a point-in-time capture of the companion’s pricing rules at the moment of the request. It is the immutable bundle consumed by the estimate service when materialising a companion charge.
location is null when both the parent and companion apply to all locations.
The option block in the companions response does not include sourceItemName. That field is display and reconciliation data; the companions block is the pricing contract consumed directly by the estimate service, which rejects any unlisted key.
Day-rate archive and export constraints
Archive responses
scheduleStatus field on the schedule changes from active to archived.
An archived schedule is read-only. Mutation endpoints return 409 Conflict when the archived schedule is their target. An active dependant can still rebase from the archived base’s published history.
The endpoint is idempotent: archiving an already-archived schedule returns 204 without error.
Export limits and responses
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) with a Content-Disposition: attachment header. The file is limited to 25 MB after generation.
PDF format returns the shared JSON export payload (Content-Type: application/json) consumed by the browser print renderer. The payload includes the schedule and revision identity snapshot, all rate lines with rendered rate text, pricing rules described as human-readable strings, and the revision’s terms text.
Both formats set Cache-Control: private, no-store and include all lines in sortOrder ascending, id ascending order. The shared payload is cost-free: it contains contractual rates and rules but never raw cost or margin values.
XLSX workbook structure
The workbook contains two sheets:- Day Rates — schedule and revision metadata, then one row per rate line with its code, description, unit, rate or rate text, and a display summary of its rules. Schedule-wide rules, terms, and the document footer follow the line table. The footer reads
Generated YYYY-MM-DD · Revision <uuid>. The date is the calendar day in the organisation’s configured time zone (fromGET /api/global/organisation-settings→timezone), falling back toPacific/Aucklandwhen the setting is absent or unreadable. - Rules — a lossless flat list of every schedule-wide and line rule, including continuation rows when a rule exceeds Excel’s cell limit.
=, +, -, or @ are prefixed with a single quote. Cell content is capped at Excel’s 32,767-character limit without splitting Unicode surrogate pairs.
Rate-option pricing and write details
Every rate option response includes apricing object that summarises the effective pricing rules for that option. The service merges the option’s owned rules with rules inherited from its Standard option where applicable.
Response pricing summary
Inline pricing rules on create and update
POST /rate-items/:id/options accepts an optional pricingRules array. The option and its rules are created in one transaction; no version is needed because the option has no rule set yet.
PUT /rate-options/:id accepts an optional pricingRules and version pair. When supplied, the option’s owned pricing rules are atomically replaced as part of the same request. Both fields must be present together; sending one without the other returns 422.
Any signed-in member or admin who can update the option can also replace its pricing rules.
A multiplier option cannot own a base pricing rule. If the option is switching basis from fixed to multiplier and the current owned rules include a base rule, the update is rejected with 422. Clear the base rule with an inline pricingRules replacement in the same request as the basis change.
Must-pick options. An option priced by steps (a stepped base) or by a rate table (a matrix base) is must-pick: the estimator chooses a step or row on the line, and the option’s regular cost never applies. A fixed must-pick option therefore carries no costRate. Create one in a single request by sending pricingRules and omitting costRate, or convert an existing option with a PUT that sends both. The guards are:
A step is never removed, because an estimate line stores its pick by the step’s key. Mark it
retired: true instead. A stepped base cannot carry a minimum or a modifier.
A rate import skips any row that would overwrite the cost of an existing must-pick option and reports it with “This rate is priced by steps. Please change it in Rates.” or “This rate is priced by a rate table. Please change it in Rates.”
Pricing a must-pick charge with nothing chosen returns 422 with code rate_table_row_not_chosen or step_not_picked and a plain-language message.
Rate-option permissions
Pricing-rule contract details
Pricing rules attach conditional price computation logic to a rate option, a day-rate schedule revision, or a day-rate schedule line. Each rule is keyed byownerType, ownerId, slot, and condition. Rules owned by a rate option can be written by any signed-in member or admin. Rules owned by a day-rate schedule revision or line require the admin role.
Owner types
Slots and rule kinds
Rule kinds
simple — A single unit price with no banding. Slot: base.
tiered — Quantity-banded pricing. Slot: base. Only allowed on rate_option owners.
matrix — Two-axis banded pricing. Slot: base. Only allowed on rate_option owners.
fixed — A fixed lump-sum amount. Slot: base.
cost_plus — A percentage markup on the underlying cost. Slot: base. Not allowed on rate_option owners.
stepped: user-named lump-sum steps, one of which the estimator picks on the line. Slot: base. Only allowed on rate_option owners, and cannot be combined with a minimum or a modifier. The option carries no costRate.
Each step has a
key (the stable identity a line’s pick stores: letters, digits, - and _, starting with a letter or digit, up to 64 characters, unique in the rule), a label (up to 80 characters, distinct among pickable steps ignoring case), an amount (a lump sum of at least 0, to the cent), and optional retired: true. A retired step is no longer offered but still prices a line that already picked it.
minimum — Sets or suppresses a minimum charge. Slots: minimum_quantity, minimum_amount.
Either value (the minimum as a number) or suppress: true must be present; exactly one must be provided.
modifier — Adjusts the effective price. Slots: modifier_additive, modifier_multiplicative.
Versioning
Every rule set has a SHA-256 version computed from the canonical JSON of the sorted rules. All write endpoints require the caller to supply the currentversion as a write-conflict guard. If the version does not match the stored rules, the request returns 409 Conflict.
Retrieve the current version with GET /api/rates/pricing-rules?ownerType=...&ownerId=....
Get rules for an owner
ownerType, ownerId, version, and rules.
Requires an authenticated caller with an organisation. Returns 404 when the owner does not exist or belongs to another organisation.
Replace rules for an owner
version (the current rule-set SHA-256) and rules (the full replacement rule list).
A rate-option owner requires a signed-in member or admin. A day-rate revision or line owner requires the admin role.
Replace rules for multiple owners
ownerType, ownerId, version, and rules.
No owner may appear more than once in the batch. Owners are locked in ascending ownerId order to prevent deadlocks.
A batch is held to its strictest owner: rate-option owners only need a signed-in member or admin, and any day-rate owner requires the admin role. The operation is all-or-nothing: a version mismatch or validation failure for any entry rolls back all writes.
Revalidate rules
ownerType + ownerId) or a candidate rule list (ownerType + rules).
Returns { valid: boolean, errors: [...], version?: string }. When ownerId is supplied, version contains the current stored version.
List condition tokens
{ tokens: string[] } with tokens sorted alphabetically.
Condition re-key response details
A condition token is shared across rate options and the pricing rules and day-rate schedule loadings that reference those options. Renaming a token requires migrating every dependent record atomically. The condition re-key endpoint handles this migration.Two-step flow
The migration uses a dry-run / commit pattern to prevent partial writes when the preview becomes stale between steps. Step 1 — dry run
Step 2 — commit
planHash must match the hash from the dry run. If the plan has changed since the dry run (because another writer modified the dependent records), the endpoint returns 409 Conflict with the message Condition re-key preview is stale; run the dry run again.
Collisions
A collision blocks the commit. Collisions are reported in the dry-run response before any write is attempted.Condition token format
Condition tokens must match^[a-z0-9]+([ _-][a-z0-9]+)*$ and be at most 40 characters. The endpoint normalises the input: it trims whitespace and lower-cases the token before validation.
Authentication
Requires a signed-in member or admin. Returns403 for a service token. Returns 404 when the rate option is not live, does not exist, or belongs to another organisation.
Day rate schedule imports
client_defined: its lines are the client’s contractual text, usable before any of them is mapped to an internal rate item.
