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

# Companies register

> Domain model for the Party types introduced in the companies register (M4)

`stralos-domain` exports the shared `Party` contract from `src/types.ts`. This page documents that contract and the modelling decisions behind it.

## Background

Before the companies register, clients, suppliers, and contractors were separate entities in separate tables. A business that both supplies materials and subcontracts work required duplicate records with no reconciliation path. The companies register replaces those three tables with a single `parties` row carrying three role booleans.

The UI label is **Companies**. The code name is `parties` — `organisation` already names the tenant that owns all data and is the column every RLS policy keys off, so reusing it here would collide with the security model.

`Client`, `Supplier`, and `Contractor` are still exported unchanged and remain load-bearing in five repos until the M6 consumer sweep retires them. M4 is purely additive.

## Core entities

### `Party`

A company. Carries at least one role — all three booleans false is not a representable state on the wire (`CHECK parties_role_required`).

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | Server-assigned. |
| `organisationId` | `EntityId` | Owning tenant. |
| `name` | `string` | Legal name. Unique per organisation among active rows only (D19), so retiring a company frees its name for a replacement record. |
| `tradingName` | `string \| null` | Trading name, if different from legal name. |
| `businessNumber` | `string \| null` | NZBN; exactly 13 digits when set. Unique per organisation among active rows, with a partial unique index on NOT NULL so companies without one do not collide. |
| `isClient` | `boolean` | This company is a client (buys work from you). |
| `isSupplier` | `boolean` | This company is a supplier (sells materials or services to you). When `true` and `status` is `active`, the company is available as a pricing source in the Rates picker. |
| `isSubcontractor` | `boolean` | This company is a subcontractor (performs work under a subcontract package). |
| `approvalStatus` | `PartyApprovalStatus` | Advisory only — see [Approval status](#approval-status). |
| `approvedUntil` | `string \| null` | ISO date. Settable only when `approvalStatus` is `"approved"`. A date in the past is accepted and displayed as expired. |
| `phone` | `string \| null` | |
| `email` | `string \| null` | |
| `colour` | `string \| null` | `#RRGGBB` hex colour. The scheduler resolves a project bar colour as `project.colour ?? party.colour ?? auto` (D14). |
| `status` | `CatalogueStatus` | `"active"` or `"inactive"`. Retirement, not deletion (D15): a retired company stays resolvable by id forever so historic estimates and price history keep rendering. |

### `PartyContact`

A person at a company. Retiring the company does not write onto its contacts (ruling 2026-09-24, replacing the D24 cascade): they stop being usable by association. Reinstating a company is a future PRD with no path yet; because nothing is written onto the children, a future reinstate needs no child writes. A contact's own `status` is `inactive` only when it was itself deleted (`DELETE /party-contacts/:id` keeps the row).

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | |
| `organisationId` | `EntityId` | |
| `partyId` | `EntityId` | The owning company. |
| `locationId` | `EntityId \| null` | Which of the company's sites this contact sits at. `null` means a company-level contact with no site. A location belonging to another company is rejected at the database level (mapped to 422 by the application). |
| `name` | `string` | |
| `email` | `string \| null` | When provided, validated as `something@something.tld` (no whitespace, a `.` after the domain label). Blank is always valid. The same rule is enforced by the Add contact form and by `POST /parties/:id/contacts`. |
| `phone` | `string \| null` | When provided, must contain only digits, spaces, `+`, `(`, `)`, `-`, and `.`; `+` is only valid as the first character; and must contain between 6 and 20 digits. Blank is always valid. The same rule is enforced by the Add contact form and by `POST /parties/:id/contacts`. |
| `role` | `string \| null` | Free text with a suggested list (`"Estimating"`, `"Project manager"`, `"Accounts payable"`, `"Accounts receivable"`, `"Quarry/dispatch"`). Open-ended so a controlled vocabulary can arrive later without a migration. |
| `status` | `CatalogueStatus` | |

## Role enums and filters

### `PartyRole`

```typescript theme={null}
type PartyRole = "client" | "supplier" | "subcontractor";
```

Used as a query filter on `GET /parties?role=`.

### Approval status

`PartyApprovalStatus` is advisory metadata about the subcontractor relationship (§6.2). It gates nothing — a suspended company still prices, still appears in pickers, and still resolves rates.

```typescript theme={null}
type PartyApprovalStatus = "not_applicable" | "pending" | "approved" | "suspended";
```

`"expired"` is not a stored value. Expiry is derived from `approvedUntil` at render time (§6.2) to avoid clock skew between rows; the list page derives its chip client-side.

`GET /parties` has no approval filter. The domain's `PartyApprovalFilter` type was removed in domain 4.0.0, and api-global does not read an `approval` query parameter.

## List requests

### `PartyListDto`

The shape returned by `GET /parties` extends `PartyRow` with two extra fields. These fields are present on every list row and are absent from `GET /parties/:id` (where the caller already holds the full collections).

| Field | Type | Notes |
| - | - | - |
| `locationCount` | `number` | Effectively active locations owned by this company. Matches the company record's Locations panel (`GET /parties/:id/locations?status=active`). A retired company always returns `0` because its locations are inactive by association. |
| `contactCount` | `number` | Active contacts for this company. Matches the company record's Contacts panel, which hides inactive (deleted) contacts. |

Each count reads exactly what the company record's own panel reads. `locationCount` counts effectively active locations (own-status active on a live company, zero on a retired one whose every location is retired by association). `contactCount` counts active contacts only, because `DELETE /party-contacts/:id` sets a contact `inactive` rather than removing the row and `GET /parties/:id/contacts` hides inactive ones. A register row whose count disagreed with the panel would be the copy nobody trusted.

The query uses two grouped reads restricted to the ids on the page (`length` ≤ 200), not a correlated subquery per row. Work is bounded by the page size rather than by the total register size.

### `GET /parties` query

`GET /parties` query parameters. `search` covers name and business number (§8.2). Pagination follows the service's hand-rolled parser: `start` >= 0, `length` defaults to 50 and is capped at 200.

| Field | Type | Notes |
| - | - | - |
| `role` | `PartyRole?` | Filter to companies that carry this role. |
| `status` | `CatalogueStatus?` | `"active"` or `"inactive"`. Defaults to `"active"`. |

### `GET /locations` owner filter

`GET /locations` supports an `owner` filter.

| Field | Type | Notes |
| - | - | - |
| `owner` | API query string | `"ours"` (`partyId IS NULL`), `"theirs"` (`partyId` is set), or `"all"`. Defaults to `"all"`. |

## `Location.partyId`

`Location` gains a nullable `partyId` field:

```typescript theme={null}
partyId?: EntityId | null;
```

`null` means the organisation's own site. When `partyId` is set, the location belongs to a specific company. `GET /locations` returns both kinds by default; pass `owner=ours` or `owner=theirs` to narrow the result.

`partyId` cannot be changed through the location PUT endpoint: the PUT schema has no `partyId` field, so moving a site between owners cannot silently re-attribute cached routes. Ownership is fixed by the creation path: `POST /locations` creates an organisation-owned location with `partyId: null`, while `POST /parties/:id/locations` creates a location owned by that company.

The field is optional rather than required so existing consumers' `Location` fixtures keep compiling until they adopt it.

## Party location endpoints

`GET /parties/:id/locations` and `POST /parties/:id/locations` manage the sites owned by a specific company.

Both endpoints return the same `geoTag`-nested shape that `GET /locations` and `POST /locations` use. Address and coordinate fields are nested under `geoTag`:

```typescript theme={null}
{
  id: EntityId;
  name: string;
  kind: "office" | "yard" | "depot" | "branch" | "quarry" | "other";
  status: CatalogueStatus;
  geoTag: {
    addressLine1?: string;
    city?: string;
    region?: string;
    countryCode: string;
    latitude?: number;
    longitude?: number;
  };
  phone: string | null;
  email: string | null;
  gstNumber: string | null;
  createdAt: string; // ISO 8601
  updatedAt: string; // ISO 8601
}
```

`POST /parties/:id/locations` takes the same body schema as `POST /locations`. The party id comes from the URL path, not the body — a `partyId` in the body is not accepted. The company must exist in the caller's organisation; an unknown or foreign-organisation id returns `404`.

The new location is immediately selectable in the Rates picker for that company.

`GET /parties/:id/locations` returns `{ items: LocationDto[] }`, unpaginated, ordered by name.

A location's `(name, addressLine1)` is unique per owner (one company, or the organisation's own sites), not per organisation (migration 0099). A same-owner clash returns `409 location_duplicate`; the frontend shows it under **Name** rather than as a toast. Another owner at the same address is allowed: create and update responses carry `sharedAddressWith`, and `GET /locations/shared-address` answers the same question before a save. See [Locations](https://help.stralos.app/api/global.md#locations) and [Shared address warning](https://help.stralos.app/api/global.md#shared-address-warning).

## Client logos

Each company can have one active logo, printed beside the client's name on a Tender download PDF. The routes (`GET /parties/:id/logo`, `POST /parties/:id/logo-uploads`, `POST /parties/:id/logo-uploads/:uploadId/confirm`, `DELETE /parties/:id/logo`) live in `src/routes/party-logos.ts` and reuse the company branding flow and file rules. Unlike the company branding logo, which is admin-only, any organisation user may write a client logo, following the register's write rule. A retired company's logo is frozen (`409 party_retired`), so historic tenders keep printing it. `GET /parties/:id` and `GET /projects/:id/tender-details` both carry the active logo with a five-minute URL. See [Client logos](https://help.stralos.app/api/global.md#client-logos).

The company record shows the card as `CompanyLogoCard` (`stralos-feature-workspace`): the chosen file is previewed and only uploaded on **Save logo**, and a retired company shows the logo read-only.

## Combined client create

`POST /parties` accepts optional `mainContact` and `address` objects and creates the company, its main contact and its first site in one transaction, so the New client step in the New project sheet can never leave a client without the contact that `POST /projects` now requires. A main contact needs a phone or an email (`422 contact_reach_required`), and an address must be a complete picked one (`422 location_address_required`). The contact is a company-level contact (`locationId` is not set). `PUT /parties/:id` does not take these fields. See [POST /parties](https://help.stralos.app/api/global.md#post-parties).

## Access control for party writes

Write operations on the register (`POST /parties`, `PUT /parties/:id`, `DELETE /parties/:id`, `POST /parties/:id/contacts`, `PUT /party-contacts/:id`, `DELETE /party-contacts/:id`, `POST /parties/:id/locations`) require a **user token**. Any signed-in organisation user, admin or member, may call them. Tenant service tokens are refused with `403 user_token_required`, even when they carry an organisation context. Read operations accept any authenticated organisation token (user or service).

## Organisation-owned location creation

`POST /locations` creates a site owned by the organisation (no `partyId`). The location is immediately selectable in the Rates picker.

## Supplier pricing identity

A supplier company's own `id` is the stable identifier used by the Rates service to store and resolve supplier-scoped rate options. There is no separate rate source id for supplier companies. Use `GET /parties/:id` to read the company, and use `isSupplier && status === "active"` to determine whether it is currently available for pricing.

## Rates deep link from a company record

The **Open in Rates** button on a company record navigates to `/rates?supplierId=<company.id>`. The Rates catalogue reads `supplierId` as a filter and shows only that company's prices.

The button is shown when `isSupplier && status === "active"`. A company that is ticked as a supplier but has been retired does not show the link.

`Party.rateSource` was removed from the `GET /parties/:id` response in migration 0059, which dropped the `rate_sources` table. The deep link previously used `rateSourceId=<rateSource.id>`; because every managed rate source was inserted with `id` equal to the party's own `id`, the value the link carried was always the company id. Only the query parameter name changed.


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