> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Rates API

> Rates module API

The Rates API owns the rate catalogue, its pricing rules, day rate schedules, and project-level rate context. Shared records such as suppliers, company locations, and rate categories stay in the Global API.

Rates routes use the shared EdDSA bearer-token verifier. Every non-exempt API
route requires a valid user or service token. See
[Authentication](https://help.stralos.app/api/authentication.md).

> [!NOTE]
>
> **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`.

> [!NOTE]
>
> **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

```text theme={null}
/api/rates
```

## Endpoint groups

| Group | Routes |
| - | - |
| Version | `GET /version` |
| Rate items | `GET /rate-items`, `POST /rate-items`, `GET /rate-items/:id`, `PUT /rate-items/:id`, `DELETE /rate-items/:id`, `GET /rate-items/:id/history`, `POST /rate-items/lookup`, `GET /rate-items/similar` |
| Rate options | `GET /rate-items/:id/options`, `POST /rate-items/:id/options`, `GET /rate-options/:id`, `PUT /rate-options/:id`, `GET /rate-options/:id/companions`, `POST /rate-options/:id/rekey-condition` |
| Rate history | `GET /history`, `POST /history/:id/restore` |
| Rate summaries | `GET /rate-summaries` |
| Rate sites | `GET /rate-sites` |
| Pricing rules | `GET /pricing-rules`, `PUT /pricing-rules/:ownerType/:ownerId`, `PUT /pricing-rules/batch`, `GET /pricing-rules/condition-tokens`, `POST /pricing-rules/revalidate` |
| Project rate context | `GET /project-rate-settings`, `PUT /project-rate-settings/rule-set`, `POST /project-rate-settings/derive-context` |
| Project rates | `GET /project-rates`, replacement and project-only creates, option updates and deletes, promotion, and pending repricing |
| Estimate rates | `POST /estimate-rates/backfill`, `PUT /estimate-rates/defaults`, `POST /estimate-rates/copy`, `POST /estimate-rates/restore`, `POST /estimate-rates/discard` (service-only) |
| Day rate schedules | Schedule, revision, line, rule, margin, rebase, publish, export, restore, and duplicate routes |
| Rate imports | Upload, survey, entity, job, parse, analysis, retry, library commit, and project commit routes |
| Kept import files | `POST /rate-import-files/lookup`, `GET /rate-import-files/:importFileId/text` |
| Day rate imports | `POST /day-rate-schedules/import/parse`, `POST /day-rate-schedules/import/commit` |

## Removed routes

These eleven routes were removed with the rate-option model. They are absent from
the OpenAPI document and return `404`. No compatibility shim exists.

```text theme={null}
GET    /rate-items/:id/offerings
POST   /rate-items/:id/offerings
PUT    /rate-offerings/:id
DELETE /rate-offerings/:id
GET    /rate-items/:id/prices
GET    /rate-offerings/:id/prices
POST   /rate-offerings/:id/prices
POST   /rate-prices
PUT    /rate-prices/:id
DELETE /rate-prices/:id
POST   /rate-price-options/batch
```

The fields those routes carried are gone with them. `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 return `404 Not Found`:

```text theme={null}
GET    /project-rate-overrides
POST   /project-rate-overrides
PUT    /project-rate-overrides/:id
DELETE /project-rate-overrides/:id
```

Existing converted prices remain project rate options with their original ids.
Use the project rates routes:

* Read `GET /project-rates?projectId=<id>&status=current`.
* Create with `POST /project-rates/replacements` or `POST /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 return `404`:

```text theme={null}
PUT    /project-rate-settings
POST   /project-rate-rules
PUT    /project-rate-rules/:id
DELETE /project-rate-rules/:id
```

Use `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](#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.

```json theme={null}
{ "version": "1.2.3", "commit": "0123456789abcdef0123456789abcdef01234567" }
```

This endpoint is **unauthenticated** and returns `Cache-Control: no-store`.

| Field | Source | Fallback |
| - | - | - |
| `version` | `SERVICE_VERSION` environment variable | `package.json` version |
| `commit` | `SERVICE_COMMIT` environment variable | `"unknown"` |

`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 returns `404`; 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

| Resource | Read | Write |
| - | - | - |
| Rate items, including the batch lookup | Authenticated caller with an organisation | Create and update: **org member or admin**. Delete: **org admin** |
| Rate options, including inline `pricingRules` | Authenticated caller with an organisation | **Org member or admin** |
| Condition re-key | — | **Org member or admin** |
| Rate summaries and rate sites | Authenticated caller with an organisation | — |
| Pricing rules | Authenticated caller with an organisation | Rate-option owners: **org member or admin**. Day-rate revision and line owners: **org admin** |
| History restore | — | **Org member or admin**. Bringing back a deleted item: **org admin** |
| Project rate context and rules | Authenticated caller with an organisation | Authenticated caller with an organisation |
| Project rates | Authenticated caller with an organisation | Authenticated caller with an organisation. Promotion: **org member or admin** |
| Day rate schedules and revisions | Authenticated caller with an organisation | **Org admin role required** |
| Day rate export | Authenticated caller with an organisation | — |
| Rate imports | — | **Org member or admin** |
| Day rate imports | — | **Org admin role required** |
| Kept import files (lookup and text) | Authenticated caller with an organisation | — |

"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

```text theme={null}
GET /api/rates/openapi.json
```

Returns the OpenAPI 3.1 document generated at service boot from the Rates API's route-descriptor registry.

* **Unauthenticated**: no bearer token is required.
* **Cached bytes**: the document is serialised once at boot and returned unchanged on every request. `Cache-Control: no-store` is set so intermediate caches do not hold stale copies.
* **Contract version**: `info.version` is 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: undocumented` in 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 returns `503` with `Retry-After: 5` and `code: "service_unavailable"`; any other unhandled error returns `500` with `code: "internal_error"`. In both cases, `reference` equals the `x-request-id` response header value and can be used to correlate the incident in service logs. See [Safe error handler](https://help.stralos.app/internal/rates-service.md#safe-error-handler) for operational details.

### Security

The OpenAPI document marks every included operation with the document-level `bearerAuth`
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.

> [!NOTE]
>
> **`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`.

| Field | Type | Description |
| - | - | - |
| `name` | string | Required. Maximum 200 characters. Must be unique among the organisation's non-deleted items (case-insensitive, with runs of whitespace normalised). Active and inactive items cannot share a normalised name; a `POST` or `PUT` that would produce a duplicate is refused with `409`. |
| `categoryId` | uuid | Required. A category in the organisation's catalogue, owned by the Global API. |
| `status` | `active` \| `inactive` \| `deleted` | Defaults to `active`. |
| `bulkingPercent` | number \| null | Bulk-material physics. `12` means 12 %. Licensed only by a `quarry_materials` category. |
| `compactionPercent` | number \| null | Bulk-material physics. Licensed only by a `quarry_materials` category. `null` means not applicable, and is not the same as `0`. |
| `maxTonnes` | number \| null | Cartage capacity. Licensed only by a category flagged as a cartage vehicle. |
| `maxCubicMetres` | number \| null | Cartage capacity. Licensed only by a category flagged as a cartage vehicle. |

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 a `404` unless `?projectId` names its project.
* `GET /rate-items/:id/options` — without `projectId` only Rates Library options are listed and a project-only item is a `404`; with it, the library’s options plus that project’s options are returned (each option carries `projectId`).
* `GET /rate-options/:id` — a project-scoped option is a `404` unless `?projectId` names its project.
* `GET /rate-options/:id/companions` — a project option as the parent is a `404` unless `?projectId` names its project, and companions are drawn from the library plus that project only.

### Batch item and option read

```text theme={null}
POST /api/rates/rate-items/lookup
```

Reads up to 100 rate items by id in one request, each with all of its options, unpaged. It is the batch form of `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.

| Query parameter | Description |
| - | - |
| `projectId` | Optional. Without it only Rates Library rows are read. With it, the library plus that project's rows are read, as for the per-item reads. |
| `estimateId` | Optional. Narrows the project rows to that estimate's. |
| `includeInactive` | `true` to include inactive and expired options. Live options only by default. |
| `includeDeleted` | `true` to include soft-deleted items. Unlike `GET /rate-items/:id`, a deleted item is left out by default. |

The response is:

```json theme={null}
{
  "items": [{ "item": { "id": "<uuid>" }, "options": [] }],
  "missingIds": ["<uuid>"]
}
```

Every id that is not answered appears in `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

```text theme={null}
GET /api/rates/rate-items/similar
```

Returns up to five live rate items whose name looks like `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.

| Query parameter | Type | Description |
| - | - | - |
| `name` | string | Required. The name being typed, 1 to 200 characters after trimming. |
| `projectId` | uuid | Optional. Adds that project estimate's own items to the Rates Library. Another project's items are never included. |
| `estimateId` | uuid | Optional, with `projectId`. Chooses the project estimate; without it, the project's default estimate is used. |
| `excludeRateItemId` | uuid | Optional. Leaves out the item being edited, so it is not reported as a duplicate of itself. |

```json theme={null}
{
  "matches": [
    {
      "rateItemId": "<uuid>",
      "name": "AP40 Basecourse",
      "category": "Aggregates",
      "categoryId": "<uuid | null>",
      "scope": "library",
      "companies": [{ "id": "<uuid>", "name": "<string | null>" }],
      "ownPrice": false,
      "similarity": 0.87
    }
  ]
}
```

| Field | Description |
| - | - |
| `scope` | `library` for a Rates Library item, `project` for an item only that project has. |
| `companies` | Suppliers with a current price on the item, sorted by name. `name` is `null` when api-global could not be read; the response is still `200`. |
| `ownPrice` | `true` when the organisation also has a current price of its own on the item. |
| `similarity` | Name similarity from 0 to 1, rounded to three decimal places. |

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.

| Token | Meaning |
| - | - |
| `labour` | Labour roles and crew items |
| `plant_vehicles` | Plant, vehicles, and trucks |
| `quarry_materials` | Bulk materials sold by volume or weight |
| `other_materials` | Other materials |
| `subcontractors` | Subcontractor items |
| `disposal` | Disposal facilities and acceptance types |

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

```text theme={null}
DELETE /api/rates/rate-items/:id
```

Deletion **marks, it does not remove**. The item's `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

```text theme={null}
GET /api/rates/rate-items/:id/history
```

Returns a paginated, newest-first log of every recorded change to a rate item and to its rate options. Each entry covers one write that moved at least one tracked field.

| Field | Type | Description |
| - | - | - |
| `entityType` | `rate_item` \| `rate_option` | Whether the change was to the item itself or to one of its options. |
| `entityId` | uuid | The id of the changed entity. |
| `action` | `created` \| `updated` \| `deleted` | What happened. |
| `changedBy` | uuid \| null | The user who made the change. `null` when the write was made by a service account with no user context. |
| `changedAt` | datetime | When the change was recorded. |
| `changes` | object | Field-level diff: `{ fieldName: { old, new } }` for every tracked field that moved on that write. Only changed fields are included. |
| `actorOrgId` | uuid \| null | The contractor company the change came through, when a contractor person made it or a contractor copy or refresh wrote it. `null` for the company's own people and for changes recorded before contractor companies existed. |
| `actorOrgName` | string \| null | The contractor company's name, stored with the change so it still reads after the link ends. |
| `source` | `manual` \| `copy` \| `refresh` | How the change was made: by hand, by the first copy from a contractor company's library, or by a later refresh from it. |

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.

| Field | Type | Description |
| - | - | - |
| `scope` | `organisation` \| `supplier` | Required. `organisation` means this is one of the organisation’s own prices. `supplier` means the price belongs to a third-party supplier. |
| `supplierId` | uuid \| null | Required when `scope` is `supplier`; must be null when `scope` is `organisation`. A `parties.id` in the Global API. |
| `locationId` | uuid \| null | Required field (nullable). The specific location this price applies to (`locations.id` in the Global API). `null` means the option applies to all locations. |
| `unit` | string | Required. The unit this option is **priced in**, which may differ from a consuming line's target unit. Maximum 40 characters. |
| `condition` | string | The condition token, defaulting to `standard`. Maximum 40 characters. The API trims whitespace and lowercases the value before applying the one-current uniqueness check, so `" NIGHT "` and `"night"` have the same identity. The stored value must match `^[a-z0-9]+([ _-][a-z0-9]+)*$`. |
| `basis` | `fixed` \| `multiplier` | Defaults to `fixed`. |
| `costRate` | number \| null | The cost. Required on a `fixed` option and **must be null** on a `multiplier` one. |
| `basisMultiplier` | number \| null | Required on a `multiplier` option and must be null on a `fixed` one. |
| `conversionFactor` | number \| null | Optional positive conversion scalar. Bulk-material (`quarry_materials`) factors are densities; other cross-unit factors require an established orientation before pricing can use them. |
| `conversionNumeratorUnit` | string \| null | The numerator unit of the conversion factor's orientation — for example `metre` in a metres-per-length factor. Null when no orientation is recorded. |
| `conversionDenominatorUnit` | string \| null | The denominator unit of the conversion factor's orientation — for example `length` in a metres-per-length factor. Null when no orientation is recorded. |
| `sourceItemCode` | string \| null | Optional. The supplier's own code for the line. Maximum 80 characters. Must be null or omitted when `scope` is `organisation` (see below). |
| `sourceItemName` | string \| null | Optional. The supplier's own name for the line, exactly as it appears on their price list or invoice. Maximum 200 characters. Display and reconciliation only: it does not affect identity, the import matcher, or pricing. On `PUT`, omitting the field leaves any stored name unchanged; sending `null` clears it. Must be null or omitted when `scope` is `organisation`. |
| `provenance` | `manual` \| `price_list` \| `project_quote` \| `invoice` | Defaults to `manual`. |
| `currency` | `NZD` | Single-currency in this phase; the column exists so multi-currency is additive later. |
| `status` | `active` \| `inactive` | Defaults to `active`. |
| `validFrom` / `validTo` | date-time | The commercial validity the source quotes. A `PUT` mutates in place and the id is stable, so a pinned option id never migrates. |
| `needsReview` | boolean | A reminder flag. It does not change how the option prices or stop it being used. |
| `notes` | string \| null | Optional. Maximum 1000 characters. Send `null` to clear. |

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:

| `rateGroup` / option unit | Derived orientation |
| - | - |
| `quarry_materials` (any unit) | `tonne` per `cubic_metre` |
| `length` | `metre` per `length` |
| `roll_length` | `metre` per `roll_length` |
| `roll_area` | `square_metre` per `roll_area` |
| `sheet` | `square_metre` per `sheet` |
| `week` or `month`, any group except `quarry_materials` and `disposal` | `day` per `week` or `day` per `month` |

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:

| Option unit | Factor read as |
| - | - |
| Any unit, `quarry_materials` group | `tonne` per `cubic_metre` (density) |
| `length` (len) | `metre` per `length` |
| `roll_length` | `metre` per `roll_length` |
| `roll_area` | `square_metre` per `roll_area` |
| `sheet` | `square_metre` per `sheet` |

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 per `week` 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

A `multiplier` 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 to `multiplier` with no Standard present is `422`.
* 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'` requires `basis = 'fixed'`.

**Every transition that removes a Standard's availability converts its dependants first**, in the same transaction: retiring it, expiring it through `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:

```text theme={null}
POST /api/rates/rate-options/:id/rekey-condition
```

The re-key is a value migration with a mandatory preview:

```jsonc theme={null}
// Step 1 — preview
{ "newToken": "night", "dryRun": true }

// Step 2 — commit, echoing the plan hash from step 1
{ "newToken": "night", "dryRun": false, "planHash": "<64 hex chars>" }
```

The dry run returns two dependant lists — **mutable** dependants (rate options, and loadings on *draft* schedule revisions), which the commit re-keys in the same transaction, and **immutable** dependants (loadings on `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

```text theme={null}
GET /api/rates/rate-options/:id/companions
```

Reverse-discovers the active fixed-charge options configured against one parent option — a call-out fee beside an hourly rate, for example — including each child’s own item, supplier, location, and frozen pricing envelope. A companion lives on its own catalogue item, so it is not reachable by listing the parent item's options.

### Retiring an option

Options are retired, never deleted, so their history stays intact. To withdraw an option from sale, set `status` 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

```text theme={null}
GET /api/rates/rate-summaries
```

Returns each non-deleted item with its live option count, its distinct sources, and a cost range **normalised to the item's `modeUnit`** (or, under a `sources` or `units` filter, to the matching options' mode unit).

```json theme={null}
{
  "items": [
    {
      "item": { "id": "...", "name": "AP40", "...": "..." },
      "modeUnit": "cubic_metre",
      "densityTPerM3Min": 1.65,
      "densityTPerM3Max": 1.70,
      "densityIssue": null,
      "optionCount": 4,
      "suppliers": [{ "id": "<uuid|null>", "name": "<string|null>" }],
      "locations": [{ "id": "<uuid>", "name": "<string|null>" }],
      "hasAllLocations": false,
      "options": [
        {
          "id": "<uuid>",
          "rateItemId": "<uuid>",
          "scope": "supplier",
          "supplierId": "<uuid|null>",
          "locationId": "<uuid|null>",
          "condition": "standard",
          "basis": "fixed",
          "costRate": 48.50,
          "unit": "cubic_metre",
          "validFrom": "<ISO 8601 datetime>",
          "validTo": null,
          "supplier": { "id": "<uuid>", "name": "<string|null>", "status": "<string|null>", "isSupplier": "<boolean|null>" },
          "location": { "id": "<uuid>", "name": "<string|null>", "partyId": "<uuid|null>", "status": "<string|null>" },
          "derivedCostRate": null,
          "pricing": { "..." },
          "fromUnitPrice": 48.50,
          "version": "<hex>",
          "sourceFingerprint": "<hex>",
          "rules": []
        }
      ],
      "costRate": { "min": 40.7, "max": 62.93, "unit": "cubic_metre" },
      "stepAmountRange": null,
      "excludedOptionCount": 1,
      "excludedReasons": {
        "currency_mismatch": 0,
        "conversion_missing": 1,
        "no_effective_cost": 0
      }
    }
  ],
  "totalCount": 42,
  "start": 0,
  "length": 20,
  "unresolvedCategoryCount": 7
}
```

`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 in `options` 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](#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](#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 the `quarry_materials` group, each summary row includes a loose-density range derived from its live priced options.

| Field | Type | Description |
| - | - | - |
| `densityTPerM3Min` | number \| null | Lowest readable density in t/m³. |
| `densityTPerM3Max` | number \| null | Highest readable density in t/m³. Equal to the minimum when the options agree. |
| `densityIssue` | string \| null | Why no readable range is available. It is `null` when a range was found and for non-bulk rate groups. |

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.

| `densityIssue` | Meaning |
| - | - |
| `no_priced_option` | The item has no live priced option. |
| `no_conversion_factor` | Live options exist, but none has a conversion factor. |
| `unreadable_orientation` | No factor can be read as mass per volume. |

### Query parameters

Supports `search`, `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

```text theme={null}
GET /api/rates/rate-sites[?rateGroup=<group>]
```

Returns the distinct supplier sites that hold a current Rates Library price, as `{ 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.

```text theme={null}
GET /api/rates/pricing-rules
PUT /api/rates/pricing-rules/:ownerType/:ownerId
PUT /api/rates/pricing-rules/batch
GET /api/rates/pricing-rules/condition-tokens
POST /api/rates/pricing-rules/revalidate
```

Rules are owner-discriminated: an owner is a **rate option**, a **day rate schedule revision**, or a **day rate schedule line**. One table and one validator serve all three, so a schedule's night loading is validated by the same code as a supplier's band schedule.

**A rule set is saved whole.** The `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

```text theme={null}
GET  /api/rates/project-rate-settings?projectId=
PUT  /api/rates/project-rate-settings/rule-set
POST /api/rates/project-rate-settings/derive-context
```

These replace the old region string match with an ordered list of rate rules and a pricing-location entry. A rule is scoped to a rate group, category, or item. Current rules use `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.

| `partyId` | `locationId` | Source target |
| - | - | - |
| `null` | `null` | Own organisation's location-less (All locations) price. |
| `null` | Location ID | Own organisation at that location. |
| Supplier ID | `null` | That supplier's location-less (All locations) price. |
| Supplier ID | Location ID | That supplier at that location. |

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-based `position` 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:

| Field | Type | Description |
| - | - | - |
| `projectId` | uuid | The project whose rule set is being replaced. |
| `expectedSettingsUpdatedAt` | datetime \| null | The `settingsUpdatedAt` from the last read. `null` if the project has no existing settings row. A mismatch is `409 settings_changed`; nothing is written. |
| `entries` | array | The full ordered list: exactly one `location` entry and any number of `rule` entries. Minimum 1 entry. Maximum 500 entries. |

Each `location` entry has:

| Field | Type | Description |
| - | - | - |
| `kind` | `"location"` | Entry type discriminant. |
| `source` | `"auto"` \| `"manual"` \| `"none"` | How the pricing location is chosen. `auto` derives the nearest routed internal location. `manual` uses the supplied `locationId`. `none` means no pricing location. |
| `locationId` | uuid \| null | Required for `manual`. Must be `null` for `none`. For `auto`, it is the location the client last reviewed — if the server derives a different location, it returns `409 derived_context_changed` with the would-be settings in `settings`, and nothing is written. |

Each `rule` entry has:

| Field | Type | Description |
| - | - | - |
| `kind` | `"rule"` | Entry type discriminant. |
| `id` | uuid | Optional. When present, updates that live rule (its scope is immutable: a changed scope returns `422 rule_scope_immutable`). When absent, creates a new rule. A live rule whose `id` is not in the list is **soft-deleted**. |
| `scopeKind` | `"group"` \| `"category"` \| `"item"` | The scope this rule applies to. |
| `scopeRef` | string | A rate-group token for `group`, or a UUID for `category` and `item`. |
| `ruleKind` | `"source"` \| `"policy"` | The selection kind. |
| `partyId` | uuid \| null | Required for a `source` rule. `null` means the organisation's own prices; a supplier ID selects that supplier. |
| `locationId` | uuid \| null | Required for a `source` rule. `null` selects the owner's location-less (All locations) prices; a location ID selects that owner's named-location prices. The location must belong to `partyId`, or the write returns `422 source_owner_mismatch`. |
| `policyKind` | `"cheapest_within_km"` | Required for `ruleKind: "policy"`. |
| `policyRadiusKm` | number | Required for `ruleKind: "policy"`. Positive, at most 2000, and a multiple of 0.1. |
| `notes` | string \| null | Optional. Maximum 500 characters. |

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:

| Field | Type | Description |
| - | - | - |
| `distanceKm` | number | Driving distance in kilometres. |
| `durationMin` | number | Estimated driving duration in minutes. |
| `fetchedAt` | string | ISO timestamp of the last route fetch. |
| `stale` | boolean | `true` when the cached route needs refreshing. |

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:

| `code` | Meaning |
| - | - |
| `settings_changed` | `expectedSettingsUpdatedAt` does not match the stored settings. Nothing was written. Re-read and retry. |
| `derived_context_changed` | The `auto` derive now picks a different location than `locationId`. The response body includes `settings` with the would-be payload. Nothing was written. Review the new location and resubmit. |
| `estimate_frozen` | The target estimate is frozen. Nothing was written. See [Writes to a frozen estimate](#writes-to-a-frozen-estimate). |

## Project rates

Project rates hold fixed prices for one project. A `replacement` 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.

```text theme={null}
GET    /api/rates/project-rates
POST   /api/rates/project-rates/replacements
POST   /api/rates/project-rates/project-only
PUT    /api/rates/project-rates/:optionId
DELETE /api/rates/project-rates/:optionId
POST   /api/rates/project-rates/:optionId/promote
POST   /api/rates/project-rates/reprice-pending
```

### 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:

```json theme={null}
{
  "pendingReprice": {
    "count": 0,
    "attempts": 0,
    "lastError": null,
    "lastAttemptAt": null
  }
}
```

When rows are pending, the block reports the highest attempt count, most recent failure, and latest attempt time.

### 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's `reprice.status` is:

| Status | Meaning |
| - | - |
| `applied` | The estimate was re-priced. |
| `pending` | Delivery failed. The outbox row remains available for retry. |
| `skipped` | There is no estimate, the estimate is frozen, or no relevant rate change remains. The row is complete. |

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/replacements`
* `POST /project-rates/project-only`
* `PUT /project-rates/:optionId`
* `DELETE /project-rates/:optionId`
* `POST /project-rates/:optionId/promote`
* `PUT /project-rate-settings/rule-set`
* `POST /project-rate-imports`

The freeze rule matches the Estimate API's (see [Frozen estimate writes](https://help.stralos.app/api/estimate.md#frozen-estimate-writes)). Tenders in `draft` or `review` that have not been hidden as not awarded, and Delivery estimates in `draft`, are editable. Any other estimate returns:

| Estimate | Response |
| - | - |
| Deleted | `409` with `code: "deleted"` and no `status` |
| Saved version | `409 estimate_frozen` with `status` and `kind: "saved_version"` |
| Not-awarded tender | `409 estimate_frozen` with `status` and `hidden: true` |
| Submitted, accepted, rejected, or archived tender | `409 estimate_frozen` with `status` |
| Delivery estimate in `in_sign_off` or `signed_off` | `409 estimate_frozen` with `status` |

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 receives `403`.

## Estimate rates

These routes are service-only. They are called by api-estimate under its `estimate-rates` service login and are not accessible to user tokens or to other service logins.

```text theme={null}
POST   /api/rates/estimate-rates/backfill
PUT    /api/rates/estimate-rates/defaults
POST   /api/rates/estimate-rates/copy
POST   /api/rates/estimate-rates/discard
POST   /api/rates/estimate-rates/restore
```

### 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:

| Field | Description |
| - | - |
| `projectId` | The project id from the request. |
| `estimateId` | The estimate id from the request. |
| `outcome` | `stamped` — rows were stamped and the default was recorded. `estimate_mismatch` — the project was already backfilled to a different estimate; nothing was changed. |
| `defaultEstimateId` | The estimate now recorded as the project's default, or `null` if no default exists. |
| `stamped` | Counts of rows stamped per entity type: `rateItems`, `rateOptions`, `projectRateRules`, `projectRateSettings`, `repriceOutbox`, `rateSetVersions`. All zero on a repeat call or mismatch. |

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:

| Field | Description |
| - | - |
| `projectId` | The project id from the request. |
| `estimateId` | The estimate id from the request. |
| `previousEstimateId` | The estimate that was the default before this call, or `null` when no default had been recorded. |
| `outcome` | `recorded` — no default existed; `estimateId` is now it. `moved` — the default named a different estimate and now names `estimateId`. `unchanged` — the default already named `estimateId`; a repeat call answers this. `estimate_discarded` — `estimateId` is the target of a copy that was discarded as abandoned; the default is left as it was. |

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:

| Field | Description |
| - | - |
| `sourceEstimateId` | The source estimate. |
| `targetEstimateId` | The target estimate. |
| `projectId` | The project. |
| `correlationId` | Echoed from the request. |
| `replayed` | `true` when the target was already copied with the same correlation id and nothing was written. |
| `rateItemIdMap` | Map of source project-only rate item id → new copy id. Library items are not copied. |
| `rateOptionIdMap` | Map of source project rate option id → new copy id. |
| `ruleIdMap` | Map of source live project rate rule id → new copy id. |

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:

| Field | Description |
| - | - |
| `projectId` | The project id from the request. |
| `targetEstimateId` | The discarded estimate. |
| `correlationId` | Echoed from the request. |
| `discarded` | Counts of rows soft-deleted: `rateItems`, `rateOptions`, `rules`, `settings`. All zero on a replay. |
| `replayed` | `true` when the copy was already discarded and nothing was written. |
| `discardedAt` | When the copy was first discarded. |

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:

| Field | Description |
| - | - |
| `sourceEstimateId` | The saved-version estimate whose rates were restored. |
| `targetEstimateId` | The working estimate that was updated. |
| `projectId` | The project. |
| `correlationId` | Echoed from the request. |
| `replayed` | `true` when a restore with this correlation id already ran for this target — nothing was written. |
| `rateItemIdMap` | Map of source project-only rate item id → the new copy's id on the target. |
| `rateOptionIdMap` | Map of source project rate option id → the new copy's id on the target. |
| `ruleIdMap` | Map of source live project rate rule id → the new copy's id on the target. |

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:

| Code | Meaning |
| - | - |
| `restore_correlation_reused` | The same `correlationId` was used to restore a different source or project onto this target. `409`. |
| `project_not_backfilled` | The project has not been backfilled to its estimates yet. `409`. |
| `restore_source_unknown` | The source estimate holds no project rates for this project. `422`. |
| `restore_target_unknown` | The target estimate is not a known estimate of this project. `422`. |

### Authentication

All routes in this group require the `estimate-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.** `current` and `superseded` revisions are immutable: line `POST`, `PUT` and `DELETE` all return `409`. The one carve-out is `PUT /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/revisions` requires `sourceRevisionId` explicitly and is `409` if 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 through `PUT /day-rate-schedule-revisions/:id` before publishing, or the publish is `422`.
* **`DELETE /day-rate-schedules/:id` archives.** It answers `204`, is idempotent, and returns `409` from 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 then `409`. The archived name stops reserving an active identity, so a new schedule may take it. Use `POST /day-rate-schedules/:id/restore` to make an archived schedule active again; restore returns `409` if 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_plus` line has no number.** It carries a percentage over internal cost and renders as `cost + 25%`; the schedule stores no money for it.

### Schedule fields

| Field | Type | Description |
| - | - | - |
| `name` | string | Required schedule name, up to 200 characters. It must be unique among the organisation's active schedules. |
| `clientId` | uuid \| null | Optional client organisation from the Global API. Writes return `404` for an unknown or foreign id. |
| `contractRef` | string \| null | Optional contract reference, up to 120 characters. |
| `lineAuthority` | `internal` \| `client_defined` | Whether your organisation or the client owns the contractual lines. |
| `scheduleStatus` | `active` \| `archived` | Schedule lifecycle state returned by the API. |

### List and get schedules

```text theme={null}
GET /api/rates/day-rate-schedules
GET /api/rates/day-rate-schedules/:id
```

The collection response contains `items`, `totalCount`, `start`, and `length`.
Each item contains its revision history in descending revision-number order,
`currentRevision`, `draftRevision`, `supersededRevisionCount`, `derivation`, and
`needsBaseReview`.

| Query parameter | Description |
| - | - |
| `clientId` | Include only schedules linked to this client. |
| `status` | Include schedules having at least one `draft`, `current`, or `superseded` revision. |
| `scheduleStatus` | `active` (the default), `archived`, or `all`. |
| `q` | Literal search against the schedule name and contract reference, up to 200 characters. |
| `start` | Zero-based offset. Defaults to `0`. |
| `length` | Page size from 1 to 1,000. Defaults to `50`. |

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

```text theme={null}
POST /api/rates/day-rate-schedules
```

An organisation admin supplies `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:

```json theme={null}
{
  "inheritance": {
    "baseRevisionId": "00000000-0000-0000-0000-000000000000",
    "adjustmentPct": 5
  }
}
```

The base must be a published revision of an active schedule. The new draft
materialises its lines at the adjustment and records the pinned base revision.
`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

```text theme={null}
POST /api/rates/day-rate-schedules/:id/duplicate
```

This creates a new schedule and revision 1 draft from an explicitly selected
published revision of `:id`.

| Field | Type | Description |
| - | - | - |
| `name` | string | Required new schedule name, up to 200 characters. |
| `clientId` | uuid \| null | Optional target client. Omission inherits the source schedule's client. |
| `sourceRevisionId` | uuid | Required current or superseded revision belonging to the source schedule. |
| `mode` | `copy` \| `inherit` | Required derivation mode. |
| `adjustmentPct` | number | Optional; defaults to `0`. Uses the same bounds and three-decimal scale as schedule creation. |

`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

```text theme={null}
PUT    /api/rates/day-rate-schedules/:id
DELETE /api/rates/day-rate-schedules/:id
POST   /api/rates/day-rate-schedules/:id/restore
```

The `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:

| Field | Type | Meaning |
| - | - | - |
| `scheduleId` | uuid | The inheriting schedule. |
| `scheduleName` | string | Its current display name. |
| `revisionId` | uuid | The current or draft revision that inherits. |
| `revisionNumber` | integer | That revision's number. |
| `revisionStatus` | `current` \| `draft` | Whether it is the live or working revision. |
| `baseRevisionId` | uuid | The published revision on the restored schedule that it inherits from. |
| `baseRevisionNumber` | integer | That base revision's number. |
| `needsBaseReview` | boolean | Whether the inheriting revision has unreviewed base changes. |

A restore can return two `409` responses. Branch on `code`, not the message:

| `code` | Meaning |
| - | - |
| `day_rate_schedule_name_taken` | Another active schedule holds the name. The response includes its `existingId` and `existingStatus: "active"`; archive or rename it before retrying. |
| `day_rate_schedule_restore_unstable` | Another schedule repeatedly took and released the name during the retry window. Nothing changed; retry the restore. |

`existingId` and `existingStatus` are absent from the unstable response because
there is no single blocking schedule to identify.

### Revision fields

| Field | Type | Description |
| - | - | - |
| `revisionNumber` | integer | Monotonically increasing number within the schedule, assigned by the server. |
| `status` | `draft` \| `current` \| `superseded` | Revision lifecycle state. |
| `effectiveFrom` | date | Required ISO 8601 effective date. |
| `effectiveTo` | date \| null | Optional inclusive end date; it cannot precede `effectiveFrom`. |
| `baseRevisionId` | uuid \| null | Published revision from another schedule from which this revision inherits. |
| `baseAckRevisionId` | uuid \| null | Newer base revision that the user has reviewed without rebasing. |
| `adjustmentPct` | number \| null | Adjustment applied to inherited rates. It is present exactly when `baseRevisionId` is present. |
| `termsText` | string \| null | Terms and conditions, up to 4,000 characters. |
| `nameSnapshot`, `clientIdSnapshot`, `contractRefSnapshot` | mixed | Schedule identity captured when the revision is created. |
| `clientNameSnapshot`, `orgNameSnapshot`, `orgLogoRef` | mixed | Resolved display identity captured when the revision is created. |
| `lineAuthoritySnapshot` | `internal` \| `client_defined` | Line authority captured when the revision is created. |
| `publishedAt`, `publishedBy` | datetime/uuid \| null | Publication audit fields; both are null on a draft. |
| `derivation` | string \| null | Human-readable origin of the revision. |
| `needsBaseReview` | boolean | Whether an inherited revision has an unacknowledged newer base. |

### Create and edit a revision

```text theme={null}
POST /api/rates/day-rate-schedules/:id/revisions
PUT  /api/rates/day-rate-schedule-revisions/:id
```

To create a draft, an organisation admin supplies `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

```text theme={null}
POST /api/rates/day-rate-schedule-revisions/:id/publish
```

Publishing changes a draft to `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

| Action | Requirement |
| - | - |
| List or get schedules | Authenticated caller with an organisation |
| Create, update, archive, restore, or duplicate a schedule | Organisation admin |
| Create, edit, publish, rebase, or acknowledge a revision | Organisation admin |
| Create, edit, delete, map, or revert a line | Organisation admin |
| Read revision lines, rules, or live margins | Authenticated caller with an organisation |

### Derivation labels

The `derivation` field describes the displayed revision:

* `Client-defined lines` means `lineAuthoritySnapshot` is `client_defined`.
* `Independent · from YYYY-MM-DD` means 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.

| Field | Type | Description |
| - | - | - |
| `lineKey` | uuid | Stable logical identity across same-kind revision copies. Changing `rateKind` mints a new key. |
| `lineCode` | string \| null | Optional code, up to 80 characters and unique within the revision when present. |
| `description` | string | Required line description, up to 200 characters. |
| `unit` | string | Required pricing unit, up to 40 characters. |
| `rateKind` | `flat` \| `fixed` \| `cost_plus` | How the sell rate is represented. |
| `dayRate` | number \| null | Non-negative sell rate with at most two decimal places. Required for `flat` and `fixed`; null for `cost_plus`. |
| `rateOptionId` | uuid \| null | Optional mapping to a catalogue rate option. |
| `activityId` | uuid \| null | Optional mapping to an activity. Mutually exclusive with `rateOptionId`. |
| `derivedFromLineId` | uuid \| null | Base-schedule line from which an inherited line was materialised. |
| `rateOverridden` | boolean | Server-managed marker indicating that an inherited rate differs from its adjusted base. |
| `sortOrder` | integer | Non-negative display order. Defaults to `0`. |

`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.

```text theme={null}
POST   /api/rates/day-rate-schedule-revisions/:id/lines
PUT    /api/rates/day-rate-schedule-revisions/:id/lines/:lineId
DELETE /api/rates/day-rate-schedule-revisions/:id/lines/:lineId
```

These routes write draft revisions only. `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

```text theme={null}
PUT /api/rates/day-rate-schedule-lines/:id/mapping
```

The strict request body accepts exactly one of these shapes:

```json theme={null}
{ "rateOptionId": "00000000-0000-0000-0000-000000000000" }
```

```json theme={null}
{ "activityId": "00000000-0000-0000-0000-000000000000" }
```

```json theme={null}
{}
```

The empty object clears the mapping. The service establishes line visibility
before resolving a supplied target so a foreign line cannot become an id
oracle. A rate option must belong to the caller's organisation. An activity is
resolved through the Activities API. Tiered and matrix pricing rules cannot be
mapping targets because a day-rate margin read has no quantity or matrix inputs;
the API checks both a selected modifier and its matching Standard option. An
option priced by steps or by a rate table has no cost to measure a day rate
against, so mapping one returns `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

```text theme={null}
POST /api/rates/day-rate-schedule-lines/:id/revert-to-inherited
```

On a draft revision, this restores the base line's `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 reports `needsBaseReview: 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

```text theme={null}
POST /api/rates/day-rate-schedules/:id/rebase-preview
```

This authenticated read takes an inherited schedule's current revision as
**ours**, its pinned revision as **old base**, and the base schedule's current
revision as **new base**. It writes nothing. The response includes
`scheduleId`, `sourceRevisionId`, `oldBaseRevisionId`,
`targetBaseRevisionId`, `adjustmentPct`, line `buckets`, and pricing-rule
`ruleBuckets`.

Every relevant line appears in exactly one bucket:

| Bucket | Result in a rebased draft |
| - | - |
| `added` | A new base line is added at the dependant adjustment. |
| `updated` | A changed base line is adopted at the dependant adjustment. |
| `retainedOverrides` | The dependant's explicit sell override is retained. |
| `removed` | A line removed from the base is detached as local unless its key is selected for removal. |
| `suppressed` | A base line already absent from the dependant stays absent. |
| `local` | A line not inherited from the base is carried forward unchanged. |
| `unchanged` | An unchanged base line has its lineage refreshed. |

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

```text theme={null}
POST /api/rates/day-rate-schedules/:id/rebase
```

An organisation admin supplies:

| Field | Type | Description |
| - | - | - |
| `targetBaseRevisionId` | uuid | Required current base revision returned by preview. |
| `applyRemovals` | uuid\[] | Unique `lineKey` values from the preview's `removed` bucket that should be omitted rather than detached as local. Defaults to `[]`. |

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

```text theme={null}
POST /api/rates/day-rate-schedules/:id/acknowledge-base
```

Use acknowledgement when the user reviewed a newer base but does not want a
new draft or changed rates.

| Field | Type | Description |
| - | - | - |
| `revisionId` | uuid | Required inherited revision belonging to `:id`. |
| `baseRevisionId` | uuid | Required current or superseded revision on the pinned base schedule. |

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

```text theme={null}
GET /api/rates/day-rate-schedule-revisions/:id/margin
```

This read returns every line in `sortOrder`, then id order, with live analysis:

| Field | Type | Description |
| - | - | - |
| `expectedCost` | number \| null | Current resolved cost of the mapped option or activity. |
| `sellRate` | number \| null | Stored sell for `flat`/`fixed`, or computed cost-plus sell. |
| `marginPct` | number \| null | `(sellRate - expectedCost) / sellRate × 100`, rounded to one decimal. |
| `unavailableReason` | string \| null | Machine-readable reason analysis could not complete. |

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.

| `unavailableReason` | Meaning |
| - | - |
| `unmapped` | The line has no cost mapping. |
| `rate_option_unavailable` | The option or item is absent, inactive, or expired. |
| `rate_option_no_current_cost` | No active Standard option with a current cost exists for the mapped dimension. |
| `rate_option_rule_requires_inputs` | The selected or Standard option has tiered or matrix rules. |
| `currency_mismatch` | The catalogue option cannot be normalised into the organisation's currency. |
| `unit_conversion_unavailable` | Cost cannot be expressed in the contractual line unit. |
| `activity_unavailable` | The activity is absent or inactive. |
| `activity_unit_mismatch` | The activity unit differs from the line unit. |
| `cost_plus_rule_unavailable` | No usable standard cost-plus rule exists at line or revision level. |
| `sell_rate_zero` | Expected cost and sell resolved, but a zero sell makes margin undefined. |

### Export

```text theme={null}
POST /api/rates/day-rate-schedule-revisions/:id/export   { "format": "xlsx" | "pdf" }
```

One endpoint, one export payload type, two response shapes:

| `format` | Response |
| - | - |
| `xlsx` | An **XLSX binary** with `Content-Disposition: attachment`. |
| `pdf` | The versioned shared **`application/json` print payload**, with `Cache-Control: private, no-store` and **no** `Content-Disposition`. The browser renders and prints it; the server does not produce a PDF. |

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

```text theme={null}
POST /api/rates/rate-imports/parse
```

Accepts bounded spreadsheet cell data and returns detected rates for review. It does not change the catalogue.

| Request field | Type | Requirements |
| - | - | - |
| `fileName` | string | 1–300 characters. |
| `sheets` | array | 1–20 sheets. Each sheet has a name of up to 120 characters and up to 2,000 rows. |
| `sheets[].rows` | string\[]\[] | Up to 40 cells per row and 300 characters per cell. |

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

```text theme={null}
POST /api/rates/rate-imports
POST /api/rates/project-rate-imports
```

Two endpoints share the same commit implementation. Use `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-imports` must be called **without** `projectId` (or with it set to `null`). Supplying a `projectId` on this route returns `422`.
* `POST /api/rates/project-rate-imports` must be called **with** a non-null `projectId`. Omitting `projectId` on this route returns `422`.

This pairing means a client built before project imports existed keeps working: its calls to `/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):

```json theme={null}
{
  "importedCount": 5,
  "createdItemCount": 2,
  "matchedItemCount": 3,
  "reprice": {
    "status": "applied",
    "estimateId": "<uuid|null>",
    "linesMoved": 3,
    "companionsRemoved": []
  }
}
```

`reprice` is absent on library imports.

A project import response also carries `replacedRateCount`: the number of placeholder prices the commit retired (see [Placeholder replacement](#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](#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:

* `importFileId` is 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 returns `422 import_file_not_found`. Every option the commit writes stores it as `source_import_file_id`, on both routes.
* `sourceDocumentId` is the api-documents project document holding the file. Project imports only: a Rates Library import that sends it returns `422 source_document_requires_project`. Every option the commit writes stores it as `source_document_id`, so the project's Tender check can read the quote the prices came from.

When either field is absent, an updated option keeps the link it already had.

`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:

| Field | Type | Description |
| - | - | - |
| `bulkingPercent` | number | Optional. Estimated bank-to-loose volume expansion as a percentage. 0–200. Suggested only when the file did not supply the value. |
| `compactionPercent` | number | Optional. Estimated loose-to-compacted volume reduction as a percentage. 0–100 (exclusive). Suggested only when the file did not supply the value. |
| `basis` | string | Required. Explains the material assumption and labels the numbers as typical estimates. 1–400 characters. |

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 `existingRateItemId` adds the option to that exact item. If the row's category resolves uniquely (or through `categoryMappings`), 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. Explicit `null` creates a new item even when analysis suggested a match. Sending `null` for a name already held by an active or inactive item returns `422` — a rate item's name is its identity and two non-deleted items cannot share one.
* When `existingRateItemId` is 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.

A successful commit returns `201 Created` with `importedCount`, `createdItemCount` and `matchedItemCount`.

| Status | Meaning |
| - | - |
| `400` | The request body is invalid. |
| `401` | No valid bearer token was supplied. |
| `403` | The caller is not a signed-in member or admin, or has no resolved organisation. |
| `409` | A database uniqueness or foreign-key constraint was violated. |
| `422` | A duplicate mapping was supplied; two or more rows share the same price slot (resolved item + source + location + condition); one or more rows request a new item under a name already in use; one or more creating rows cannot resolve to a catalogue category; a row's unit is not allowed by the category that wins; recategorising an existing item would leave incompatible physical fields or live prices; a representative cost or band price has more than 2 decimal places; a row contains an invalid band schedule; a row omits `locationId` (`location_required`); `sourceDocumentId` is sent on a Rates Library import (`source_document_requires_project`); or `importFileId` does not name a live file of the organisation (`import_file_not_found`). No rows are written. |

### 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.

| Field | Type | Description |
| - | - | - |
| `rateOptionId` | uuid | The placeholder's rate option. |
| `supplierId` | uuid \| null | Its supplier, or `null` for the organisation's own price. |
| `supplierName` | string \| null | The supplier's name, or `null` for your own price or when api-global could not be read. |
| `locationId` | uuid \| null | Its location, or `null` for all locations. |
| `locationName` | string \| null | The location's name, or `null` for all locations or when it could not be read. |
| `condition` | string | The condition token. |
| `unit` | string | The priced unit. |
| `costRate` | number \| null | The price. |

`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`.

| Status | Code | Meaning |
| - | - | - |
| `422` | `replace_target_gone` | The price `replacesRateOptionId` names has changed or gone since the poll. Open the import again. |
| `422` | `replace_target_mismatch` | `replacesRateOptionId` belongs to a different item from the row's. |
| `422` | `replace_needs_project` | `replacesRateOptionId` was sent on a Rates Library import. |

### 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

```text theme={null}
POST /api/rates/rate-imports/uploads
```

Send `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

```text theme={null}
POST /api/rates/rate-imports/survey
GET  /api/rates/rate-imports/surveys/:surveyId
```

Send the `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:

| Status | Meaning |
| - | - |
| `queued` | Waiting for a worker. |
| `running` | Ingestion or structure analysis is in progress. |
| `complete` | The structural result is available. |
| `failed` | Analysis stopped; `error` contains a safe user-facing reason. |

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

```text theme={null}
POST /api/rates/rate-imports/jobs
GET  /api/rates/rate-imports/jobs/:jobId
```

Job creation accepts `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.

Sending both or neither returns `400`.

Each confirmed block carries the following fields:

| Field | Type | Required | Description |
| - | - | - | - |
| `id` | string | Yes | Unique block identifier, maximum 40 characters. |
| `sheet` | string | Yes | Sheet name, maximum 120 characters. |
| `headerRow` | integer or `null` | Yes | One-based header row. A crosstab requires a row; a table may use `null`. |
| `firstDataRow`, `lastDataRow` | integer | Yes | Inclusive one-based data-row bounds. |
| `layout` | `table` or `crosstab` | Yes | Block layout. |
| `title` | string | No | Optional block label, maximum 200 characters. |
| `columns` | object | Yes | Zero-based roles. `name` is required; `unit`, `code`, `supplier`, `locationScope`, `condition`, and `cost` are optional. A table requires `cost`. |
| `pivotColumns` | integer\[] | Yes | Price columns for a crosstab; send `[]` for a table. |
| `pivotMeaning` | string | For crosstabs | One of `supplier`, `location`, `condition`, `size`, `date`, or `other`. |
| `headerGroups` | array | Yes | Reviewed grouping rows (at most one in the current contract). Each member has a one-based `row`, a `meaning` from the same set as `pivotMeaning`, and one or more `spans`; each span has zero-based `fromColumn` and `toColumn` bounds plus a non-empty `label`. Spans must not overlap. Send `[]` when there are none. |
| `ignoredColumns` | integer\[] | Yes | Reviewed numeric columns that are not prices; send `[]` when there are none. |

`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](#placeholder-replacement) 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:

```text theme={null}
POST /api/rates/rate-imports/jobs/:jobId/blocks/:blockId/retry
```

The retry returns `202 { jobId }`; a block not in `failed` state returns `409`.

#### Optional: settle catalogue records and re-read

```text theme={null}
POST /api/rates/rate-imports/entities
```

This endpoint is shared by the survey and selection-first workflows, but it is not a prerequisite for the first extraction. The current survey client starts an extraction job as soon as the survey completes, with no entity call and no vocabulary. On the review screen, ordinary record creation and mapping settle the affected rows in place. If the reviewer changes what a detected heading represents and chooses **Read the rates again**, the client resolves those decisions here and starts another extraction with the resulting vocabulary.

Send exactly one run identifier — `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.

| Field | Requirements |
| - | - |
| `surveyId` / `analysisId` | UUID. Send exactly one. |
| `idempotencyKey` | UUID required for every request. |
| `candidates` | Required array. Each entry has `id`, `kind` (`source`, `location`, or `category`), `name`, and an optional `parentCandidateId`. |
| `decisions` | Required array. Each decision identifies its `candidateId` and uses `action: "create"` with a `fields` object, `action: "map"` with a `targetId`, or `action: "ignore"`. |
| `unclassifiedIds` | Optional array, default `[]`. Include every unclassified heading id reported by the run. |
| `classifications` | Optional array, default `[]`. Resolve each listed unclassified id with `resolution: "discard"`, or `resolution: "promote"` plus a candidate `kind`, optional `parentCandidateId`, and a required nested `decision`. |

For a create decision, `fields` depends on the candidate kind:

* A source requires `name`. `sourceKind` is `supplier` by default and also accepts `internal`; `addressLine1`, `city`, `region`, and two-letter `countryCode` are optional.
* A location requires `name` and `kind` (`office`, `yard`, `depot`, `branch`, `quarry`, or `other`); its two-letter `countryCode` is optional. Its candidate must carry `parentCandidateId` referencing a source candidate whose decision successfully maps or creates the parent source.
* A category requires `name`, `parentGroup`, and 1–40 `allowedUnitCodes`.

The response contains `resolved` and `failed` entries. Each `failed` entry is `{ candidateId, kind, name, code, message }`. Two codes ask the person to act:

| `code` | Meaning |
| - | - |
| `admin_required` | api-global keeps this create for admins: a new category, or one of the company's own locations. `name` is what to ask an admin to add. |
| `location_address_required` | api-global refuses a location without an address and coordinates. `message` is api-global's own sentence. Add the site with its address, then map the row to it. |

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 to `POST /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](#commit-reviewed-rates) 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

```text theme={null}
POST /api/rates/rate-imports/parses
GET  /api/rates/rate-imports/parses/:parseId
```

The POST accepts `fileName` and `uploadId` and returns `202 { parseId }`.

Poll until the parse status is terminal:

| Status | Meaning |
| - | - |
| `queued` | Waiting for a worker. |
| `running` | Parse in progress. |
| `complete` | The grid, proposed blocks, and selection suggestion are available. |
| `failed` | Parse stopped; `error` contains a safe user-facing reason. |

A `complete` response includes:

* `sheets` — the parsed grid.
* `importFileId`: the kept copy of the file to pass to the commit, or `null` when 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, or `null` if none is stored. When present, it contains `selectionId`, nullable `label`, `lastUsedAt`, nullable `sourceScope` (`organisation` or `supplier`), nullable `supplierId` (a party UUID, null for organisation scope or when the source was never learned), `blocks`, and `dismissedCells`. The two identity fields (`sourceScope` and `supplierId`) converge independently. A commit naming multiple suppliers yields `sourceScope: null` for 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 clear `label`. 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 through `POST /rate-imports/analyses` so `validateConfirmedSelection` checks them against the current grid. A moved column returns `column_out_of_range`; a document that gained rows returns `uncovered_cell`. The current region canvas leaves this field unused because confirmed blocks do not define one rectangular column range.

#### Start and poll an analysis

```text theme={null}
POST /api/rates/rate-imports/analyses
GET  /api/rates/rate-imports/analyses/:analysisId
```

The POST accepts `parseId` and exactly one selection shape:

* `blocks` contains 1–60 confirmed blocks and may be accompanied by `dismissedCells`.
* `region` contains one rectangle: `sheet`, 1-based inclusive `firstRow` and `lastRow`, and 0-based inclusive `firstColumn` and `lastColumn`.

The service validates either selection against its own parse record and returns `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 to `POST /api/rates/rate-imports/entities` using the [optional settlement and re-read contract](#optional-settle-catalogue-records-and-re-read). 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

Pass `analysisId` 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 to `POST /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

```text theme={null}
POST /api/rates/rate-import-files/lookup
GET  /api/rates/rate-import-files/:importFileId/text
```

When a parse or survey reads an uploaded file, the file service keeps a copy and api-rates records it. The commit links each option it writes to that record. These two routes let api-estimate's Tender check find and read the file a price came from without copying it into the project's documents. Both run with the caller's own token, so every read is confined to the caller's organisation. Any authenticated caller with an organisation can use them.

### 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. Its `fixed` 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.

```text theme={null}
GET /api/rates/rate-options/:id/companions
```

Returns the active fixed companion options associated with the rate option identified by `: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 `validTo` date;
* 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.

A companion option whose supplier or location is inactive or missing is excluded rather than returning an error.

### Response shape

```json theme={null}
{
  "parentOptionId": "<uuid>",
  "companions": [
    {
      "item": {
        "id": "<uuid>",
        "name": "<string>",
        "categoryId": "<uuid|null>",
        "category": "<string>",
        "rateGroup": "<string|null>"
      },
      "option": {
        "id": "<uuid>",
        "rateItemId": "<uuid>",
        "sourceItemCode": "<string|null>",
        "condition": "<string>",
        "unit": "<string>",
        "conversionFactor": "<number|null>",
        "costRate": "<number>",
        "currency": "NZD",
        "provenance": "<manual|price_list|project_quote|invoice>",
        "scope": "<organisation|supplier>",
        "supplierId": "<uuid|null>",
        "locationId": "<uuid|null>",
        "supplier": { "id": "<uuid>", "name": "<string|null>", "status": "<active|inactive|null>", "isSupplier": "<boolean|null>" },
        "location": { "id": "<uuid>", "name": "<string|null>", "partyId": "<uuid|null>", "status": "<active|inactive|null>" }
      },
      "pricing": {
        "baseUnitCost": "<number>",
        "envelope": {
          "envelopeVersion": 1,
          "sourceOwner": { "ownerType": "rate_option", "ownerId": "<uuid>" },
          "sourceFingerprint": "<hex-64>",
          "capturedAt": "<datetime>",
          "rules": [...]
        }
      }
    }
  ]
}
```

The `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

```text theme={null}
DELETE /api/rates/day-rate-schedules/:id
```

Archives the schedule. Archiving is a soft operation: the schedule, all its revisions, lines, mappings, and pricing rules remain readable. The `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.

| Status | Meaning |
| - | - |
| `204` | The schedule was archived (or was already archived). |
| `401` | No valid bearer token was supplied. |
| `403` | The caller does not hold the org admin role. |
| `404` | The schedule does not exist or belongs to another organisation. |

### Export limits and responses

```text theme={null}
POST /api/rates/day-rate-schedule-revisions/:id/export
```

Generates a client-facing export of a schedule revision. The caller must be an authenticated member of the schedule's organisation; no admin role is required.

The request body selects the output format:

```json theme={null}
{ "format": "xlsx" }
```

or

```json theme={null}
{ "format": "pdf" }
```

**XLSX format** returns a spreadsheet file (`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 (from `GET /api/global/organisation-settings` → `timezone`), falling back to `Pacific/Auckland` when 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.

Text cells are protected against spreadsheet formula injection: values beginning with `=`, `+`, `-`, or `@` are prefixed with a single quote. Cell content is capped at Excel's 32,767-character limit without splitting Unicode surrogate pairs.

| Limit | Value |
| - | - |
| Maximum lines per export | 10,000 |
| Maximum pricing rules per export | 50,000 |
| Maximum XLSX file size | 25 MB |

| Status | Meaning |
| - | - |
| `200` | Export generated successfully. |
| `401` | No valid bearer token was supplied. |
| `403` | The caller has no resolved organisation. |
| `404` | The revision does not exist or belongs to another organisation. |
| `422` | The revision exceeds the line or rule export limit, or the generated XLSX exceeds 25 MB. |

## Rate-option pricing and write details

Every rate option response includes a `pricing` 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

| Field | Description |
| - | - |
| `version` | SHA-256 of the option's own rule set. Changes whenever owned rules change. |
| `sourceFingerprint` | SHA-256 of the effective (inherited + owned) rule set. |
| `ruleKind` | Kind of the effective base rule: `simple`, `tiered`, `matrix`, `fixed`, `cost_plus`, or `stepped`. `null` when no base rule is set. |
| `ruleShape` | Short description of the rule shape: `3 bands` for a tiered rule, `Rate table 3×4` (rows × columns) for a rate table, `fixed` for a fixed charge, and `2 steps` for a stepped rule (pickable steps only). `null` for other kinds. |
| `fromUnitPrice` | Lowest effective unit price for banded rules, or the scalar option cost after Standard-condition modifiers. `null` when neither can be resolved, and always `null` for a stepped option. |
| `stepCount` | Number of pickable (not retired) steps for a stepped base. `null` for every other kind. |
| `stepAmountRange` | `{ min, max }` over the pickable step amounts for a stepped base. `null` for every other kind. |
| `matrixPriceRange` | `{ min, max }` over every cell price of a rate table, in dollars per charged unit. `null` for every other kind. |
| `minimumQuantity` | Effective minimum quantity value. `null` when not set or suppressed. |
| `minimumAmount` | Effective minimum amount value. `null` when not set or suppressed. |
| `modifiers` | Array of effective modifier rules. Each entry has `basis`, `value`, `label`, and `condition`. |
| `rules` | The full list of effective conditioned pricing rules. |

### 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:

| Situation | Code |
| - | - |
| A cost sent beside a stepped base | `422 stepped_has_no_cost` |
| A cost sent beside a rate table | `422 rate_table_has_no_cost` |
| A rules write that drops the stepped base without giving the option a cost | `422 stepped_needs_cost` |
| A rules write that drops the rate table without giving the option a cost | `422 rate_table_needs_cost` |
| A Standard option with live multiplier options made stepped or given a rate table | `422 stepped_standard_has_multipliers` or `422 rate_table_standard_has_multipliers` |
| A step removed from a stepped rule that stays stepped | `422 step_key_removed` |

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

| Operation | Role |
| - | - |
| Read options (`GET`) | Authenticated member |
| Create and update options, including inline `pricingRules` | Signed-in member or admin |

## 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 by `ownerType`, `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

| `ownerType` | Description |
| - | - |
| `rate_option` | Pricing rules owned by a rate option. The condition must always be `standard` for rate option owners. |
| `day_rate_schedule_revision` | Rules on a day-rate schedule revision. |
| `day_rate_schedule_line` | Rules on a day-rate schedule line. |

### Slots and rule kinds

| Slot | Allowed rule kinds | Description |
| - | - | - |
| `base` | `simple`, `tiered`, `matrix`, `fixed`, `cost_plus`, `stepped` | The primary pricing rule for the owner. |
| `minimum_quantity` | `minimum` | Sets or suppresses a minimum charge quantity. Not allowed on revision owners. |
| `minimum_amount` | `minimum` | Sets or suppresses a minimum charge amount. |
| `modifier_additive` | `modifier` | Applies a per-unit additive modifier. Modifier basis must be `additive_per_unit`. |
| `modifier_multiplicative` | `modifier` | Applies a multiplicative modifier. Modifier basis must be `multiplicative`. |

### Rule kinds

**`simple`** — A single unit price with no banding. Slot: `base`.

**`tiered`** — Quantity-banded pricing. Slot: `base`. Only allowed on `rate_option` owners.

| Field | Description |
| - | - |
| `tierBehaviour` | `whole_quantity` applies the matched band's price to the full quantity. `progressive` applies each band's price only to the quantity within that band. |
| `bands` | Array of `{ minQuantity, unitPrice }` objects. Minimum 2, maximum 40. The first band's `minQuantity` must be 0. |

**`matrix`** — Two-axis banded pricing. Slot: `base`. Only allowed on `rate_option` owners.

| Field | Description |
| - | - |
| `tierBehaviour` | Must be `whole_quantity`. |
| `secondary` | The side down the table. Either ranges (`kind` omitted or `"ranges"`, with `key`, `label`, `unit`, and up to 12 `bands`) or named rows (`kind: "named"`, with `key`, `label`, and up to 12 `rows` of `{ key, label }`). A stored `defaultBandIndex` is accepted for older rules but ignored by pricing: a rate table has no default row. |
| `primary` | Primary axis definition: `bands` (up to 12 breakpoints). |
| `prices` | 2-D array of unit prices indexed by `[primary][secondary]`. |

**`fixed`** — A fixed lump-sum amount. Slot: `base`.

| Field | Description |
| - | - |
| `companionOfOptionId` | Optional UUID of a companion rate option. When set, the fixed amount applies only when the companion option is selected. |

**`cost_plus`** — A percentage markup on the underlying cost. Slot: `base`. Not allowed on `rate_option` owners.

| Field | Description |
| - | - |
| `percent` | The markup percentage as a number. |

**`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`.

| Field | Description |
| - | - |
| `steps` | 1 to 50 steps, at least one not retired. |

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`.

| Field | Description |
| - | - |
| `modifierBasis` | `additive_per_unit` for the additive slot; `multiplicative` for the multiplicative slot. |
| `value` | The modifier value. |
| `label` | Human-readable label describing the modifier. |

### 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 current `version` 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

```text theme={null}
GET /api/rates/pricing-rules?ownerType=<type>&ownerId=<uuid>
```

Returns the current rule set for the specified owner. The response includes `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

```text theme={null}
PUT /api/rates/pricing-rules/:ownerType/:ownerId
```

Atomically replaces all rules for the specified owner. The request body must include `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.

| Status | Meaning |
| - | - |
| `400` | The request body is malformed. |
| `403` | The caller does not hold the role the owner type requires. |
| `404` | The owner does not exist or belongs to another organisation. |
| `409` | The supplied `version` does not match the current stored version. |
| `422` | One or more rules are invalid for the given owner type. |

### Replace rules for multiple owners

```text theme={null}
PUT /api/rates/pricing-rules/batch
```

Atomically replaces rule sets for up to 500 owners in a single request. The body is an array of objects, each with `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

```text theme={null}
POST /api/rates/pricing-rules/revalidate
```

Validates a rule set against domain constraints without writing. Accepts either a stored owner reference (`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

```text theme={null}
GET /api/rates/pricing-rules/condition-tokens
```

Returns all distinct condition tokens in use across rate options and pricing rules for the caller's organisation. The response has the form `{ 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**

```text theme={null}
POST /api/rates/rate-options/:id/rekey-condition
```

Request body:

```json theme={null}
{
  "newToken": "new-condition-name",
  "dryRun": true
}
```

The response describes what the migration will change:

| Field | Description |
| - | - |
| `rateOptionId` | The option whose condition is being changed. |
| `oldToken` | The current condition token. |
| `newToken` | The proposed new token. |
| `planHash` | SHA-256 of the migration plan. Must be supplied unchanged in the commit step. |
| `mutableDependants` | Rate options and draft schedule loadings that will be updated. |
| `preservedPublishedDependants` | Published schedule loadings that will be carried forward with the old token preserved in published history. |
| `collisions` | Conflicts that would prevent the migration. Empty when the plan is safe. |

**Step 2 — commit**

```text theme={null}
POST /api/rates/rate-options/:id/rekey-condition
```

```json theme={null}
{
  "newToken": "new-condition-name",
  "dryRun": false,
  "planHash": "<hash from step 1>"
}
```

The `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.

| Collision type | Meaning |
| - | - |
| `rate_option_unique` | Another rate option already uses `newToken` for the same item, scope, supplier, and location. |
| `pricing_rule_unique` | A pricing rule for the same owner already has a rule in the same slot with `newToken`. |
| `reserved_standard` | The re-key would leave a standard option that other multiplier options depend on without its `standard` condition. |

### 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. Returns `403` 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

```text theme={null}
POST /api/rates/day-rate-schedules/import/parse
POST /api/rates/day-rate-schedules/import/commit
```

The same two-step shape for a client's Schedule of Rates. An imported schedule is always `client_defined`: its lines are the client's contractual text, usable before any of them is mapped to an internal rate item.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.