API Contract - steilerDev/cornerstone GitHub Wiki

API Contract

The API contract evolves incrementally as each epic is implemented. This page documents endpoint specifications, request/response shapes, and error patterns for all routes.

Current Endpoints

Health Check

GET /api/health

Auth required: No

Response (200 OK):

{
  "status": "ok",
  "timestamp": "2026-02-07T12:00:00.000Z"
}

GET /api/health is a liveness check only: it touches neither the database nor password hashing.

Readiness Check

GET /api/health/ready

Auth required: No

Response (200 OK):

{
  "status": "ready",
  "timestamp": "2026-02-07T12:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
429 RATE_LIMIT_EXCEEDED The first (uncached) self-check found the password-hashing queue full; the next probe retries
500 INTERNAL_ERROR The database query or the password-hashing self-check fails

Behavior:

  • Every call runs SELECT 1 against the database.
  • The password-hashing self-check (hash a fixed string with the current scrypt parameters, then verify it) runs once per process, on the first call, and only a success is cached. A failure clears the cache, so the next call retries it. Concurrent first calls share one self-check. One hash costs about 128 MiB (ADR-010, Amendments), which is why it is not repeated on every probe.
  • The Docker image's HEALTHCHECK calls this endpoint every 30 seconds (Dockerfile). The sample docker-compose.yml healthcheck calls GET /api/health instead.

Application Configuration

GET /api/config

Auth required: No

Returns deployment-level configuration that the client needs for rendering. This endpoint is public because the client needs configuration (e.g., display currency) before authentication.

Response (200 OK):

{
  "currency": "EUR",
  "vatRate": 0.19,
  "autoItemizeEnabled": false,
  "llmEnabled": false
}
Field Type Description
currency string ISO 4217 currency code configured via the CURRENCY environment variable.
vatRate number VAT/sales-tax rate as a fraction (e.g. 0.19 = 19%) configured via the VAT_RATE environment variable. Default: 0.19.
autoItemizeEnabled boolean true when LLM_BASE_URL, LLM_API_KEY, and LLM_MODEL are all set. Controls visibility of the auto-itemize UI.
llmEnabled boolean Same condition as autoItemizeEnabled, under a name that is not auto-itemize-specific. Story #1901.

Which flag to use. llmEnabled and autoItemizeEnabled are always equal — both mean "LLM_BASE_URL, LLM_API_KEY, and LLM_MODEL are all set". llmEnabled is the preferred field for any new LLM-backed feature; autoItemizeEnabled is retained only for the existing auto-itemize UI and should be treated as deprecated. New code must not introduce a third alias — if the two ever need to diverge (per-feature LLM toggles), that is a contract change requiring an ADR, not a new boolean.

Environment variables:

Variable Default Description
CURRENCY EUR ISO 4217 currency code for display formatting (e.g., EUR, USD, CHF).
VAT_RATE 0.19 VAT/sales-tax rate as a fraction for budget-line gross-up math (e.g., 0.19, 0.20).
LLM_BASE_URL — Base URL of an OpenAI-compatible LLM API (e.g., https://api.openai.com/v1). Required to enable auto-itemize.
LLM_API_KEY — Bearer token for the LLM provider. Required to enable auto-itemize.
LLM_MODEL — Model identifier (e.g., gpt-4o, claude-haiku-4-5, gemini-2.5-flash). Required to enable auto-itemize.
LLM_REQUEST_TIMEOUT_MS 30000 LLM request timeout in milliseconds.
LLM_MAX_TOKENS 16384 Maximum output tokens per LLM call. Increase if extraction returns LLM_INVALID_RESPONSE with finishReason: "length".
LLM_PROVIDER (auto) Provider hint: openai, anthropic, gemini, ollama, or generic. Auto-detected from LLM_BASE_URL when unset.

Notes:

  • This endpoint always returns 200. It cannot fail.
  • The currency value is set at deployment time and applies to all users in the instance.
  • The client fetches this once at startup and uses the value for all Intl.NumberFormat currency formatting.
  • autoItemizeEnabled is false by default. All three LLM env vars (LLM_BASE_URL, LLM_API_KEY, LLM_MODEL) must be set to enable auto-itemize. The LLM API key is never returned to the client; only the boolean flag is surfaced.
  • See ADR-031 for the full LLM integration design and security posture.

EPIC-01: Authentication & User Management

Authentication Flow Overview

Cornerstone supports two authentication methods:

  1. Local authentication -- Email/password login for the initial admin account (setup flow) and as a fallback
  2. OIDC authentication -- OpenID Connect sign-in for existing accounts. By default OIDC never creates accounts (opt-in just-in-time provisioning of member accounts via OIDC_JIT_PROVISIONING, default false; see ADR-035's #2142 amendment): a first OIDC login links the provider identity to the existing account whose email matches the provider's verified email claim (see GET /api/auth/oidc/callback and ADR-035)

All authenticated API requests use a server-side session. The session token is delivered as an HttpOnly cookie (cornerstone_session). There are no bearer tokens or API keys.

Session Cookie

Property Value
Name cornerstone_session
HttpOnly true
SameSite Strict
Secure Configurable via SECURE_COOKIES env var (default: true)
Path /
Max-Age Configurable via SESSION_DURATION env var (default: 604800 seconds = 7 days)

The cookie is set on successful login (local or OIDC) and cleared on logout.

Environment Variables (Auth)

Variable Default Description
SESSION_DURATION 604800 Session lifetime in seconds (default: 7 days)
SECURE_COOKIES true Set Secure flag on cookies; set to false for local dev without TLS
TRUST_PROXY false Trust X-Forwarded-* headers from a reverse proxy
AUTH_RATE_LIMIT_MAX 20 Login endpoint rate limit: max requests per IP per window (positive integer)
AUTH_RATE_LIMIT_WINDOW 15 minutes Login endpoint rate limit: time window (ms-library format, e.g. 15 minutes, 1h)
OIDC_ISSUER (none) OIDC provider issuer URL (e.g., https://auth.example.com/realms/main)
OIDC_CLIENT_ID (none) OIDC client ID
OIDC_CLIENT_SECRET (none) OIDC client secret
OIDC_JIT_PROVISIONING false Opt-in: create a member account when an OIDC sign-in matches no account
EXTERNAL_URL (none) Public base URL (e.g., https://cornerstone.example.com); OIDC callback origin

OIDC is enabled when the three variables OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET are set (server/src/plugins/config.ts, oidcEnabled). If any of the three is missing, GET /api/auth/oidc/login returns 404 OIDC_NOT_CONFIGURED, GET /api/auth/oidc/callback redirects to /login?error=oidc_not_configured, and POST /api/users with authProvider: "oidc" (an SSO-only account) returns 400 OIDC_NOT_CONFIGURED.

OIDC_JIT_PROVISIONING does not take part in enabling OIDC and has no effect while OIDC is disabled. It accepts true or false (case-insensitive); any other value fails startup. With true, an OIDC sign-in that resolves to no account creates a member account (see the callback's Behavior, step 8). Enable it only with a provider that restricts who can sign in: there is no domain or email allow-list, so with an open provider such as consumer Google anyone who finds the login URL can register.

The callback URL is not configured through a dedicated environment variable — there is no OIDC_REDIRECT_URI, and the server never reads one. The redirect URI sent to the provider is derived per request as <EXTERNAL_URL>/api/auth/oidc/callback, falling back to the incoming request's own origin (request.protocol + request.host) when EXTERNAL_URL is unset. Behind a reverse proxy, set EXTERNAL_URL: both halves of the fallback are gated on TRUST_PROXY — request.protocol reads X-Forwarded-Proto and request.host reads X-Forwarded-Host only when TRUST_PROXY=true, so with it unset the fallback yields the container's own internal scheme and host, not the public one. A correct scheme is therefore not sufficient to make the fallback safe. The redirect URI registered with the OIDC provider must match the derived value exactly.

Rate Limiting (Auth)

POST /api/auth/login is rate limited per client identity (see Rate-Limit Key below), configured via AUTH_RATE_LIMIT_MAX and AUTH_RATE_LIMIT_WINDOW. Exceeding the limit returns 429 with the standard error shape and code RATE_LIMIT_EXCEEDED, alongside the standard x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, and retry-after response headers. Behind a reverse proxy, TRUST_PROXY=true is required for the limit to be keyed on the real client address rather than the proxy's.

Password-Hashing Queue

Separately from the login rate limit, every request that runs scrypt (ADR-010) goes through one in-process queue. That covers POST /api/auth/setup, POST /api/auth/login (including the dummy verification for unknown or password-less accounts), POST /api/users/me/password, POST /api/users for a local account, and the first GET /api/health/ready. At most two derivations run at once. When 50 callers are already waiting, a further request is rejected immediately with 429 RATE_LIMIT_EXCEEDED and the message Too many requests. Please try again shortly..

  • The body is the same for every account, so it reveals nothing about which accounts exist.
  • No rate-limit headers are sent, because this is a capacity limit, not a per-client quota.
  • On login, a wrong password is recorded against the account before the extra dummy verification for legacy hashes runs, so a full queue cannot be used to skip the lockout counter.
  • The best-effort rehash after a successful login swallows this error like any other, and the login still succeeds.

Rate-Limit Key

The key is not the raw client IP. It is request.ip passed through @fastify/rate-limit's normalizeIP:

Client address form Key
IPv4 (203.0.113.7) the address itself, lowercased — one bucket per address
IPv4-mapped IPv6 (::ffff:203.0.113.7) unwrapped to the dotted IPv4 form (203.0.113.7)
Any other IPv6 (2001:db8:abcd:12::1) the /64 network prefix in canonical form (2001:db8:abcd:12::)

The /64 truncation means every address within a client's IPv6 prefix shares a single bucket. This is required, not incidental: residential and hosting IPv6 allocations hand out a /64 (or larger) to a single subscriber, so a per-address key lets an attacker rotate through 2^64 addresses and defeat the limit entirely. A keyGenerator returning request.ip verbatim reintroduces exactly that bypass — this was CVE-2026-15144 (issue #1995).

The invariant: the rate-limit key must always be normalizeIP-normalized, at the same subnet width the plugin is configured with. rateLimitPlugin satisfies this with an explicit, exported key generator rather than the library's default:

const IPV6_SUBNET = 64;

export function rateLimitKeyGenerator(request: Pick<FastifyRequest, 'ip'>): string {
  return normalizeIP(request.ip ?? 'unknown', IPV6_SUBNET);
}

The same IPV6_SUBNET constant is passed to normalizeIP and to the plugin's own ipv6Subnet option, so the two cannot drift. Two hazards make this explicit generator necessary rather than merely equivalent to the library default:

  • request.ip is typed string but is genuinely null when the socket carries no address metadata. normalizeIP dereferences its argument (ip.toLowerCase()), so a generator that forwards request.ip unguarded throws and turns every rate-limited endpoint into a 500. The ?? 'unknown' fallback puts such requests in one shared bucket instead.
  • @fastify/rate-limit gates its own normalization on generator identity. It applies normalizeIP only when keyGenerator is the exact defaultKeyGenerator reference; every other generator is invoked as keyGenerator(req) with no subnet argument and no normalization wrapper. So any custom generator — including one that just wraps the default — silently loses the /64 truncation unless it calls normalizeIP itself. That gate is what made CVE-2026-15144 possible.

Invariants any change to rateLimitKeyGenerator MUST preserve:

  • Call normalizeIP on every return path. Returning request.ip verbatim, or falling through to a raw address on a guard/error path, is exactly CVE-2026-15144.
  • Pass the subnet width explicitly, from the same constant given to ipv6Subnet. Relying on normalizeIP's own parameter default makes the ipv6Subnet option silently ineffective and lets the two values drift apart.
  • Keep the null guard. request.ip's string type is a lie at runtime; removing the fallback reintroduces the 500.
  • Never parse X-Forwarded-For / X-Real-IP by hand. Redundant — TRUST_PROXY already makes request.ip reflect those headers — and such code typically bypasses normalizeIP on the fallback path, reintroducing the bypass for proxied requests only, which is harder to spot.

POST /api/auth/setup has a separate, fixed limit of 5 requests per 15 minutes (not configurable).


Auth Endpoints

GET /api/auth/me

Returns the current authentication state. This is the first endpoint the client calls on app load to determine what UI to show.

Auth required: No (returns different shapes based on auth state)

Response (200 OK -- authenticated):

{
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "[email protected]",
    "displayName": "Admin User",
    "role": "admin",
    "authProvider": "local",
    "oidcLinked": false,
    "createdAt": "2026-02-08T10:00:00.000Z"
  },
  "oidcEnabled": true
}

Response (200 OK -- not authenticated, setup required):

{
  "user": null,
  "setupRequired": true,
  "oidcEnabled": false
}

Response (200 OK -- not authenticated, setup complete):

{
  "user": null,
  "setupRequired": false,
  "oidcEnabled": true
}

Notes:

  • This endpoint never returns 401. It always returns 200 with the current state.
  • setupRequired: true means zero users exist in the database; the client should show the setup form.
  • oidcEnabled tells the client whether to show the "Login with SSO" button.

POST /api/auth/setup

Creates the first admin user. Only works when zero users exist in the database.

Auth required: No

Request body:

{
  "email": "[email protected]",
  "displayName": "Admin User",
  "password": "securepassword123"
}
Field Type Validation
email string Required. Valid email format.
displayName string Required. 1-100 characters.
password string Required. Minimum 12 characters.

Response (201 Created):

{
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "[email protected]",
    "displayName": "Admin User",
    "role": "admin",
    "authProvider": "local",
    "oidcLinked": false,
    "createdAt": "2026-02-08T10:00:00.000Z"
  }
}

The response also sets the cornerstone_session cookie (the admin is logged in immediately after setup).

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid email, password too short, or missing fields
403 SETUP_COMPLETE At least one user already exists
429 RATE_LIMIT_EXCEEDED The password-hashing queue is full (see Password-Hashing Queue); same body for every account

POST /api/auth/login

Authenticates a local user with email and password.

Auth required: No

Request body:

{
  "email": "[email protected]",
  "password": "securepassword123"
}
Field Type Validation
email string Required.
password string Required.

Response (200 OK):

{
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "[email protected]",
    "displayName": "Admin User",
    "role": "admin",
    "authProvider": "local",
    "oidcLinked": false,
    "createdAt": "2026-02-08T10:00:00.000Z"
  }
}

The response also sets the cornerstone_session cookie.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing email or password
401 INVALID_CREDENTIALS Wrong email/password, or the account has no password, deactivated or not (no information leakage about which)
401 ACCOUNT_DEACTIVATED Account with a password has been deactivated
423 ACCOUNT_LOCKED Too many failed attempts; details.lockedUntil carries the unlock timestamp
429 RATE_LIMIT_EXCEEDED The login rate limit is exceeded (see Rate Limiting (Auth)), or the password-hashing queue is full (see Password-Hashing Queue); same body for every account

Security notes:

  • The INVALID_CREDENTIALS error uses a generic message ("Invalid email or password") that does not reveal whether the email exists or the password was wrong.
  • Deactivated accounts that have a password receive ACCOUNT_DEACTIVATED to distinguish from invalid credentials (the user should contact an admin).
  • Accounts without a password_hash (authProvider: "oidc": OIDC just-in-time provisioned, or admin-created SSO-only via POST /api/users) attempting local login receive INVALID_CREDENTIALS, including when they are deactivated. The no-password branch runs a dummy scrypt verify and returns before the deactivated and lockout checks, so the response (status, code, message and timing) is the same as for an unknown email and does not reveal that the address has an account.
  • Unknown emails and accounts without a password are checked against a dummy scrypt hash made once per process with the current parameters, so their timing matches a real verification after the first such request.
  • After a successful login, a password hash stored with the legacy scrypt parameters is rehashed with the current ones and saved (best-effort: a failure is logged and the login still succeeds). The response is unaffected. See ADR-010, Amendments.
  • Local accounts that have been linked to OIDC (oidcLinked: true) keep their password and can still sign in here. Linking never changes an account's id, role, authProvider, or password_hash.

POST /api/auth/logout

Destroys the current session and clears the session cookie.

Auth required: Yes

Request body: None

Response (204 No Content): Empty body. The cornerstone_session cookie is cleared.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

GET /api/auth/oidc/login

Initiates the OIDC Authorization Code flow by redirecting the user to the OIDC provider.

Auth required: No

Query parameters:

Parameter Type Description
redirect string Optional. In-app URL (pathname, search and hash) to land on after a successful sign-in. Default: / (the Home path)

redirect is accepted only when safeAppPath (@cornerstone/shared, shared/src/routes/safePath.ts) returns it unchanged. The value must be a string of 1 to 2048 characters (MAX_APP_PATH_LENGTH) that starts with /, does not start with //, and contains no \, no :// and no ASCII control character (U+0000 to U+001F, U+007F). Browsers read \ as / and strip tab and newline inside URLs, so /\evil.example or /<tab>/evil.example would otherwise become the off-site //evil.example. The rule validates and never rewrites: there is no decoding or normalization. An absent or rejected value falls back to the Home path, /. The client's sign-in page (/login?next=) sends its next value here, so the SSO sign-in lands on the deep link that the signed-out user asked for.

Response (302 Found): Redirects to the OIDC provider's authorization endpoint with:

  • response_type=code
  • client_id from config
  • redirect_uri from config
  • scope=openid email profile
  • state = cryptographically random value (stored server-side temporarily for CSRF protection)

Error responses:

HTTP Status Error Code When
404 OIDC_NOT_CONFIGURED OIDC environment variables are not set

GET /api/auth/oidc/callback

Handles the OIDC provider's redirect after the user authenticates.

Auth required: No

Query parameters (set by the OIDC provider):

Parameter Type Description
code string Authorization code from the provider
state string CSRF state parameter to validate
error string Error code from the provider (if auth failed)
error_description string Error description from the provider (if auth failed)

Response (302 Found): On success, redirects to the app (the redirect value validated by GET /api/auth/oidc/login and stored with the state, or /). The cornerstone_session cookie is set.

Behavior:

  1. Validates the state parameter against the stored value
  2. Exchanges the code for tokens using the OIDC token endpoint. The redirect_uri sent in this exchange is derived by the same expression as the login leg (<EXTERNAL_URL> or the request origin, + /api/auth/oidc/callback, keeping the callback's query string) — see Environment Variables (Auth)
  3. Extracts sub, email, email_verified, name and preferred_username from the ID token (only a boolean true counts as verified; an absent claim is unverified). The two name claims are used only by step 8 (provisioning)
  4. Subject match. Looks up the user by oidc_subject = sub (any auth_provider). If found, that account signs in — neither an email claim nor email_verified is required on this path (the subject is already bound)
  5. Email present. No subject match: an empty or missing email claim redirects with missing_email (error code OIDC_MISSING_EMAIL)
  6. Verified-email gate. If email_verified !== true, redirects with oidc_email_unverified. This check runs before any email lookup, so an unverified token can never probe which emails have accounts
  7. Email match. Looks up the account by email, case-insensitively. An exact (case-sensitive) match is preferred. If there is no exact match and more than one account matches case-insensitively, the result is ambiguous and treated as no match. A match continues at step 9; no match continues at step 8
  8. No match: reject or provision. With OIDC_JIT_PROVISIONING unset or false (the default), redirects with oidc_no_matching_account: OIDC does not create the account, which only initial setup (POST /api/auth/setup) or an admin (POST /api/users) can do. With the flag true, the callback creates the account (outcome provisioned), except that it still redirects with oidc_no_matching_account when the email was ambiguous in step 7 (fail closed) or the users table is empty (initial setup has not run; provisioning a member first would permanently block POST /api/auth/setup). The new account has role = 'member' (never admin), authProvider = 'oidc', oidc_subject = sub, the claim's email, and no password. displayName is the first non-empty of name, preferred_username, then the email, trimmed and capped at 100 code points. The insert runs in the step 7 transaction. Transactions serialize within the process, so a second sign-in for the same sub finds the account at step 4 and never inserts. If a write from outside the process (another instance or an external tool) already bound the sub, the insert hits the unique index on oidc_subject. The violation is caught, the account is re-read by subject, and it signs in as a subject match. The server logs provisioning at info with the new userId only. Continues at step 11
  9. Deactivated. If the matched account is deactivated, redirects with account_deactivated and does not link. (A deactivated account found in step 4 is refused the same way.) A deactivated match is never replaced by a provisioned account
  10. Link. Sets the matched account's oidc_subject to sub. If the account was already linked to a different subject, the re-bind is accepted (gated by step 6) and logged at warn. id, role, authProvider, and password_hash are unchanged
  11. Creates a session and sets the cookie
  12. Redirects to the app

Error responses (all via redirect to /login?error=<code>, plus &next=<path> when the sign-in state is known):

next is the app redirect stored with the state, URL-encoded. It is appended only after a valid state was consumed (account_deactivated, missing_email, oidc_email_unverified, oidc_no_matching_account, and oidc_error from the token exchange), or on the provider-error branch when the provider's redirect carries a valid state. That branch now consumes the state, so the state can no longer be reused. next is never appended for oidc_not_configured or invalid_state (or a missing state), never when the stored value is the Home path /, and never when it fails safeAppPath. The key order is error, then next, so a redirect without next stays exactly /login?error=<code>. The login page keeps next for the next sign-in attempt; its error banner does not depend on it.

Redirect Error When
oidc_not_configured OIDC env vars not set
oidc_error OIDC provider returned an error, or the token exchange failed
invalid_state State parameter mismatch (CSRF)
missing_email No account is bound to this subject and the ID token has no (or an empty) email claim
oidc_email_unverified No account is bound to this subject and the token's email_verified is not true
oidc_no_matching_account No subject or email match, and provisioning is off, ambiguous, or blocked (step 8)
account_deactivated The matched account has been deactivated

The redirect codes are exactly the OIDC_LOGIN_ERROR_CODES tuple in @cornerstone/shared (OidcLoginErrorCode). The server builds every redirect through a helper typed against it, and the login page translates only codes in the tuple (an unknown ?error= value renders no banner). Adding a code means adding it to the tuple; the client's I18N_UNION_KEYS.oidcLoginError locale-parity test then requires its translation.

Notes:

  • OIDC callback errors redirect to the login page with a query parameter rather than returning JSON, because this endpoint is called via browser redirect (not AJAX).
  • The three OIDC account-resolution rejections map to the error codes OIDC_MISSING_EMAIL (403), OIDC_EMAIL_UNVERIFIED (403), and OIDC_NO_MATCHING_ACCOUNT (403) in the Error Codes table; the endpoint surfaces them only as the redirect codes above.
  • The earlier email_conflict redirect (and the EMAIL_CONFLICT error code) no longer exist: an OIDC identity whose verified email matches a local account is linked to it rather than rejected.

User Endpoints

GET /api/users/me

Returns the full profile of the currently authenticated user.

Auth required: Yes

Response (200 OK):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "[email protected]",
  "displayName": "Admin User",
  "role": "admin",
  "authProvider": "local",
  "oidcLinked": false,
  "createdAt": "2026-02-08T10:00:00.000Z",
  "updatedAt": "2026-02-08T10:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

PATCH /api/users/me

Updates the current user's profile. Only displayName can be changed by the user.

Auth required: Yes

Request body:

{
  "displayName": "New Display Name"
}
Field Type Validation
displayName string Required. 1-100 characters.

Response (200 OK):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "[email protected]",
  "displayName": "New Display Name",
  "role": "admin",
  "authProvider": "local",
  "oidcLinked": false,
  "createdAt": "2026-02-08T10:00:00.000Z",
  "updatedAt": "2026-02-08T12:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid or missing displayName
401 UNAUTHORIZED No valid session

POST /api/users/me/password

Changes the current user's password. Only available for local auth users.

Auth required: Yes

Request body:

{
  "currentPassword": "oldpassword123",
  "newPassword": "newpassword456"
}
Field Type Validation
currentPassword string Required. Must match stored hash.
newPassword string Required. Minimum 12 characters.

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing fields or newPassword too short
401 UNAUTHORIZED No valid session
401 INVALID_CREDENTIALS Current password is incorrect
403 FORBIDDEN User's authProvider is oidc (no local password)
429 RATE_LIMIT_EXCEEDED The password-hashing queue is full (see Password-Hashing Queue); same body for every account

Notes:

  • The 403 covers every authProvider: "oidc" account: OIDC just-in-time provisioned and admin-created SSO-only (POST /api/users with authProvider: "oidc"). It runs in the handler, after body schema validation, so such a user sending an invalid body gets 400, not 403.
  • This is the only password endpoint, and it requires the current password. No endpoint sets a password for an account that has none, and authProvider never changes, so an SSO-only account stays SSO-only (#2122).

GET /api/users

Returns a list of all users. Supports search filtering.

Auth required: Yes (any authenticated user; the route has no role check, so members can list users too)

Query parameters:

Parameter Type Description
q string Optional. Search filter matching email or display_name (case-insensitive LIKE)

Response (200 OK):

{
  "users": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "email": "[email protected]",
      "displayName": "Admin User",
      "role": "admin",
      "authProvider": "local",
      "oidcLinked": false,
      "createdAt": "2026-02-08T10:00:00.000Z",
      "deactivatedAt": null
    },
    {
      "id": "660e8400-e29b-41d4-a716-446655440001",
      "email": "[email protected]",
      "displayName": "Member User",
      "role": "member",
      "authProvider": "oidc",
      "oidcLinked": true,
      "createdAt": "2026-02-08T11:00:00.000Z",
      "deactivatedAt": null
    }
  ]
}

Notes:

  • Returns both active and deactivated users (deactivated users have deactivatedAt set)
  • No pagination needed for <5 users; will be revisited if the user count model changes
  • The password_hash and oidc_subject fields are never exposed in API responses

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

Unknown query parameters are silently dropped, not rejected: the querystring schema declares additionalProperties: false, and Fastify's default AJV configuration (removeAdditional: true) strips the extra keys before the handler runs.


POST /api/users (Admin only)

Creates a local account with a password, or (with authProvider: "oidc") an SSO-only account with no password. Besides initial setup (POST /api/auth/setup) and opt-in OIDC just-in-time provisioning (OIDC_JIT_PROVISIONING, see ADR-035), this is the only way an account is created.

Auth required: Yes (admin role)

Request body (email, displayName and role are required; password is required for a local account and forbidden for an SSO-only one; unknown properties are stripped, not rejected):

{
  "email": "[email protected]",
  "displayName": "Member User",
  "password": "a-long-password",
  "role": "member"
}

SSO-only account (requires OIDC to be enabled):

{
  "email": "[email protected]",
  "displayName": "Member User",
  "role": "member",
  "authProvider": "oidc"
}
Field Type Validation
email string Required. format: email, max 255 characters
displayName string Required. 1-100 characters
password string 12-255 characters. Required when authProvider is "local" or absent; forbidden when it is "oidc"
role string Required. "admin" or "member" (no default applied)
authProvider string Optional. "local" (default) or "oidc". "oidc" creates an SSO-only account and requires OIDC to be enabled

Lengths are counted in code points (AJV minLength/maxLength).

Response (201 Created): the new user wrapped in a user key (unlike PATCH /api/users/:id, which returns the bare object). The shape is the User Response Shape.

{
  "user": {
    "id": "770e8400-e29b-41d4-a716-446655440002",
    "email": "[email protected]",
    "displayName": "Member User",
    "role": "member",
    "authProvider": "local",
    "createdAt": "2026-10-01T10:00:00.000Z",
    "updatedAt": "2026-10-01T10:00:00.000Z",
    "deactivatedAt": null,
    "oidcLinked": false
  }
}

An SSO-only account is returned with "authProvider": "oidc" and "oidcLinked": false.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Schema failure: missing/invalid field, or an authProvider other than "local"/"oidc" (details.fields[] lists the failures)
400 VALIDATION_ERROR password sent with authProvider: "oidc", or password missing for a local account (details: { "field": "password" })
400 OIDC_NOT_CONFIGURED authProvider: "oidc" while OIDC is disabled (OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET not all set)
401 UNAUTHORIZED No valid session
403 FORBIDDEN User is not an admin
409 CONFLICT email exactly matches an existing account's email (details.email echoes it)
429 RATE_LIMIT_EXCEEDED A local account (authProvider "local" or absent) while password-hashing queue is full (see Password-Hashing Queue); same body for every account

Notes:

  • Check order: the authentication hook runs at preValidation (401), then body schema validation (400), then the admin role check at preHandler (403). A non-admin sending an invalid body therefore gets 400, not 403. The handler then applies, in this order: the password rule (400 VALIDATION_ERROR), the OIDC configuration check for "oidc" (400 OIDC_NOT_CONFIGURED), and the email conflict check (409). An "oidc" request that also carries a password gets VALIDATION_ERROR even while OIDC is disabled. No row is written on any error.
  • The password rule is enforced in the handler, not in the JSON schema, so its two failures carry details.field rather than the schema's details.fields[]. Omitting password never silently creates an SSO-only account: authProvider: "oidc" must be explicit.
  • OIDC_NOT_CONFIGURED is 400 here (the request is well-formed but the instance cannot satisfy it), unlike GET /api/auth/oidc/login, where the route itself is unavailable and it is 404.
  • A local account's password is hashed with scrypt (ADR-010) before storage. No session is created for the new user and the calling admin's session is unchanged.
  • SSO-only accounts are stored with auth_provider = 'oidc', password_hash = NULL and oidc_subject = NULL. They have oidcLinked: false until the user's first OIDC sign-in with a verified email that matches the account, which binds the subject (GET /api/auth/oidc/callback, link step). Until then, and whenever OIDC is disabled, the account cannot sign in at all. Local login answers INVALID_CREDENTIALS (see POST /api/auth/login), and POST /api/users/me/password answers 403.
  • SSO-only is permanent. No endpoint sets a password for an account without one, and PATCH /api/users/:id cannot change authProvider. Because emails are never released (deactivation is a soft delete and the conflict check includes deactivated accounts), switching a person to a local account means: change the SSO-only account's email to a placeholder, deactivate it, then create a new local account with the real email. History stays on the old account. See ADR-035, Amendment (#2122).
  • The uniqueness check is an exact, case-sensitive comparison (findByEmail), and it includes deactivated accounts: an email held by a deactivated user returns 409. Case variants ([email protected] vs [email protected]) can coexist, consistent with ADR-035.
  • The limits match the rest of the API: displayName 1-100 as on PATCH /api/users/me and PATCH /api/users/:id, and a 12-character password minimum as on POST /api/users/me/password. This endpoint has no route-specific rate limit.

PATCH /api/users/:id (Admin only)

Updates a user's role or display name. Admins can also update a user's email.

Auth required: Yes (admin role)

Path parameters:

Parameter Type Description
id string User UUID

Request body (all fields optional, at least one required):

{
  "displayName": "Updated Name",
  "email": "[email protected]",
  "role": "admin"
}
Field Type Validation
displayName string 1-100 characters
email string Valid email format
role string "admin" or "member"

Response (200 OK):

{
  "id": "660e8400-e29b-41d4-a716-446655440001",
  "email": "[email protected]",
  "displayName": "Updated Name",
  "role": "admin",
  "authProvider": "oidc",
  "oidcLinked": true,
  "createdAt": "2026-02-08T11:00:00.000Z",
  "deactivatedAt": null
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid fields or no fields provided
401 UNAUTHORIZED No valid session
403 FORBIDDEN User is not an admin
404 NOT_FOUND User ID does not exist
409 LAST_ADMIN Attempting to demote the last admin to member
409 CONFLICT Email already in use by another user

DELETE /api/users/:id (Admin only)

Deactivates a user account (soft delete). Sets deactivated_at and invalidates all active sessions.

Auth required: Yes (admin role)

Path parameters:

Parameter Type Description
id string User UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
403 FORBIDDEN User is not an admin
404 NOT_FOUND User ID does not exist
409 SELF_DEACTIVATION Admin attempting to deactivate themselves
409 LAST_ADMIN Attempting to deactivate the last admin

Notes:

  • This is a soft delete: the user record is retained with deactivated_at set to the current timestamp
  • All active sessions for the deactivated user are deleted immediately
  • Deactivated users cannot log in. Local login returns ACCOUNT_DEACTIVATED for an account with a password and INVALID_CREDENTIALS for one without (OIDC-provisioned or SSO-only, see POST /api/auth/login); the OIDC callback redirects with account_deactivated
  • An already-deactivated user can be "re-deleted" without error (idempotent)

POST /api/users/:id/unlock (Admin only)

Clears a login lockout. Local login locks an account for 15 minutes after 10 consecutive failed attempts (423 ACCOUNT_LOCKED, see the Error Codes table); this endpoint resets failed_login_attempts to 0 and locked_until to NULL immediately.

Auth required: Yes (admin role)

Path parameters:

Parameter Type Description
id string User UUID

Request body: None (the route declares no body schema; a body, if sent, is ignored).

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
403 FORBIDDEN User is not an admin
404 NOT_FOUND User ID does not exist

Notes:

  • Idempotent: unlocking an account that is not locked also returns 204 (and bumps updated_at).
  • The endpoint does not check deactivated_at; unlocking a deactivated account returns 204 but does not reactivate it.
  • Sessions are not touched.

Route Protection

All /api/* routes are protected by a Fastify preValidation hook (server/src/plugins/auth.ts), so an unauthenticated request gets 401 before body validation runs. Role checks (requireRole) run later, at preHandler, after validation. The hook skips these routes (PUBLIC_ROUTES, matched on the route pattern):

Unprotected Routes Reason
GET /api/config Client needs currency config before auth
GET /api/health Health check for Docker/monitoring
GET /api/health/ready Readiness check for Docker/monitoring
GET /api/auth/me Must work without auth to detect setup state
POST /api/auth/setup Must work without auth (no users exist yet)
POST /api/auth/login Must work without auth (logging in)
POST /api/auth/logout Must succeed even when the session has already expired
GET /api/auth/oidc/login Must work without auth (initiating OIDC flow)
GET /api/auth/oidc/callback Must work without auth (OIDC provider callback)

Admin-only routes additionally check user.role === 'admin' via a requireRole('admin') decorator.


EPIC-03: Work Items Core CRUD & Properties

Work items are the central entity of the application. All endpoints in this section require authentication. Both admin and member roles can perform all work item operations (no role restriction).

Work Item Response Shapes

Work item summary (used in list responses):

interface WorkItemSummary {
  id: string;
  title: string;
  status: WorkItemStatus;
  startDate: string | null; // PLANNED start (stored, never floored to today); null for an undated task
  endDate: string | null; // PLANNED end
  projectedStartDate: string | null; // Forecast start (after the today floor); equals startDate unless late/held up; start of the forecast for an undated task
  projectedEndDate: string | null; // Forecast end; equals endDate unless late/held up
  isLate: boolean; // The today floor moved this item itself (never for completed or undated)
  lateDays: number | null; // >= 1 exactly when isLate
  isHeldUp: boolean; // Not late itself, but moved by an unfinished late predecessor
  actualStartDate: string | null; // Actual start date (YYYY-MM-DD)
  actualEndDate: string | null; // Actual end date (YYYY-MM-DD)
  durationDays: number | null;
  assignedUser: UserSummary | null;
  assignedVendor: VendorSummary | null;
  area: AreaSummary | null;
  createdAt: string;
  updatedAt: string;
}

Work item detail (used in single-item responses):

interface WorkItemDetail {
  id: string;
  title: string;
  description: string | null;
  status: WorkItemStatus;
  startDate: string | null; // PLANNED start (stored, never floored to today); null for an undated task
  endDate: string | null; // PLANNED end
  projectedStartDate: string | null; // Forecast start (after the today floor); equals startDate unless late/held up; start of the forecast for an undated task
  projectedEndDate: string | null; // Forecast end; equals endDate unless late/held up
  isLate: boolean; // The today floor moved this item itself (never for completed or undated)
  lateDays: number | null; // >= 1 exactly when isLate
  isHeldUp: boolean; // Not late itself, but moved by an unfinished late predecessor
  actualStartDate: string | null; // Actual start date (YYYY-MM-DD)
  actualEndDate: string | null; // Actual end date (YYYY-MM-DD)
  durationDays: number | null;
  startAfter: string | null;
  startBefore: string | null;
  assignedUser: UserSummary | null;
  assignedVendor: VendorSummary | null;
  area: AreaSummary | null;
  createdBy: UserSummary | null;
  subtasks: SubtaskResponse[];
  dependencies: {
    predecessors: DependencyResponse[];
    successors: DependencyResponse[];
  };
  budgets: WorkItemBudgetLine[];
  createdAt: string;
  updatedAt: string;
}

Supporting types:

type WorkItemStatus = 'not_started' | 'in_progress' | 'completed';
type DependencyType = 'finish_to_start' | 'start_to_start' | 'finish_to_finish' | 'start_to_finish';
type ConfidenceLevel = 'own_estimate' | 'professional_estimate' | 'quote' | 'invoice';

interface UserSummary {
  id: string;
  displayName: string;
  email: string;
}

interface AreaAncestor {
  id: string;
  name: string;
  color: string | null;
}

interface AreaSummary {
  id: string;
  name: string;
  color: string | null;
  /** Root-first ancestor chain. Does NOT include the area itself. Empty array for root-level areas. */
  ancestors: AreaAncestor[];
}

interface SubtaskResponse {
  id: string;
  title: string;
  isCompleted: boolean;
  sortOrder: number;
  createdAt: string;
  updatedAt: string;
}

interface DependencyResponse {
  workItem: WorkItemSummary;
  dependencyType: DependencyType;
  leadLagDays: number; // Lead (negative) or lag (positive) offset in days; default 0
}

interface WorkItemBudgetLine {
  id: string;
  workItemId: string;
  description: string | null;
  plannedAmount: number;
  confidence: ConfidenceLevel;
  confidenceMargin: number; // Computed: 0.20, 0.10, 0.05, or 0.00
  budgetCategory: BudgetCategoryResponse | null;
  budgetSource: BudgetSourceSummary | null;
  vendor: VendorSummary | null;
  actualCost: number; // Computed: sum of itemized amounts from ALL linked invoices, including quotations. See "Quotation handling for `actualCost`" below.
  actualCostPaid: number; // Computed: aggregates per-deposit 'paid'/'claimed' contributions (proportional split) + residual when parent invoice is 'paid'/'claimed'. Excludes quotations.
  invoiceCount: number; // Computed: count of linked invoices
  invoiceLink: BudgetLineInvoiceLink | null; // The (at most one) linked invoice; null if unlinked
  createdBy: UserSummary | null;
  createdAt: string;
  updatedAt: string;
}

interface BudgetLineInvoiceLink {
  invoiceBudgetLineId: string;
  invoiceId: string;
  invoiceNumber: string | null;
  invoiceDate: string;
  invoiceStatus: InvoiceStatus;
  itemizedAmount: number;
  vendorId: string | null; // Denormalized from the invoice's vendor
  vendorName: string | null; // Denormalized from the invoice's vendor (may be null only if the vendor row has no name)
}

interface BudgetSourceSummary {
  id: string;
  name: string;
  sourceType: string;
}

interface VendorSummary {
  id: string;
  name: string;
  trade: TradeSummary | null;
}

interface TradeSummary {
  id: string;
  name: string;
  color: string | null;
}

Notes on the detail response:

  • area is the spatial location of the work item (nullable). Only id, name, and color are embedded.
  • assignedVendor is the vendor assigned to this work item (nullable, mutually exclusive with assignedUser).
  • subtasks are always embedded, sorted by sortOrder ascending.
  • dependencies.predecessors lists work items that this item depends on. dependencies.successors lists work items that depend on this item. Both include a summary of the related work item.
  • budgets are always embedded, listing all budget lines for the work item. Each budget line includes computed fields (confidenceMargin, actualCost, actualCostPaid, invoiceCount) derived from linked invoices and the confidence level enum.
  • Notes are NOT embedded in the detail response. They are fetched separately via GET /api/work-items/:id/notes to allow independent pagination if needed in the future.
  • The work item's trade is derived transitively: work_item.assignedVendor.trade. There is no direct trade field on work items.

Work Item Endpoints

GET /api/work-items

Returns a paginated, filterable, sortable list of work items.

startDate/endDate are the planned dates. The forecast fields (projectedStartDate, projectedEndDate, isLate, lateDays, isHeldUp) come from the same read-time projection as GET /api/timeline (contract 4, #2199). start_date/end_date sorting and the four date filters use the shown date, actualStartDate ?? projectedStartDate (respectively end): the date every client surface displays. Rows without a shown date sort last in both directions; ties break on createdAt then id. A bounded date filter excludes rows without a shown date.

Auth required: Yes

Query parameters:

Parameter Type Default Description
page integer 1 Page number (1-indexed)
pageSize integer 25 Items per page (max 100)
status string (none) Filter by status. One of: not_started, in_progress, completed
assignedUserId string (none) Filter by assigned user UUID
assignedVendorId string (none) Filter by assigned vendor UUID
areaId string (none) Filter by area UUID or comma-separated list of area UUIDs. For each supplied ID the server expands descendants server-side; results are the union of all matching subtrees. Use the sentinel value __none__ to include items with no area assignment (e.g., ?areaId=__none__ returns only unassigned items; ?areaId=__none__,<uuid> returns unassigned items OR items in the specified area subtree).
q string (none) Search title and description (case-insensitive substring match)
budgetLinesMin number (none) Filter: minimum budget line count (inclusive)
budgetLinesMax number (none) Filter: maximum budget line count (inclusive)
startDateFrom string (none) Filter: shown start date >= value (YYYY-MM-DD, inclusive; 400 VALIDATION_ERROR if malformed)
startDateTo string (none) Filter: shown start date <= value (YYYY-MM-DD, inclusive)
endDateFrom string (none) Filter: shown end date >= value (YYYY-MM-DD, inclusive)
endDateTo string (none) Filter: shown end date <= value (YYYY-MM-DD, inclusive)
sortBy string created_at Sort field. One of: title, status, start_date, end_date, created_at, updated_at
sortOrder string desc Sort direction: asc or desc

Response (200 OK):

{
  "items": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "title": "Foundation Work",
      "status": "in_progress",
      "startDate": "2026-03-01",
      "endDate": "2026-03-15",
      "actualStartDate": "2026-03-02",
      "actualEndDate": null,
      "durationDays": 14,
      "assignedUser": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "assignedVendor": null,
      "area": {
        "id": "area-kitchen",
        "name": "Kitchen",
        "color": "#F59E0B",
        "ancestors": [
          { "id": "area-ground-floor", "name": "Ground Floor", "color": "#3B82F6" },
          { "id": "area-living-spaces", "name": "Living Spaces", "color": "#10B981" }
        ]
      },
      "createdAt": "2026-02-15T10:00:00.000Z",
      "updatedAt": "2026-02-15T10:00:00.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "totalItems": 1,
    "totalPages": 1
  },
  "filterMeta": {
    "budgetLines": { "min": 0, "max": 5 }
  }
}

The filterMeta object contains min/max bounds for each numeric filterable column, computed from all rows matching the current non-numeric filters. Used by the frontend to set appropriate slider ranges on number filters. Returns { "min": 0, "max": 0 } per column when no rows match.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid query parameters (e.g., invalid status value, page < 1, pageSize > 100)
401 UNAUTHORIZED No valid session

POST /api/work-items

Creates a new work item.

Auth required: Yes

Request body:

{
  "title": "Foundation Work",
  "description": "Excavation and concrete pouring for the foundation",
  "status": "not_started",
  "startDate": "2026-03-01",
  "endDate": "2026-03-15",
  "durationDays": 14,
  "startAfter": "2026-02-28",
  "startBefore": "2026-03-10",
  "assignedUserId": "550e8400-e29b-41d4-a716-446655440000",
  "areaId": "area-kitchen"
}
Field Type Required Validation
title string Yes 1-500 characters
description string No Max 10000 characters
status string No One of: not_started, in_progress, completed. Default: not_started
startDate string No ISO 8601 date (YYYY-MM-DD)
endDate string No ISO 8601 date (YYYY-MM-DD). Must be >= startDate if both provided
actualStartDate string No ISO 8601 date (YYYY-MM-DD) or null. Overrides auto-population on status transition.
actualEndDate string No ISO 8601 date (YYYY-MM-DD) or null. Overrides auto-population on status transition.
durationDays integer No >= 0
startAfter string No ISO 8601 date (YYYY-MM-DD)
startBefore string No ISO 8601 date (YYYY-MM-DD). Must be >= startAfter if both provided
assignedUserId string No Must be a valid, active user UUID. Mutually exclusive with assignedVendorId.
assignedVendorId string No Must be a valid vendor UUID. Mutually exclusive with assignedUserId.
areaId string No Must be a valid area UUID

The createdBy field is automatically set to the authenticated user's ID.

Response (201 Created):

Returns a WorkItemDetail object (same shape as GET /api/work-items/:id).

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty title, invalid status, invalid date format, startDate > endDate, startAfter > startBefore, non-existent assignedUserId/assignedVendorId/areaId, both assignedUserId and assignedVendorId set
401 UNAUTHORIZED No valid session

GET /api/work-items/:id

Returns a single work item with full detail including tags, subtasks, and dependencies.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Work item UUID

Response (200 OK):

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "title": "Foundation Work",
  "description": "Excavation and concrete pouring for the foundation",
  "status": "in_progress",
  "startDate": "2026-03-01",
  "endDate": "2026-03-15",
  "actualStartDate": "2026-03-02",
  "actualEndDate": null,
  "durationDays": 14,
  "startAfter": "2026-02-28",
  "startBefore": "2026-03-10",
  "assignedUser": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "displayName": "Admin User",
    "email": "[email protected]"
  },
  "assignedVendor": null,
  "area": { "id": "area-kitchen", "name": "Kitchen", "color": "#F59E0B", "ancestors": [] },
  "createdBy": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "displayName": "Admin User",
    "email": "[email protected]"
  },
  "subtasks": [
    {
      "id": "s1",
      "title": "Excavate site",
      "isCompleted": true,
      "sortOrder": 0,
      "createdAt": "2026-02-15T10:00:00.000Z",
      "updatedAt": "2026-02-16T08:00:00.000Z"
    },
    {
      "id": "s2",
      "title": "Pour concrete",
      "isCompleted": false,
      "sortOrder": 1,
      "createdAt": "2026-02-15T10:00:00.000Z",
      "updatedAt": "2026-02-15T10:00:00.000Z"
    }
  ],
  "dependencies": {
    "predecessors": [
      {
        "workItem": {
          "id": "prev-item-id",
          "title": "Site Survey",
          "status": "completed",
          "startDate": "2026-02-20",
          "endDate": "2026-02-22",
          "actualStartDate": "2026-02-20",
          "actualEndDate": "2026-02-22",
          "durationDays": 2,
          "assignedUser": null,
          "assignedVendor": null,
          "area": null,
          "createdAt": "2026-02-10T10:00:00.000Z",
          "updatedAt": "2026-02-22T10:00:00.000Z"
        },
        "dependencyType": "finish_to_start",
        "leadLagDays": 0
      }
    ],
    "successors": []
  },
  "budgets": [
    {
      "id": "b1",
      "workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "description": "Foundation concrete materials",
      "plannedAmount": 8500.0,
      "confidence": "quote",
      "confidenceMargin": 0.05,
      "budgetCategory": {
        "id": "bc-materials",
        "name": "Materials",
        "description": "Raw materials and building supplies",
        "color": "#3B82F6",
        "sortOrder": 0,
        "createdAt": "2026-02-20T10:00:00.000Z",
        "updatedAt": "2026-02-20T10:00:00.000Z"
      },
      "budgetSource": {
        "id": "bs-1",
        "name": "ABC Bank Mortgage",
        "sourceType": "bank_loan"
      },
      "vendor": {
        "id": "v1",
        "name": "ABC Concrete",
        "trade": { "id": "trade-masonry", "name": "Masonry", "color": "#78716C" }
      },
      "actualCost": 2500.0,
      "actualCostPaid": 2500.0,
      "invoiceCount": 1,
      "createdBy": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-02-15T10:00:00.000Z",
      "updatedAt": "2026-02-15T10:00:00.000Z"
    }
  ],
  "createdAt": "2026-02-15T10:00:00.000Z",
  "updatedAt": "2026-02-16T08:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

PATCH /api/work-items/:id

Updates a work item. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Work item UUID

Request body (all fields optional, at least one required):

{
  "title": "Updated Foundation Work",
  "description": "Updated description",
  "status": "completed",
  "startDate": "2026-03-01",
  "endDate": "2026-03-20",
  "actualStartDate": "2026-03-02",
  "actualEndDate": "2026-03-18",
  "durationDays": 19,
  "startAfter": null,
  "startBefore": null,
  "assignedUserId": "550e8400-e29b-41d4-a716-446655440000",
  "areaId": "area-kitchen"
}
Field Type Validation
title string 1-500 characters
description string | null Max 10000 characters; null clears the description
status string One of: not_started, in_progress, completed
startDate string | null ISO 8601 date or null to clear
endDate string | null ISO 8601 date or null to clear. Must be >= startDate if both are set
actualStartDate string | null ISO 8601 date or null to clear. Overrides auto-population on status transition.
actualEndDate string | null ISO 8601 date or null to clear. Overrides auto-population on status transition.
durationDays integer | null >= 0 or null to clear
startAfter string | null ISO 8601 date or null to clear
startBefore string | null ISO 8601 date or null to clear. Must be >= startAfter if both are set
assignedUserId string | null Valid active user UUID or null to unassign. Mutually exclusive with assignedVendorId.
assignedVendorId string | null Valid vendor UUID or null to unassign. Mutually exclusive with assignedUserId.
areaId string | null Valid area UUID or null to clear

Notes:

  • Nullable fields can be explicitly set to null to clear the value.
  • Actual dates auto-population: When status changes, actual dates are auto-populated as follows:
    • not_started -> in_progress: sets actualStartDate to today (if not explicitly provided)
    • in_progress -> completed: sets actualEndDate to today (if not explicitly provided)
    • not_started -> completed: sets actualEndDate to today (if not explicitly provided and not already set), and sets a missing actualStartDate to the resolved actual end date, the actualEndDate the work item has after the call (#2209; before, it was always today, which stored start > end for "Mark done" with a past date)
    • If actualStartDate or actualEndDate is explicitly included in the request body (even as null), the explicit value is used and auto-population is skipped for that field.
    • Backward moves (completed -> in_progress, in_progress -> not_started) keep both actual dates. Actual dates later than today are accepted.

Response (200 OK):

Returns the updated WorkItemDetail object. When the call changed status, actualStartDate or actualEndDate, the body also carries a top-level undo token (contract 11).

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid field values, startDate > endDate, startAfter > startBefore, non-existent assignedUserId/assignedVendorId/areaId, both assignedUserId and assignedVendorId set (in the request, or one in the request and the other already stored), no fields provided
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

DELETE /api/work-items/:id

Deletes a work item. Cascades to notes, subtasks, budget lines, and dependencies.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Work item UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

Tag Endpoints (REMOVED — EPIC-18)

All tag endpoints have been removed in EPIC-18. Tags are replaced by structured Area and Trade dimensions. See ADR-028.


Area Endpoints

Areas provide hierarchical spatial organization of the construction project. The area list is expected to remain small (fewer than ~100 areas) so no pagination is used. All area endpoints require authentication. Both admin and member roles can perform all area operations.

GET /api/areas

Returns all areas as a flat list sorted by sort_order ascending, then name ascending. The client builds the tree structure using parentId references.

Auth required: Yes

Query parameters:

Parameter Type Default Description
search string (none) Filter by name (case-insensitive substring match)

Response (200 OK):

{
  "areas": [
    {
      "id": "area-ground",
      "name": "Ground Floor",
      "parentId": null,
      "color": "#3B82F6",
      "description": "Main living level",
      "sortOrder": 0,
      "createdAt": "2026-03-19T10:00:00.000Z",
      "updatedAt": "2026-03-19T10:00:00.000Z"
    },
    {
      "id": "area-kitchen",
      "name": "Kitchen",
      "parentId": "area-ground",
      "color": "#F59E0B",
      "description": null,
      "sortOrder": 0,
      "createdAt": "2026-03-19T10:00:00.000Z",
      "updatedAt": "2026-03-19T10:00:00.000Z"
    }
  ]
}

Response type:

interface AreaResponse {
  id: string;
  name: string;
  parentId: string | null;
  color: string | null;
  description: string | null;
  sortOrder: number;
  createdAt: string;
  updatedAt: string;
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

GET /api/areas/:id

Returns a single area.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Area UUID

Response (200 OK):

{
  "area": {
    "id": "area-kitchen",
    "name": "Kitchen",
    "parentId": "area-ground",
    "color": "#F59E0B",
    "description": null,
    "sortOrder": 0,
    "createdAt": "2026-03-19T10:00:00.000Z",
    "updatedAt": "2026-03-19T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Area ID does not exist

POST /api/areas

Creates a new area.

Auth required: Yes

Request body:

{
  "name": "Kitchen",
  "parentId": "area-ground",
  "color": "#F59E0B",
  "description": "Main kitchen area",
  "sortOrder": 0
}
Field Type Required Validation
name string Yes 1-200 characters. Must be unique among siblings under same parent.
parentId string | null No Must be a valid area UUID if provided. Default: null (top-level).
color string | null No Hex color code matching /^#[0-9A-Fa-f]{6}$/, or null
description string | null No Max 2000 characters
sortOrder integer No >= 0. Default: 0

Response (201 Created):

{
  "area": {
    "id": "area-kitchen",
    "name": "Kitchen",
    "parentId": "area-ground",
    "color": "#F59E0B",
    "description": "Main kitchen area",
    "sortOrder": 0,
    "createdAt": "2026-03-19T10:00:00.000Z",
    "updatedAt": "2026-03-19T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty name, name too long, invalid color format, non-existent parentId
401 UNAUTHORIZED No valid session
409 CONFLICT An area with the same name already exists under the same parent

PATCH /api/areas/:id

Updates an area. All fields are optional; only provided fields are updated.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Area UUID

Request body (at least one field required):

{
  "name": "Updated Kitchen",
  "color": "#FFAA00"
}
Field Type Validation
name string 1-200 characters. Must be unique among siblings under the same parent.
parentId string | null Valid area UUID or null. Cannot create circular references (self or ancestor).
color string | null Hex color code or null to clear
description string | null Max 2000 characters; null clears the field
sortOrder integer >= 0

Response (200 OK):

{
  "area": {
    "id": "area-kitchen",
    "name": "Updated Kitchen",
    "parentId": "area-ground",
    "color": "#FFAA00",
    "description": "Main kitchen area",
    "sortOrder": 0,
    "createdAt": "2026-03-19T10:00:00.000Z",
    "updatedAt": "2026-03-19T11:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid fields, no fields provided, circular parentId reference, non-existent parentId
401 UNAUTHORIZED No valid session
404 NOT_FOUND Area ID does not exist
409 CONFLICT An area with the new name already exists under the same parent

DELETE /api/areas/:id

Deletes an area. Fails with 409 Conflict if the area or any of its descendant areas are referenced by work items or household items.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Area UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Area ID does not exist
409 AREA_IN_USE Area or a descendant is referenced by work items or household items

Notes:

  • AREA_IN_USE: details is not sent: the error is raised with suppressDetails (#315 hardening), so its counts reach only the server log and the response carries code and message alone. Server-side counts (log only): workItemCount, householdItemCount.
  • Deletion cascades to all descendant areas (via ON DELETE CASCADE on parent_id), but only if the entire subtree has no item references.

Trade Endpoints

Trades provide flat craft/skill classification. Trades are assigned to vendors and transitively to work items via vendor assignment. The trade list is expected to remain small (fewer than ~50 trades) so no pagination is used. All trade endpoints require authentication. Both admin and member roles can perform all trade operations.

GET /api/trades

Returns all trades sorted by sort_order ascending, then name ascending. Each item carries vendorCount (#2213), the number of companies whose trade_id is this trade; the Trades sheet shows it ("‹n› companies"), states it in the Merge dialog ("‹n› companies move to ‹kept trade›") and uses it to show Delete as blocked before the user confirms. Clients that display trades A–Z by their translated name sort client-side.

Auth required: Yes

Query parameters:

Parameter Type Default Description
search string (none) Filter by name (case-insensitive substring match)

Response (200 OK):

{
  "trades": [
    {
      "id": "trade-plumbing",
      "name": "Plumbing",
      "color": "#0EA5E9",
      "description": "Water supply, drainage, gas piping",
      "translationKey": "trades.plumbing",
      "sortOrder": 0,
      "createdAt": "2026-03-19T10:00:00.000Z",
      "updatedAt": "2026-03-19T10:00:00.000Z",
      "vendorCount": 3
    }
  ]
}

Response type:

interface TradeResponse {
  id: string;
  name: string;
  color: string | null;
  description: string | null;
  translationKey: string | null; // built-in trades only; the client shows t(translationKey) with `name` as fallback
  sortOrder: number;
  createdAt: string;
  updatedAt: string;
}

// GET /api/trades items (#2213)
interface TradeListItem extends TradeResponse {
  vendorCount: number;
}

interface TradeListResponse {
  trades: TradeListItem[];
}

color, description and sortOrder stay in the data and the API, but since #2213 no view renders the colour or description and no screen edits the three (Architecture › Areas & Trades).

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

GET /api/trades/:id

Returns a single trade.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Trade UUID

Response (200 OK):

{
  "trade": {
    "id": "trade-plumbing",
    "name": "Plumbing",
    "color": "#0EA5E9",
    "description": "Water supply, drainage, gas piping",
    "sortOrder": 0,
    "createdAt": "2026-03-19T10:00:00.000Z",
    "updatedAt": "2026-03-19T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Trade ID does not exist

POST /api/trades

Creates a new trade.

Auth required: Yes

Request body:

{
  "name": "Custom Trade",
  "color": "#FF5733",
  "description": "Description of the trade",
  "sortOrder": 15
}
Field Type Required Validation
name string Yes 1-200 characters. Must be unique (case-insensitive).
color string | null No Hex color code matching /^#[0-9A-Fa-f]{6}$/, or null
description string | null No Max 2000 characters
sortOrder integer No >= 0. Default: 0

Response (201 Created):

{
  "trade": {
    "id": "trade-uuid",
    "name": "Custom Trade",
    "color": "#FF5733",
    "description": "Description of the trade",
    "sortOrder": 15,
    "createdAt": "2026-03-19T10:00:00.000Z",
    "updatedAt": "2026-03-19T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty name, name too long, invalid color format
401 UNAUTHORIZED No valid session
409 CONFLICT A trade with the same name already exists (case-insensitive)

PATCH /api/trades/:id

Updates a trade. All fields are optional; only provided fields are updated.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Trade UUID

Request body (at least one field required):

{
  "name": "Updated Trade Name",
  "color": "#FFAA00"
}
Field Type Validation
name string 1-200 characters. Must be unique (case-insensitive).
color string | null Hex color code or null to clear
description string | null Max 2000 characters; null clears the field
sortOrder integer >= 0

Renaming a built-in trade (#2213). When name is given and its trimmed value differs from the stored name (case-sensitive), the server also sets translation_key to NULL, in the same UPDATE. Without it the client keeps showing t(translationKey) and the rename has no visible effect in any locale. The new name then shows as typed in every locale. A name equal to the stored one keeps the key. The same rule applies to PATCH /api/budget-categories/:id and PATCH /api/household-item-categories/:id.

Response (200 OK):

{
  "trade": {
    "id": "trade-plumbing",
    "name": "Updated Trade Name",
    "color": "#FFAA00",
    "description": "Water supply, drainage, gas piping",
    "sortOrder": 0,
    "createdAt": "2026-03-19T10:00:00.000Z",
    "updatedAt": "2026-03-19T11:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid fields or no fields provided
401 UNAUTHORIZED No valid session
404 NOT_FOUND Trade ID does not exist
409 CONFLICT A trade with the new name already exists (case-insensitive)

DELETE /api/trades/:id

Deletes a trade. Fails with 409 Conflict if the trade is referenced by any vendors.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Trade UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Trade ID does not exist
409 TRADE_IN_USE Trade is referenced by vendors

Notes:

  • TRADE_IN_USE: details is not sent: the error is raised with suppressDetails (#315 hardening), so its counts reach only the server log and the response carries code and message alone. Server-side counts (log only): vendorCount.
  • Trades with no vendor references can be freely deleted.
  • The Trades sheet shows Delete as blocked up front from the list's vendorCount; the 409 above stays the authority (another user may have assigned the trade since the list loaded). Trades have no GET /api/delete-impact entry (EPIC-21 contract 19): a delete that succeeds affects nothing else.

POST /api/trades/:id/merge

Merges the trade :id (the duplicate) into targetTradeId (the trade to keep), #2213: every company whose trade_id is :id gets targetTradeId, then :id is deleted. Nothing else references trades (vendors.trade_id is the only foreign key), so no data is lost. Not undoable (no snapshot, no undo token); the confirm dialog states the consequence with the duplicate's vendorCount.

Auth required: Yes (member or admin)

Path parameters:

Parameter Type Description
id string Trade UUID of the trade to remove

Request body:

{ "targetTradeId": "trade-electrical" }
Field Type Required Validation
targetTradeId string Yes Non-empty; an existing trade; not equal to :id

Behaviour. One transaction:

  1. :id must exist (404), targetTradeId must exist and differ from :id (400).
  2. UPDATE vendors SET trade_id = :targetTradeId, updated_at = :now WHERE trade_id = :id (the moved companies' updated_at changes, like any edit of their trade).
  3. DELETE FROM trades WHERE id = :id.

The kept trade's name, translationKey, color, description and sortOrder are unchanged.

Response (200 OK):

{
  "trade": {
    "id": "trade-electrical",
    "name": "Electrical",
    "color": null,
    "description": null,
    "translationKey": "trades.electrical",
    "sortOrder": 1,
    "createdAt": "2026-03-19T10:00:00.000Z",
    "updatedAt": "2026-03-19T10:00:00.000Z",
    "vendorCount": 5
  },
  "movedVendorCount": 2
}
interface TradeMergeRequest {
  targetTradeId: string;
}

interface TradeMergeResponse {
  trade: TradeListItem; // the kept trade, vendorCount after the merge
  movedVendorCount: number; // companies moved from :id (0 is valid)
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR targetTradeId missing or empty; equals :id; target trade does not exist
401 UNAUTHORIZED No valid session
404 NOT_FOUND Trade :id does not exist

A missing target is 400, not 404, matching how other bodies treat an unknown referenced id (e.g. tradeId on POST /api/vendors). A second merge of the same :id (double submit) returns 404.


Note Endpoints

Notes are nested under work items. All note endpoints require the parent work item to exist.

GET /api/work-items/:workItemId/notes

Returns all notes for a work item, sorted by created_at descending (newest first).

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Response (200 OK):

{
  "notes": [
    {
      "id": "n1",
      "content": "Foundation concrete has cured successfully.",
      "createdBy": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User"
      },
      "createdAt": "2026-02-16T14:00:00.000Z",
      "updatedAt": "2026-02-16T14:00:00.000Z"
    }
  ]
}

The createdBy object contains id and displayName only (not the full user response). If the creating user has been deleted (SET NULL), createdBy is null.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

POST /api/work-items/:workItemId/notes

Adds a note to a work item. The createdBy is automatically set to the authenticated user.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Request body:

{
  "content": "Foundation concrete has cured successfully."
}
Field Type Required Validation
content string Yes 1-10000 characters

Response (201 Created):

{
  "id": "n1",
  "content": "Foundation concrete has cured successfully.",
  "createdBy": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "displayName": "Admin User"
  },
  "createdAt": "2026-02-16T14:00:00.000Z",
  "updatedAt": "2026-02-16T14:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty content
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

PATCH /api/work-items/:workItemId/notes/:noteId

Updates a note's content. Only the note's author or an admin can edit a note.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID
noteId string Note UUID

Request body:

{
  "content": "Updated note content."
}
Field Type Required Validation
content string Yes 1-10000 characters

Response (200 OK): Returns the updated note object.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty content
401 UNAUTHORIZED No valid session
403 FORBIDDEN Non-admin user trying to edit another user's note
404 NOT_FOUND Work item or note does not exist

DELETE /api/work-items/:workItemId/notes/:noteId

Deletes a note. Only the note's author or an admin can delete a note.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID
noteId string Note UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
403 FORBIDDEN Non-admin user trying to delete another user's note
404 NOT_FOUND Work item or note does not exist

Subtask Endpoints

Subtasks are nested under work items. All subtask endpoints require the parent work item to exist.

GET /api/work-items/:workItemId/subtasks

Returns all subtasks for a work item, sorted by sort_order ascending.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Response (200 OK):

{
  "subtasks": [
    {
      "id": "s1",
      "title": "Excavate site",
      "isCompleted": true,
      "sortOrder": 0,
      "createdAt": "2026-02-15T10:00:00.000Z",
      "updatedAt": "2026-02-16T08:00:00.000Z"
    },
    {
      "id": "s2",
      "title": "Pour concrete",
      "isCompleted": false,
      "sortOrder": 1,
      "createdAt": "2026-02-15T10:00:00.000Z",
      "updatedAt": "2026-02-15T10:00:00.000Z"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

POST /api/work-items/:workItemId/subtasks

Adds a subtask to a work item. If sortOrder is not provided, the subtask is appended to the end (max sort_order + 1).

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Request body:

{
  "title": "Excavate site",
  "sortOrder": 0
}
Field Type Required Validation
title string Yes 1-500 characters
sortOrder integer No >= 0

Response (201 Created):

{
  "id": "s1",
  "title": "Excavate site",
  "isCompleted": false,
  "sortOrder": 0,
  "createdAt": "2026-02-15T10:00:00.000Z",
  "updatedAt": "2026-02-15T10:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty title, non-integer sortOrder
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

PATCH /api/work-items/:workItemId/subtasks/:subtaskId

Updates a subtask's title, completion status, and/or sort order.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID
subtaskId string Subtask UUID

Request body (at least one field required):

{
  "title": "Updated subtask title",
  "isCompleted": true,
  "sortOrder": 2
}
Field Type Validation
title string 1-500 characters
isCompleted boolean true or false
sortOrder integer >= 0

Response (200 OK): Returns the updated subtask object.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid fields or no fields provided
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item or subtask does not exist

DELETE /api/work-items/:workItemId/subtasks/:subtaskId

Deletes a subtask.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID
subtaskId string Subtask UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item or subtask does not exist

PATCH /api/work-items/:workItemId/subtasks/reorder

Bulk reorders all subtasks for a work item. Accepts an ordered array of subtask IDs and updates the sort_order of each subtask to match the array index.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Request body:

{
  "subtaskIds": ["s2", "s1", "s3"]
}
Field Type Required Validation
subtaskIds string[] Yes Array of subtask UUIDs. Must contain ALL subtask IDs belonging to this work item (no subset reordering).

Each subtask's sort_order is set to its index in the array (0-indexed). All subtask updatedAt timestamps are updated.

Response (200 OK):

{
  "subtasks": [
    {
      "id": "s2",
      "title": "Pour concrete",
      "isCompleted": false,
      "sortOrder": 0,
      "createdAt": "...",
      "updatedAt": "..."
    },
    {
      "id": "s1",
      "title": "Excavate site",
      "isCompleted": true,
      "sortOrder": 1,
      "createdAt": "...",
      "updatedAt": "..."
    },
    {
      "id": "s3",
      "title": "Inspect foundation",
      "isCompleted": false,
      "sortOrder": 2,
      "createdAt": "...",
      "updatedAt": "..."
    }
  ]
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty array, any subtask ID does not belong to this work item, array does not contain all subtask IDs for this work item, duplicate IDs
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

Dependency Endpoints

Dependencies define predecessor/successor relationships between work items for scheduling.

GET /api/work-items/:id/dependencies

Returns both predecessors and successors for a work item.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Work item UUID

Response (200 OK):

{
  "predecessors": [
    {
      "workItem": {
        "id": "prev-item-id",
        "title": "Site Survey",
        "status": "completed",
        "startDate": "2026-02-20",
        "endDate": "2026-02-22",
        "actualStartDate": "2026-02-20",
        "actualEndDate": "2026-02-22",
        "durationDays": 2,
        "assignedUser": null,
        "assignedVendor": null,
        "area": null,
        "createdAt": "2026-02-10T10:00:00.000Z",
        "updatedAt": "2026-02-22T10:00:00.000Z"
      },
      "dependencyType": "finish_to_start",
      "leadLagDays": 0
    }
  ],
  "successors": [
    {
      "workItem": {
        "id": "next-item-id",
        "title": "Framing",
        "status": "not_started",
        "startDate": null,
        "endDate": null,
        "actualStartDate": null,
        "actualEndDate": null,
        "durationDays": null,
        "assignedUser": null,
        "assignedVendor": null,
        "area": null,
        "createdAt": "2026-02-10T10:00:00.000Z",
        "updatedAt": "2026-02-10T10:00:00.000Z"
      },
      "dependencyType": "finish_to_start",
      "leadLagDays": 0
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

POST /api/work-items/:id/dependencies

Adds a dependency to a work item. The work item identified by :id becomes the successor (it depends on predecessorId).

Auth required: Yes

Path parameters:

Parameter Type Description
id string Successor work item UUID (the item that depends on the predecessor)

Request body:

{
  "predecessorId": "prev-item-id",
  "dependencyType": "finish_to_start",
  "leadLagDays": 3
}
Field Type Required Validation
predecessorId string Yes Must be a valid work item UUID, different from :id
dependencyType string No One of: finish_to_start, start_to_start, finish_to_finish, start_to_finish. Default: finish_to_start
leadLagDays integer No Lead (negative) or lag (positive) offset in days. Default: 0

Circular dependency detection: Before creating the dependency, the server performs a depth-first traversal of the dependency graph starting from the predecessor, following the successor direction. If the traversal reaches the current work item (:id), a cycle would be created and the request is rejected.

Response (201 Created):

{
  "predecessorId": "prev-item-id",
  "successorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "dependencyType": "finish_to_start",
  "leadLagDays": 3
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing predecessorId, self-referencing dependency (predecessorId === :id), invalid dependencyType
401 UNAUTHORIZED No valid session
404 NOT_FOUND Either work item (successor or predecessor) does not exist
409 CIRCULAR_DEPENDENCY Adding this dependency would create a cycle. details is { cycle } (array of work item IDs in the detected cycle) and nothing else; there is no details.code
409 DUPLICATE_DEPENDENCY A dependency between these two work items already exists

PATCH /api/work-items/:id/dependencies/:predecessorId

Updates a dependency's properties (dependency type and/or lead/lag days).

Auth required: Yes

Path parameters:

Parameter Type Description
id string Successor work item UUID
predecessorId string Predecessor work item UUID

Request body (at least one field required):

{
  "dependencyType": "start_to_start",
  "leadLagDays": -2
}
Field Type Validation
dependencyType string One of: finish_to_start, start_to_start, finish_to_finish, start_to_finish
leadLagDays integer Lead (negative) or lag (positive) offset in days

Response (200 OK):

{
  "predecessorId": "prev-item-id",
  "successorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "dependencyType": "start_to_start",
  "leadLagDays": -2
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR No fields provided, invalid dependencyType
401 UNAUTHORIZED No valid session
404 NOT_FOUND The dependency (this predecessor/successor pair) does not exist

DELETE /api/work-items/:id/dependencies/:predecessorId

Removes a dependency from a work item.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Successor work item UUID
predecessorId string Predecessor work item UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND The dependency (this predecessor/successor pair) does not exist

Budget Line Endpoints

Budget lines are nested under work items. Each budget line represents a cost estimate or allocation with its own confidence level, optional vendor, budget category, and budget source. All budget line endpoints require the parent work item to exist. Budget lines are NOT paginated (a work item typically has fewer than ~20 budget lines).

GET /api/work-items/:workItemId/budgets

Returns all budget lines for a work item.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Response (200 OK):

{
  "budgets": [
    {
      "id": "b1",
      "workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "description": "Foundation concrete materials",
      "plannedAmount": 8500.0,
      "confidence": "quote",
      "confidenceMargin": 0.05,
      "budgetCategory": {
        "id": "bc-materials",
        "name": "Materials",
        "description": "Raw materials and building supplies",
        "color": "#3B82F6",
        "sortOrder": 0,
        "createdAt": "2026-02-20T10:00:00.000Z",
        "updatedAt": "2026-02-20T10:00:00.000Z"
      },
      "budgetSource": {
        "id": "bs-1",
        "name": "ABC Bank Mortgage",
        "sourceType": "bank_loan"
      },
      "vendor": {
        "id": "v1",
        "name": "ABC Concrete",
        "trade": { "id": "trade-masonry", "name": "Masonry", "color": "#78716C" }
      },
      "actualCost": 2500.0,
      "actualCostPaid": 2500.0,
      "invoiceCount": 1,
      "createdBy": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-02-15T10:00:00.000Z",
      "updatedAt": "2026-02-15T10:00:00.000Z"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

POST /api/work-items/:workItemId/budgets

Creates a new budget line for a work item. The createdBy is automatically set to the authenticated user.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Request body:

{
  "description": "Foundation concrete materials",
  "plannedAmount": 8500.0,
  "confidence": "quote",
  "budgetCategoryId": "bc-materials",
  "budgetSourceId": "bs-1",
  "vendorId": "v1"
}
Field Type Required Validation
description string | null No Max 500 characters
plannedAmount number Yes Must be >= 0
confidence string No One of: own_estimate, professional_estimate, quote, invoice. Default: own_estimate
budgetCategoryId string | null No Must be a valid budget category UUID if provided
budgetSourceId string | null No Must be a valid budget source UUID if provided
vendorId string | null No Must be a valid vendor UUID if provided

Response (201 Created):

{
  "budget": {
    "id": "b1",
    "workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "description": "Foundation concrete materials",
    "plannedAmount": 8500.0,
    "confidence": "quote",
    "confidenceMargin": 0.05,
    "budgetCategory": {
      "id": "bc-materials",
      "name": "Materials",
      "description": "Raw materials and building supplies",
      "color": "#3B82F6",
      "sortOrder": 0,
      "createdAt": "2026-02-20T10:00:00.000Z",
      "updatedAt": "2026-02-20T10:00:00.000Z"
    },
    "budgetSource": {
      "id": "bs-1",
      "name": "ABC Bank Mortgage",
      "sourceType": "bank_loan"
    },
    "vendor": {
      "id": "v1",
      "name": "ABC Concrete",
      "trade": { "id": "trade-masonry", "name": "Masonry", "color": "#78716C" }
    },
    "actualCost": 0,
    "actualCostPaid": 0,
    "invoiceCount": 0,
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "createdAt": "2026-02-20T14:00:00.000Z",
    "updatedAt": "2026-02-20T14:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing plannedAmount, negative amount, invalid confidence value, non-existent budgetCategoryId/budgetSourceId/vendorId, description too long
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item ID does not exist

PATCH /api/work-items/:workItemId/budgets/:budgetId

Updates a budget line. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically. This endpoint supports same-table moves only (move within work items) — to move a budget line to a household item, use PATCH /api/invoices/:invoiceId/budget-lines/:id instead.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID
budgetId string Budget line UUID

Request body (all fields optional, at least one required):

{
  "description": "Updated description",
  "plannedAmount": 9000.0,
  "confidence": "invoice",
  "budgetCategoryId": "bc-materials",
  "budgetSourceId": null,
  "vendorId": "v1"
}
Field Type Validation
description string | null Max 500 characters; null clears the description
plannedAmount number Must be >= 0
confidence string One of: own_estimate, professional_estimate, quote, invoice
budgetCategoryId string | null Valid budget category UUID or null to clear
budgetSourceId string | null Valid budget source UUID or null to clear
vendorId string | null Valid vendor UUID or null to clear

Response (200 OK):

Returns the updated WorkItemBudgetLine object wrapped in { "budget": ... }.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR No fields provided, negative amount, invalid confidence value, non-existent budgetCategoryId/budgetSourceId/vendorId, description too long
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item or budget line does not exist, or budget line does not belong to this work item

Notes:

  • Cross-table moves (work item to household item or vice versa) are not supported by this endpoint. Use PATCH /api/invoices/:invoiceId/budget-lines/:id to move budget lines between work items and household items.

DELETE /api/work-items/:workItemId/budgets/:budgetId

Deletes a budget line. Fails with 409 Conflict if the budget line has invoices linked to it.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID
budgetId string Budget line UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item or budget line does not exist, or budget line does not belong to this work item
409 BUDGET_LINE_IN_USE Budget line has linked invoices and cannot be deleted

Notes:

  • BUDGET_LINE_IN_USE: details is not sent: the error is raised with suppressDetails (#315 hardening), so its counts reach only the server log and the response carries code and message alone. Server-side counts (log only): invoiceCount.
  • Budget lines with no linked invoices can be freely deleted.
  • The budget line must belong to the specified work item. If the budget line exists but belongs to a different work item, a 404 is returned.

GET /api/work-items/:workItemId/subsidy-payback

Calculates the expected subsidy payback range for a specific work item. Returns the total min/max payback amounts and a per-subsidy breakdown. Only non-rejected subsidies linked to the work item are included.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Response (200 OK):

{
  "workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "minTotalPayback": 8500.0,
  "maxTotalPayback": 12500.0,
  "subsidies": [
    {
      "subsidyProgramId": "sp-001",
      "name": "Energy Efficiency Grant",
      "reductionType": "percentage",
      "reductionValue": 15,
      "minPayback": 6000.0,
      "maxPayback": 10000.0
    },
    {
      "subsidyProgramId": "sp-002",
      "name": "Regional Housing Support",
      "reductionType": "fixed",
      "reductionValue": 2500,
      "minPayback": 2500.0,
      "maxPayback": 2500.0
    }
  ]
}

Response type definitions:

interface WorkItemSubsidyPaybackResponse {
  workItemId: string;
  /** Minimum total payback across all non-rejected linked subsidies. */
  minTotalPayback: number;
  /** Maximum total payback across all non-rejected linked subsidies. */
  maxTotalPayback: number;
  /** Per-subsidy breakdown. Empty array when no subsidies are linked. */
  subsidies: WorkItemSubsidyPaybackEntry[];
}

interface WorkItemSubsidyPaybackEntry {
  subsidyProgramId: string;
  name: string;
  reductionType: 'percentage' | 'fixed';
  reductionValue: number;
  /** Minimum expected payback (lower bound based on confidence margins). */
  minPayback: number;
  /** Maximum expected payback (upper bound based on confidence margins). */
  maxPayback: number;
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item does not exist

Notes:

  • For percentage subsidies, the payback for each matching budget line is computed as: lineAmount * (reductionValue / 100). The min/max range reflects confidence margins on non-invoiced budget lines. For invoiced lines, the actual cost is used (minPayback === maxPayback for that line).
  • lineAmount for a non-invoiced line is the VAT-effective planned amount: a net line (includesVat: false) is grossed up by 1 + VAT_RATE (server config vatRate, default 0.19, exposed by GET /api/config) and rounded to cents before the confidence margin and the subsidy percentage apply; a gross line is used as stored. This matches GET /api/budget/overview (subsidySummary) and GET /api/budget/breakdown (subsidyPayback), so the same line yields the same payback on every endpoint.
  • For fixed subsidies, the payback is the fixed amount regardless of budget line values: minPayback === maxPayback === reductionValue.
  • A subsidy matches a budget line if: (a) the subsidy is linked to the work item, (b) the subsidy is not rejected, and (c) the subsidy's applicable categories include the budget line's category (or the subsidy has no category restrictions, making it universal).
  • When no subsidies are linked to the work item, all totals are 0 and subsidies is an empty array.
  • The confidence margin ranges are: own_estimate (plus/minus 20%), professional_estimate (plus/minus 10%), quote (plus/minus 5%), invoice (plus/minus 0%).

GET /api/work-items/:workItemId/dependent-household-items

Returns all household items that depend on this work item (via household_item_deps). This is the reverse direction of the GET /api/household-items/:id/dependencies endpoint. Not paginated.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Response (200 OK):

{
  "householdItems": [
    {
      "id": "hi-001",
      "name": "Kitchen Faucet",
      "category": "hic-fixtures",
      "status": "ordered",
      "targetDeliveryDate": "2026-05-15",
      "earliestDeliveryDate": "2026-05-10",
      "latestDeliveryDate": "2026-05-20"
    }
  ]
}

Response type definitions:

interface WorkItemDependentHouseholdItemsResponse {
  householdItems: WorkItemLinkedHouseholdItemSummary[];
}

interface WorkItemLinkedHouseholdItemSummary {
  id: string;
  name: string;
  category: HouseholdItemCategory;
  status: HouseholdItemStatus;
  targetDeliveryDate: string | null; // Computed by scheduling engine from constraints & dependencies
  earliestDeliveryDate: string | null; // User-editable constraint
  latestDeliveryDate: string | null; // User-editable constraint
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item does not exist

Notes:

  • Returns an empty array when no household items depend on this work item.
  • The response shape is a lightweight summary (no vendor, area, quantity, or budget info). Use GET /api/household-items/:id for full details on a specific household item.

EPIC-05: Budget Management

Budget management endpoints for tracking construction costs, vendors, invoices, financing sources, and subsidy programs. All endpoints in this section require authentication. Both admin and member roles can perform all budget operations (no role restriction).

Budget Category Response Shape

interface BudgetCategoryResponse {
  id: string;
  name: string;
  description: string | null;
  color: string | null;
  sortOrder: number;
  createdAt: string; // ISO 8601
  updatedAt: string; // ISO 8601
}

Budget Category Endpoints

Budget categories are a small, finite collection (typically 10-20 items). They are NOT paginated.

GET /api/budget-categories

Returns all budget categories sorted by sort_order ascending, then name ascending (#2215: the name tie-break makes the order deterministic when rows share a sort_order).

Auth required: Yes

Response (200 OK):

{
  "categories": [
    {
      "id": "bc-materials",
      "name": "Materials",
      "description": "Raw materials and building supplies",
      "color": "#3B82F6",
      "sortOrder": 0,
      "createdAt": "2026-02-20T10:00:00.000Z",
      "updatedAt": "2026-02-20T10:00:00.000Z"
    },
    {
      "id": "bc-labor",
      "name": "Labor",
      "description": "Contractor and worker labor costs",
      "color": "#EF4444",
      "sortOrder": 1,
      "createdAt": "2026-02-20T10:00:00.000Z",
      "updatedAt": "2026-02-20T10:00:00.000Z"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

POST /api/budget-categories

Creates a new budget category.

Auth required: Yes

Request body:

{
  "name": "Appliances",
  "description": "Kitchen and household appliances",
  "color": "#F472B6",
  "sortOrder": 10
}
Field Type Required Validation
name string Yes 1-100 characters. Must be unique (case-insensitive).
description string No Max 500 characters.
color string No Hex color code matching /^#[0-9A-Fa-f]{6}$/, or null
sortOrder integer No >= 0. Default: appended (see below)

Default position (#2215). When sortOrder is omitted the new category is appended: sort_order = MAX(sort_order) + 1 over the list (0 when the list is empty). Before #2215 the default was 0, which put a new row at the top among the rows sharing 0.

Response (201 Created):

{
  "budgetCategory": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "name": "Appliances",
    "description": "Kitchen and household appliances",
    "color": "#F472B6",
    "translationKey": null,
    "sortOrder": 10,
    "createdAt": "2026-02-20T14:00:00.000Z",
    "updatedAt": "2026-02-20T14:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty name, name too long, description too long, invalid color format, negative sortOrder
401 UNAUTHORIZED No valid session
409 CONFLICT A category with the same name already exists (case-insensitive)

PATCH /api/budget-categories/reorder

Saves the whole order of the list in one request (#2215). Body { "ids": string[] } with every id of the list exactly once; 200 OK with { "categories": [...] } in the new order; 409 REORDER_SET_MISMATCH when the ids are not exactly the current set. Full specification: Reference List Reorder.


GET /api/budget-categories/:id

Returns a single budget category.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Budget category UUID

Response (200 OK):

{
  "budgetCategory": {
    "id": "bc-materials",
    "name": "Materials",
    "description": "Raw materials and building supplies",
    "color": "#3B82F6",
    "translationKey": "budgetCategories.materials",
    "sortOrder": 0,
    "createdAt": "2026-02-20T10:00:00.000Z",
    "updatedAt": "2026-02-20T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Budget category ID does not exist

PATCH /api/budget-categories/:id

Updates a budget category's name, description, color, and/or sort order.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Budget category UUID

Request body (at least one field required):

{
  "name": "Building Materials",
  "description": "Updated description",
  "color": "#2563EB",
  "sortOrder": 0
}
Field Type Validation
name string 1-100 characters. Must be unique (case-insensitive).
description string | null Max 500 characters; null clears the description
color string | null Hex color code or null to clear
sortOrder integer >= 0

Renaming a built-in category (#2213). Same rule as PATCH /api/trades/:id: when name is given and its trimmed value differs from the stored name (case-sensitive), the server sets translation_key to NULL in the same UPDATE, so the typed name shows in every locale. A name equal to the stored one keeps the key.

Response (200 OK):

{
  "budgetCategory": {
    "id": "bc-materials",
    "name": "Building Materials",
    "description": "Updated description",
    "color": "#2563EB",
    "translationKey": null,
    "sortOrder": 0,
    "createdAt": "2026-02-20T10:00:00.000Z",
    "updatedAt": "2026-02-20T15:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid fields or no fields provided
401 UNAUTHORIZED No valid session
404 NOT_FOUND Budget category ID does not exist
409 CONFLICT A category with the new name already exists (case-insensitive)

DELETE /api/budget-categories/:id

Deletes a budget category. Fails with 409 Conflict if the category is referenced by work item budget lines (via work_item_budgets.budget_category_id), by household item budget lines (via household_item_budgets.budget_category_id) or by subsidy programs (via subsidy_program_categories).

Auth required: Yes

Path parameters:

Parameter Type Description
id string Budget category UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Budget category ID does not exist
409 CATEGORY_IN_USE Category is referenced by budget lines or subsidy programs and cannot be deleted

Notes:

  • CATEGORY_IN_USE: details is not sent: the error is raised with suppressDetails (#315 hardening), so its counts reach only the server log and the response carries code and message alone. Server-side counts (log only): subsidyProgramCount, budgetLineCount, householdBudgetCount.

Vendor Response Shapes

Vendor summary (used in list responses):

interface VendorResponse {
  id: string;
  name: string;
  trade: TradeSummary | null;
  phone: string | null;
  email: string | null;
  address: string | null;
  notes: string | null;
  createdBy: UserSummary | null;
  createdAt: string; // ISO 8601
  updatedAt: string; // ISO 8601
}

Vendor detail (used in single-item responses, adds computed fields):

interface VendorDetailResponse extends VendorResponse {
  invoiceCount: number;
  outstandingBalance: number;
}

Notes on computed fields:

  • invoiceCount is the total number of invoices for this vendor (all statuses).
  • outstandingBalance is ADR-039 "Still to pay": pending invoices minus their non-refund progress payments, plus pending progress payments (also on offers). Paid and claimed (submitted) invoices never count. Uses the same helper as the Invoices page open-payable figure. Returns 0 if nothing is left to pay. (changed in #2194, D-06; previously summed pending + claimed invoice amounts)

Vendor Endpoints

Vendors/contractors are a core budget management entity. The vendor list may grow to dozens of entries for a large construction project, so pagination is used. All vendor endpoints require authentication. Both admin and member roles can perform all vendor operations (no role restriction).

GET /api/vendors

Returns a paginated, searchable list of vendors (companies) sorted by name ascending. The Companies list (/companies, story 1.7, #2213) reads it.

Auth required: Yes

Query parameters:

Parameter Type Default Description
page integer 1 Page number (1-indexed)
pageSize integer 25 Items per page (max 100)
q string (none) Folded search over the company and its contact persons (see Search below). At most 500 characters before trimming (SEARCH_QUERY_RAW_MAX_LENGTH), else 400 VALIDATION_ERROR (#2213)
tradeIds string (none) Comma-separated trade ids the client matched q against by translated name; ORed into the q match (see Trade ids below). Ignored without a non-empty q
tradeId string (none) Filter by trade UUID. An unknown id is not an error; it matches no company. Combines with q (AND)
sortBy string name Sort field. One of: name, trade, last_on_site, created_at, updated_at
sortOrder string asc Sort direction: asc or desc

Search (q, #2213). The list uses the same matcher as the vendor group of GET /api/search (EPIC-21 contract 6), with one field list shared by both, so a company the command search finds for a text is also in the list for that text. After trimming, an empty q is no filter. A company matches when fold(q) is a substring of fold() of any of: its name, its trade's stored name, its phone, its e-mail, or the name, phone or e-mail of any of its contact persons. When q holds at least 3 digits, it also matches when digits(q) is a substring of digits() of the company's phone or of any contact person's phone, so a digit run typed without spaces or separators finds a number stored with them. fold/digits are the SQL functions in Architecture › SQL functions (ä/ae, ö/oe, ü/ue, ß/ss and case fold alike); the client does no folding. q filters only; it does not change the sort (no relevance rank, unlike /api/search). The trade text matched is the stored trades.name; translated names are covered by tradeIds.

Trade ids (tradeIds, #2213). The translated display name of a built-in trade (t(translationKey)) exists only in the client, so the server cannot match it. The client therefore folds the typed text with foldSearchText and compares it (substring) with the folded display name of every trade it holds (useTrades), and sends the ids of the trades that match as tradeIds. The server then treats a company as matching q when the text match above holds or vendors.trade_id IN (tradeIds). Rules (shared with GET /api/search, one parser):

  • Comma-separated; entries trimmed, empties dropped, duplicates removed. Absent or empty = no trade term.
  • At most 50 entries after de-duplication and each 1–64 characters; otherwise 400 VALIDATION_ERROR with details.tradeIds. (Trades are a small list, see Trade Endpoints; 50 bounds the IN (…) list.)
  • Ids are bound parameters. Unknown ids are ignored (they match no company); no existence check, no error.
  • Only used together with a non-empty q. It widens the text match; it never filters on its own. tradeId stays the filter and still combines with AND, so tradeId=A&q=x&tradeIds=B returns companies of trade A that match x by text (B can't add any, since they are not in A).
  • The client sends it whenever at least one trade matches; an English stored name matching twice (text and id) is harmless.

Sort. last_on_site sorts by lastOnSite; companies without one come last in both directions. Every sort breaks ties by name ascending, then id ascending, so paging is stable.

Response (200 OK):

{
  "vendors": [
    {
      "id": "v1",
      "name": "ABC Plumbing",
      "trade": { "id": "trade-plumbing", "name": "Plumbing", "color": "#0EA5E9" },
      "phone": "+1-555-0100",
      "email": "[email protected]",
      "address": "123 Main St, Springfield",
      "notes": "Licensed and insured",
      "firstContactPhone": null,
      "contactCount": 2,
      "lastOnSite": "2026-02-18",
      "createdBy": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-02-20T10:00:00.000Z",
      "updatedAt": "2026-02-20T10:00:00.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "totalItems": 1,
    "totalPages": 1
  }
}

firstContactPhone (string | null): phone of the first contact person (by creation order) with a non-blank phone; null when none has one. List endpoint only. The UI shows it when the vendor has no phone of its own (a phone that is null or blank after trimming).

List-only fields (VendorListItem, shared/src/types/vendor.ts; story 1.7, #2213; always sent by this endpoint, optional in the type like firstContactPhone):

Field Type Meaning
contactCount number Number of contact persons (vendor_contacts rows) of the company. 0 when none
lastOnSite string | null YYYY-MM-DD: the latest entry_date of a saved (status = 'saved') daily_log diary entry whose metadata.vendorId is this company; null when there is none

lastOnSite source: daily_log is the only diary entry type that links a company (DailyLogMetadata.vendorId, validated against vendors on write). delivery entries carry the supplier as free text (metadata.vendor), which links nothing and is not used; drafts and automatic entries never count. The date is taken as stored (no cap at today). Computed in SQL (MAX(entry_date) grouped by json_extract(metadata, '$.vendorId'), joined to vendors) so sortBy=last_on_site sorts before paging; no index or schema change, a scan of the daily_log rows is cheap at this scale. Work-item actual dates are deliberately not a fallback: a task's actual start or end says when work was recorded as started or done, not that the company was on site, and mixing the two would make the column mean two things.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid query parameters (e.g., page < 1, pageSize > 100, invalid sortBy)
401 UNAUTHORIZED No valid session

POST /api/vendors

Creates a new vendor.

Auth required: Yes

Request body:

{
  "name": "ABC Plumbing",
  "tradeId": "trade-plumbing",
  "phone": "+1-555-0100",
  "email": "[email protected]",
  "address": "123 Main St, Springfield",
  "notes": "Licensed and insured"
}
Field Type Required Validation
name string Yes 1-200 characters
tradeId string No Must be a valid trade UUID
phone string No Max 50 characters
email string No Max 255 characters. Valid email format if provided.
address string No Max 500 characters
notes string No Max 10000 characters

The createdBy field is automatically set to the authenticated user's ID.

Response (201 Created):

{
  "vendor": {
    "id": "v1",
    "name": "ABC Plumbing",
    "trade": { "id": "trade-plumbing", "name": "Plumbing", "color": "#0EA5E9" },
    "phone": "+1-555-0100",
    "email": "[email protected]",
    "address": "123 Main St, Springfield",
    "notes": "Licensed and insured",
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "createdAt": "2026-02-20T14:00:00.000Z",
    "updatedAt": "2026-02-20T14:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty name, name too long, invalid email format, other field length violations
401 UNAUTHORIZED No valid session

GET /api/vendors/:id

Returns a single vendor with full details including computed invoice statistics.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Vendor UUID

Response (200 OK):

{
  "vendor": {
    "id": "v1",
    "name": "ABC Plumbing",
    "trade": { "id": "trade-plumbing", "name": "Plumbing", "color": "#0EA5E9" },
    "phone": "+1-555-0100",
    "email": "[email protected]",
    "address": "123 Main St, Springfield",
    "notes": "Licensed and insured",
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "invoiceCount": 3,
    "outstandingBalance": 4500.0,
    "createdAt": "2026-02-20T10:00:00.000Z",
    "updatedAt": "2026-02-20T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Vendor ID does not exist

PATCH /api/vendors/:id

Updates a vendor's information. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Vendor UUID

Request body (all fields optional, at least one required):

{
  "name": "ABC Plumbing Co.",
  "tradeId": "trade-hvac",
  "phone": "+1-555-0101",
  "email": "[email protected]",
  "address": "456 Oak Ave, Springfield",
  "notes": "Updated notes"
}
Field Type Validation
name string 1-200 characters
tradeId string | null Valid trade UUID or null to clear
phone string | null Max 50 characters; null clears the field
email string | null Max 255 characters. Valid email format if provided. null clears the field.
address string | null Max 500 characters; null clears the field
notes string | null Max 10000 characters; null clears the field

Response (200 OK):

{
  "vendor": {
    "id": "v1",
    "name": "ABC Plumbing Co.",
    "trade": { "id": "trade-hvac", "name": "HVAC", "color": "#8B5CF6" },
    "phone": "+1-555-0101",
    "email": "[email protected]",
    "address": "456 Oak Ave, Springfield",
    "notes": "Updated notes",
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "invoiceCount": 3,
    "outstandingBalance": 4500.0,
    "createdAt": "2026-02-20T10:00:00.000Z",
    "updatedAt": "2026-02-20T15:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid fields or no fields provided
401 UNAUTHORIZED No valid session
404 NOT_FOUND Vendor ID does not exist

DELETE /api/vendors/:id

Deletes a vendor. Fails with 409 Conflict if the vendor has invoices or is referenced by work item budget lines (via work_item_budgets.vendor_id).

Auth required: Yes

Path parameters:

Parameter Type Description
id string Vendor UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Vendor ID does not exist
409 VENDOR_IN_USE Vendor has invoices or is referenced by budget lines and cannot be deleted

Notes:

  • VENDOR_IN_USE: details is not sent: the error is raised with suppressDetails (#315 hardening), so its counts reach only the server log and the response carries code and message alone. Server-side counts (log only): invoiceCount, budgetLineCount.
  • Vendors with no invoices and no budget line references can be freely deleted.

Invoice Response Shape

type InvoiceStatus = 'pending' | 'paid' | 'claimed';
type InvoiceDepositStatus = 'pending' | 'paid' | 'claimed';
type InvoiceDepositEntryType = 'deposit' | 'refund';

interface InvoiceBudgetLineSummary {
  id: string; // invoice_budget_lines.id
  workItemBudgetId: string | null;
  householdItemBudgetId: string | null;
  itemizedAmount: number;
  budgetLineDescription: string | null; // from the linked budget line
  categoryName: string | null; // from the linked budget line's category
  categoryColor: string | null;
  plannedAmount: number; // from the linked budget line
}

interface InvoiceDeposit {
  id: string;
  invoiceId: string;
  amount: number; // > 0
  dueDate: string; // YYYY-MM-DD
  paidDate: string | null; // YYYY-MM-DD when status is paid or claimed
  claimedDate: string | null; // YYYY-MM-DD when status is claimed
  description: string | null; // max 500 chars
  status: InvoiceDepositStatus;
  entryType: InvoiceDepositEntryType; // 'deposit' | 'refund', default 'deposit', immutable after creation (Story #1876)
  budgetSourceId: string | null; // Story #1891: optional direct link to a budget source; tagged deposits contribute 100% to their source
  createdBy: UserSummary | null;
  createdAt: string; // ISO 8601 datetime
  updatedAt: string; // ISO 8601 datetime
}

interface InvoiceResponse {
  id: string;
  vendorId: string;
  vendorName: string; // Resolved from vendors table
  invoiceNumber: string | null;
  amount: number;
  date: string; // ISO 8601 date (YYYY-MM-DD)
  dueDate: string | null; // ISO 8601 date
  status: InvoiceStatus;
  notes: string | null;
  budgetLines: InvoiceBudgetLineSummary[]; // Linked budget lines with itemized amounts
  remainingAmount: number; // amount - SUM(budgetLines[].itemizedAmount)
  deposits: InvoiceDeposit[]; // ordered by dueDate ASC, createdAt ASC
  finalPaymentAmount: number; // amount - SUM(deposits[].amount), >= 0
  openAmount?: number; // Story #2046: only present on GET /api/invoices rows when openOnly=true
  createdBy: UserSummary | null;
  createdAt: string; // ISO 8601
  updatedAt: string; // ISO 8601
}

Notes on budgetLines and remainingAmount:

  • budgetLines is always included in invoice responses (empty array if no budget lines are linked).
  • remainingAmount is computed server-side: invoice.amount - SUM(budgetLines[].itemizedAmount). It represents the portion of the invoice not yet attributed to any budget line.
  • The previous workItemBudgetId field has been removed from the invoice response (EPIC-15). Budget line linkage is now exclusively managed through the invoice_budget_lines junction table.

Notes on deposits and finalPaymentAmount:

  • deposits is ordered by dueDate ascending, then createdAt ascending. In list responses (GET /api/vendors/:vendorId/invoices), this field is always an empty array. On GET /api/invoices, it is also always an empty array except when openOnly=true (Story #2046), in which case it is populated with the invoice's full deposit list (not filtered to pending — see the openOnly note above). In all cases finalPaymentAmount is refund-aware (computed from the full deposits data) regardless of whether deposits itself is populated.
  • finalPaymentAmount is computed server-side: invoice.amount - Σ(deposit-type entries) - Σ(refund-type entries where status is paid or claimed). It represents the amount still awaiting payment after all deposit installments and received refunds. With no refund entries, this is identical to the pre-refund formula. openAmount (Story #2046, see the openOnly note above) is a separate, additive figure — it is not a replacement for finalPaymentAmount.

Notes on invoice status:

  • pending: Invoice received, payment not yet made (default)
  • paid: Payment has been completed
  • claimed: Invoice has been submitted for reimbursement (e.g., to a subsidy program or financing source)
  • quotation: A quote, not yet a bill. It is converted into the final invoice in place (new amount, lines and status pending or paid) with POST /api/invoices/:invoiceId/convert-quotation (Story #2107). paid is only a status: the invoice records no payment date.

The previous overdue status has been removed. Overdue detection is better handled as a computed state by comparing dueDate to the current date, rather than a manually-set status.


Invoice Endpoints

Invoices are nested under vendors and track payments for construction work. All invoice endpoints require authentication. Both admin and member roles can perform all invoice operations (no role restriction). All invoice endpoints require the parent vendor to exist.

The invoice list is NOT paginated. A vendor typically has fewer than ~50 invoices; pagination adds complexity without benefit at this scale.

GET /api/vendors/:vendorId/invoices

Returns all invoices for a vendor, sorted by date descending (newest first).

Auth required: Yes

Path parameters:

Parameter Type Description
vendorId string Vendor UUID

Response (200 OK):

{
  "invoices": [
    {
      "id": "inv-1",
      "vendorId": "v1",
      "vendorName": "ABC Construction",
      "invoiceNumber": "INV-2026-001",
      "amount": 2500.0,
      "date": "2026-03-15",
      "dueDate": "2026-04-15",
      "status": "pending",
      "notes": "First payment for foundation work",
      "budgetLines": [
        {
          "id": "ibl-1",
          "workItemBudgetId": "b1",
          "householdItemBudgetId": null,
          "itemizedAmount": 2500.0,
          "budgetLineDescription": "Foundation concrete materials",
          "categoryName": "Materials",
          "categoryColor": "#3B82F6",
          "plannedAmount": 8500.0
        }
      ],
      "remainingAmount": 0,
      "createdBy": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-03-15T10:00:00.000Z",
      "updatedAt": "2026-03-15T10:00:00.000Z"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Vendor ID does not exist

POST /api/vendors/:vendorId/invoices

Creates a new invoice for a vendor. The createdBy is automatically set to the authenticated user. Budget line linking is done separately via the POST /api/invoices/:invoiceId/budget-lines endpoint (EPIC-15).

Auth required: Yes

Path parameters:

Parameter Type Description
vendorId string Vendor UUID

Request body:

{
  "invoiceNumber": "INV-2026-001",
  "amount": 2500.0,
  "date": "2026-03-15",
  "dueDate": "2026-04-15",
  "status": "pending",
  "notes": "First payment for foundation work"
}
Field Type Required Validation
invoiceNumber string No Max 100 characters
amount number Yes Must be > 0
date string Yes ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR
dueDate string No ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR. Must be >= date if provided.
status string No One of: pending, paid, claimed, quotation. Default: pending
notes string No Max 10000 characters

Response (201 Created):

{
  "invoice": {
    "id": "inv-1",
    "vendorId": "v1",
    "vendorName": "ABC Construction",
    "invoiceNumber": "INV-2026-001",
    "amount": 2500.0,
    "date": "2026-03-15",
    "dueDate": "2026-04-15",
    "status": "pending",
    "notes": "First payment for foundation work",
    "budgetLines": [],
    "remainingAmount": 2500.0,
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "createdAt": "2026-03-15T10:00:00.000Z",
    "updatedAt": "2026-03-15T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing amount or date, amount <= 0, invalid date format, dueDate < date, invalid status
401 UNAUTHORIZED No valid session
404 NOT_FOUND Vendor ID does not exist

PATCH /api/vendors/:vendorId/invoices/:invoiceId

Updates an invoice. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.

Auth required: Yes

Path parameters:

Parameter Type Description
vendorId string Source vendor UUID — used to locate the invoice being updated
invoiceId string Invoice UUID

Request body (all fields optional, at least one required):

{
  "invoiceNumber": "INV-2026-001-REV",
  "amount": 2750.0,
  "date": "2026-03-16",
  "dueDate": "2026-04-16",
  "status": "paid",
  "notes": "Updated after revision",
  "vendorId": "v2"
}
Field Type Validation
invoiceNumber string | null Max 100 characters; null clears the field
amount number Must be > 0. When the new amount is lower than the current one (compared in cents), the sum of linked itemizedAmount values must not exceed the new amount (ITEMIZED_SUM_EXCEEDS_INVOICE). Deposits are never checked: net deposits may exceed the new amount (#2188). An unchanged or raised amount is not checked. The check and the write run in one transaction.
date string ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR
dueDate string | null ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR; null clears the field. Must be >= date if both are set (uses existing date if not being updated).
status string One of: pending, paid, claimed, quotation
notes string | null Max 10000 characters; null clears the field
vendorId string Min length 1. Target vendor to reassign the invoice to. Omit to leave the vendor unchanged (backward compatible). If it references a vendor that does not exist, the endpoint returns 404 and the invoice is left unchanged.

Response (200 OK):

When the call changed status, the body also carries a top-level undo sibling of invoice (contract 11).

{
  "invoice": {
    "id": "inv-1",
    "vendorId": "v1",
    "vendorName": "ABC Construction",
    "invoiceNumber": "INV-2026-001-REV",
    "amount": 2750.0,
    "date": "2026-03-16",
    "dueDate": "2026-04-16",
    "status": "paid",
    "notes": "Updated after revision",
    "budgetLines": [
      {
        "id": "ibl-1",
        "workItemBudgetId": "b1",
        "householdItemBudgetId": null,
        "itemizedAmount": 2500.0,
        "budgetLineDescription": "Foundation concrete materials",
        "categoryName": "Materials",
        "categoryColor": "#3B82F6",
        "plannedAmount": 8500.0
      }
    ],
    "remainingAmount": 250.0,
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "createdAt": "2026-03-15T10:00:00.000Z",
    "updatedAt": "2026-03-20T14:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR No fields provided, amount <= 0, malformed or impossible date (e.g. 2026-02-31), dueDate < date, invalid status
400 ITEMIZED_SUM_EXCEEDS_INVOICE amount is lowered below the sum of linked itemized amounts. details: { invoiceTotal, itemizedTotal }, where invoiceTotal is the requested new amount
401 UNAUTHORIZED No valid session
404 NOT_FOUND Source vendor (path vendorId) or invoice ID does not exist, the invoice does not belong to the source vendor, or the target vendor (body vendorId) does not exist (message: "Vendor not found")

Notes:

  • The invoice must belong to the specified source vendor (path vendorId). If the invoice exists but belongs to a different vendor, a 404 is returned (not 403) to avoid leaking information about other vendors' invoices.
  • Vendor reassignment: The path vendorId identifies the source vendor (used to locate the invoice); the optional vendorId in the request body is the target vendor to reassign the invoice to. If the body vendorId references a vendor that does not exist, the endpoint returns 404 (NOT_FOUND, "Vendor not found") and the invoice is left unchanged. Omitting the body vendorId leaves the vendor unchanged (backward compatible). Reassignment preserves the invoice's budget-line links.

DELETE /api/vendors/:vendorId/invoices/:invoiceId

Deletes an invoice.

Auth required: Yes

Path parameters:

Parameter Type Description
vendorId string Vendor UUID
invoiceId string Invoice UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Vendor ID or invoice ID does not exist, or invoice does not belong to this vendor

Notes:

  • Deleting an invoice will update the vendor's computed outstandingBalance and invoiceCount fields (visible in subsequent GET /api/vendors/:id responses).
  • The invoice must belong to the specified vendor. If the invoice exists but belongs to a different vendor, a 404 is returned.

Source Reports

Source reports provide detailed cost tracking for a specific budget source, filtered by status. Reports include invoices linked to the source through budget lines, their allocations, and associated documents (Paperless-ngx references with archive serial numbers and titles). Reports degrade gracefully when Paperless-ngx is unreachable—documents show with null metadata rather than failing the entire request.

All source report endpoints require authentication. Both admin and member roles can perform all operations (no role restriction).

GET /api/source-reports

Generate a source report for a given budget source and report type.

Auth required: Yes

Query parameters:

Parameter Type Required Description
type string Yes Report type: budget-overview (all statuses), claim (pending+paid), proof-of-funds (claimed only)
sourceId string Yes Budget source UUID

Response (200 OK):

{
  "report": {
    "type": "claim",
    "source": {
      "id": "src-1",
      "name": "Bank Loan A",
      "sourceType": "bank_loan",
      "reference": "Loan #2026-001",
      "contactAddress": "123 Finance St, City"
    },
    "invoices": [
      {
        "invoiceId": "inv-1",
        "vendorId": "v-1",
        "vendorName": "Builder Co",
        "invoiceNumber": "INV-2026-001",
        "date": "2026-03-01",
        "status": "paid",
        "invoiceAmount": 5000.0,
        "allocatedAmount": 2500.0,
        "lineKind": "invoice",
        "isSplit": false,
        "splitKind": null,
        "documents": [
          {
            "documentId": 42,
            "archiveSerialNumber": 1042,
            "title": "Invoice from Builder Co",
            "attachmentType": "invoice"
          }
        ],
        "budgetLinesForSource": [
          {
            "id": "ibl-1",
            "description": "Foundation works",
            "allocatedPortion": 2500.0,
            "linkedItem": {
              "type": "work_item",
              "id": "wi-1",
              "name": "Foundation",
              "areaId": "area-1",
              "areaName": "Basement"
            }
          }
        ],
        "depositsVisibleToSource": []
      }
    ],
    "totalAmount": 2500.0,
    "unallocatedInvoices": [
      {
        "invoiceId": "inv-2",
        "vendorId": "v-2",
        "vendorName": "Supplier B",
        "invoiceNumber": "INV-2026-002",
        "date": "2026-03-05",
        "status": "pending",
        "invoiceAmount": 1000.0
      }
    ],
    "generatedAt": "2026-03-20T14:30:00.000Z"
  }
}
Field Type Description
type string Echoed report type (budget-overview, claim, or proof-of-funds)
source object Budget source summary (id, name, sourceType, reference, contactAddress)
invoices array Invoices with budget lines linked to this source, filtered by report status slice
invoices[].invoiceId string Invoice UUID
invoices[].vendorId string Vendor UUID
invoices[].vendorName string Vendor display name
invoices[].invoiceNumber string | null Invoice number from the vendor (may be null)
invoices[].date string Invoice date (YYYY-MM-DD)
invoices[].status string Invoice status (unchanged; shows the invoice's own status, not per-piece status)
invoices[].invoiceAmount number Total invoice amount
invoices[].allocatedAmount number Net contribution to this source for the report's status slice, rounded to 2dp. Negative for refund-adjustment lines. Combines Rail A (line-derived pro-rata; tagged deposits omitted from the line apportionment but still reducing the residual) + Rail B (tagged deposits 100% to source).
invoices[].lineKind string invoice if allocatedAmount > 0, refund-adjustment if < 0
invoices[].isSplit boolean True iff the invoice's funding spans 2+ distinct budget sources, counted across both budget lines (via work_item_budgets/household_item_budgets) and deposits tagged with a budgetSourceId. A deposit-only invoice with two differently-tagged deposits is split; an invoice whose lines and tagged deposit all point at the same source is not.
invoices[].splitKind 'lines' | 'deposits' | 'both' | null Discriminates why the invoice is split. The predicate per arm is "does this arm contain a source ≠ the requested sourceId" — not "does this arm contain ≥2 distinct sources". 'lines': a budget line resolves to a foreign source. 'deposits': a tagged deposit references a foreign source. 'both': at least one of each. null: not split. A deposit-only foreign source paired with exactly one line-source (the requested source itself) still yields 'deposits' — the single-line arm is not exempt from the foreign-source check. splitKind !== null iff isSplit === true (structural invariant, not runtime-asserted). Never discloses the other source's identity, only the categorical value (#1911).
invoices[].budgetLinesForSource array Budget line subtraction rows scoped to the requested sourceId — not all of the invoice's lines. Lines funded by another source are absent, not present with allocatedPortion: 0. Absence semantics: [] means "none of this invoice's lines belong to this source" (or, for claim reports, none contributes a non-zero amount) — not "this invoice has no lines"; invoices included only via a tagged deposit (Rail B) are always []. Used client-side for exclusions (client subtracts from allocatedAmount). Never the basis for an unscoped conclusion such as "is this invoice split?" — use isSplit/splitKind (ADR-036). See "Budget Line Scope" below.
invoices[].budgetLinesForSource[].id string Budget line ID
invoices[].budgetLinesForSource[].description string | null Budget line description or null
invoices[].budgetLinesForSource[].allocatedPortion number Contribution of this line to the source in this report's status slice (subtraction-only, never independently summed)
invoices[].budgetLinesForSource[].linkedItem object | null Linked work item or household item: { type: 'work_item' | 'household_item', id, name, areaId, areaName } or null if unassigned
invoices[].budgetLinesForSource[].linkedItem.areaId string | null ID of the item's own leaf area (the direct area assignment), or null if the item has no area assignment
invoices[].budgetLinesForSource[].linkedItem.areaName string | null Name of the item's own leaf area (the direct area assignment), or null if the item has no area assignment. Never a parent-area path — always the leaf area name only.
invoices[].documents array Paperless-ngx document references for this invoice (filtered by the attachment-tier rule below, Story #1930)
invoices[].documents[].documentId number Paperless-ngx document ID
invoices[].documents[].archiveSerialNumber number | null Archive serial number from Paperless-ngx (null if Paperless unreachable/unconfigured)
invoices[].documents[].title string | null Document title from Paperless-ngx (null if Paperless unreachable/unconfigured)
invoices[].documents[].attachmentType string | null Attachment tier: quotation, deposit, invoice, or null for untagged (see Attachment Tier Rule below)
invoices[].depositsVisibleToSource array Deposit rows visible to the requested sourceId: the invoice's untagged deposits plus those tagged to this source. Deposits tagged to another source are absent. Absence semantics: [] means "no untagged and no this-source-tagged deposits" — not "this invoice has no deposits". Same-scope predicates (e.g. "does this source hold a tagged deposit?") are sound over this array; unscoped ones are not (ADR-036). Informational (never summed).
invoices[].depositsVisibleToSource[].id string Deposit ID
invoices[].depositsVisibleToSource[].amount number Deposit/refund amount (stored positive; display logic applies sign from entryType)
invoices[].depositsVisibleToSource[].status string Deposit status: pending, paid, claimed
invoices[].depositsVisibleToSource[].entryType string deposit or refund (Story #1876)
invoices[].depositsVisibleToSource[].dueDate string Due date (YYYY-MM-DD)
invoices[].depositsVisibleToSource[].paidDate string | null Paid date (YYYY-MM-DD) or null
invoices[].depositsVisibleToSource[].claimedDate string | null Claimed date (YYYY-MM-DD) or null
invoices[].depositsVisibleToSource[].budgetSourceId string | null Always either the requested sourceId (tagged to this source) or null (untagged). Never another source's ID — foreign-tagged deposits are filtered out of the array entirely. Story #1891.
totalAmount number Sum of all allocatedAmount values, computed from rounded per-line values (Rail A + Rail B)
unallocatedInvoices array Invoices matching the status slice with zero budget lines for this source AND no tagged deposits to this source
generatedAt string ISO 8601 timestamp when the report was generated

Source Scope — budgetLinesForSource[] and depositsVisibleToSource[] are scoped projections:

Both arrays are projections scoped to the requested sourceId, and their names say so (#2017). Neither is the invoice's full contents, and an empty array is never evidence that the invoice has no lines or no deposits.

invoices[].budgetLinesForSource[] contains only the invoice's budget lines funded by the requested sourceId, never the invoice's full set of lines. The Rail A query in sourceReportService.ts filters on work_item_budgets.budget_source_id = :sourceId / household_item_budgets.budget_source_id = :sourceId before any per-line detail is built, so a line belonging to a different source is absent from the array entirely — it is not present with allocatedPortion: 0.

invoices[].depositsVisibleToSource[] contains the invoice's untagged deposits plus those tagged to the requested sourceId. A deposit tagged to a different source is absent entirely, so every row's budgetSourceId is either null or the requested sourceId.

Consequences that consumers must not get wrong:

  • budgetLinesForSource[] is a subtraction basis, not an inventory. It exists so the client can subtract excluded lines from allocatedAmount (which is itself this-source-only). Summing it does not reconstruct invoiceAmount, and its length is not the invoice's line count.
  • Absence of a second line (or of a foreign-tagged deposit) is not evidence that the invoice is single-source. Whether an invoice's funding spans multiple sources is answered only by invoices[].isSplit, which is computed by a separate UNION over budget lines and tagged deposits across all sources, and why it is split only by invoices[].splitKind. Inferring either from budgetLinesForSource.length, from depositsVisibleToSource, or from sum(allocatedPortion) < invoiceAmount is a proxy that misclassifies deposit-only splits and invoices with unfunded remainders. This distinction is load-bearing for the split/deposit-reduced legend annotations (#1898, #1965) and for the splitKind discriminator (#1911).
  • Same-scope predicates over these arrays are correct. "Does this source hold a tagged deposit on this invoice?" is a question about the requested source, so depositsVisibleToSource.some((d) => d.budgetSourceId === sourceId) answers it exactly (hasOwnTaggedDeposit in buildReportContent.ts). The rule is scope-matching, not a ban on reading the arrays — see ADR-036: Scoped Projections and Predicate Scope.

Breaking rename (#2017). These fields were previously named budgetLines and deposits. The rename changed the JSON field names on this endpoint with no compatibility shim: the only consumer is the bundled client, which ships in the same container from the same repository, and no reference exists in docs/ or outside this repository.

One further filter applies to claim reports only: a this-source line whose contribution rounds to exactly 0 is omitted. This drops structurally-present-but-fully-offset lines (a quotation invoice whose bank-funded portion is eliminated by invoiced-but-untagged sweepable deposits). For budget-overview and proof-of-funds, zero-contribution lines of the requested source are retained.

Attachment Tier Rule (Story #1930):

Documents form an ordered evidentiary tier, and each report type declares a tier floor. A document is embedded in the report iff its tier is at or above the report type's floor:

Tier attachmentType
1 (weakest) quotation
2 deposit
3 (strongest) invoice
Report type Tier floor Embeds
budget-overview quotation (1) quotation, deposit, invoice
claim deposit (2) deposit, invoice — never quotation
proof-of-funds invoice (3) invoice only

attachmentType: null (untagged/legacy) is treated as tier invoice (3) — the strongest tier — so untagged documents are included in every report type. This is deliberate: null links are legacy/ambiguous data (pre-Story-#1877 links, or the invoice detail page's "Add Document" picker when the user skipped the type choice), not known-weak evidence; treating null as the weakest tier would silently drop attachments from claim/proof-of-funds reports for existing data.

The rule depends only on the report type and the document's own attachmentType — it does not consult the invoice's status, the deposit split, or the report's target-status slice. Implemented once, in server/src/services/shared/attachmentTierUtils.ts (isDocumentIncludedForReportType) — do not hard-code a report type's floor at a second site.

If an invoice has no documents or none survive filtering, the documents array is empty. Documents are never omitted for any other reason; they always appear (with null metadata if Paperless is unreachable, if the document does not carry the configured filter tag, or if that tag cannot be resolved).

Paperless-ngx Degradation:

  • If Paperless-ngx is unreachable or unconfigured, or the configured PAPERLESS_FILTER_TAG cannot be resolved, all document fields (archiveSerialNumber, title) are null
  • With PAPERLESS_FILTER_TAG configured, a document that does not carry the tag gets null archiveSerialNumber and title, like a missing one
  • The endpoint returns 200 OK; it never returns 5xx due to Paperless failures
  • Documents are never omitted; they always appear with null metadata

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid report type or missing query params
401 UNAUTHORIZED No valid session
404 NOT_FOUND Budget source does not exist

POST /api/source-reports/mark-claimed

Mark invoices as claimed within a budget source scope, with source-scoped deposit sweep. Updates both invoices and their deposits in a single transaction. All work is atomic—any error rolls back all changes.

Auth required: Yes

Request body:

{
  "sourceId": "src-123",
  "invoiceIds": ["inv-1", "inv-2"],
  "depositIds": ["dep-1", "dep-2", "dep-3"]
}
Field Type Required Validation
sourceId string Yes Budget source UUID (1+ chars). Must exist in the system.
invoiceIds array Yes Array of invoice UUIDs. May be empty (see validation rules below).
depositIds array Yes Array of deposit UUIDs. May be empty (always required field). Server filters to sweep-eligible deposits (budgetSourceId === null OR === sourceId).

Response (200 OK):

{
  "claimedInvoiceIds": ["inv-1", "inv-2"],
  "claimedDepositIds": ["dep-1", "dep-3"]
}
Field Type Description
claimedInvoiceIds array Invoice UUIDs that were flipped from pending/paid to claimed
claimedDepositIds array Deposit UUIDs that were flipped to claimed status

Claimability Rules:

An invoice (from invoiceIds) is claimable if:

  1. Status is pending or paid, OR
  2. It has ≥1 sweep-eligible deposit whose current status allows claiming (per ALLOWED_TRANSITIONS)

This makes quotation invoices with sweepable deposits claimable (Story #1896), and already-claimed invoices with claimable deposits claimable.

Other-Source Budget Line Interest:

The "other-source budget line interest" check (used to determine the invoice flip rule in Behavior below) examines only budget lines with a non-null budget_source_id that differs from the operation's sourceId. Budget lines with a NULL budget_source_id (unattributed to any specific source) are deliberately NOT counted as "other-source interest" — they do not prevent the invoice from being claimed.

Deposit Sweep Scope:

Only deposits with budgetSourceId === null (untagged) OR budgetSourceId === sourceId (tagged to this source) are swept. Deposits tagged to another budget source are never modified by this operation.

Behavior:

  • Invoices with status pending or paid AND no other-source budget line interest → flipped to claimed; diary event fired (if enabled)
  • Invoices with status pending or paid BUT other-source budget line interest → left unchanged; only sweep-eligible deposits flipped (deposit-only close-out; see Story #1895)
  • Quotation-status invoices → left unchanged; only sweep-eligible deposits flipped (deposit-only close-out; see Story #1896)
  • Already-claimed invoices → left unchanged; only sweep-eligible deposits flipped (deposit-only close-out)
  • All sweep-eligible deposits whose ALLOWED_TRANSITIONS include claimed → flipped to claimed; claimedDate = today() (YYYY-MM-DD); paidDate preserved (never cleared); diary event fired (if enabled)

Request Validation Rules:

  • At least one of invoiceIds or depositIds must be non-empty; it is an error to provide both as empty arrays.
  • sourceId must refer to an existing budget source; if not found, the request returns 404.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Both invoiceIds and depositIds arrays are empty, or missing required field
401 UNAUTHORIZED No valid session
404 NOT_FOUND sourceId does not refer to an existing budget source
409 INVOICES_NOT_CLAIMABLE One or more invoices are not claimable. Details: { invoiceIds: [...offending IDs...] } — NO rows are modified if this error is thrown.

POST /api/source-reports/generate-content

Generate AI-assisted report content: a cover letter and factual descriptions for invoices. Story #1901, revised by #1931.

Auth required: Yes

Request body:

{
  "type": "budget-overview",
  "sourceId": "src-123",
  "language": "en",
  "includedInvoiceIds": ["inv-1", "inv-2"],
  "excludedLineIds": ["line-a"]
}
Field Type Required Validation
type string Yes Enum: budget-overview, claim, proof-of-funds
sourceId string Yes Budget source UUID. Must exist (1-100 chars).
language string Yes Enum: en (English), de (German)
includedInvoiceIds array Yes Array of invoice UUIDs to include (1-200 items, each 1-100 chars). Filtered server-side to report's actual invoices.
excludedLineIds array No Budget line IDs to exclude from descriptions and amount calculation (≤500 items).

Response (200 OK):

{
  "letterSubject": "Construction Project Financial Report — 2024",
  "letterBody": "Please find attached our construction project financial report...",
  "descriptions": {
    "inv-1": "Foundation excavation and site preparation",
    "inv-2": "Concrete foundation pour and curing"
  }
}
Field Type Description
letterSubject string Professional cover letter subject line.
letterBody string Formal cover letter body only, generated in the requested language. Excludes the salutation, the closing formula and the signature, which the client prints around it. A German body starts lowercase after the comma-terminated salutation (Sehr geehrte Damen und Herren,), unless its first word is a noun or the formal Sie.
descriptions object Map of invoiceId → usage description. Only included invoices appear.

Behavior:

  • Server-side re-fetches the report to avoid trusting client-provided invoice data
  • includedInvoiceIds are filtered to the report's actual invoice IDs server-side
  • Throws EMPTY_SELECTION if no included invoices overlap with the report
  • Budget line amounts are excluded from the total when their line ID is in excludedLineIds
  • Linked work-item and household-item descriptions are fetched and enriched
  • All invoice notes and item descriptions are truncated (500/300 chars)
  • Output descriptions are filtered to only included invoice IDs (defense-in-depth)
  • Generated content is NOT persisted; it is returned for client preview/editing

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid request structure or enum values
400 EMPTY_SELECTION No included invoices overlap with the report's actual invoice IDs
401 UNAUTHORIZED No valid session
404 NOT_FOUND Budget source does not exist
502 LLM_INVALID_RESPONSE LLM returned malformed JSON or missing required fields
502 LLM_UNREACHABLE LLM provider is unreachable (network error, timeout, or invalid URL)
502 LLM_UPSTREAM_ERROR LLM provider returned HTTP error (non-2xx status)
503 LLM_NOT_CONFIGURED LLM gateway is not configured (all LLM env vars not set)

Notes:

  • All LLM errors suppress details in the HTTP response to avoid leaking prompt data or LLM-returned vendor/amount information to the client
  • Generate-content is a read-only operation. No database modifications occur
  • Language selection controls the generated letter's language, independent of input data language
  • Currency is sourced from the CURRENCY environment variable (exposed via /api/config)
  • descriptions is keyed by the invoice IDs the server accepted from includedInvoiceIds; a client must not assume a key exists for an ID that was filtered out
  • Length limits: letterSubject 150, letterBody 2000, per-invoice description 200 characters. The limit stated in the prompt and the limit enforced by the response validator are the same value by construction — both derive from REPORT_CONTENT_LIMITS in server/src/services/llmGateway/contentLimits.ts (#1931). An overlong field is truncated to the limit, never rejected; a response omitting any requested invoice ID is rejected with LLM_INVALID_RESPONSE
  • letterBody, letterSubject, and each descriptions[].description are stripped of markdown and HTML markup server-side by the response validator before length truncation (#1952), so a field that exceeds its cap only because of markup characters is not truncated unnecessarily. Removed: emphasis markers (**, __, *, _) when genuinely flanked as emphasis, ATX heading markers (# through ###### ) at a line start, bullet markers (- , * , + ) at a line start, numbered markers (1. , 1) ) at a line start when the line belongs to a run of two or more consecutive numbered lines, and HTML tags (<b>, </p>, <br/>, etc.). Inner text is always preserved, as are all \n/\n\n line and paragraph breaks — a stripped marker never merges two lines. The stripped value is trimmed, so tag removal leaves no stray leading or trailing whitespace. If stripping would leave a field empty or whitespace-only, the original value is returned unchanged.
  • Stripping is deliberately conservative: a false positive that silently alters a reference number is worse than the markup it removes, because the reader has no way to know a character went missing. Emphasis markers follow CommonMark flanking rules — an opening marker must be followed by a non-space and a closing marker preceded by one, and _ additionally must not be intraword — so unpaired footnote markers (5 EUR* zzgl. MwSt), asterisks used for multiplication (3 * 2 Einheiten), and underscores inside identifiers, reference numbers, or e-mail addresses (budget_line_id, RE_2024_117, [email protected]) all pass through unchanged. Numbered markers are run-gated because German writes ordinals and dates with a trailing period, so a lone 15. Mai 2026 … or 2. Rate in Höhe von … at a line start is prose, not a list, and keeps its number; bullet markers are not run-gated, since a leading - is not idiomatic prose. Mid-line hyphens (Pos. 3 - Dachstuhl, Mo-Fr, 2024-117), # inside a line (Rechnung #2024-117), a < not opening a well-formed tag (Beträge < 500 EUR), and German characters (umlauts, ß, €) are likewise untouched. An already-compliant plain-prose field passes through byte-identical.
  • The prompt-level plain-prose instruction (REPORT_CONTENT_SYSTEM_PROMPT rule 4 in server/src/services/llmGateway/prompts.ts, #1932) remains in place and is complementary, not redundant: the prompt keeps the common case clean and cheap, the validator makes the guarantee hard. Clients render all three fields literally with no markup parsing (coverLetterPdf.ts, ReportContentEditor.tsx).
  • The letterBody exclusions (no salutation, closing formula or signature; German lowercase start) are enforced only by the prompt (REPORT_CONTENT_SYSTEM_PROMPT rule 4 and the output-format line in server/src/services/llmGateway/prompts.ts, #2159), not by the response validator. A model that ignores the instruction can still return a body that opens with a greeting or ends with a sign-off, which the client then prints next to its own salutation and closing. Clients must not assume the guarantee is hard; the user reviews and edits the body in ReportContentEditor.tsx before the PDF is generated.
  • Requires the LLM gateway to be configured. Clients should gate the UI on llmEnabled from GET /api/config rather than relying on the LLM_NOT_CONFIGURED error path

Invoice Deposits

Invoice deposits track staged partial payments within a single invoice. A deposit goes through a state machine: pending → paid, paid → claimed, paid → pending, claimed → paid. As of Story #1878, pending → claimed is also allowed (deposits can be claimed directly without going through paid first). Deposits are ordered by dueDate ascending, then createdAt ascending in response bodies.

All deposit endpoints require authentication. Both admin and member roles can perform all operations (no role restriction).

Refund cap (#2188): deposit-type entries are never checked against invoice.amount; net deposits (Σ deposit-type − Σ refund-type) may exceed it. The only sum rule is a refund cap, applied on refund-type POST and on a refund-type PATCH that increases the amount (compared in cents): rejected with REFUND_EXCEEDS_INVOICE when Σ refund-type (other entries + requested amount) > max(invoice.amount, Σ deposit-type). Both sums include entries in every status and use the tolerant exceedsAmount comparison (cent precision). Refund decreases/deletes, deposit-type creates/increases/decreases/deletes and amount-neutral edits (status, dates, description, source) are never checked. The cap is checked only at refund write time; it is not re-checked when deposits are decreased or deleted or when the invoice amount is lowered.

Every check reads the sums and writes the change in the same transaction.

GET /api/invoices/:invoiceId/deposits

Returns all deposits for a given invoice.

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID

Response (200 OK):

{
  "deposits": [
    {
      "id": "dep-1",
      "invoiceId": "inv-1",
      "amount": 500.0,
      "dueDate": "2026-04-01",
      "paidDate": null,
      "claimedDate": null,
      "description": "First installment",
      "status": "pending",
      "createdBy": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-03-15T10:00:00.000Z",
      "updatedAt": "2026-03-15T10:00:00.000Z"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Invoice ID does not exist

POST /api/invoices/:invoiceId/deposits

Creates a new deposit or refund for an invoice. The createdBy is automatically set to the authenticated user. Refunds are entered as positive amounts; the application treats them as negative contributions in aggregations (Story #1876).

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID

Request body:

{
  "amount": 500.0,
  "dueDate": "2026-04-01",
  "description": "First installment",
  "entryType": "deposit",
  "status": "pending",
  "paidDate": null,
  "claimedDate": null,
  "budgetSourceId": null
}
Field Type Required Validation
amount number Yes Must be > 0 (refunds stored positive; negation is display-time)
dueDate string Yes ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR
description string No Max 500 characters
entryType string No One of: deposit, refund. Default: deposit. Immutable after creation.
status string No One of: pending, paid, claimed. Default: pending
paidDate string No ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR. Only valid when status is paid or claimed
claimedDate string No ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR. Only valid when status is claimed
budgetSourceId string No Budget source UUID or null. Links this deposit to a source for aggregations (Story #1891).

Invariants (see "Refund cap" in the section intro):

  • Deposit-type entry: never checked against invoice.amount (#2188).
  • Refund-type entry: Σ refund-type (existing) + amount ≤ max(invoice.amount, Σ deposit-type) (gross, all statuses).

Response (201 Created):

{
  "deposit": {
    "id": "dep-1",
    "invoiceId": "inv-1",
    "amount": 500.0,
    "dueDate": "2026-04-01",
    "paidDate": null,
    "claimedDate": null,
    "description": "First installment",
    "entryType": "deposit",
    "status": "pending",
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "createdAt": "2026-03-15T10:00:00.000Z",
    "updatedAt": "2026-03-15T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing amount or dueDate, amount <= 0, malformed or impossible date (e.g. 2026-02-31) in dueDate/paidDate/claimedDate
400 REFUND_EXCEEDS_INVOICE Refund-type entry: gross sum of refunds would exceed max(invoiceTotal, depositTotal). details: { invoiceTotal, depositTotal, refundCap, currentRefundSum, requestedAmount, availableHeadroom }, where depositTotal is the gross sum of deposit-type entries, refundCap = max(invoiceTotal, depositTotal) and availableHeadroom = max(0, refundCap − currentRefundSum), all rounded to cents
400 INVALID_DEPOSIT_STATUS_TRANSITION Status transition not allowed (e.g., creating with status claimed)
400 INVALID_DEPOSIT_DATE_FOR_STATUS paidDate/claimedDate set when status doesn't support them
401 UNAUTHORIZED No valid session
404 NOT_FOUND Invoice ID does not exist

PATCH /api/invoices/:invoiceId/deposits/:depositId

Updates a deposit. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically. Note: entryType is immutable after creation. Fastify's default removeAdditional: true AJV setting silently strips any entryType field from the PATCH body before validation — the request succeeds (200) but the stored value is unchanged, it is never rejected with a 400.

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID
depositId string Deposit UUID

Request body (all fields optional, at least one required):

{
  "amount": 600.0,
  "dueDate": "2026-04-05",
  "description": "Revised first installment",
  "status": "paid",
  "paidDate": "2026-04-02",
  "budgetSourceId": null
}
Field Type Validation
amount number Must be > 0. When the amount increases, only a refund-type entry is checked, against the refund cap from the section intro (the sums exclude this entry's current amount). A deposit-type entry, a decrease and an unchanged amount are never checked.
dueDate string ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR
description string | null Max 500 characters; null clears the field
status string One of: pending, paid, claimed. Transitions must be allowed.
paidDate string | null ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR. Only valid when status is paid or claimed
claimedDate string | null ISO 8601 calendar date (YYYY-MM-DD); impossible dates such as 2026-02-31 are rejected with VALIDATION_ERROR. Only valid when status is claimed
budgetSourceId string | null Budget source UUID or null. Editable regardless of deposit status or edit-mode (Story #1891).

State Machine & Date Side-Effects:

Allowed transitions:

  • pending → paid: Auto-sets paidDate = today() unless paidDate is supplied in the request. Clears claimedDate.
  • pending → claimed (Story #1878): Auto-sets both paidDate = today() and claimedDate = today() unless supplied in the request.
  • paid → claimed: Auto-sets claimedDate = today() unless claimedDate is supplied in the request. Preserves paidDate.
  • paid → pending: Clears both paidDate and claimedDate.
  • claimed → paid: Clears claimedDate, preserves paidDate.

Any other transition (claimed → pending) is disallowed and returns 400 INVALID_DEPOSIT_STATUS_TRANSITION. Sending the current status again (e.g. pending → pending) is not a transition: it is accepted, changes no status or date, and writes no event. The allowed targets come from the shared PROGRESS_PAYMENT_TRANSITIONS map (shared/src/lib/statusTransitions.ts, #2209); the error's details.allowedTargets keeps the order pending: [paid, claimed], paid: [claimed, pending], claimed: [paid].

Dates later than today are accepted for paidDate and claimedDate.

Response (200 OK):

When the call changed status, paidDate or claimedDate, the body also carries a top-level undo sibling of deposit (contract 11). The vendor-scoped twin behaves identically.

{
  "deposit": {
    "id": "dep-1",
    "invoiceId": "inv-1",
    "amount": 600.0,
    "dueDate": "2026-04-05",
    "paidDate": "2026-04-02",
    "claimedDate": null,
    "description": "Revised first installment",
    "entryType": "deposit",
    "status": "paid",
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "createdAt": "2026-03-15T10:00:00.000Z",
    "updatedAt": "2026-03-15T10:30:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid field values (amount <= 0, malformed or impossible date such as 2026-02-31, etc.)
400 REFUND_EXCEEDS_INVOICE Refund-type entry, amount increased: gross sum of refunds would exceed the refund cap. details as on POST (currentRefundSum excludes this entry)
400 INVALID_DEPOSIT_STATUS_TRANSITION Transition not allowed
400 INVALID_DEPOSIT_DATE_FOR_STATUS paidDate/claimedDate set when status doesn't support them
401 UNAUTHORIZED No valid session
404 NOT_FOUND Invoice or deposit does not exist, or deposit does not belong to invoice

DELETE /api/invoices/:invoiceId/deposits/:depositId

Deletes a deposit. Deletions are never checked against the invoice amount or the refund cap (#2188).

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID
depositId string Deposit UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Invoice or deposit does not exist, or deposit does not belong to invoice

Vendor-Scoped Deposit Endpoints

The same deposit operations are also available under vendor scope for consistency:

  • GET /api/vendors/:vendorId/invoices/:invoiceId/deposits
  • POST /api/vendors/:vendorId/invoices/:invoiceId/deposits
  • PATCH /api/vendors/:vendorId/invoices/:invoiceId/deposits/:depositId
  • DELETE /api/vendors/:vendorId/invoices/:invoiceId/deposits/:depositId

These endpoints follow the exact same specifications as their /api/invoices counterparts. The only difference is the URL path includes vendorId for consistency with vendor-scoped invoice operations. Both URL prefixes are equivalent and served by identical handlers.


Standalone Invoice Endpoints

These endpoints provide cross-vendor access to invoices. They complement the vendor-scoped endpoints above and are useful for listing all invoices across all vendors or fetching a single invoice without knowing its vendor.

All standalone invoice endpoints require authentication. Both admin and member roles can perform all operations (no role restriction).

GET /api/invoices

Returns a paginated list of all invoices across all vendors. Supports filtering by status, vendor, and text search on invoice number, company name, or description.

Auth required: Yes

Query parameters:

Parameter Type Default Constraints Description
page integer 1 >= 1 Page number (1-indexed)
pageSize integer 25 1-100 Items per page
q string -- Max 200 Search in invoice number, company (vendor) name, or description (case-insensitive)
status pending | paid | claimed -- -- Filter by invoice status
vendorId string -- -- Filter by vendor UUID
amountMin number -- -- Filter: minimum invoice amount (inclusive)
amountMax number -- -- Filter: maximum invoice amount (inclusive)
dateFrom string (YYYY-MM-DD) -- -- Filter: earliest invoice date (inclusive)
dateTo string (YYYY-MM-DD) -- -- Filter: latest invoice date (inclusive)
dueDateFrom string (YYYY-MM-DD) -- -- Filter: earliest due date (inclusive)
dueDateTo string (YYYY-MM-DD) -- -- Filter: latest due date (inclusive)
sortBy date | amount | status | vendor_name | due_date date -- Sort field. See note below on openOnly's default ordering.
sortOrder asc | desc desc -- Sort direction
openOnly boolean false -- Story #2046: return only open items — invoices with status = 'pending' or at least one deposit with status = 'pending' (of either entryType)

Response (200 OK):

{
  "invoices": [
    {
      "id": "inv-1",
      "vendorId": "v1",
      "vendorName": "ABC Construction",
      "invoiceNumber": "INV-2026-001",
      "amount": 2500.0,
      "date": "2026-03-15",
      "dueDate": "2026-04-15",
      "status": "pending",
      "notes": "First payment for foundation work",
      "budgetLines": [
        {
          "id": "ibl-1",
          "workItemBudgetId": "b1",
          "householdItemBudgetId": null,
          "itemizedAmount": 2500.0,
          "budgetLineDescription": "Foundation concrete materials",
          "categoryName": "Materials",
          "categoryColor": "#3B82F6",
          "plannedAmount": 8500.0
        }
      ],
      "remainingAmount": 0,
      "createdBy": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-03-15T10:00:00.000Z",
      "updatedAt": "2026-03-15T10:00:00.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "totalItems": 42,
    "totalPages": 2
  },
  "summary": {
    "pending": { "count": 5, "totalAmount": 12500.0 },
    "paid": { "count": 30, "totalAmount": 75000.0 },
    "claimed": { "count": 7, "totalAmount": 17500.0 },
    "quotation": { "count": 0, "totalAmount": 0.0 },
    "overdue": { "count": 2, "totalAmount": 5000.0 },
    "claimable": { "count": 5, "totalAmount": 12500.0 },
    "quotationCoveredByDeposits": 0.0,
    "openPayable": { "count": 5, "totalAmount": 25310.0 },
    "refundsDue": { "count": 1, "totalAmount": 1200.0 }
  },
  "filterMeta": {
    "amount": { "min": 150.0, "max": 25000.0 }
  }
}

Notes:

  • The summary field is always the global unfiltered summary across all invoices, regardless of the current filter or search parameters. This provides consistent dashboard-level totals. This also applies to summary.openPayable and summary.refundsDue (Story #2046) — they do not change when openOnly or any other filter is applied.
  • summary.overdue includes pending invoices with a dueDate before today (local time). This is a subset of summary.pending and helps highlight invoices requiring immediate attention.
  • Results are sorted by date descending by default (newest invoices first). Exception: when openOnly=true and sortBy is omitted, results are instead sorted ascending by earliest open due date — the minimum of the invoice's own dueDate (when the invoice is pending) and the dueDates of its pending deposits. Invoices with no dated open item sort last. Passing an explicit sortBy overrides this and behaves as usual.
  • The q parameter matches the invoice number, the company (vendor) name, or the invoice description (notes) using case-insensitive substring matching; %, _ and \ match literally.
  • Story #2046 — openOnly: when true, only invoices that are themselves pending or that carry at least one pending deposit (either entryType) are returned. Pagination (totalItems/totalPages) counts invoices only — deposits never consume page capacity. On these responses only, each invoice's deposits array is populated (see the note on deposits above) and each invoice carries an additional openAmount field:
    • openAmount = (status === 'pending' ? max(0, amount − Σ deposit-type entries of any status) : 0) + Σ (pending deposit-type entries). Pending refunds are excluded from openAmount — they are reported separately via summary.refundsDue.
    • summary.openPayable = { count, totalAmount } over all invoices (not just the current page/filter): totalAmount is the sum of every invoice's openAmount, count is the number of invoices with openAmount > 0. This total is computed from the exact same per-invoice figures returned in openAmount — summing invoices[].openAmount across every page of an openOnly=true result set, with no other filters applied, reproduces summary.openPayable.totalAmount exactly. If q, vendorId, or a date/amount range filter narrows the visible invoices, the summed openAmount values are only a subset and will not equal the (always global/filter-independent) summary.openPayable.totalAmount.
    • summary.refundsDue = { count, totalAmount }: totalAmount is the sum of pending refund-type deposit amounts (positive), count is the number of invoices carrying at least one pending refund. Refunds are never netted into openPayable.
    • A quotation invoice with a pending deposit is returned (its pending deposit is an open item), but the invoice's own amount contributes nothing to openAmount or openPayable.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid query parameters (page < 1, pageSize out of range, etc.)
401 UNAUTHORIZED No valid session

GET /api/invoices/:invoiceId

Returns a single invoice by ID, regardless of which vendor it belongs to.

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID

Response (200 OK):

{
  "invoice": {
    "id": "inv-1",
    "vendorId": "v1",
    "vendorName": "ABC Construction",
    "invoiceNumber": "INV-2026-001",
    "amount": 2500.0,
    "date": "2026-03-15",
    "dueDate": "2026-04-15",
    "status": "pending",
    "notes": "First payment for foundation work",
    "budgetLines": [
      {
        "id": "ibl-1",
        "workItemBudgetId": "b1",
        "householdItemBudgetId": null,
        "itemizedAmount": 2500.0,
        "budgetLineDescription": "Foundation concrete materials",
        "categoryName": "Materials",
        "categoryColor": "#3B82F6",
        "plannedAmount": 8500.0
      }
    ],
    "remainingAmount": 0,
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "createdAt": "2026-03-15T10:00:00.000Z",
    "updatedAt": "2026-03-15T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Invoice ID does not exist

POST /api/invoices/:invoiceId/convert-quotation

Story #2107. Converts an invoice with status: 'quotation' into the final invoice in place. The call can set the final amount, dates, number, notes and status, re-amount the existing budget lines, and optionally link the final Paperless document. It is one atomic operation: every validation runs before any write, and all writes happen in a single SQLite transaction. The invoice keeps its id, vendor, deposits and budget-line links.

Auth required: Yes. Both admin and member roles may call it; there is no role restriction.

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID

Request body (ConvertQuotationRequest in @cornerstone/shared):

The schema declares additionalProperties: false, but Fastify's default AJV options include removeAdditional: true. Unknown properties are therefore silently dropped, not rejected: the request proceeds as if they were absent.

{
  "amount": 11800.0,
  "date": "2026-09-28",
  "invoiceNumber": "RE-2026-0412",
  "dueDate": "2026-10-12",
  "notes": "Final invoice after site measurement",
  "status": "pending",
  "conversionNote": "Converted from quotation (quoted 12,500.00 €) on 30.09.2026.",
  "budgetLines": [
    { "id": "ibl-1", "itemizedAmount": 7300.0 },
    { "id": "ibl-2", "itemizedAmount": 4500.0 }
  ],
  "paperlessDocumentId": 412
}
Field Type Required Validation / semantics
amount number Yes Final invoice amount, major units, > 0
date string Yes Final invoice date, ISO 8601 (YYYY-MM-DD). Must be a real calendar date: impossible dates such as 2026-02-31 are rejected (round-trip check)
invoiceNumber string | null No Max 100 characters. Omitted = keep the quotation's number; null = clear. The value is trimmed and a blank string is stored as null.
dueDate string | null No ISO 8601, real calendar date. Omitted = keep the stored due date; null = clear. The effective due date (sent or stored) must be ≥ date, otherwise 400.
notes string | null No Max 10000 characters. The user's final notes before the history line. Omitted = keep the quotation's notes; null = clear them. See "Notes and the history line" below.
status string Yes Target status, one of pending, paid. paid is a status only: the conversion records no payment date, and the invoices table has no paid-date column. claimed and quotation are rejected.
conversionNote string Yes 1-1000 characters, and non-blank after trim. A localized history line composed by the client (the server has no i18n). Always appended to the notes.
budgetLines array No Max 200 entries. Partial re-amounting of existing lines, see below. Omitted or [] = keep the current itemization unchanged.
budgetLines[].id string Yes invoice_budget_lines.id (the id of an entry in Invoice.budgetLines). Must belong to this invoice; must not repeat within the array.
budgetLines[].itemizedAmount number Yes New itemized amount for that line, major units, > 0
paperlessDocumentId integer No >= 1. Paperless-ngx document to link to the invoice with attachmentType: 'invoice', see the document-link table below. Paperless is not contacted, so this works when Paperless is not configured.

Notes and the history line:

The stored notes is base + "\n\n" + conversionNote.trim(), where base is the trimmed notes from the request (or the stored notes when notes is omitted). When base is empty or null, the stored value is the trimmed conversionNote alone. The combined value must be at most 10000 characters, otherwise the call returns 400 VALIDATION_ERROR. Because conversionNote is required, every conversion made through the API leaves a trace in the invoice notes. Anything the client wants to record about the conversion (for example the quoted amount, or that the final payment was marked paid) goes into this free-text line. There is no structured field for it.

Budget lines (partial update):

  • Only the lines listed in budgetLines change. Every other line of the invoice keeps its itemizedAmount.
  • Lines cannot be added or removed here. To itemize new lines, use POST /api/invoices/:invoiceId/budget-lines or auto-itemize after the conversion.
  • The itemized-sum rule is checked against the resulting itemization (each listed line's new amount plus every unlisted line's current amount) and the new amount. It uses the tolerant exceedsAmount comparison. This applies even when budgetLines is omitted: a final amount below the existing itemized total is rejected with ITEMIZED_SUM_EXCEEDS_INVOICE.

Deposits:

Deposits are never modified and never block conversion; net deposits may exceed amount (#2188).

Document link (paperlessDocumentId):

The endpoint looks up an existing link with entityType: 'invoice', this invoiceId and this paperlessDocumentId:

Existing link Result
None A new link is created with attachmentType: 'invoice'
attachmentType: 'invoice' No-op
attachmentType: null (untagged) Upgraded in place to 'invoice'
attachmentType: 'quotation' or 'deposit' 409 DUPLICATE_DOCUMENT_LINK, and the whole conversion is rolled back. The quotation or deposit link is left unchanged.

Atomicity and side effects:

  • The whole operation runs in one transaction. It re-reads the invoice and runs every check, including the document-link tier decision, before the first write. Only then does it write the invoice, the line amounts and the document link. If any step fails, nothing is changed; there is no half-converted invoice.
  • The status check runs inside the transaction, so a second concurrent conversion of the same invoice gets 409 INVOICE_NOT_QUOTATION.
  • The invoice_status diary auto-event (quotation → the target status) is written after the transaction commits, as for PATCH. It honours DIARY_AUTO_EVENTS today; Planned removal (#2223): the variable is removed and the event is always written (ADR-040 §4). Diary write failures are swallowed and logged, so they can neither roll back the conversion nor fail the request.
  • The endpoint is not idempotent: once the invoice is converted, a repeat call returns 409 INVOICE_NOT_QUOTATION.

Response (200 OK): { "invoice": Invoice }, the full invoice (see Invoice Response Shape) re-read after commit. status, amount, date, dueDate, invoiceNumber, notes, budgetLines[].itemizedAmount, remainingAmount, deposits and finalPaymentAmount reflect the conversion.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Schema failure (missing required field, status not pending/paid, bad date format, length limits), impossible calendar date, effective due date (sent or stored) < date, blank conversionNote, combined notes > 10000, duplicate budgetLines[].id
400 ITEMIZED_SUM_EXCEEDS_INVOICE The resulting itemized total exceeds amount. details: { invoiceTotal, itemizedTotal }
401 UNAUTHORIZED No valid session
404 NOT_FOUND The invoice does not exist, or a budgetLines[].id is not a line of this invoice ("Invoice budget line not found in this invoice")
409 INVOICE_NOT_QUOTATION The invoice's current status is not quotation. details: { status } (the current status)
409 DUPLICATE_DOCUMENT_LINK paperlessDocumentId is already linked to this invoice on the quotation or deposit tier

Checks run in this order. When a request breaks several rules, the first failure in this order is returned:

  1. Request-schema validation (Fastify/AJV, before the handler runs): 400 VALIDATION_ERROR. For example, status: 'claimed' sent to an already-converted invoice gets 400, not 409.
  2. Invoice exists: 404 NOT_FOUND.
  3. Invoice status is quotation: 409 INVOICE_NOT_QUOTATION.
  4. Fields (amount, calendar dates, effective due date ≥ date): 400 VALIDATION_ERROR.
  5. Notes (non-blank conversionNote, combined length ≤ 10000): 400 VALIDATION_ERROR.
  6. Budget lines (duplicate ids: 400; id not on this invoice: 404).
  7. Itemized sum: 400 ITEMIZED_SUM_EXCEEDS_INVOICE.
  8. Document-link lookup and tier decision: 409 DUPLICATE_DOCUMENT_LINK.

Only then are the invoice, the line amounts and the document link written. Every check precedes every write.


Budget Source Response Shape

type BudgetSourceType = 'bank_loan' | 'credit_line' | 'savings' | 'other' | 'discretionary';
type BudgetSourceStatus = 'active' | 'exhausted' | 'closed';

interface BudgetSourceResponse {
  id: string;
  name: string;
  sourceType: BudgetSourceType;
  totalAmount: number;
  usedAmount: number; // Computed: SUM(planned_amount) of linked budget lines (planned allocation perspective)
  availableAmount: number; // Computed: totalAmount - usedAmount (planned perspective)
  claimedAmount: number; // Computed: aggregates per-deposit 'claimed' contributions (proportional split) + residual when parent invoice is 'claimed'
  actualAvailableAmount: number; // Computed: totalAmount - claimedAmount (actual perspective)
  paidAmount: number; // Computed: aggregates per-deposit 'paid'+'claimed' (everything-non-pending) contributions (proportional split) + residual when parent invoice is 'paid'/'claimed'
  projectedAmount: number; // Computed: blended projection using confidence margins for non-invoiced lines
  isDiscretionary: boolean; // true for the system-managed Discretionary Funding source
  interestRate: number | null;
  terms: string | null;
  notes: string | null;
  reference: string | null; // Reference identifier (e.g., account number, contract ID) for Bank Report Wizard; Story #1877
  contactAddress: string | null; // Contact address for the source (e.g., bank address) for Bank Report Wizard; Story #1877
  status: BudgetSourceStatus;
  createdBy: UserSummary | null;
  createdAt: string; // ISO 8601
  updatedAt: string; // ISO 8601
}

Notes on computed fields:

  • usedAmount represents the planned allocation perspective -- summed over all work_item_budgets and household_item_budgets rows referencing this source: an invoiced line contributes its itemized_amount, a non-invoiced line its VAT-effective planned_amount (a net line, includes_vat = 0, is grossed up by 1 + VAT_RATE (server config vatRate, default 0.19, exposed by GET /api/config), rounded to cents, exactly like projectedAmount). It reflects how much of the source has been earmarked for budget lines, on the same gross basis as totalAmount.
  • availableAmount is totalAmount - usedAmount, showing how much planned capacity remains (VAT-effective, because usedAmount is).
  • claimedAmount represents the actual drawdown perspective -- the sum of claimed contributions from invoices linked (via invoice_budget_lines) to budget lines referencing this source. When an invoice has deposits, contributions are split proportionally across deposit statuses and the residual amount (see "Deposit-aware aggregation" below).
  • unclaimedAmount represents the sum of paid contributions (non-claimed, non-pending) from linked invoices, using the same proportional split.
  • paidAmount equals claimedAmount + unclaimedAmount (i.e., all non-pending contributions).
  • actualAvailableAmount is totalAmount - claimedAmount, showing how much of the source's funds remain after actual drawdowns.
  • projectedAmount is a blended projection: invoiced budget lines use actual cost, non-invoiced lines apply confidence margins to planned_amount. A net line (includes_vat = 0) has its planned amount grossed up by 1 + VAT_RATE (server config vatRate, default 0.19, exposed by GET /api/config) before the margin is applied, the same as the budget overview and breakdown.
  • isDiscretionary is true only for the system-managed "Discretionary Funding" source (seeded by migration 0021). This source captures invoice remainder amounts and budget lines with no assigned source. It cannot be deleted or have its sourceType changed.

Deposit-aware aggregation: When an invoice has deposits, each invoice_budget_lines row's contribution is split proportionally:

  • Each deposit d contributes ibl.itemizedAmount × (d.amount / invoice.amount) under d.status (e.g., a 5000€ deposit on a 10000€ invoice contributes 50% of the itemized amount under that deposit's status).
  • The residual contributes ibl.itemizedAmount × ((invoice.amount − Σ d.amount) / invoice.amount) under the parent invoice's status.
  • Zero-deposit invoices behave identically to before: 100% of ibl.itemizedAmount contributes under the parent invoice's status.
  • Implemented in server/src/services/shared/depositAggregateUtils.ts.

Tagged-deposit aggregation (Story #1891): Source reports use two independent aggregation rails:

  • Rail A (line-derived, pro-rata; tagged deposits omitted from the line apportionment only): Each invoice_budget_lines row's contribution for the source uses the pro-rata split above, BUT deposits with non-null budget_source_id do not receive a pro-rata apportionment across the invoice's budget lines — Rail B already credits them 100% to their own source. A tagged deposit remains in the residual denominator: the residual is (invoice.amount − Σ non-refund deposit amounts) / invoice.amount over all deposits, tagged or not, exactly as in the untagged pro-rata formula above. Omitting a tagged deposit from the residual as well would let the residual re-absorb money Rail B has already counted, double-counting it (fixed in #1894; the pre-fix text described that buggy behaviour). Note the residual fraction is invoice-level, so a deposit tagged to source A also reduces the Rail A share of a line belonging to source B on the same invoice — this is what makes the rails reconcile. This rail produces zero contribution to the source if the invoice has zero budget lines for that source and only tagged deposits to other sources.
  • Rail B (deposit-direct, 100% to source): The sum of all invoice_deposits rows where budget_source_id = sourceId, signed by entryType (deposit = +, refund = -), filtered to the report's target status slice (e.g., claimed, paid, or pending depending on report type). These deposits contribute 100% of their amount to their source, independent of budget lines. A deposit-only invoice (zero ibls for source) appears in the report if it has ≥1 tagged deposit.
  • Combined: allocatedAmount = round2dp(railA + railB). lineKind derives from the combined sign. unallocatedInvoices excludes invoices with ≥1 tagged deposit to the source.
  • Reconciliation invariant: for a single invoice fully covered by budget lines, summing allocatedAmount across all sources reconstructs the invoice amount exactly. Example — a 1000€ paid invoice with a 600€ line to source A, a 400€ line to source B, and a 150€ deposit tagged to A: residual fraction is (1000 − 150) / 1000 = 0.85, so A gets 600 × 0.85 + 150 = 660 and B gets 400 × 0.85 = 340, totalling 1000.

Budget Source Endpoints

Budget sources represent financing sources for the construction project (e.g., bank loans, credit lines, savings). Budget sources are a small collection and are NOT paginated. All budget source endpoints require authentication. Both admin and member roles can perform all budget source operations (no role restriction).

GET /api/budget-sources

Returns all budget sources. Non-discretionary sources are sorted alphabetically by name first, followed by the discretionary source last.

Auth required: Yes

Response (200 OK):

{
  "budgetSources": [
    {
      "id": "bs-1",
      "name": "ABC Bank Mortgage",
      "sourceType": "bank_loan",
      "totalAmount": 350000.0,
      "usedAmount": 125000.0,
      "availableAmount": 225000.0,
      "claimedAmount": 45000.0,
      "actualAvailableAmount": 305000.0,
      "paidAmount": 30000.0,
      "projectedAmount": 140000.0,
      "isDiscretionary": false,
      "interestRate": 3.5,
      "terms": "30-year fixed, monthly payments",
      "notes": "Primary financing source",
      "status": "active",
      "createdBy": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-02-20T10:00:00.000Z",
      "updatedAt": "2026-02-20T10:00:00.000Z"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

POST /api/budget-sources

Creates a new budget source.

Auth required: Yes

Request body:

{
  "name": "ABC Bank Mortgage",
  "sourceType": "bank_loan",
  "totalAmount": 350000.0,
  "interestRate": 3.5,
  "terms": "30-year fixed, monthly payments",
  "notes": "Primary financing source",
  "status": "active"
}
Field Type Required Validation
name string Yes 1-200 characters
sourceType string Yes One of: bank_loan, credit_line, savings, other (note: discretionary is not allowed)
totalAmount number Yes Must be > 0
interestRate number | null No 0-100 if provided
terms string | null No Free text
notes string | null No Free text
reference string | null No 0-200 characters; reference identifier (account number, contract ID) for Bank Report Wizard
contactAddress string | null No 0-500 characters; contact address (e.g., bank address) for Bank Report Wizard
status string No One of: active, exhausted, closed. Default: active

The createdBy field is automatically set to the authenticated user's ID. The discretionary source type is reserved for the system-seeded Discretionary Funding source and cannot be used in create requests.

Response (201 Created):

{
  "budgetSource": {
    "id": "bs-1",
    "name": "ABC Bank Mortgage",
    "sourceType": "bank_loan",
    "totalAmount": 350000.0,
    "usedAmount": 0,
    "availableAmount": 350000.0,
    "claimedAmount": 0,
    "actualAvailableAmount": 350000.0,
    "paidAmount": 0,
    "projectedAmount": 0,
    "isDiscretionary": false,
    "interestRate": 3.5,
    "terms": "30-year fixed, monthly payments",
    "notes": "Primary financing source",
    "status": "active",
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "createdAt": "2026-02-20T14:00:00.000Z",
    "updatedAt": "2026-02-20T14:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty name, name too long, missing/invalid sourceType, totalAmount <= 0, interestRate out of range, invalid status
401 UNAUTHORIZED No valid session

GET /api/budget-sources/:id

Returns a single budget source with computed allocation and drawdown fields.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Budget source UUID

Response (200 OK):

{
  "budgetSource": {
    "id": "bs-1",
    "name": "ABC Bank Mortgage",
    "sourceType": "bank_loan",
    "totalAmount": 350000.0,
    "usedAmount": 125000.0,
    "availableAmount": 225000.0,
    "claimedAmount": 45000.0,
    "actualAvailableAmount": 305000.0,
    "paidAmount": 30000.0,
    "projectedAmount": 140000.0,
    "isDiscretionary": false,
    "interestRate": 3.5,
    "terms": "30-year fixed, monthly payments",
    "notes": "Primary financing source",
    "status": "active",
    "createdBy": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "displayName": "Admin User",
      "email": "[email protected]"
    },
    "createdAt": "2026-02-20T10:00:00.000Z",
    "updatedAt": "2026-02-20T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Budget source ID does not exist

PATCH /api/budget-sources/:id

Updates a budget source's fields. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Budget source UUID

Request body (all fields optional, at least one required):

{
  "name": "ABC Bank Mortgage (Updated)",
  "sourceType": "bank_loan",
  "totalAmount": 400000.0,
  "interestRate": 3.25,
  "terms": "Updated terms",
  "notes": "Updated notes",
  "status": "active"
}
Field Type Validation
name string 1-200 characters
sourceType string One of: bank_loan, credit_line, savings, other (note: discretionary is not allowed)
totalAmount number Must be > 0
interestRate number | null 0-100 if provided; null clears the field
terms string | null null clears the field
notes string | null null clears the field
reference string | null 0-200 characters; null clears the field
contactAddress string | null 0-500 characters; null clears the field
status string One of: active, exhausted, closed

Response (200 OK):

Returns the updated BudgetSourceResponse object wrapped in { "budgetSource": ... }. When the call changed status, the body also carries a top-level undo sibling of budgetSource (contract 11); Undo restores status and updatedAt only.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR No fields provided, invalid field values
401 UNAUTHORIZED No valid session
404 NOT_FOUND Budget source ID does not exist

DELETE /api/budget-sources/:id

Deletes a budget source. Fails with 409 Conflict if the source is referenced by budget lines (via work_item_budgets.budget_source_id or household_item_budgets.budget_source_id), or if the source is the system-managed discretionary source.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Budget source UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Budget source ID does not exist
409 BUDGET_SOURCE_IN_USE Budget source is referenced by budget lines and cannot be deleted
409 DISCRETIONARY_SOURCE The discretionary funding source cannot be deleted

Notes:

  • The DISCRETIONARY_SOURCE error is returned when attempting to delete the system-managed Discretionary Funding source:
    {
      "error": {
        "code": "DISCRETIONARY_SOURCE",
        "message": "The discretionary funding source cannot be deleted"
      }
    }
  • BUDGET_SOURCE_IN_USE: details is not sent: the error is raised with suppressDetails (#315 hardening), so its counts reach only the server log and the response carries code and message alone. Server-side counts (log only): budgetLineCount.
  • Budget sources with no budget line references can be freely deleted.

GET /api/budget-sources/:sourceId/budget-lines

Retrieves all budget lines allocated to a specific budget source, grouped by parent entity type (work item vs household item).

Auth required: Yes

Path parameters:

Parameter Type Description
sourceId string Budget source UUID

Response (200 OK):

interface BudgetSourceBudgetLine extends BaseBudgetLine {
  parentId: string;
  parentName: string;
  area: AreaSummary | null;
  hasClaimedInvoice: boolean;
}

interface BudgetSourceBudgetLinesResponse {
  workItemLines: BudgetSourceBudgetLine[];
  householdItemLines: BudgetSourceBudgetLine[];
}

Response shape (JSON):

{
  "workItemLines": [
    {
      "id": "budget-line-uuid",
      "description": "Optional description",
      "plannedAmount": 1500.0,
      "confidence": "quote",
      "confidenceMargin": 0.05,
      "budgetCategory": {
        "id": "cat-uuid",
        "name": "Materials",
        "description": "Building materials",
        "color": "#FF5733",
        "translationKey": "category.materials",
        "sortOrder": 1,
        "createdAt": "2026-02-07T12:00:00.000Z",
        "updatedAt": "2026-02-07T12:00:00.000Z"
      },
      "budgetSource": {
        "id": "source-uuid",
        "name": "Bank Loan",
        "sourceType": "bank_loan"
      },
      "vendor": {
        "id": "vendor-uuid",
        "name": "Home Depot",
        "trade": null
      },
      "actualCost": 500.0,
      "actualCostPaid": 250.0,
      "invoiceCount": 2,
      "invoiceLink": {
        "invoiceBudgetLineId": "ibl-uuid",
        "invoiceId": "invoice-uuid",
        "invoiceNumber": "INV-001",
        "invoiceDate": "2026-02-05T00:00:00.000Z",
        "invoiceStatus": "claimed",
        "itemizedAmount": 250.0,
        "vendorId": "vendor-uuid",
        "vendorName": "Home Depot"
      },
      "quantity": 10,
      "unit": "pieces",
      "unitPrice": 150.0,
      "includesVat": true,
      "createdBy": {
        "id": "user-uuid",
        "displayName": "John Doe",
        "email": "[email protected]"
      },
      "createdAt": "2026-02-07T12:00:00.000Z",
      "updatedAt": "2026-02-07T12:00:00.000Z",
      "parentId": "work-item-uuid",
      "parentName": "Foundation Work",
      "area": {
        "id": "area-uuid",
        "name": "Foundation",
        "color": "#FF5733"
      },
      "hasClaimedInvoice": true
    }
  ],
  "householdItemLines": []
}

Field notes:

  • BaseBudgetLine extends with four additional fields:
    • parentId: The work item or household item ID that owns this budget line
    • parentName: The title (work item) or name (household item) of the parent entity
    • area: The area that the parent entity is assigned to (null if parent has no area)
    • hasClaimedInvoice: Boolean — true if any linked invoice has status = 'claimed' OR any deposit on a linked invoice has status = 'claimed'. This is a status-existence check (not an amount threshold): a pending invoice with a claimed deposit returns true; a claimed invoice whose residual is fully absorbed by non-claimed deposits also returns true. Fixed in #1897 to be deposit-aware.
  • actualCost: Sum of itemized amounts from all linked invoices including quotations (any status). For quotation-linked lines, treat as a point estimate and apply ±5% (CONFIDENCE_MARGINS.quote) when surfacing a min/max range. See "Quotation handling for actualCost".
  • actualCostPaid: Deposit-aware proportional sum for 'paid'/'claimed' statuses. Each linked invoice's itemized amount is split proportionally across its deposits; each portion is counted if its contributing status is 'paid' or 'claimed'. When an invoice has no deposits the full itemized amount counts if the invoice's own status is 'paid' or 'claimed'. Quotations are always excluded. Fixed in #1897 to be deposit-aware (was previously whole-invoice-by-parent-status).
  • invoiceLink: The first linked invoice summary (null if no invoices linked); reflects the primary invoice for the budget line. Includes denormalized vendorId / vendorName so callers do not need a separate vendor fetch.
  • The area field in the response is null if the parent work item/household item has no area assignment
  • All invoice and related data is computed at query time (not stored)

Sorting:

Lines within each group (workItemLines, householdItemLines) are sorted by:

  1. Area name (ascending, nulls last)
  2. Parent item name (ascending)
  3. Created at (ascending)

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Budget source ID does not exist

PATCH /api/budget-sources/:sourceId/budget-lines/move

Move budget lines from the current budget source to a target budget source.

Auth required: Yes

Path parameters:

Parameter Type Description
sourceId string Source budget UUID

Request body:

interface MoveBudgetLinesRequest {
  workItemBudgetIds: string[];
  householdItemBudgetIds: string[];
  targetSourceId: string;
}
{
  "workItemBudgetIds": ["budget-line-uuid-1", "budget-line-uuid-2"],
  "householdItemBudgetIds": ["household-budget-line-uuid"],
  "targetSourceId": "target-source-uuid"
}

Response (200 OK):

interface MoveBudgetLinesResponse {
  movedWorkItemLines: number;
  movedHouseholdItemLines: number;
}
{
  "movedWorkItemLines": 2,
  "movedHouseholdItemLines": 1
}

Validation order:

The endpoint validates in this specific order:

  1. Source budget source exists (404 if missing)
  2. Target source ID is different from source ID (400 SAME_SOURCE if equal)
  3. Target budget source exists (404 if missing)
  4. At least one budget line ID is provided (400 EMPTY_SELECTION if both arrays empty)
  5. All budget line IDs belong to the source (409 STALE_OWNERSHIP if any missing or belong to different source)

Error responses:

HTTP Status Error Code When
400 SAME_SOURCE targetSourceId equals sourceId
400 EMPTY_SELECTION Both workItemBudgetIds and householdItemBudgetIds are empty arrays
401 UNAUTHORIZED No valid session
404 NOT_FOUND Source or target budget source does not exist
409 STALE_OWNERSHIP Any budget line ID is missing or belongs to a different source

Behavior:

  • The operation is atomic: either all budget lines are moved or none are (wrapped in a transaction).
  • Budget lines retain all their properties (planned amount, category, vendor, etc.) and only their budget_source_id and updated_at are changed.
  • Both empty arrays are allowed only if at least one is non-empty; providing two empty arrays triggers EMPTY_SELECTION.

Subsidy Program Response Shape

A subsidy program (UI: "grant") as returned by every endpoint below (SubsidyProgram, shared/src/types/subsidyProgram.ts):

{
  "id": "sp-1",
  "name": "Efficient heating grant",
  "description": "Grant for replacing an old boiler",
  "eligibility": "Owner-occupied homes",
  "reductionType": "percentage",
  "reductionValue": 20,
  "applicationStatus": "applied",
  "applicationDeadline": "2026-12-31",
  "notes": null,
  "maximumAmount": 5000,
  "applicableCategories": [{ "id": "bc-1", "name": "Heating", "...": "Budget Category shape" }],
  "includesNoCategoryItems": false,
  "createdBy": { "id": "u-1", "displayName": "Example User", "email": "[email protected]" },
  "createdAt": "2026-03-01T10:00:00.000Z",
  "updatedAt": "2026-03-02T09:30:00.000Z"
}
Field Type Description
reductionType string percentage or fixed
reductionValue number > 0; at most 100 when reductionType is percentage
applicationStatus string eligible, applied, approved, received, rejected
applicationDeadline string | null Stored as sent (not validated as a date)
maximumAmount number | null Cap on the grant amount, > 0, or null for no cap
applicableCategories BudgetCategory[] The budget categories the grant applies to (Budget Category Response Shape), ordered by category sortOrder, then name
includesNoCategoryItems boolean Whether cost lines without a budget category also qualify
createdBy UserSummary | null null when the creating user was deleted

Subsidy Program Endpoints

All endpoints require a valid session (member or admin).

GET /api/subsidy-programs

Lists all subsidy programs, sorted by name ascending. Not paginated.

Response (200 OK): { "subsidyPrograms": SubsidyProgram[] }

Error responses: 401 UNAUTHORIZED.


POST /api/subsidy-programs

Creates a subsidy program. Request fields are those of PATCH below; name, reductionType and reductionValue are required, applicationStatus defaults to eligible, includesNoCategoryItems to false, and the other fields to null / no categories. createdBy is the requesting user.

Response (201 Created): { "subsidyProgram": SubsidyProgram }

Error responses: 400 VALIDATION_ERROR (as for PATCH, plus a missing required field); 401 UNAUTHORIZED.


GET /api/subsidy-programs/:id

Response (200 OK): { "subsidyProgram": SubsidyProgram }

Error responses: 401 UNAUTHORIZED; 404 NOT_FOUND (no program with that id).


PATCH /api/subsidy-programs/:id

Updates a subsidy program. All fields are optional; only provided fields change, and updatedAt is set on every successful call.

Path parameters:

Parameter Type Description
id string Subsidy program UUID

Request body (at least one field):

{
  "applicationStatus": "approved",
  "maximumAmount": 4000,
  "categoryIds": ["bc-1", "bc-2"]
}
Field Type Validation
name string 1–200 characters after trimming; stored trimmed
reductionType string percentage or fixed
reductionValue number > 0. The percentage cap is checked on the effective pair: when the resulting reductionType (the request's, else the stored one) is percentage, the resulting reductionValue (the request's, else the stored one) must be at most 100. So a request that only switches reductionType to percentage is rejected while the stored value is over 100
description string | null No length limit; null clears
eligibility string | null No length limit; null clears
applicationStatus string eligible, applied, approved, received, rejected. Any status may follow any other (no transition rules)
applicationDeadline string | null Not validated as a date; null clears
notes string | null No length limit; null clears
maximumAmount number | null > 0, or null for no cap
categoryIds string[] Replaces all applicable categories ([] removes them all). Every id must be an existing budget category
includesNoCategoryItems boolean

Unknown fields are stripped by AJV (removeAdditional), not rejected; a body left empty after stripping returns 400 VALIDATION_ERROR.

Side effects:

  • When applicationStatus changes, a subsidy_status system event is written (ADR-020; de-duplicated per ADR-040 W1 and W3). Sending the current status again writes nothing.
  • When applicationStatus changes, the body carries an Undo token (contract 11); Undo restores applicationStatus and updatedAt only.

Response (200 OK): { "subsidyProgram": SubsidyProgram }, plus a top-level undo sibling when a token was issued:

{
  "subsidyProgram": { "id": "sp-1", "applicationStatus": "approved", "...": "SubsidyProgram" },
  "undo": { "token": "u_6f1c0d9e2a7b4c5d8e9f0a1b2c3d4e5f", "expiresAt": "2026-10-06T09:00:30.000Z" }
}

undo is absent (never null) when applicationStatus did not change.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR No fields; a field fails its rule above (bad enum, reductionValue ≤ 0 or a percentage over 100, maximumAmount ≤ 0, name blank or over 200 characters, unknown or duplicate categoryIds)
401 UNAUTHORIZED No valid session
404 NOT_FOUND No program with that id

DELETE /api/subsidy-programs/:id

Deletes a subsidy program, its category links, its purchase links (household_item_subsidies) and its document links. GET /api/delete-impact/subsidy_program/:id (contract 19) returns these counts in advance.

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND No program with that id
409 SUBSIDY_PROGRAM_IN_USE Tasks are linked to the program (work_item_subsidies); details not sent (#315, workItemCount is logged only)

Budget Overview Response Shape

The budget overview provides aggregated project-level budget data for the dashboard. All fields are computed (not stored in the database).

type ConfidenceLevel = 'own_estimate' | 'professional_estimate' | 'quote' | 'invoice';

// Margin factors: own_estimate=0.20, professional_estimate=0.10, quote=0.05, invoice=0.00
const CONFIDENCE_MARGINS: Record<ConfidenceLevel, number>;

interface BudgetOverview {
  availableFunds: number; // SUM(total_amount) of active budget sources
  sourceCount: number; // Count of active budget sources

  minPlanned: number; // Total min with confidence margins and subsidy reductions
  maxPlanned: number; // Total max with confidence margins and subsidy reductions

  projectedMin: number; // Blended: invoiced lines use actualCost (quotation lines use actualCost × 0.95); non-invoiced use minPlanned
  projectedMax: number; // Blended: invoiced lines use actualCost (quotation lines use actualCost × 1.05); non-invoiced use maxPlanned

  actualCost: number; // Sum of itemized amounts from all linked invoices (any status, INCLUDING quotations)
  actualCostPaid: number; // Paid/claimed invoices only (excludes quotations)
  actualCostClaimed: number; // Claimed invoices only (subset of actualCostPaid)

  remainingVsMinPlanned: number; // availableFunds - minPlanned
  remainingVsMaxPlanned: number; // availableFunds - maxPlanned
  remainingVsProjectedMin: number; // availableFunds - projectedMin
  remainingVsProjectedMax: number; // availableFunds - projectedMax
  remainingVsActualCost: number; // availableFunds - actualCost
  remainingVsActualPaid: number; // availableFunds - actualCostPaid
  remainingVsActualClaimed: number; // availableFunds - actualCostClaimed

  remainingVsMinPlannedWithPayback: number; // availableFunds + minTotalPayback - minPlanned
  remainingVsMaxPlannedWithPayback: number; // availableFunds + maxTotalPayback - maxPlanned

  areaSummaries: AreaBudgetSummary[];

  unassignedSummary: { planned: number; actual: number; variance: number } | null;

  subsidySummary: {
    totalReductions: number; // Total subsidy reductions across all budget lines
    activeSubsidyCount: number; // Count of non-rejected subsidy programs
    minTotalPayback: number; // Sum of min expected payback across all work items with linked subsidies
    maxTotalPayback: number; // Sum of max expected payback across all work items with linked subsidies
  };
}

Notes on AreaBudgetSummary:

interface AreaBudgetSummary {
  areaId: string;
  name: string;
  parentId: string | null;
  planned: number; // Raw sum of work_item_budgets.planned_amount for all work items in this area's subtree (no confidence-margin transformation)
  actual: number; // Sum of non-quotation invoice amounts linked to those work items' budget lines
  variance: number; // planned - actual
}

Notes on projected (blended) fields:

  • The projectedMin and projectedMax fields provide a blended cost view that combines actuals and estimates. For each budget line:
    • If the line has any linked invoices, the invoice total is used (actual cost is known).
    • If the line has no invoices, the planned range (minPlanned/maxPlanned with confidence margins and subsidy reductions) is used.
  • Planned amounts of net lines (includesVat: false) are grossed up by 1 + VAT_RATE (server config vatRate, default 0.19, exposed by GET /api/config) before margins and subsidies apply. Gross lines are used as stored. GET /api/budget/breakdown and the budget-source projectedAmount use the same rule.
  • This gives a more accurate picture as the project progresses and estimates are replaced by actual invoices.

Notes on subsidy reductions:

  • A subsidy applies to a budget line only if: (a) the subsidy is linked to the budget line's work item, (b) the subsidy's status is not rejected, and (c) the subsidy's applicable categories include the budget line's category (or the subsidy has no category restrictions, making it universal).
  • Percentage subsidies reduce each matching line's planned amount by the percentage.
  • Fixed subsidies are divided equally across all matching budget lines for that (work item, subsidy) pair.

Notes on payback-adjusted remaining perspectives:

  • remainingVsMinPlannedWithPayback and remainingVsMaxPlannedWithPayback account for expected subsidy payback amounts. Formula: availableFunds + totalPayback - totalPlanned. This answers the question "how much remains if we receive the expected subsidy reimbursements?"
  • subsidySummary.minTotalPayback and subsidySummary.maxTotalPayback aggregate payback calculations across all work items that have linked, non-rejected subsidies. The min/max range reflects confidence margins on non-invoiced budget lines (same rules as the per-work-item GET /api/work-items/:workItemId/subsidy-payback endpoint, including the gross-up of net lines by 1 + VAT_RATE before the subsidy percentage applies).

Budget Overview Endpoint

GET /api/budget/overview

Returns aggregated project-level budget data for the dashboard. This endpoint computes all values on-the-fly from the underlying tables (budget sources, budget lines, invoices, subsidy programs).

Auth required: Yes

Response (200 OK):

{
  "overview": {
    "availableFunds": 500000.0,
    "sourceCount": 2,
    "minPlanned": 280000.0,
    "maxPlanned": 370000.0,
    "actualCost": 85000.0,
    "actualCostPaid": 70000.0,
    "actualCostClaimed": 25000.0,
    "remainingVsMinPlanned": 220000.0,
    "remainingVsMaxPlanned": 130000.0,
    "remainingVsActualCost": 415000.0,
    "remainingVsActualPaid": 430000.0,
    "remainingVsActualClaimed": 475000.0,
    "remainingVsMinPlannedWithPayback": 235000.0,
    "remainingVsMaxPlannedWithPayback": 147500.0,
    "subsidySummary": {
      "totalReductions": 15000.0,
      "activeSubsidyCount": 2,
      "minTotalPayback": 15000.0,
      "maxTotalPayback": 17500.0
    }
  }
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

Notes:

  • Returns zero-valued fields when there is no budget data (no sources, no budget lines, no invoices). The response shape is always the same regardless of whether data exists.
  • The overview endpoint has no query parameters -- it always returns the full project-level summary.

Budget Breakdown Response Shape

The budget breakdown provides an itemized cost breakdown grouped by hierarchical areas, with individual budget lines per item. Used by the expandable cost breakdown table on the budget dashboard.

type CostDisplay = 'actual' | 'projected' | 'mixed';
// - 'actual': all budget lines have invoices (display actualCost only)
// - 'projected': no budget lines have invoices (display projectedMin–projectedMax range)
// - 'mixed': some lines invoiced, some not (display both actual and projected)

interface BreakdownBudgetLine {
  id: string;
  description: string | null;
  plannedAmount: number;
  confidence: ConfidenceLevel; // 'own_estimate' | 'professional_estimate' | 'quote' | 'invoice'
  actualCost: number;
  hasInvoice: boolean;
  isQuotation: boolean;
  budgetSourceId: string | null;
  origin: 'manual' | 'auto'; // how this budget line was created
}

interface BreakdownWorkItem {
  workItemId: string;
  title: string;
  projectedMin: number;
  projectedMax: number;
  rawProjectedMin: number; // gross projected min (no subsidy deduction)
  rawProjectedMax: number; // gross projected max (no subsidy deduction)
  actualCost: number;
  subsidyPayback: number;
  minSubsidyPayback: number; // min expected payback (using confidence margin lows)
  costDisplay: CostDisplay;
  budgetLines: BreakdownBudgetLine[];
}

interface BreakdownArea<TItem> {
  areaId: string | null; // null = synthetic "Unassigned" bucket
  name: string;
  parentId: string | null; // null = root node
  color: string | null;
  projectedMin: number; // rolled-up across subtree
  projectedMax: number;
  actualCost: number;
  subsidyPayback: number;
  rawProjectedMin: number;
  rawProjectedMax: number;
  minSubsidyPayback: number;
  items: TItem[]; // items directly assigned to this area (not descendants)
  children: BreakdownArea<TItem>[]; // pre-pruned child nodes
}

interface BreakdownHouseholdItem {
  householdItemId: string;
  name: string;
  projectedMin: number;
  projectedMax: number;
  rawProjectedMin: number;
  rawProjectedMax: number;
  actualCost: number;
  subsidyPayback: number;
  minSubsidyPayback: number;
  costDisplay: CostDisplay;
  budgetLines: BreakdownBudgetLine[];
}

interface BreakdownTotals {
  projectedMin: number;
  projectedMax: number;
  rawProjectedMin: number;
  rawProjectedMax: number;
  actualCost: number;
  subsidyPayback: number;
  minSubsidyPayback: number;
}

interface BudgetBreakdown {
  workItems: {
    areas: BreakdownArea<BreakdownWorkItem>[];
    totals: BreakdownTotals;
  };
  householdItems: {
    areas: BreakdownArea<BreakdownHouseholdItem>[];
    totals: BreakdownTotals;
  };
}

Notes on costDisplay:

  • Determined per item based on its budget lines. If all lines have invoices, the cost is fully 'actual'. If none have invoices, it is 'projected'. If a mix exists, it is 'mixed'.
  • The frontend uses this field to decide which cost columns to render in the breakdown table.

Notes on subsidyPayback:

  • Reflects the expected subsidy reimbursement for each item/category/total.
  • Uses the same blended calculation as the budget overview: invoiced lines use actual cost, non-invoiced lines use confidence-adjusted planned amounts, with net lines grossed up by 1 + VAT_RATE.

Budget Breakdown Endpoint

GET /api/budget/breakdown

Returns an itemized cost breakdown of all work items and household items, grouped by hierarchical areas. Each item includes its individual budget lines with planned amounts, confidence levels, and actual costs.

Auth required: Yes

Response (200 OK):

{
  "breakdown": {
    "workItems": {
      "areas": [
        {
          "areaId": null,
          "name": "Unassigned",
          "parentId": null,
          "color": null,
          "projectedMin": 5000.0,
          "projectedMax": 7000.0,
          "actualCost": 0,
          "subsidyPayback": 0,
          "rawProjectedMin": 5500.0,
          "rawProjectedMax": 6500.0,
          "minSubsidyPayback": 0,
          "items": [],
          "children": []
        },
        {
          "areaId": "area-foundation",
          "name": "Foundation",
          "parentId": null,
          "color": "#3B82F6",
          "projectedMin": 110000.0,
          "projectedMax": 145000.0,
          "actualCost": 45000.0,
          "subsidyPayback": 5000.0,
          "rawProjectedMin": 115000.0,
          "rawProjectedMax": 150000.0,
          "minSubsidyPayback": 4800.0,
          "items": [
            {
              "workItemId": "wi-foundation",
              "title": "Foundation Work",
              "projectedMin": 80000.0,
              "projectedMax": 105000.0,
              "actualCost": 45000.0,
              "subsidyPayback": 5000.0,
              "rawProjectedMin": 85000.0,
              "rawProjectedMax": 110000.0,
              "minSubsidyPayback": 4800.0,
              "costDisplay": "mixed",
              "budgetLines": [
                {
                  "id": "bl-1",
                  "description": "Concrete supply",
                  "plannedAmount": 50000.0,
                  "confidence": "invoice",
                  "actualCost": 45000.0,
                  "hasInvoice": true,
                  "isQuotation": false,
                  "budgetSourceId": "src-1"
                },
                {
                  "id": "bl-2",
                  "description": "Rebar and forms",
                  "plannedAmount": 30000.0,
                  "confidence": "professional_estimate",
                  "actualCost": 0,
                  "hasInvoice": false,
                  "isQuotation": false,
                  "budgetSourceId": null
                }
              ]
            }
          ],
          "children": []
        }
      ],
      "totals": {
        "projectedMin": 115000.0,
        "projectedMax": 152000.0,
        "actualCost": 45000.0,
        "subsidyPayback": 5000.0,
        "rawProjectedMin": 120500.0,
        "rawProjectedMax": 156500.0,
        "minSubsidyPayback": 4800.0
      }
    },
    "householdItems": {
      "areas": [
        {
          "areaId": "area-kitchen",
          "name": "Kitchen",
          "parentId": null,
          "color": "#10B981",
          "projectedMin": 8000.0,
          "projectedMax": 9600.0,
          "actualCost": 3500.0,
          "subsidyPayback": 0,
          "rawProjectedMin": 8400.0,
          "rawProjectedMax": 10080.0,
          "minSubsidyPayback": 0,
          "items": [
            {
              "householdItemId": "hi-dishwasher",
              "name": "Dishwasher",
              "projectedMin": 3500.0,
              "projectedMax": 3500.0,
              "actualCost": 3500.0,
              "subsidyPayback": 0,
              "rawProjectedMin": 3500.0,
              "rawProjectedMax": 3500.0,
              "minSubsidyPayback": 0,
              "costDisplay": "actual",
              "budgetLines": [
                {
                  "id": "hbl-1",
                  "description": "Bosch dishwasher",
                  "plannedAmount": 3200.0,
                  "confidence": "invoice",
                  "actualCost": 3500.0,
                  "hasInvoice": true,
                  "isQuotation": false,
                  "budgetSourceId": "src-2"
                }
              ]
            }
          ],
          "children": []
        }
      ],
      "totals": {
        "projectedMin": 8000.0,
        "projectedMax": 9600.0,
        "actualCost": 3500.0,
        "subsidyPayback": 0,
        "rawProjectedMin": 8400.0,
        "rawProjectedMax": 10080.0,
        "minSubsidyPayback": 0
      }
    },
    "budgetSources": [
      {
        "id": "src-1",
        "name": "Primary Bank Loan",
        "totalAmount": 800000.0,
        "projectedMin": 220000.0,
        "projectedMax": 340000.0
      },
      {
        "id": "src-2",
        "name": "Home Improvement Grant",
        "totalAmount": 50000.0,
        "projectedMin": 3500.0,
        "projectedMax": 3500.0
      }
    ]
  }
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

Notes:

  • Work items and household items are grouped in hierarchical areas. Root areas (where parentId === null) appear at the top level, with their children nested in the children array.
  • Each area node contains rolled-up totals across its entire subtree (all descendants' items).
  • Items in each area are directly assigned to that area only (not descendants).
  • A synthetic "Unassigned" area node (areaId: null) appears first when one or more items have no area assignment.
  • Empty-subtree areas (containing no items in the subtree) are pruned from the response.
  • Areas are sorted by sortOrder ASC then name ASC at each level.
  • Returns empty area arrays and zero totals when no budget data exists.
  • The breakdown endpoint has no query parameters -- it always returns the full itemized breakdown (client-side filtering only).
  • Each BreakdownBudgetLine includes a budgetSourceId (string or null) indicating its assigned budget source, an origin field ('manual' | 'auto') indicating how the line was created, and an isQuotation boolean flag. Lines with budgetSourceId === null are not assigned to any source.
  • The budgetSources array contains all budget sources that have at least one budget line assigned to them. Aggregate figures (projectedMin and projectedMax) represent the perspective-dependent cost range across all lines assigned to that source. The frontend computes allocatedCost at the active perspective and derives remaining = totalAmount - allocatedCost.

EPIC-06: Timeline, Gantt Chart & Dependency Management

Timeline and scheduling endpoints for milestones, auto-scheduling, and aggregated timeline data. All endpoints in this section require authentication. Both admin and member roles can perform all timeline operations (no role restriction).

Milestone Response Shapes

Milestone summary (used in list responses):

interface MilestoneSummary {
  id: number;
  title: string;
  description: string | null;
  targetDate: string; // ISO 8601 date
  isCompleted: boolean;
  completedAt: string | null; // ISO 8601 timestamp
  color: string | null;
  workItemCount: number; // Computed: count of linked work items
  createdBy: UserSummary | null;
  createdAt: string; // ISO 8601 timestamp
  updatedAt: string; // ISO 8601 timestamp
}

Milestone detail (used in single-item responses):

interface MilestoneDetail {
  id: number;
  title: string;
  description: string | null;
  targetDate: string; // ISO 8601 date
  isCompleted: boolean;
  completedAt: string | null; // ISO 8601 timestamp
  color: string | null;
  workItems: WorkItemSummary[]; // Linked work items (full summary)
  dependentWorkItems: WorkItemDependentSummary[]; // Work items that require this milestone to complete first
  createdBy: UserSummary | null;
  createdAt: string; // ISO 8601 timestamp
  updatedAt: string; // ISO 8601 timestamp
}

Dependent work item summary (used in MilestoneDetail.dependentWorkItems and the dependents endpoints):

interface WorkItemDependentSummary {
  id: string;
  title: string;
}

Milestone Endpoints

Milestones represent major project progress points. The milestone list is expected to remain small (fewer than ~50 milestones) so no pagination is used.

GET /api/milestones

Returns all milestones sorted by target_date ascending, with linked work item count.

Auth required: Yes

Response (200 OK):

{
  "milestones": [
    {
      "id": 1,
      "title": "Foundation Complete",
      "description": "All foundation work finished and inspected",
      "targetDate": "2026-04-15",
      "isCompleted": false,
      "completedAt": null,
      "color": "#EF4444",
      "workItemCount": 3,
      "createdBy": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-02-20T10:00:00.000Z",
      "updatedAt": "2026-02-20T10:00:00.000Z"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

POST /api/milestones

Creates a new milestone. The createdBy is automatically set to the authenticated user.

Auth required: Yes

Request body:

{
  "title": "Foundation Complete",
  "description": "All foundation work finished and inspected",
  "targetDate": "2026-04-15",
  "color": "#EF4444"
}
Field Type Required Validation
title string Yes 1-200 characters
description string | null No Max 2000 characters
targetDate string Yes ISO 8601 date (YYYY-MM-DD)
color string | null No Hex color code matching /^#[0-9A-Fa-f]{6}$/, or null

Response (201 Created):

{
  "id": 1,
  "title": "Foundation Complete",
  "description": "All foundation work finished and inspected",
  "targetDate": "2026-04-15",
  "isCompleted": false,
  "completedAt": null,
  "color": "#EF4444",
  "workItems": [],
  "createdBy": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "displayName": "Admin User",
    "email": "[email protected]"
  },
  "createdAt": "2026-02-20T10:00:00.000Z",
  "updatedAt": "2026-02-20T10:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Empty title, title too long, missing targetDate, invalid date format, invalid color format
401 UNAUTHORIZED No valid session

GET /api/milestones/:id

Returns a single milestone with its linked work items (full summary).

Auth required: Yes

Path parameters:

Parameter Type Description
id integer Milestone integer ID

Response (200 OK):

{
  "id": 1,
  "title": "Foundation Complete",
  "description": "All foundation work finished and inspected",
  "targetDate": "2026-04-15",
  "isCompleted": false,
  "completedAt": null,
  "color": "#EF4444",
  "workItems": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "title": "Pour Foundation",
      "status": "in_progress",
      "startDate": "2026-03-15",
      "endDate": "2026-03-25",
      "actualStartDate": "2026-03-16",
      "actualEndDate": null,
      "durationDays": 10,
      "assignedUser": null,
      "assignedVendor": null,
      "area": null,
      "createdAt": "2026-02-15T10:00:00.000Z",
      "updatedAt": "2026-02-15T10:00:00.000Z"
    }
  ],
  "createdBy": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "displayName": "Admin User",
    "email": "[email protected]"
  },
  "createdAt": "2026-02-20T10:00:00.000Z",
  "updatedAt": "2026-02-20T10:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Milestone ID does not exist

PATCH /api/milestones/:id

Updates a milestone. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.

Auth required: Yes

Path parameters:

Parameter Type Description
id integer Milestone integer ID

Request body (all fields optional, at least one required):

{
  "title": "Foundation Complete & Inspected",
  "description": "Updated description",
  "targetDate": "2026-04-20",
  "isCompleted": true,
  "color": "#22C55E"
}
Field Type Validation
title string 1-200 characters
description string | null Max 2000 characters; null clears the description
targetDate string ISO 8601 date (YYYY-MM-DD)
isCompleted boolean true or false; setting to true auto-sets completedAt
color string | null Hex color code or null to clear

Notes:

  • When isCompleted is set to true, the completedAt field is automatically set to the current timestamp.
  • When isCompleted is set to false, the completedAt field is automatically cleared to null.

Response (200 OK):

Returns the updated MilestoneDetail object. When the call changed isCompleted or completedAt, the body also carries a top-level undo token (contract 11). Setting isCompleted to false clears completedAt; a completedAt later than today is accepted.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid fields or no fields provided
401 UNAUTHORIZED No valid session
404 NOT_FOUND Milestone ID does not exist

DELETE /api/milestones/:id

Deletes a milestone. Cascades to milestone-work-item associations (the work items themselves are not deleted).

Auth required: Yes

Path parameters:

Parameter Type Description
id integer Milestone integer ID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Milestone ID does not exist

Milestone-Work Item Link Endpoints

POST /api/milestones/:id/work-items

Links a work item to a milestone.

Auth required: Yes

Path parameters:

Parameter Type Description
id integer Milestone integer ID

Request body:

{
  "workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
Field Type Required Validation
workItemId string Yes Must be a valid work item UUID

Response (201 Created):

{
  "milestoneId": 1,
  "workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing workItemId
401 UNAUTHORIZED No valid session
404 NOT_FOUND Milestone or work item does not exist
409 DUPLICATE_DEPENDENCY Work item is already linked to (contributes to) this milestone
409 CIRCULAR_DEPENDENCY Work item already depends on this milestone, so it cannot also contribute to it

DELETE /api/milestones/:id/work-items/:workItemId

Unlinks a work item from a milestone.

Auth required: Yes

Path parameters:

Parameter Type Description
id integer Milestone integer ID
workItemId string Work item UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Milestone, work item, or the link between them does not exist

Milestone Dependent Endpoints

A milestone has two kinds of work-item relationship. A linked work item contributes to the milestone (milestone_work_items, managed by the Milestone-Work Item Link Endpoints above). A dependent work item requires the milestone to complete before it can start (work_item_milestone_deps). A work item cannot be both for the same milestone: that pair would be a cycle, and the second link is rejected with 409 CIRCULAR_DEPENDENCY. The same two relationships are reachable from the work-item side through the Work Item Milestone Endpoints below.

POST /api/milestones/:id/dependents/:workItemId

Makes a work item depend on this milestone. Equivalent to POST /api/work-items/:workItemId/milestones/required/:milestoneId, with a different response shape.

Auth required: Yes

Path parameters:

Parameter Type Description
id integer Milestone integer ID
workItemId string Work item UUID

Request body: None.

Response (201 Created): the milestone's full dependent list after the insert.

{
  "dependentWorkItems": [
    { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "Pour foundation" }
  ]
}

dependentWorkItems is WorkItemDependentSummary[] (see Milestone Response Shapes).

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR id is not an integer
401 UNAUTHORIZED No valid session
404 NOT_FOUND Milestone or work item does not exist
409 DUPLICATE_DEPENDENCY Work item already depends on this milestone
409 CIRCULAR_DEPENDENCY Work item already contributes to (is linked to) this milestone, so it cannot also depend on it

DELETE /api/milestones/:id/dependents/:workItemId

Removes a work item from the milestone's dependents.

Auth required: Yes

Path parameters: as for POST /api/milestones/:id/dependents/:workItemId.

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR id is not an integer
401 UNAUTHORIZED No valid session
404 NOT_FOUND Milestone, work item, or the dependency between them does not exist

GET /api/milestones/:id/dependent-household-items

Lists the household items that depend on this milestone (household-item dependencies with predecessorType: "milestone").

Auth required: Yes

Path parameters:

Parameter Type Description
id integer Milestone integer ID

Response (200 OK):

{
  "householdItems": [
    {
      "id": "hi-001",
      "name": "Kitchen Cabinets",
      "category": "hic-furniture",
      "status": "planned",
      "targetDeliveryDate": "2026-05-10",
      "earliestDeliveryDate": null,
      "latestDeliveryDate": null
    }
  ]
}

householdItems is WorkItemLinkedHouseholdItemSummary[] (see the work-item household-items response). The milestone's existence is not checked: an unknown id returns 200 with an empty list, not 404.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR id is not an integer
401 UNAUTHORIZED No valid session

Work Item Milestone Endpoints

Routes under /api/work-items/:workItemId/milestones manage the same two relationships as the milestone-side endpoints, from the work item's side. Required milestones are ones the work item depends on (work_item_milestone_deps). Linked milestones are ones it contributes to (milestone_work_items).

Response shape (WorkItemMilestones, returned by GET and both POST endpoints):

interface WorkItemMilestones {
  required: MilestoneSummaryForWorkItem[]; // Milestones this work item depends on
  linked: MilestoneSummaryForWorkItem[]; // Milestones this work item contributes to
}

interface MilestoneSummaryForWorkItem {
  id: number;
  name: string; // The milestone's `title`
  targetDate: string | null; // ISO 8601 date
}

GET /api/work-items/:workItemId/milestones

Returns the work item's required and linked milestones.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID

Response (200 OK):

{
  "required": [{ "id": 1, "name": "Permits approved", "targetDate": "2026-03-01" }],
  "linked": [{ "id": 2, "name": "Shell complete", "targetDate": "2026-06-30" }]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item does not exist

POST /api/work-items/:workItemId/milestones/required/:milestoneId

Makes the work item depend on the milestone.

Auth required: Yes

Path parameters:

Parameter Type Description
workItemId string Work item UUID
milestoneId integer Milestone integer ID

Request body: None.

Response (201 Created): the work item's full WorkItemMilestones after the insert.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR milestoneId is not an integer
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item or milestone does not exist
409 DUPLICATE_DEPENDENCY Work item already depends on this milestone
409 CIRCULAR_DEPENDENCY Work item already contributes to this milestone, so it cannot also depend on it

DELETE /api/work-items/:workItemId/milestones/required/:milestoneId

Removes the work item's dependency on the milestone.

Auth required: Yes

Path parameters: as for the POST above.

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR milestoneId is not an integer
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item, milestone, or the work item's dependency on it does not exist

POST /api/work-items/:workItemId/milestones/linked/:milestoneId

Links the work item to the milestone, so it contributes to the milestone's completion. Equivalent to POST /api/milestones/:id/work-items, with a different response shape.

Auth required: Yes

Path parameters: as for POST .../milestones/required/:milestoneId.

Request body: None.

Response (201 Created): the work item's full WorkItemMilestones after the insert.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR milestoneId is not an integer
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item or milestone does not exist
409 DUPLICATE_DEPENDENCY Work item is already linked to this milestone
409 CIRCULAR_DEPENDENCY Work item already depends on this milestone, so it cannot also contribute to it

DELETE /api/work-items/:workItemId/milestones/linked/:milestoneId

Unlinks the work item from the milestone.

Auth required: Yes

Path parameters: as for POST .../milestones/required/:milestoneId.

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR milestoneId is not an integer
401 UNAUTHORIZED No valid session
404 NOT_FOUND Work item, milestone, or the link between them does not exist

Scheduling Endpoints

POST /api/schedule

Runs the auto-scheduling engine using the Critical Path Method (CPM). See ADR-014 for algorithm details.

The endpoint is read-only — it returns the computed schedule without persisting any changes to the database. The client displays results for user review, then applies accepted changes via individual PATCH /api/work-items/:id calls. This supports what-if analysis and gives users full control over which schedule changes to accept.

Auth required: Yes

Request body:

{
  "mode": "full",
  "anchorWorkItemId": null
}
Field Type Required Validation
mode string Yes One of: full, cascade
anchorWorkItemId string | null No Work item UUID. Required when mode is cascade; ignored when mode is full.

Scheduling modes:

Mode Description
full Schedules all work items from scratch based on dependencies, constraints, and the current date
cascade Starting from the anchor work item, propagates date changes only to downstream successors

Response (200 OK):

{
  "scheduledItems": [
    {
      "workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "previousStartDate": "2026-03-01",
      "previousEndDate": "2026-03-15",
      "scheduledStartDate": "2026-03-05",
      "scheduledEndDate": "2026-03-19",
      "latestStartDate": "2026-03-05",
      "latestFinishDate": "2026-03-19",
      "totalFloat": 0,
      "isCritical": true
    },
    {
      "workItemId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
      "previousStartDate": null,
      "previousEndDate": null,
      "scheduledStartDate": "2026-03-20",
      "scheduledEndDate": "2026-03-30",
      "latestStartDate": "2026-03-25",
      "latestFinishDate": "2026-04-04",
      "totalFloat": 5,
      "isCritical": false
    }
  ],
  "criticalPath": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
  "warnings": [
    {
      "workItemId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
      "type": "start_before_violated",
      "message": "Scheduled start date (2026-04-10) exceeds start-before constraint (2026-04-01)"
    },
    {
      "workItemId": "d4e5f6a7-b8c9-0123-defa-234567890123",
      "type": "no_duration",
      "message": "Work item has no duration set; scheduled as zero-duration"
    }
  ]
}

Response type definitions:

interface ScheduleResponse {
  scheduledItems: ScheduledItem[];
  criticalPath: string[]; // Array of work item IDs on the critical path (zero float)
  warnings: ScheduleWarning[];
}

interface ScheduledItem {
  workItemId: string;
  previousStartDate: string | null; // Current start_date before scheduling
  previousEndDate: string | null; // Current end_date before scheduling
  scheduledStartDate: string; // Earliest start (ES) - ISO 8601 date
  scheduledEndDate: string; // Earliest finish (EF) - ISO 8601 date
  latestStartDate: string; // Latest start (LS) - ISO 8601 date
  latestFinishDate: string; // Latest finish (LF) - ISO 8601 date
  totalFloat: number; // Days of slack: LS - ES (0 = critical)
  isCritical: boolean; // true if on the critical path
  isLate: boolean; // true when dates were clamped to today (behind schedule)
}

type ScheduleWarningType = 'start_before_violated' | 'no_duration' | 'already_completed';

interface ScheduleWarning {
  workItemId: string;
  type: ScheduleWarningType;
  message: string;
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid mode, missing anchorWorkItemId for cascade mode
401 UNAUTHORIZED No valid session
404 NOT_FOUND Anchor work item does not exist (cascade mode)
409 CIRCULAR_DEPENDENCY The dependency graph contains a cycle. The details field includes cycle (array of work item IDs)

Notes:

  • The endpoint is read-only — no database changes are made. The client applies accepted changes via PATCH /api/work-items/:id.
  • Tasks with no dependencies and no constraints retain their current dates (they are not moved by the scheduling engine).
  • The scheduling engine only processes work items that have at least one dependency or a duration_days value. Orphan work items with no dates and no dependencies are excluded.
  • Items with status completed are included in the schedule calculation (their dates are fixed), but a warning of type already_completed is generated if their dates would change.
  • The criticalPath array contains work item IDs in topological order (from project start to project end).
  • The isLate flag indicates that the scheduling engine clamped an item's dates to today because they fell in the past. Rule 2: a not_started item whose computed start is before today has its start floored to today. Rule 3: an in_progress item whose computed end is before today has its end floored to today. When actual dates are set (Rule 1), they take precedence and isLate is false.
  • autoReschedule (server-side, after writes) persists the result of the same engine without Rules 2/3; isLate here is the preview with the floor. The floor is a read-time projection on the timeline and work-item reads (contract 4, #2199).

Timeline Endpoints

GET /api/timeline

Returns an aggregated timeline view combining work items (with dates, dependencies, and scheduling metadata), milestones, and critical path information. This endpoint is optimized for rendering the Gantt chart and calendar views.

Auth required: Yes

Query parameters:

Parameter Type Required Validation
from string No ISO 8601 date (YYYY-MM-DD). Filters to work items overlapping this start date.
to string No ISO 8601 date (YYYY-MM-DD). Filters to work items overlapping this end date. Must be >= from if both provided.
milestoneId number No Milestone ID. Filters to work items linked to this milestone (plus their inter-dependencies).

When from/to are provided, only work items whose date range overlaps [from, to] are returned (i.e., startDate <= to AND endDate >= from). Work items with only one date set are included if that date falls within the range. Dependencies are filtered to only those between returned work items. Milestones are filtered to those with at least one linked work item in the result set (or all milestones if no date filter is applied). The critical path is recomputed over the full dataset regardless of filters (since filtering could break the path).

When milestoneId is provided, only work items linked to that milestone are returned, along with dependencies between those work items. The specified milestone is always included in the milestones array. Cannot be combined with from/to.

Response (200 OK):

{
  "workItems": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "title": "Foundation Work",
      "status": "in_progress",
      "startDate": "2026-03-01",
      "endDate": "2026-03-15",
      "actualStartDate": "2026-03-02",
      "actualEndDate": null,
      "durationDays": 14,
      "startAfter": "2026-02-28",
      "startBefore": "2026-03-10",
      "assignedUser": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "area": { "id": "area-kitchen", "name": "Kitchen", "color": "#F59E0B", "ancestors": [] }
    }
  ],
  "dependencies": [
    {
      "predecessorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "successorId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
      "dependencyType": "finish_to_start",
      "leadLagDays": 0
    }
  ],
  "milestones": [
    {
      "id": 1,
      "title": "Foundation Complete",
      "targetDate": "2026-04-15",
      "isCompleted": false,
      "completedAt": null,
      "color": "#EF4444",
      "workItemIds": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]
    }
  ],
  "householdItems": [
    {
      "id": "hi-001",
      "name": "Kitchen Faucet",
      "category": "fixtures",
      "status": "ordered",
      "targetDeliveryDate": "2026-05-15",
      "isLate": false,
      "earliestDeliveryDate": "2026-03-16",
      "latestDeliveryDate": "2026-04-01",
      "actualDeliveryDate": null,
      "area": { "id": "area-kitchen", "name": "Kitchen", "color": "#F59E0B", "ancestors": [] },
      "dependencyIds": [
        { "predecessorType": "work_item", "predecessorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }
      ]
    }
  ],
  "criticalPath": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
  "dateRange": {
    "earliest": "2026-03-01",
    "latest": "2026-12-15"
  }
}

Response type definitions:

interface TimelineResponse {
  workItems: TimelineWorkItem[];
  dependencies: TimelineDependency[];
  milestones: TimelineMilestone[];
  householdItems: TimelineHouseholdItem[];
  criticalPath: string[]; // Work item IDs on the critical path
  dateRange: TimelineDateRange | null; // null when no work items have dates
}

interface TimelineWorkItem {
  id: string;
  title: string;
  status: WorkItemStatus;
  startDate: string | null; // PLANNED start (stored); null for an undated task
  endDate: string | null; // PLANNED end (stored)
  projectedStartDate: string | null; // Forecast start (after the today floor); equals startDate unless late/held up; start of the forecast for an undated task
  projectedEndDate: string | null; // Forecast end; equals endDate unless late/held up
  isLate: boolean; // The today floor moved this item itself (never for completed or undated)
  lateDays: number | null; // >= 1 exactly when isLate
  isHeldUp: boolean; // Not late itself, but moved by an unfinished late predecessor
  actualStartDate: string | null; // Actual start date for delay tracking
  actualEndDate: string | null; // Actual end date for delay tracking
  durationDays: number | null;
  startAfter: string | null; // Earliest start constraint (scheduling)
  startBefore: string | null; // Latest start constraint (scheduling)
  assignedUser: UserSummary | null;
  assignedVendor: VendorSummary | null;
  area: AreaSummary | null;
}

interface TimelineDependency {
  predecessorId: string;
  successorId: string;
  dependencyType: DependencyType;
  leadLagDays: number;
}

interface TimelineMilestone {
  id: number;
  title: string;
  targetDate: string;
  isCompleted: boolean;
  completedAt: string | null; // ISO 8601 timestamp when completed
  color: string | null;
  workItemIds: string[]; // IDs of linked work items
  projectedDate: string | null; // Forecast: latest projectedEndDate among contributing work items; null when none
  isLate: boolean; // Not completed and projectedDate > targetDate
  lateDays: number | null; // projectedDate - targetDate in days when isLate, else null
  isEarly: boolean; // Not completed and projectedDate < targetDate
  earlyDays: number | null; // targetDate - projectedDate in days when isEarly, else null
  isCritical: boolean; // On the (forecast) critical path
}

interface TimelineDateRange {
  earliest: string; // ISO 8601 date — minimum over planned and forecast start dates of all returned work items
  latest: string; // ISO 8601 date — maximum over planned and forecast end dates of all returned work items
}

/** Household item entry — zero-duration CPM node scheduled via work item/milestone dependencies. */
interface TimelineHouseholdItem {
  id: string;
  name: string;
  category: HouseholdItemCategory;
  status: HouseholdItemStatus;
  targetDeliveryDate: string | null; // Computed by scheduling engine from constraints & dependencies
  isLate: boolean; // True when actual_delivery_date > target_delivery_date
  earliestDeliveryDate: string | null; // User-editable constraint (minimum acceptable date)
  latestDeliveryDate: string | null; // User-editable constraint (hard deadline)
  actualDeliveryDate: string | null; // Actual delivery date (set when item arrives)
  area?: AreaSummary | null; // The purchase's area (#2198); always sent by the server
  dependencyIds: HouseholdItemDepRef[]; // References to work item/milestone predecessors
}

interface HouseholdItemDepRef {
  predecessorType: 'work_item' | 'milestone';
  predecessorId: string;
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid query parameters (bad date format, from > to, non-existent milestoneId, combining milestoneId with from/to)
401 UNAUTHORIZED No valid session

Notes:

  • Without query parameters, the timeline endpoint returns ALL work items that have a stored or forecast date (startDate, endDate, projectedStartDate or projectedEndDate non-null), ALL dependencies, ALL milestones, and the current critical path.
  • The critical path is always computed over the full dataset using the CPM algorithm (same as POST /api/schedule but read-only). For the small dataset sizes expected (<200 items), this is performant. Filtering parameters affect which items are returned but not the critical path calculation.
  • startDate/endDate are the stored planned dates and are no longer replaced for not-started items (D-38). Forecast dates, isLate, lateDays and isHeldUp come from the one read-time projection (engine run without and with the today floor; contract 4, #2199). The critical path and milestone projectedDate/isLate/isEarly use the forecast run.
  • dateRange is computed from the returned work items over planned and forecast dates. If no work items have dates set, dateRange is null.
  • This endpoint does not include budget information -- it is purpose-built for the Gantt chart and calendar views. Use GET /api/work-items/:id for full work item details including budgets.
  • The householdItems array contains household items with their computed delivery dates (earliestDeliveryDate, latestDeliveryDate) and dependency references. Household items are zero-duration CPM nodes positioned on the timeline based on their predecessor work items and milestones. The isLate flag is true when the computed earliest delivery date is in the past and was floored to today by the scheduler.

Conventions

Base URL

All API endpoints are prefixed with /api/.

Error Response Shape

All error responses conform to the ApiErrorResponse interface defined in @cornerstone/shared:

interface ApiErrorResponse {
  error: {
    code: ErrorCode; // Machine-readable error code (see table below)
    message: string; // Human-readable description
    details?: Record<string, unknown>; // Optional additional context
  };
}

Example response:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "User not found",
    "details": { "id": "550e8400-e29b-41d4-a716-446655440000" }
  }
}

The details field is omitted entirely (not null) when there is no additional context.

Error Codes

All error codes are defined as an ErrorCode string literal union type in @cornerstone/shared. The code field in every error response is constrained to one of these values.

Error Code HTTP Status Description When Used
NOT_FOUND 404 Resource not found A specific entity (by ID) does not exist
ROUTE_NOT_FOUND 404 API route not found The requested /api/* path does not match any registered route
VALIDATION_ERROR 400 Request validation failed Request body, query params, or path params fail JSON schema validation; business logic validation fails; or an unmapped Fastify/plugin error with a status below 500
UNAUTHORIZED 401 Not authenticated No valid session/token provided
FORBIDDEN 403 Not authorized Authenticated but insufficient permissions
CONFLICT 409 Resource conflict Duplicate unique constraint, optimistic locking conflict, or another state conflict that has no more specific code
INTERNAL_ERROR 500 Internal server error Unhandled/unexpected server errors, and unmapped Fastify/plugin errors with a status of 500 or above
SETUP_COMPLETE 403 Setup already done POST /api/auth/setup called when users already exist
INVALID_CREDENTIALS 401 Authentication failed Wrong email/password on login or password change
ACCOUNT_DEACTIVATED 401 Account disabled Deactivated account with a password attempting local login (an account without a password gets INVALID_CREDENTIALS)
ACCOUNT_LOCKED 423 Account temporarily locked Local login after too many failed attempts; details.lockedUntil carries the unlock timestamp
RATE_LIMIT_EXCEEDED 429 Too many requests Login endpoint rate limit exceeded (AUTH_RATE_LIMIT_MAX per AUTH_RATE_LIMIT_WINDOW), or the password-hashing queue is full on any scrypt endpoint
SELF_DEACTIVATION 409 Cannot deactivate self Admin trying to deactivate their own account
LAST_ADMIN 409 Cannot remove last admin Attempting to demote or deactivate the only remaining admin
OIDC_NOT_CONFIGURED 404 / 400 OIDC not available OIDC env vars not set: 404 on GET /api/auth/oidc/login; 400 on POST /api/users with authProvider: "oidc"
OIDC_MISSING_EMAIL 403 OIDC email claim missing OIDC callback: no account bound to the subject and the ID token has no email claim (surfaced as redirect missing_email)
OIDC_EMAIL_UNVERIFIED 403 OIDC email not verified OIDC callback: no account bound to the subject and the ID token's email_verified is not true (surfaced as redirect oidc_email_unverified)
OIDC_NO_MATCHING_ACCOUNT 403 No account for OIDC identity OIDC callback: no subject or unambiguous email match, and JIT provisioning is off, the email is ambiguous, or setup has not run (redirect oidc_no_matching_account)
CIRCULAR_DEPENDENCY 409 Link would create a cycle Work-item dependency create, the milestone contribute/require link endpoints, and POST /api/schedule. details.cycle (work item IDs) on work-item dependency create and schedule
DUPLICATE_DEPENDENCY 409 Link already exists Work-item and household-item dependency create (same predecessor/successor pair), and the four milestone link endpoints (same work item/milestone pair)
CATEGORY_IN_USE 409 Budget category in use Budget category is referenced by work items or subsidy programs and cannot be deleted
VENDOR_IN_USE 409 Vendor in use Vendor has invoices or is referenced by budget lines and cannot be deleted
BUDGET_SOURCE_IN_USE 409 Budget source in use Budget source is referenced by budget lines and cannot be deleted
DISCRETIONARY_SOURCE 409 Discretionary source is protected Attempt to delete the Discretionary Funding budget source
SUBSIDY_PROGRAM_IN_USE 409 Subsidy program in use Subsidy program is referenced by work items and cannot be deleted
BUDGET_LINE_IN_USE 409 Budget line in use Budget line has linked invoices and cannot be deleted
AREA_IN_USE 409 Area in use Area is still referenced and cannot be deleted
TRADE_IN_USE 409 Trade in use Trade is still referenced and cannot be deleted
SAME_SOURCE 400 Same source and target Budget-line move between sources where source and target are the same budget source
EMPTY_SELECTION 400 Nothing selected Budget-line move with no budget line IDs, or report-content generation with no invoices selected
STALE_OWNERSHIP 409 Stale budget-line ownership Budget-line move where one or more IDs do not exist or no longer belong to the specified source
REORDER_SET_MISMATCH 409 Reference list changed Reference-list reorder (PATCH …/reorder on budget categories, household item categories, orientations) whose ids are not exactly the current set; details.missingIds, details.unknownIds
INVOICES_NOT_CLAIMABLE 409 Invoices not claimable Source-report claim where one or more invoices are not in a claimable state; details.invoiceIds lists them
PAPERLESS_NOT_CONFIGURED 503 Paperless-ngx not configured /api/paperless/* (except /status) and invoice auto-itemize when PAPERLESS_URL/PAPERLESS_API_TOKEN are not set
PAPERLESS_UNREACHABLE 502 Paperless-ngx unreachable Network failure connecting to the Paperless-ngx instance
PAPERLESS_ERROR 502 Paperless-ngx upstream error Paperless-ngx returned a non-success status
DUPLICATE_DOCUMENT_LINK 409 Duplicate document link The document is already linked to the entity (on convert-quotation: on the quotation/deposit tier)
ITEMIZED_SUM_EXCEEDS_INVOICE 400 Itemized sum exceeds invoice Sum of invoice_budget_lines.itemized_amount would exceed the invoice amount
BUDGET_LINE_ALREADY_LINKED 409 Budget line linked elsewhere Linking a budget line that is already linked to a different invoice; on auto-itemize commits also the same line twice in one request, or append on a line already linked to this invoice (#2149)
BUDGET_LINE_ALREADY_ASSIGNED 409 Budget line already assigned Assigning an orphan budget line to a parent item when it already has one
INVALID_DEPOSIT_STATUS_TRANSITION 400 Invalid deposit status transition Attempted status transition is not allowed by the state machine
INVALID_DEPOSIT_DATE_FOR_STATUS 400 Invalid date for deposit status paidDate or claimedDate set when the status does not support it
REFUND_EXCEEDS_INVOICE 400 Refunds exceed refund cap Sum of refund-type entries would exceed the larger of the invoice amount and the deposit-type sum
INVOICE_NOT_QUOTATION 409 Invoice is not a quotation POST /api/invoices/:invoiceId/convert-quotation on an invoice whose status is not quotation (Story #2107)
INVALID_METADATA 400 Invalid diary metadata Diary entry metadata fails the per-entry-type shape rules (e.g. daily_log weather, signatures)
INVALID_ENTRY_TYPE 400 Invalid diary entry type Creating a diary entry with a non-manual (automatic-only) entry type
IMMUTABLE_ENTRY 403 Diary entry is immutable Updating an automatic entry or a saved diary entry with signatures, mutating a photo (upload, update, delete, reorder, annotation save/clear) on a saved diary entry with signatures (drafts stay editable, #2124), or promoting an automatic entry
ALREADY_SAVED 400 Diary entry already saved Promoting a diary entry whose status is already saved
PAYLOAD_TOO_LARGE 413 Payload too large Photo upload exceeds PHOTO_MAX_FILE_SIZE_MB, or Fastify body/multipart limits are exceeded
BACKUP_IN_PROGRESS 409 Backup/restore running Another backup or restore is already in progress
BACKUP_NOT_FOUND 404 Backup not found The named backup archive does not exist in BACKUP_DIR
BACKUP_FAILED 500 Backup failed Creating the backup archive failed
RESTORE_FAILED 500 Restore failed Restoring from a backup archive failed
LLM_NOT_CONFIGURED 503 LLM not configured LLM-backed endpoint called while the LLM_* variables are not set
LLM_UNREACHABLE 502 LLM provider unreachable Network failure calling the LLM provider (details suppressed from the response)
LLM_INVALID_RESPONSE 502 LLM invalid response LLM returned output that cannot be parsed/validated (details suppressed)
LLM_UPSTREAM_ERROR 502 LLM upstream error LLM provider returned an error status (details suppressed)

Clients translate by code, never by message. code is the stable contract. message is untranslated English developer text for logs and API consumers, and the UI must not render it. The client maps code to copy through translateApiError() (errors.json), or through a page-specific t() key where a generic code (typically CONFLICT) has a page-specific meaning. In client/src, an ESLint no-restricted-syntax rule rejects reading .error.message from an API error body, including destructuring message from it (#2129). ApiClientError#message carries the same server text; the lint rule does not see it, so code review enforces that one.

A specific reason is always the top-level code, never nested as details.code: the client cannot translate a reason it has to dig for. details carries data only (for example cycle or invoiceIds). The ErrorCode union is derived from the runtime tuple ERROR_CODES in @cornerstone/shared, and a client test fails when any member lacks an errors.json key in any locale. A code with no emitting path is deleted rather than kept as "Reserved".

Validation Error Details

When code is VALIDATION_ERROR and the error originates from Fastify's JSON schema validation (AJV), the details field contains field-level information:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "fields": [
        {
          "path": "/name",
          "message": "must be string",
          "params": { "type": "string" }
        },
        {
          "path": "/",
          "message": "must have required property 'email'",
          "params": { "missingProperty": "email" }
        }
      ]
    }
  }
}

Each entry in fields includes:

  • path -- JSON pointer to the field that failed validation (e.g., /name, /address/zip), or / for root-level errors
  • message -- Human-readable validation failure message from AJV
  • params -- AJV-specific parameters for the validation rule (optional, present when AJV provides them)

Production Error Sanitization

In production mode (NODE_ENV=production), INTERNAL_ERROR responses always return the message "An internal error occurred" regardless of the actual error. This prevents leaking internal details (database errors, file paths, stack traces) to clients. In development mode, the original error message is preserved for debugging.

HTTP Status Codes

Code Usage
200 Successful retrieval or update
201 Successful creation
204 Successful deletion or action with no response body
302 OIDC redirects
400 Validation error (malformed request)
401 Not authenticated or invalid credentials
403 Not authorized (insufficient role) or action not permitted
404 Resource not found or route not found
409 Conflict (duplicate, self-deactivation, last admin, entity in use)
413 Payload too large (upload/body limits)
423 Account temporarily locked
429 Rate limit exceeded
500 Internal server error
502 Bad gateway (Paperless-ngx or LLM upstream failure)
503 Optional integration not configured (Paperless-ngx, LLM)

Content Type

All requests and responses use application/json, except:

  • OIDC redirect endpoints (GET /api/auth/oidc/login and GET /api/auth/oidc/callback) which return HTTP redirects (302)
  • 204 No Content responses which have no body

Pagination

Offset-based pagination is used for list endpoints that may return many items. The standard query parameters and response shape are:

Query parameters:

Parameter Type Default Constraints Description
page integer 1 >= 1 Page number (1-indexed)
pageSize integer 25 1-100 Items per page

Response metadata:

All paginated list responses include a pagination object alongside the items array:

{
  "items": [...],
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "totalItems": 47,
    "totalPages": 2
  }
}
Field Type Description
page integer Current page number
pageSize integer Items per page (as requested, clamped to 1-100)
totalItems integer Total number of items matching the filter criteria
totalPages integer Computed: ceil(totalItems / pageSize)

Notes:

  • Page numbers are 1-indexed. Requesting page 0 or negative numbers returns a validation error.
  • Requesting a page beyond totalPages returns an empty items array with valid pagination metadata.
  • The GET /api/users endpoint does not use pagination (fewer than 5 users). The GET /api/areas endpoint does not use pagination (areas are expected to be fewer than ~100). The GET /api/trades endpoint does not use pagination (trades are expected to be fewer than ~50). The GET /api/budget-categories endpoint does not use pagination (budget categories are a small, finite collection). The GET /api/budget-sources endpoint does not use pagination (budget sources are a small, finite collection). The GET /api/work-items/:id/budgets endpoint does not use pagination (budget lines per work item are a small collection). The GET /api/vendors/:id/invoices endpoint does not use pagination (invoices per vendor are typically fewer than ~50). The GET /api/milestones endpoint does not use pagination (milestones are expected to be fewer than ~50).
  • The GET /api/invoices endpoint (standalone, cross-vendor) uses standard pagination since it spans all vendors and may return many items.

Filtering & Sorting

List endpoints support filtering and sorting via query parameters:

Filtering:

  • Filter parameters use the exact field name (e.g., status, assignedUserId, areaId)
  • Multiple values for the same filter are not supported in a single parameter; use one value per filter
  • Multiple different filters are combined with AND logic
  • Text search uses the q parameter for case-insensitive substring matching

Sorting:

  • sortBy specifies the field to sort by (default varies per endpoint)
  • sortOrder specifies direction: asc or desc (default varies per endpoint)
  • Only one sort field is supported per request

User Response Shape

All user objects returned by the API follow a consistent shape. The password_hash and oidc_subject fields are never included in API responses; whether a subject is bound is exposed only as the derived boolean oidcLinked.

authProvider and oidcLinked are independent: authProvider records how the account was created (local = setup/admin-created with a password, oidc = created without a password: OIDC just-in-time provisioning or an admin-created SSO-only account), while oidcLinked records whether an OIDC identity is currently bound. An oidc account from JIT provisioning is always linked; an admin-created SSO-only account is oidcLinked: false until its first OIDC sign-in. A local account that has signed in via OIDC once is authProvider: "local", oidcLinked: true, and can use both sign-in methods. See ADR-035.

interface UserResponse {
  id: string;
  email: string;
  displayName: string;
  role: 'admin' | 'member';
  authProvider: 'local' | 'oidc'; // how the account was CREATED — never changes on linking
  oidcLinked: boolean; // derived: oidc_subject IS NOT NULL — the account can sign in via OIDC
  createdAt: string; // ISO 8601
  updatedAt?: string; // ISO 8601 (included in profile endpoints)
  deactivatedAt?: string | null; // ISO 8601 or null (included in admin list)
}

The server emits oidcLinked, updatedAt and deactivatedAt on every user object (toUserResponse in server/src/services/userService.ts has a single shape); the optional markers above mirror the shared type, not what any endpoint omits. oidcLinked is required in the shared type (shared/src/types/user.ts), so a client never needs to handle an absent value.


EPIC-08: Paperless-ngx Document Integration

Overview

EPIC-08 adds two groups of endpoints:

  1. Paperless-ngx Proxy (/api/paperless/*) -- proxies requests to a Paperless-ngx instance, transforming responses into Cornerstone's own shapes. Keeps the API token server-side.
  2. Document Links (/api/document-links) -- CRUD for linking Paperless-ngx documents to Cornerstone entities (work items, household items, invoices).

Both groups require authentication. All proxy endpoints return 503 SERVICE_UNAVAILABLE with error code PAPERLESS_NOT_CONFIGURED if the Paperless-ngx integration is not configured (missing PAPERLESS_URL or PAPERLESS_API_TOKEN environment variables).

Environment Variables

Variable Default Description
PAPERLESS_URL (none) Base URL of the Paperless-ngx instance
PAPERLESS_API_TOKEN (none) API authentication token for Paperless-ngx
PAPERLESS_FILTER_TAG (none) Optional tag name. When set, only documents with this tag are visible through Cornerstone (see Filter tag below)

Filter tag (PAPERLESS_FILTER_TAG)

When a filter tag is configured, Cornerstone shows only documents that carry it, and it fails closed: it never falls back to unfiltered results.

Read Tag configured and resolved Tag configured but unresolvable (not found, lookup error or timeout) No tag configured
GET /api/paperless/documents (list and full-text search) restricted to the tag (tags__id__all) 502 PAPERLESS_ERROR unfiltered
By-id reads: GET /api/paperless/documents/:id, …/:id/thumb, …/:id/preview, and every read built on the single-document fetch (getDocument: invoice auto-itemize and its preview) a document without the tag returns 404 NOT_FOUND, the same shape as a missing document 502 PAPERLESS_ERROR any document
Command search, Paperless group (EPIC-21 contract 6) restricted to the tag group status: "unavailable" unfiltered
Linked-document metadata (bulk fetch, getDocuments): GET /api/document-links, GET /api/source-reports, POST /api/source-reports/generate-content a linked document without the tag is treated like a missing one: its metadata is null (see each endpoint) every linked document is treated as missing (all metadata null); the request still succeeds any document
Upload: POST /api/paperless/documents the document is tagged with it 502 PAPERLESS_ERROR; nothing is sent to Paperless uploaded untagged

The 404 for an untagged document is deliberate: a caller cannot tell an existing but hidden document from a missing one. GET /api/paperless/status reports filterTag: null when the configured tag does not resolve.

See ADR-015 for design rationale.


Paperless-ngx Proxy Endpoints

GET /api/paperless/status

Returns the current status of the Paperless-ngx integration (configured, reachable).

Auth required: Yes

Response (200 OK -- configured and reachable):

{
  "configured": true,
  "reachable": true,
  "error": null
}

Response (200 OK -- configured but unreachable):

{
  "configured": true,
  "reachable": false,
  "error": "Connection refused"
}

Response (200 OK -- not configured):

{
  "configured": false,
  "reachable": false,
  "error": null
}

Notes:

  • This endpoint always returns 200 OK. The status is encoded in the response body.
  • Unlike other proxy endpoints, this does not return 503 when Paperless-ngx is not configured.
  • The reachable check makes a lightweight call to the Paperless-ngx API to verify connectivity.

GET /api/paperless/documents

Search and browse documents in Paperless-ngx.

Auth required: Yes

Query parameters:

Parameter Type Default Description
query string (none) Full-text search query
tags string (none) Comma-separated Paperless-ngx tag IDs to filter by
correspondent number (none) Filter by Paperless-ngx correspondent ID
documentType number (none) Filter by Paperless-ngx document type ID
page number 1 Page number (1-indexed)
pageSize number 25 Items per page (max 100)
sortBy string created Sort field: created, added, modified, title, archive_serial_number
sortOrder string desc Sort direction: asc or desc

Response (200 OK):

{
  "documents": [
    {
      "id": 42,
      "title": "Invoice from Builder Co",
      "content": "This invoice covers the foundation work...",
      "tags": [
        {
          "id": 5,
          "name": "invoice",
          "color": "#ff0000",
          "documentCount": 15
        }
      ],
      "created": "2026-01-15",
      "added": "2026-01-16T08:30:00Z",
      "modified": "2026-01-16T08:30:00Z",
      "correspondent": "Builder Co",
      "documentType": "Invoice",
      "archiveSerialNumber": 1042,
      "originalFileName": "invoice-2026-001.pdf",
      "pageCount": 2,
      "searchHit": {
        "score": 0.95,
        "highlights": "This <em>invoice</em> covers the foundation work...",
        "rank": 1
      }
    }
  ],
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "totalItems": 142,
    "totalPages": 6
  }
}

Notes:

  • The searchHit field is only present when a query parameter is provided. It is null for non-search listings.
  • The content field may be truncated for list views. Use the detail endpoint for full content.
  • Tag, correspondent, and document type names are resolved server-side (the Paperless-ngx API returns IDs; the proxy resolves them to names).
  • Filter tag: with PAPERLESS_FILTER_TAG set, results are restricted to that tag; if the tag cannot be resolved the request fails with 502 PAPERLESS_ERROR and never returns unfiltered results (see Filter tag above).
  • Page stability (#2101): for listings without query, the proxy sends Paperless-ngx ordering=<sortBy>,id (both keys in the same direction as sortOrder). The id tiebreaker makes the order total, so consecutive page requests neither repeat nor skip documents. This matters because created is date-only (API v9+), so ties are common. Full-text searches (query set) are ranked by Paperless-ngx's search index, which accepts only a single sort field, so no tiebreaker is sent and page boundaries are not guaranteed stable. Clients that accumulate pages (the infinite-scroll document browser) must de-duplicate by id.

Error responses:

HTTP Status Error Code When
503 PAPERLESS_NOT_CONFIGURED Paperless-ngx integration not set up
502 PAPERLESS_UNREACHABLE Cannot connect to Paperless-ngx
502 PAPERLESS_ERROR Paperless-ngx returned an error, or the configured filter tag cannot be resolved

GET /api/paperless/documents/:id

Fetch metadata for a single Paperless-ngx document.

Auth required: Yes

URL parameters:

Parameter Type Description
id number Paperless-ngx document ID

Response (200 OK):

{
  "document": {
    "id": 42,
    "title": "Invoice from Builder Co",
    "content": "Full document text content here...",
    "tags": [
      {
        "id": 5,
        "name": "invoice",
        "color": "#ff0000",
        "documentCount": 15
      }
    ],
    "created": "2026-01-15",
    "added": "2026-01-16T08:30:00Z",
    "modified": "2026-01-16T08:30:00Z",
    "correspondent": "Builder Co",
    "documentType": "Invoice",
    "archiveSerialNumber": 1042,
    "originalFileName": "invoice-2026-001.pdf",
    "pageCount": 2
  }
}

Error responses:

HTTP Status Error Code When
404 NOT_FOUND Document not found in Paperless-ngx, or (filter tag configured) the document does not carry the filter tag
503 PAPERLESS_NOT_CONFIGURED Paperless-ngx integration not set up
502 PAPERLESS_UNREACHABLE Cannot connect to Paperless-ngx
502 PAPERLESS_ERROR Paperless-ngx returned an error, or the configured filter tag cannot be resolved

GET /api/paperless/documents/:id/thumb

Fetch the thumbnail image for a Paperless-ngx document. Returns a binary image response (passthrough from Paperless-ngx).

Auth required: Yes

URL parameters:

Parameter Type Description
id number Paperless-ngx document ID

Response (200 OK):

  • Content-Type: image/webp (or as returned by Paperless-ngx)
  • Body: Binary image data

Error responses:

HTTP Status Error Code When
404 NOT_FOUND Document not found in Paperless-ngx, or (filter tag configured) the document does not carry the filter tag
503 PAPERLESS_NOT_CONFIGURED Paperless-ngx integration not set up
502 PAPERLESS_UNREACHABLE Cannot connect to Paperless-ngx
502 PAPERLESS_ERROR The configured filter tag cannot be resolved

GET /api/paperless/documents/:id/preview

Fetch the preview PDF or original document for viewing. Returns a binary response (passthrough from Paperless-ngx).

Auth required: Yes

URL parameters:

Parameter Type Description
id number Paperless-ngx document ID

Response (200 OK):

  • Content-Type: application/pdf (or as returned by Paperless-ngx)
  • Body: Binary document data

Error responses:

HTTP Status Error Code When
404 NOT_FOUND Document not found in Paperless-ngx, or (filter tag configured) the document does not carry the filter tag
503 PAPERLESS_NOT_CONFIGURED Paperless-ngx integration not set up
502 PAPERLESS_UNREACHABLE Cannot connect to Paperless-ngx
502 PAPERLESS_ERROR The configured filter tag cannot be resolved

GET /api/paperless/tags

List all tags from Paperless-ngx.

Auth required: Yes

Response (200 OK):

{
  "tags": [
    {
      "id": 5,
      "name": "invoice",
      "color": "#ff0000",
      "documentCount": 15
    },
    {
      "id": 12,
      "name": "contract",
      "color": "#0000ff",
      "documentCount": 8
    }
  ]
}

Notes:

  • Tags are not paginated (Paperless-ngx instances typically have fewer than ~100 tags).
  • The documentCount field indicates how many documents in Paperless-ngx have this tag.

Error responses:

HTTP Status Error Code When
503 PAPERLESS_NOT_CONFIGURED Paperless-ngx integration not set up
502 PAPERLESS_UNREACHABLE Cannot connect to Paperless-ngx
502 PAPERLESS_ERROR Paperless-ngx returned an error

GET /api/paperless/correspondents

List all correspondents from Paperless-ngx. Used to populate the searchable correspondent (vendor) filter in the Paperless-first invoice picker.

Auth required: Yes

Response (200 OK):

{
  "correspondents": [
    { "id": 3, "name": "Builder Co" },
    { "id": 7, "name": "Elektro Müller GmbH" }
  ]
}

Notes:

  • Correspondents are not paginated (Paperless-ngx instances typically have fewer than ~100 correspondents). Sorted by id ascending.
  • Returns { "correspondents": [] } when the instance has no correspondents (empty-state, not an error).
  • Mirrors the GET /api/paperless/tags pattern. Not cached — fetched on demand when the picker opens.
  • The returned id is passed verbatim to the correspondent query parameter on GET /api/paperless/documents to filter the document list.

Error responses:

HTTP Status Error Code When
503 PAPERLESS_NOT_CONFIGURED Paperless-ngx integration not set up
502 PAPERLESS_UNREACHABLE Cannot connect to Paperless-ngx
502 PAPERLESS_ERROR Paperless-ngx returned an error

POST /api/paperless/documents

Upload a PDF document to Paperless-ngx's consumption pipeline for processing and archival.

Auth required: Yes

Content-Type: multipart/form-data

Request body (multipart fields):

Field Type Required Description
document file Yes PDF file to upload (must be application/pdf)
title string Yes Display title for the document in Paperless-ngx

Response (201 Created):

{
  "taskId": "12345678-1234-5678-1234-567812345678"
}
Field Type Description
taskId string Paperless-ngx consumption task UUID. Upload processes asynchronously; no document ID returned. The document appears in Paperless-ngx after consumption completes.

Notes:

  • Only PDF files are accepted. Non-PDF uploads return 400 VALIDATION_ERROR.
  • File must be provided in the document multipart field. Missing file returns 400 VALIDATION_ERROR.
  • Title must be provided in the title multipart field. Missing title returns 400 VALIDATION_ERROR.
  • If PAPERLESS_FILTER_TAG is configured, the document is automatically tagged with that tag. If the tag cannot be resolved, the upload fails with 502 PAPERLESS_ERROR and nothing is sent to Paperless-ngx, so an upload can never land outside the tag (it would be invisible to Cornerstone).
  • Documents do NOT appear immediately in Paperless-ngx; they are queued for consumption. Query the consumption task status in Paperless-ngx to track progress.
  • File size is limited by the global multipart limit (default 50MB); oversized files return 413 PAYLOAD_TOO_LARGE.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Non-PDF file, missing file, or missing title
401 UNAUTHORIZED No valid session
413 PAYLOAD_TOO_LARGE File exceeds the multipart size limit
503 PAPERLESS_NOT_CONFIGURED Paperless-ngx integration not set up
502 PAPERLESS_UNREACHABLE Cannot connect to Paperless-ngx
502 PAPERLESS_ERROR Paperless-ngx returned an error, or the configured filter tag cannot be resolved (nothing uploaded)

Document Link Endpoints

These endpoints manage the links between Cornerstone entities and Paperless-ngx documents. They operate on the local document_links table, not on Paperless-ngx directly.

POST /api/document-links

Create a link between a Cornerstone entity and a Paperless-ngx document.

Auth required: Yes

Request body:

{
  "entityType": "invoice",
  "entityId": "550e8400-e29b-41d4-a716-446655440000",
  "paperlessDocumentId": 42,
  "attachmentType": "quotation"
}
Field Type Validation
entityType string Required. One of: work_item, household_item, invoice, budget_source, subsidy_program.
entityId string Required. Must reference an existing entity of the given type.
paperlessDocumentId number Required. Must be a positive integer.
attachmentType string | null | undefined Optional. Only meaningful for entityType='invoice'. One of: quotation, deposit, invoice. Ignored for other entity types (normalized to null). Story #1877

Response (201 Created):

{
  "documentLink": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "entityType": "invoice",
    "entityId": "550e8400-e29b-41d4-a716-446655440000",
    "paperlessDocumentId": 42,
    "attachmentType": "quotation",
    "createdBy": {
      "id": "user-uuid",
      "displayName": "Admin User"
    },
    "createdAt": "2026-03-01T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid entity type, missing fields, or invalid ID format
404 NOT_FOUND Referenced entity does not exist
409 DUPLICATE_DOCUMENT_LINK This exact link already exists

GET /api/document-links

List all document links for a given entity, enriched with Paperless-ngx document metadata.

Auth required: Yes

Query parameters:

Parameter Type Required Description
entityType string Yes One of: work_item, household_item, invoice, budget_source, subsidy_program
entityId string Yes ID of the entity to get links for

Response (200 OK):

{
  "documentLinks": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "entityType": "invoice",
      "entityId": "550e8400-e29b-41d4-a716-446655440000",
      "paperlessDocumentId": 42,
      "attachmentType": "invoice",
      "createdBy": {
        "id": "user-uuid",
        "displayName": "Admin User"
      },
      "createdAt": "2026-03-01T10:00:00.000Z",
      "document": {
        "id": 42,
        "title": "Invoice from Builder Co",
        "content": null,
        "tags": [
          {
            "id": 5,
            "name": "invoice",
            "color": "#ff0000",
            "documentCount": 15
          }
        ],
        "created": "2026-01-15",
        "added": "2026-01-16T08:30:00Z",
        "modified": "2026-01-16T08:30:00Z",
        "correspondent": "Builder Co",
        "documentType": "Invoice",
        "archiveSerialNumber": 1042,
        "originalFileName": "invoice-2026-001.pdf",
        "pageCount": 2
      }
    }
  ]
}

Notes:

  • Document metadata is fetched from Paperless-ngx on each request (no caching in Phase 1).
  • If a linked document has been deleted from Paperless-ngx, the document field will be null. The link itself is preserved.
  • With PAPERLESS_FILTER_TAG configured, a linked document that does not carry the tag is treated the same way: document is null and the link is preserved. If the configured tag cannot be resolved, every document is null (see Paperless-ngx › Filter tag).
  • The content field is set to null in list responses to reduce payload size. Use GET /api/paperless/documents/:id for full content.
  • Document links are not paginated (an entity typically has fewer than ~20 linked documents).
  • If Paperless-ngx is not configured, document links are returned without metadata (all document fields are null).

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing or invalid entityType or entityId

GET /api/document-links/linked-ids

Returns the distinct set of Paperless-ngx document IDs that are linked to any entity in the system (across all entity types and entity IDs). Used by the document picker to implement system-wide "hide already-linked documents" filtering.

Auth required: Yes (session cookie)

Query parameters: None

Response (200 OK):

{
  "paperlessDocumentIds": [42, 99, 137]
}

Returns an empty array [] when no documents are linked anywhere.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

PATCH /api/document-links/:id

Tag, retag, or untag (clear) a document link's attachment type. This is used to categorize invoice source documents by their role (quotation, deposit, or invoice) in the cost claim report.

Non-invoice entity types ignore the provided value and always store null (attachment typing is only meaningful for invoices). Story #1877.

Auth required: Yes

URL parameters:

Parameter Type Description
id string Document link ID

Request body:

{
  "attachmentType": "quotation"
}
Field Type Validation
attachmentType string | null Required. One of: quotation, deposit, invoice, or null to remove the tag.

Response (200 OK):

{
  "documentLink": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "entityType": "invoice",
    "entityId": "550e8400-e29b-41d4-a716-446655440000",
    "paperlessDocumentId": 42,
    "attachmentType": "quotation",
    "createdBy": {
      "id": "user-uuid",
      "displayName": "Admin User"
    },
    "createdAt": "2026-03-01T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid attachmentType value
401 UNAUTHORIZED No valid session
404 NOT_FOUND Document link not found

DELETE /api/document-links/:id

Remove a link between a Cornerstone entity and a Paperless-ngx document.

Auth required: Yes

URL parameters:

Parameter Type Description
id string Document link ID

Response (204 No Content)

Error responses:

HTTP Status Error Code When
404 NOT_FOUND Document link not found

EPIC-04: Household Items & Furniture Management

Household items and furniture management endpoints. Household items are a distinct entity from work items (see ADR-016), with their own purchase status workflow, category system, delivery tracking, and budget integration. All endpoints require authentication. Both admin and member roles can perform all household item operations (no role restriction).

Household Item Response Shapes

/**
 * Household item category is a string ID referencing the household_item_categories table.
 * No longer a fixed enum — categories are user-manageable via CRUD endpoints.
 * Default seeded values: 'hic-furniture', 'hic-appliances', 'hic-fixtures',
 * 'hic-decor', 'hic-electronics', 'hic-outdoor', 'hic-storage', 'hic-other'.
 */
type HouseholdItemCategory = string;

/** Household item category entity (from household_item_categories table). */
interface HouseholdItemCategoryEntity {
  id: string;
  name: string;
  color: string | null;
  sortOrder: number;
  createdAt: string; // ISO 8601
  updatedAt: string; // ISO 8601
}

type HouseholdItemStatus = 'planned' | 'purchased' | 'scheduled' | 'arrived';

/** Returned in list responses (GET /api/household-items). */
interface HouseholdItemSummary {
  id: string;
  name: string;
  description: string | null;
  category: HouseholdItemCategory;
  status: HouseholdItemStatus;
  vendor: VendorSummary | null;
  url: string | null;
  area: AreaSummary | null;
  quantity: number;
  orderDate: string | null; // YYYY-MM-DD
  targetDeliveryDate: string | null; // YYYY-MM-DD (computed by scheduling engine)
  isLate: boolean; // True when actual_delivery_date > target_delivery_date
  actualDeliveryDate: string | null; // YYYY-MM-DD
  earliestDeliveryDate: string | null; // YYYY-MM-DD (user-editable constraint)
  latestDeliveryDate: string | null; // YYYY-MM-DD (user-editable constraint)
  createdBy: UserSummary | null;
  createdAt: string; // ISO 8601
  updatedAt: string; // ISO 8601
  /** Number of budget lines. */
  budgetLineCount: number;
  /** Sum of the VAT-effective plannedAmount across all budget lines (net lines grossed up by 1 + VAT_RATE, rounded to cents per line). */
  totalPlannedAmount: number;
  /** Budget aggregates (planned, actual, subsidy, net). `totalPlanned` equals `totalPlannedAmount` (VAT-effective); a percentage subsidy's reduction is computed from it, and `netCost = totalPlanned - subsidyReduction`. */
  budgetSummary: HouseholdItemBudgetAggregate;
}

/** Returned in detail responses (GET /api/household-items/:id). */
interface HouseholdItemDetail extends HouseholdItemSummary {
  /** Dependencies on work items and milestones. */
  dependencies: HouseholdItemDepDetail[];
  /** Linked subsidy program summaries. */
  subsidies: SubsidyProgramSummary[];
}

/** A single dependency row with predecessor details. */
interface HouseholdItemDepDetail {
  householdItemId: string;
  predecessorType: 'work_item' | 'milestone';
  predecessorId: string;
  dependencyType: 'finish_to_start' | 'start_to_start' | 'finish_to_finish' | 'start_to_finish';
  leadLagDays: number;
  predecessor: {
    id: string;
    title: string; // work item title or milestone title
    status: string | null; // work item status (null for milestones)
    endDate: string | null; // work item endDate or milestone targetDate
    area: AreaSummary | null; // non-null only when predecessorType === 'work_item'; null for milestone predecessors
  };
}

/** Lightweight subsidy reference embedded in detail response. */
interface SubsidyProgramSummary {
  id: string;
  name: string;
  reductionType: 'percentage' | 'fixed';
  reductionValue: number;
  applicationStatus: SubsidyApplicationStatus;
}

Household Item Endpoints

GET /api/household-items

Returns a paginated, filterable, sortable list of household items.

Auth required: Yes

Query parameters:

Parameter Type Default Description
page number 1 Page number (1-indexed)
pageSize number 25 Items per page (max 100)
category string (all) Filter by category ID (e.g., hic-furniture, hic-appliances). Must be a valid household_item_categories.id.
status string (all) Filter by status (e.g., planned, purchased)
vendorId string (all) Filter by vendor UUID
areaId string (all) Filter by area UUID or comma-separated list of area UUIDs. For each supplied ID the server expands descendants server-side; results are the union of all matching subtrees. Use the sentinel value __none__ to include items with no area assignment (e.g., ?areaId=__none__ returns only unassigned items; ?areaId=__none__,<uuid> returns unassigned items OR items in the specified area subtree).
q string (none) Full-text search across name, description
plannedCostMin number (none) Filter: minimum total VAT-effective planned cost (inclusive; same basis as totalPlannedAmount)
plannedCostMax number (none) Filter: maximum total VAT-effective planned cost (inclusive; same basis as totalPlannedAmount)
actualCostMin number (none) Filter: minimum total actual cost (inclusive)
actualCostMax number (none) Filter: maximum total actual cost (inclusive)
budgetLinesMin number (none) Filter: minimum budget line count (inclusive)
budgetLinesMax number (none) Filter: maximum budget line count (inclusive)
sortBy string created_at Sort field: name, category, status, order_date, target_delivery_date, created_at, updated_at
sortOrder string desc Sort direction: asc or desc

Response (200 OK):

{
  "items": [
    {
      "id": "hi-001",
      "name": "Kitchen Island",
      "description": "Custom maple kitchen island with granite top",
      "category": "hic-furniture",
      "status": "purchased",
      "vendor": {
        "id": "v-ikea",
        "name": "IKEA",
        "trade": null
      },
      "url": "https://example.com/kitchen-island",
      "area": { "id": "area-kitchen", "name": "Kitchen", "color": "#F59E0B", "ancestors": [] },
      "quantity": 1,
      "orderDate": "2026-03-01",
      "targetDeliveryDate": "2026-04-15",
      "isLate": false,
      "actualDeliveryDate": null,
      "earliestDeliveryDate": "2026-04-10",
      "latestDeliveryDate": "2026-04-20",
      "createdBy": {
        "id": "u1",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-02-28T10:00:00.000Z",
      "updatedAt": "2026-03-01T14:30:00.000Z",
      "budgetLineCount": 2,
      "totalPlannedAmount": 3500.0,
      "budgetSummary": {
        "totalPlanned": 3500.0,
        "totalActual": 0,
        "subsidyReduction": 0,
        "netCost": 3500.0
      }
    }
  ],
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "totalItems": 12,
    "totalPages": 1
  },
  "filterMeta": {
    "plannedCost": { "min": 0, "max": 3500.0 },
    "actualCost": { "min": 0, "max": 0 },
    "budgetLines": { "min": 0, "max": 2 }
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid query parameters
401 UNAUTHORIZED No valid session

Notes:

  • Planned cost is VAT-effective everywhere on this endpoint: totalPlannedAmount, budgetSummary.totalPlanned, the plannedCostMin/plannedCostMax filters and filterMeta.plannedCost all sum each budget line's planned_amount with net lines (includes_vat = 0) grossed up by 1 + VAT_RATE (server config vatRate, default 0.19, exposed by GET /api/config) and rounded to cents per line. This is the same figure the household-item detail page and GET /api/budget/breakdown show, so filtering by planned cost matches what the user sees.

POST /api/household-items

Creates a new household item. The createdBy is automatically set to the authenticated user.

Auth required: Yes

Request body:

{
  "name": "Kitchen Island",
  "description": "Custom maple kitchen island with granite top",
  "category": "hic-furniture",
  "status": "planned",
  "vendorId": "v-ikea",
  "url": "https://example.com/kitchen-island",
  "areaId": "area-kitchen",
  "quantity": 1,
  "orderDate": null,
  "earliestDeliveryDate": "2026-04-01",
  "latestDeliveryDate": "2026-04-30",
  "actualDeliveryDate": null
}
Field Type Required Validation
name string Yes 1-500 characters
description string | null No Max 5000 characters
category string No A valid household_item_categories.id. Default: hic-other
status string No One of: planned, purchased, scheduled, arrived. Default: planned
vendorId string | null No Must be a valid vendor UUID if provided
url string | null No Max 2000 characters
areaId string | null No Must be a valid area UUID if provided
quantity number No Integer >= 1. Default: 1
orderDate string | null No YYYY-MM-DD format
earliestDeliveryDate string | null No YYYY-MM-DD format; user-editable constraint (minimum acceptable date)
latestDeliveryDate string | null No YYYY-MM-DD format; user-editable constraint (hard deadline)
actualDeliveryDate string | null No YYYY-MM-DD format

Response (201 Created):

{
  "householdItem": {
    "id": "hi-001",
    "name": "Kitchen Island",
    "...": "full HouseholdItemDetail shape"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid or missing fields
401 UNAUTHORIZED No valid session
400 VALIDATION_ERROR Non-existent vendorId or areaId

GET /api/household-items/:id

Returns the full detail of a household item, including tags, linked work items, and linked subsidies.

Auth required: Yes

URL parameters:

Parameter Type Description
id string Household item UUID

Response (200 OK):

{
  "householdItem": {
    "id": "hi-001",
    "name": "Kitchen Island",
    "description": "Custom maple kitchen island with granite top",
    "category": "hic-furniture",
    "status": "purchased",
    "vendor": { "id": "v-ikea", "name": "IKEA", "trade": null },
    "url": "https://example.com/kitchen-island",
    "area": { "id": "area-kitchen", "name": "Kitchen", "color": "#F59E0B", "ancestors": [] },
    "quantity": 1,
    "orderDate": "2026-03-01",
    "targetDeliveryDate": "2026-04-15",
    "isLate": false,
    "actualDeliveryDate": null,
    "earliestDeliveryDate": "2026-04-10",
    "latestDeliveryDate": "2026-04-20",
    "createdBy": { "id": "u1", "displayName": "Admin User", "email": "[email protected]" },
    "createdAt": "2026-02-28T10:00:00.000Z",
    "updatedAt": "2026-03-01T14:30:00.000Z",
    "budgetLineCount": 2,
    "totalPlannedAmount": 3500.0,
    "subsidies": [
      {
        "id": "sp-001",
        "name": "Energy Efficiency Grant",
        "reductionType": "percentage",
        "reductionValue": 15,
        "applicationStatus": "approved"
      }
    ]
  }
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item ID not found

PATCH /api/household-items/:id

Updates an existing household item. All fields are optional; only provided fields are updated.

Auth required: Yes

URL parameters:

Parameter Type Description
id string Household item UUID

Request body:

{
  "name": "Updated Kitchen Island",
  "status": "scheduled",
  "areaId": "area-kitchen"
}
Field Type Required Validation
name string No 1-500 characters
description string | null No Max 5000 characters
category string No A valid household_item_categories.id
status string No One of the valid statuses
vendorId string | null No Must be a valid vendor UUID if provided; null clears the vendor
url string | null No Max 2000 characters
areaId string | null No Valid area UUID or null to clear
quantity number No Integer >= 1
orderDate string | null No YYYY-MM-DD format
earliestDeliveryDate string | null No YYYY-MM-DD format; user-editable constraint
latestDeliveryDate string | null No YYYY-MM-DD format; user-editable constraint
actualDeliveryDate string | null No YYYY-MM-DD format. Auto-set to today when status changes to "arrived" if not already set.

Response (200 OK):

When the call changed status or actualDeliveryDate, the body also carries a top-level undo sibling of householdItem (contract 11). Backward status moves keep actualDeliveryDate; a date later than today is accepted.

{
  "householdItem": {
    "...": "full HouseholdItemDetail shape"
  }
}

Notes:

  • When status is changed to "arrived" and actualDeliveryDate is not provided in the request and is currently null, the server auto-populates actualDeliveryDate with today's date (YYYY-MM-DD).
  • Explicit actualDeliveryDate values in the request body are always respected (auto-set does not override).

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid fields or no fields provided
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item not found; non-existent vendorId or areaId

DELETE /api/household-items/:id

Deletes a household item and all associated data (notes, budget lines, dependency links, subsidy links). Also cleans up any document_links entries for this household item.

Auth required: Yes

URL parameters:

Parameter Type Description
id string Household item UUID

Response (204 No Content)

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item ID not found

Notes:

  • Cascade deletes handle household_item_notes, household_item_budgets, household_item_deps, and household_item_subsidies via FK ON DELETE CASCADE.
  • document_links entries must be cleaned up in application code (no FK constraint on polymorphic table).

Household Item Note Endpoints

Notes are nested under household items. Each note represents a comment or observation about the item. Notes are NOT paginated (a household item typically has fewer than ~50 notes).

GET /api/household-items/:householdItemId/notes

Returns all notes for a household item, ordered by creation date (newest first).

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID

Response (200 OK):

{
  "notes": [
    {
      "id": "n1",
      "householdItemId": "hi-001",
      "content": "Confirmed color matches kitchen theme",
      "createdBy": {
        "id": "u1",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-03-01T10:00:00.000Z",
      "updatedAt": "2026-03-01T10:00:00.000Z"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item ID not found

POST /api/household-items/:householdItemId/notes

Creates a new note on a household item. The createdBy is automatically set to the authenticated user.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID

Request body:

{
  "content": "Confirmed color matches kitchen theme"
}
Field Type Required Validation
content string Yes 1-10000 characters

Response (201 Created):

{
  "note": {
    "id": "n1",
    "householdItemId": "hi-001",
    "content": "Confirmed color matches kitchen theme",
    "createdBy": { "id": "u1", "displayName": "Admin User", "email": "[email protected]" },
    "createdAt": "2026-03-01T10:00:00.000Z",
    "updatedAt": "2026-03-01T10:00:00.000Z"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid or missing content
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item ID not found

PATCH /api/household-items/:householdItemId/notes/:noteId

Updates an existing note. Only the note author or an admin can update a note.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID
noteId string Note UUID

Request body:

{
  "content": "Updated note content"
}
Field Type Required Validation
content string Yes 1-10000 characters

Response (200 OK):

{
  "note": {
    "...": "full note shape"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid content
401 UNAUTHORIZED No valid session
403 FORBIDDEN User is not the author and not an admin
404 NOT_FOUND Household item or note ID not found

DELETE /api/household-items/:householdItemId/notes/:noteId

Deletes a note. Only the note author or an admin can delete a note.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID
noteId string Note UUID

Response (204 No Content)

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
403 FORBIDDEN User is not the author and not an admin
404 NOT_FOUND Household item or note ID not found

Household Item Budget Line Endpoints

Budget lines are nested under household items. Each budget line represents a cost estimate or allocation with its own confidence level, optional vendor, budget category, and budget source. Budget lines are NOT paginated. The structure mirrors work item budget lines exactly. As of EPIC-15, household item budget lines can be linked to invoices via the invoice_budget_lines junction table (same model as work item budget lines).

GET /api/household-items/:householdItemId/budgets

Returns all budget lines for a household item.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID

Response (200 OK):

{
  "budgets": [
    {
      "id": "hb-001",
      "householdItemId": "hi-001",
      "description": "Kitchen island base unit",
      "plannedAmount": 2500.0,
      "confidence": "quote",
      "confidenceMargin": 0.05,
      "budgetCategory": {
        "id": "bc-furniture",
        "name": "Furniture",
        "description": "Furniture and fixtures",
        "color": "#8B5CF6",
        "sortOrder": 3,
        "createdAt": "2026-02-20T10:00:00.000Z",
        "updatedAt": "2026-02-20T10:00:00.000Z"
      },
      "budgetSource": {
        "id": "bs-1",
        "name": "Savings Account",
        "sourceType": "savings"
      },
      "vendor": {
        "id": "v-ikea",
        "name": "IKEA",
        "trade": null
      },
      "actualCost": 0,
      "actualCostPaid": 0,
      "invoiceCount": 0,
      "createdBy": {
        "id": "u1",
        "displayName": "Admin User",
        "email": "[email protected]"
      },
      "createdAt": "2026-02-28T10:00:00.000Z",
      "updatedAt": "2026-02-28T10:00:00.000Z"
    }
  ]
}

Notes:

  • As of EPIC-15, actualCost, actualCostPaid, and invoiceCount are computed from invoice_budget_lines for household item budget lines, using the same logic as work item budget lines. The invoiceLink field is also populated when a link exists.
  • confidenceMargin is a computed field: own_estimate = 0.20, professional_estimate = 0.10, quote = 0.05, invoice = 0.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item ID not found

POST /api/household-items/:householdItemId/budgets

Creates a new budget line for a household item. The createdBy is automatically set to the authenticated user.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID

Request body:

{
  "description": "Kitchen island base unit",
  "plannedAmount": 2500.0,
  "confidence": "quote",
  "budgetCategoryId": "bc-furniture",
  "budgetSourceId": "bs-1",
  "vendorId": "v-ikea"
}
Field Type Required Validation
description string | null No Max 500 characters
plannedAmount number Yes Must be >= 0
confidence string No One of: own_estimate, professional_estimate, quote, invoice. Default: own_estimate
budgetCategoryId string | null No Ignored -- automatically set to bc-household-items
budgetSourceId string | null No Must be a valid budget source UUID if provided
vendorId string | null No Must be a valid vendor UUID if provided

Response (201 Created):

{
  "budget": {
    "...": "full budget line shape"
  }
}

Notes:

  • budgetCategoryId is automatically assigned to bc-household-items (seeded by migration 0016). Any user-supplied value is silently ignored. This ensures all household item budget lines are grouped under the "Household Items" budget category for reporting.

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid or missing fields
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item, budget source, or vendor not found

PATCH /api/household-items/:householdItemId/budgets/:budgetId

Updates an existing budget line. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically. This endpoint supports same-table moves only (move within household items) — to move a budget line to a work item, use PATCH /api/invoices/:invoiceId/budget-lines/:id instead.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID
budgetId string Budget line UUID

Request body (all fields optional, at least one required):

{
  "plannedAmount": 2800.0,
  "confidence": "invoice"
}
Field Type Validation
description string | null Max 500 characters; null clears the description
plannedAmount number Must be >= 0
confidence string One of: own_estimate, professional_estimate, quote, invoice
budgetCategoryId string | null Valid budget category UUID or null to clear
budgetSourceId string | null Valid budget source UUID or null to clear
vendorId string | null Valid vendor UUID or null to clear

Response (200 OK):

{
  "budget": {
    "...": "full budget line shape"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid fields or no fields provided
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item, budget line, or reference ID not found

Notes:

  • Cross-table moves (household item to work item or vice versa) are not supported by this endpoint. Use PATCH /api/invoices/:invoiceId/budget-lines/:id to move budget lines between work items and household items.

DELETE /api/household-items/:householdItemId/budgets/:budgetId

Deletes a budget line from a household item.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID
budgetId string Budget line UUID

Response (204 No Content)

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item or budget line ID not found

Household Item Dependency Endpoints

Manages dependencies between household items and work items or milestones. Dependencies define scheduling relationships (e.g., "this household item delivery depends on this work item finishing") that the scheduling engine uses to compute earliestDeliveryDate and latestDeliveryDate. Not paginated.

GET /api/household-items/:id/dependencies

Returns all dependencies for a household item, with predecessor details.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Household item UUID

Response (200 OK):

{
  "dependencies": [
    {
      "householdItemId": "hi-001",
      "predecessorType": "work_item",
      "predecessorId": "wi-001",
      "dependencyType": "finish_to_start",
      "leadLagDays": 3,
      "predecessor": {
        "id": "wi-001",
        "title": "Install Kitchen Cabinets",
        "status": "not_started",
        "endDate": "2026-05-05"
      }
    },
    {
      "householdItemId": "hi-001",
      "predecessorType": "milestone",
      "predecessorId": "1",
      "dependencyType": "finish_to_start",
      "leadLagDays": 0,
      "predecessor": {
        "id": "1",
        "title": "Kitchen Complete",
        "status": null,
        "endDate": "2026-06-01"
      }
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item ID not found

POST /api/household-items/:id/dependencies

Creates a dependency between a household item and a work item or milestone.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Household item UUID

Request body:

{
  "predecessorType": "work_item",
  "predecessorId": "wi-001",
  "dependencyType": "finish_to_start",
  "leadLagDays": 3
}
Field Type Required Validation
predecessorType string Yes "work_item" or "milestone"
predecessorId string Yes Must be a valid work item UUID or milestone ID
dependencyType string No One of: finish_to_start, start_to_start, finish_to_finish, start_to_finish. Default: finish_to_start
leadLagDays number No Integer. Positive = lag, negative = lead. Default: 0

Response (201 Created):

{
  "dependency": {
    "householdItemId": "hi-001",
    "predecessorType": "work_item",
    "predecessorId": "wi-001",
    "dependencyType": "finish_to_start",
    "leadLagDays": 3,
    "predecessor": {
      "id": "wi-001",
      "title": "Install Kitchen Cabinets",
      "status": "not_started",
      "endDate": "2026-05-05"
    }
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing or invalid fields
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item or predecessor not found
409 DUPLICATE_DEPENDENCY A dependency on this predecessor (same predecessorType and predecessorId) already exists

DELETE /api/household-items/:id/dependencies/:predecessorType/:predecessorId

Removes a dependency from a household item.

Auth required: Yes

Path parameters:

Parameter Type Description
id string Household item UUID
predecessorType string work_item or milestone
predecessorId string Work item UUID or milestone ID

Response (204 No Content)

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item or dependency not found

Household Item Subsidy Link Endpoints

Manages the M:N relationship between household items and subsidy programs. Not paginated.

GET /api/household-items/:householdItemId/subsidies

Returns all subsidy programs linked to a household item.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID

Response (200 OK):

{
  "subsidies": [
    {
      "id": "sp-001",
      "name": "Energy Efficiency Grant",
      "reductionType": "percentage",
      "reductionValue": 15,
      "applicationStatus": "approved"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item ID not found

POST /api/household-items/:householdItemId/subsidies

Links a subsidy program to a household item.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID

Request body:

{
  "subsidyProgramId": "sp-001"
}
Field Type Required Validation
subsidyProgramId string Yes Must be a valid subsidy program UUID

Response (201 Created):

{
  "subsidy": {
    "id": "sp-001",
    "name": "Energy Efficiency Grant",
    "reductionType": "percentage",
    "reductionValue": 15,
    "applicationStatus": "approved"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing or invalid subsidyProgramId
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item or subsidy program ID not found
409 CONFLICT Link already exists

DELETE /api/household-items/:householdItemId/subsidies/:subsidyProgramId

Removes a subsidy program link from a household item.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID
subsidyProgramId string Subsidy program UUID

Response (204 No Content)

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item, subsidy program, or link not found

Household Item Subsidy Payback Endpoint

GET /api/household-items/:householdItemId/subsidy-payback

Calculates the expected subsidy payback range for a household item. Returns the total min/max payback amounts and a per-subsidy breakdown. Only non-rejected subsidies linked to the household item are included. Same calculation logic as the work item subsidy payback endpoint.

Auth required: Yes

Path parameters:

Parameter Type Description
householdItemId string Household item UUID

Response (200 OK):

{
  "householdItemId": "hi-001",
  "minTotalPayback": 375.0,
  "maxTotalPayback": 525.0,
  "subsidies": [
    {
      "subsidyProgramId": "sp-001",
      "name": "Energy Efficiency Grant",
      "reductionType": "percentage",
      "reductionValue": 15,
      "minPayback": 375.0,
      "maxPayback": 525.0
    }
  ]
}

Response type definitions:

interface HouseholdItemSubsidyPaybackResponse {
  householdItemId: string;
  /** Minimum total payback across all non-rejected linked subsidies. */
  minTotalPayback: number;
  /** Maximum total payback across all non-rejected linked subsidies. */
  maxTotalPayback: number;
  /** Per-subsidy breakdown. Empty array when no subsidies are linked. */
  subsidies: SubsidyPaybackEntry[];
}

/** Reuses the same SubsidyPaybackEntry shape as work item subsidy payback. */
interface SubsidyPaybackEntry {
  subsidyProgramId: string;
  name: string;
  reductionType: 'percentage' | 'fixed';
  reductionValue: number;
  /** Minimum expected payback (lower bound based on confidence margins). */
  minPayback: number;
  /** Maximum expected payback (upper bound based on confidence margins). */
  maxPayback: number;
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Household item ID not found

Notes:

  • Same payback calculation rules as GET /api/work-items/:workItemId/subsidy-payback:
    • Percentage subsidies: lineAmount * (reductionValue / 100) with confidence margins on non-invoiced budget lines; a net line's planned amount is grossed up by 1 + VAT_RATE (server config vatRate, default 0.19, exposed by GET /api/config) first.
    • Fixed subsidies: minPayback === maxPayback === reductionValue.
    • Confidence margin ranges: own_estimate (+-20%), professional_estimate (+-10%), quote (+-5%), invoice (+-0%).
  • When no subsidies are linked, all totals are 0 and subsidies is an empty array.

Household Item Category Endpoints

Household item categories are user-manageable lookup values. Categories are not paginated (expected to remain a small set). All endpoints require authentication; both admin and member roles have access.

GET /api/household-item-categories

Returns all household item categories, sorted by sortOrder ascending, then name ascending (#2215).

Auth required: Yes

Response (200 OK):

{
  "categories": [
    {
      "id": "hic-furniture",
      "name": "Furniture",
      "color": "#8B5CF6",
      "sortOrder": 0,
      "createdAt": "2026-03-06T10:00:00.000Z",
      "updatedAt": "2026-03-06T10:00:00.000Z"
    },
    {
      "id": "hic-appliances",
      "name": "Appliances",
      "color": "#3B82F6",
      "sortOrder": 1,
      "createdAt": "2026-03-06T10:00:00.000Z",
      "updatedAt": "2026-03-06T10:00:00.000Z"
    }
  ]
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

POST /api/household-item-categories

Creates a new household item category.

Auth required: Yes

Request body:

{
  "name": "Smart Home",
  "color": "#10B981",
  "sortOrder": 8
}
Field Type Required Validation
name string Yes 1-100 characters; must be unique (case-insensitive)
color string | null No Hex color code #RRGGBB format. Default: null
sortOrder number No Non-negative integer. Default: appended (#2215)

Response (201 Created):

{
  "id": "a1b2c3d4-...",
  "name": "Smart Home",
  "color": "#10B981",
  "sortOrder": 8,
  "createdAt": "2026-03-06T12:00:00.000Z",
  "updatedAt": "2026-03-06T12:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Invalid or missing fields
401 UNAUTHORIZED No valid session
409 CONFLICT A category with the same name already exists (case-insensitive)

PATCH /api/household-item-categories/reorder

Saves the whole order of the list in one request (#2215). Body { "ids": string[] } with every id of the list exactly once; 200 OK with { "categories": [...] } in the new order; 409 REORDER_SET_MISMATCH when the ids are not exactly the current set. Full specification: Reference List Reorder.


When sortOrder is omitted on create, the category is appended: sort_order = MAX(sort_order) + 1 (0 when the list is empty; #2215, previously 0).

GET /api/household-item-categories/:id

Returns a single household item category by ID.

Auth required: Yes

URL parameters:

Parameter Type Description
id string Category ID

Response (200 OK):

{
  "id": "hic-furniture",
  "name": "Furniture",
  "color": "#8B5CF6",
  "sortOrder": 0,
  "createdAt": "2026-03-06T10:00:00.000Z",
  "updatedAt": "2026-03-06T10:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Category not found

PATCH /api/household-item-categories/:id

Updates a household item category. All fields are optional; at least one must be provided.

Auth required: Yes

URL parameters:

Parameter Type Description
id string Category ID

Request body:

{
  "name": "Updated Name",
  "color": "#EF4444",
  "sortOrder": 5
}
Field Type Required Validation
name string No 1-100 characters; must be unique (case-insensitive)
color string | null No Hex color code #RRGGBB format; null clears color
sortOrder number No Non-negative integer

Renaming a built-in category (#2213). Same rule as PATCH /api/trades/:id: when name is given and its trimmed value differs from the stored name (case-sensitive), the server sets translation_key to NULL in the same UPDATE, so the typed name shows in every locale. A name equal to the stored one keeps the key.

Response (200 OK):

{
  "id": "hic-furniture",
  "name": "Updated Name",
  "color": "#EF4444",
  "sortOrder": 5,
  "createdAt": "2026-03-06T10:00:00.000Z",
  "updatedAt": "2026-03-06T14:00:00.000Z"
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR No fields provided, or invalid field values
401 UNAUTHORIZED No valid session
404 NOT_FOUND Category not found
409 CONFLICT A category with the new name already exists (case-insensitive)

DELETE /api/household-item-categories/:id

Deletes a household item category. Fails if any household items reference this category.

Auth required: Yes

URL parameters:

Parameter Type Description
id string Category ID

Response (204 No Content)

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Category not found
409 CATEGORY_IN_USE Category is referenced by one or more household items

Notes:

  • CATEGORY_IN_USE: details is not sent: the error is raised with suppressDetails (#315 hardening), so its counts reach only the server log and the response carries code and message alone. Server-side counts (log only): householdItemCount.

Budget Overview Update

The existing GET /api/budget/overview endpoint (EPIC-05) must be updated to include household item budget lines in its aggregation. The following changes apply:

  • totalPlanned includes household item budget lines in addition to work item budget lines.
  • byCategory breakdowns include household item budget line contributions per category.
  • bySource breakdowns include household item budget line contributions per source.
  • subsidySummary includes household items that have linked, non-rejected subsidies.
  • The response shape does not change; the backend aggregates across both work_item_budgets and household_item_budgets via UNION queries.

EPIC-09: Dashboard & Project Health Center

User Preferences

User preferences store per-user UI settings as key-value pairs. Values are always strings; complex data (arrays, objects) is JSON-encoded by the client before storage.

Shared Types

/** A single user preference */
interface UserPreference {
  key: string;
  value: string;
  updatedAt: string;
}

/** Well-known preference keys */
type PreferenceKey =
  | 'dashboard.hiddenCards' // JSON array of card IDs to hide
  | 'theme'; // 'light' | 'dark' | 'system'

Notes on preference keys:

  • Keys use dot-notation namespacing (e.g., dashboard.hiddenCards)
  • Unknown keys are accepted and stored -- the API does not validate key names, allowing the client to evolve preference keys without backend changes
  • Values are opaque strings from the API perspective; the client is responsible for encoding/decoding

GET /api/users/me/preferences

Returns all preferences for the current user.

Auth required: Yes

Response (200 OK):

{
  "preferences": [
    {
      "key": "dashboard.hiddenCards",
      "value": "[\"budget-variance\",\"vendor-summary\"]",
      "updatedAt": "2026-03-05T10:00:00.000Z"
    },
    {
      "key": "theme",
      "value": "dark",
      "updatedAt": "2026-03-05T09:30:00.000Z"
    }
  ]
}

Notes:

  • Returns an empty preferences array if the user has no saved preferences
  • Not paginated (preference count is inherently small per user)

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

PATCH /api/users/me/preferences

Upsert a single preference. Creates the preference if it does not exist, or updates the value if it does.

Auth required: Yes

Request body:

{
  "key": "dashboard.hiddenCards",
  "value": "[\"budget-variance\"]"
}

Validation:

  • key: Required, non-empty string, max 100 characters
  • value: Required, string, max 4096 characters

Response (200 OK):

{
  "key": "dashboard.hiddenCards",
  "value": "[\"budget-variance\"]",
  "updatedAt": "2026-03-05T10:30:00.000Z"
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing or invalid key or value
401 UNAUTHORIZED No valid session

DELETE /api/users/me/preferences/:key

Deletes a single preference by key.

Auth required: Yes

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND No preference with the given key exists for this user

Household Settings

Application-level settings for household metadata consumed by the Bank Report Wizard and other features. These settings apply to the entire household (not per-user).

Shared Types

/** Languages the household writes its own entries in (#2215). Runtime tuple; type derived from it. */
export const ENTRIES_LANGUAGES = ['de', 'en'] as const;
export type EntriesLanguage = (typeof ENTRIES_LANGUAGES)[number];
/** Last step of the unset-value resolution (see "Unset value" below). */
export const FALLBACK_ENTRIES_LANGUAGE: EntriesLanguage = 'en';

/** Household-wide settings for Bank Report Wizard and other features. */
interface HouseholdSettings {
  householdName: string | null;
  householdAddress: string | null;
  /** "Language of your entries" (#2215). Never null: resolved as in "Unset value" when no row exists. */
  entriesLanguage: EntriesLanguage;
}

/** Request body for updating household settings. */
interface UpdateHouseholdSettingsRequest {
  householdName?: string | null;
  householdAddress?: string | null;
  entriesLanguage?: EntriesLanguage;
}

Notes on settings:

  • Both fields are nullable and optional in requests (partial updates supported)

  • Setting a field to null clears it; omitting a field leaves it unchanged

  • Settings return nulls before any values have been written (lazy initialization)

  • Story #1877: Initial use case is Bank Report Wizard sender metadata

  • entriesLanguage (#2215, Settings › Project setup › House, "Language of your entries"): the language the household writes its notes, descriptions and other free text in. It is not the UI language (that is the per-user locale preference, Settings › Account) and not a server env setting (so not on GET /api/config). Stored in app_settings under the key entries_language as de or en; no migration (rows are lazily created). It is never null, and it cannot be cleared, only changed.

  • Unset value of entriesLanguage (owner decision on #2215: "until someone sets it, it matches the language of the person who set up the house"). While no valid entries_language row exists (no row, or a stored value outside ENTRIES_LANGUAGES), the server derives the value on every read and never writes it:

    1. The founder is the earliest-created user: SELECT id FROM users ORDER BY created_at ASC, id ASC LIMIT 1, regardless of role and of deactivation. That is the account POST /api/auth/setup created (OIDC provisioning refuses an empty users table, so nothing can precede it).
    2. If the founder's user_preferences row with key = 'locale' holds de or en, that is the value.
    3. Otherwise (no founder yet, no stored locale, system, or anything else) it is FALLBACK_ENTRIES_LANGUAGE (en). The server cannot see a browser language, so system is not resolved further.

    Why it cannot flip because of another user. The founder is fixed for the life of the database: users are never hard-deleted (deactivation keeps the row), created_at and id never change, and the query ignores role, so promoting, demoting, adding or deactivating users does not change who the founder is. Only the founder's own locale preference is read, so another user's locale change never reaches the derivation. The founder changing their own UI language does move the unset value; that is the owner's rule taken literally ("matches the language of the person who set up the house"). The first PATCH /api/settings that carries entriesLanguage writes the row, and from then on the stored value is returned and nothing is derived. The client never writes the derived value back (the House form sends entriesLanguage only when the user changed the select), so loading the page does not freeze it by accident.

    Rejected alternatives. Freezing the value on the first GET races with the client's first locale sync right after setup (both run on the first page load), so the stored result would depend on request order. Writing it during POST /api/auth/setup would need the client to send a language to the setup endpoint (an auth-route change) and would still leave every existing instance without a row. "Earliest admin" would change with role changes. A read-time derivation over a fixed founder is the smallest deterministic rule.

  • Client use of entriesLanguage: user-entered text is rendered with lang={entriesLanguage} on its element so the browser's hyphens: auto hyphenates with the right dictionary. The UI reads it from the settings it already loads for the house name (one fetch, shared context); no other endpoint carries it.

  • Who may change settings: any authenticated user, admin or member (the Project setup page is member-guarded). This holds for all three fields.


GET /api/settings

Returns current household settings.

Auth required: Yes

Response (200 OK):

{
  "settings": {
    "householdName": "Johnson Family",
    "householdAddress": "123 Main Street, Springfield, IL 62701, USA",
    "entriesLanguage": "de"
  }
}

Notes:

  • Returns nulls for name and address not yet configured. entriesLanguage is always present: the stored value, else the founder's stored de/en locale, else "en" (see "Unset value of entriesLanguage")
  • Does not require prior writes (settings are lazily initialized)

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session

PATCH /api/settings

Updates household settings. All fields are optional; only provided fields are updated.

Auth required: Yes

Request body:

{
  "householdName": "Johnson Family",
  "householdAddress": "123 Main Street, Springfield, IL 62701, USA",
  "entriesLanguage": "en"
}
Field Type Validation
householdName string | null Optional. Max 200 characters; null clears the field.
householdAddress string | null Optional. Max 500 characters; null clears the field.
entriesLanguage string Optional. JSON schema enum: ENTRIES_LANGUAGES (de, en); null is a 400.

At least one field must be provided (the service's "no fields" check counts entriesLanguage).

Response (200 OK):

{
  "settings": {
    "householdName": "Johnson Family",
    "householdAddress": "123 Main Street, Springfield, IL 62701, USA",
    "entriesLanguage": "en"
  }
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR No fields provided, fields too long, entriesLanguage null or not in ENTRIES_LANGUAGES
401 UNAUTHORIZED No valid session

Dashboard Data Sources

The dashboard does not introduce new data endpoints. It aggregates data from existing endpoints:

Dashboard Section Data Source Existing Endpoint
Budget Overview Budget aggregation GET /api/budget/overview
Budget by Source Budget source breakdown GET /api/budget-sources
Subsidy Status Subsidy program summaries GET /api/subsidy-programs
Timeline / Upcoming Timeline data (milestones, work items) GET /api/timeline
Pending Invoices Invoice list filtered by status GET /api/invoices?status=pending
Work Item Progress Status counts derived from timeline data GET /api/timeline (client-side aggregation of workItems[].status)

EPIC-15: Budget-Line Invoice Linking Rework

EPIC-15 replaces the 1:1 FK model between invoices and budget lines with a many-to-many junction table. See ADR-018 for the architectural rationale.

Invoice Budget Line Response Shape

interface InvoiceBudgetLineResponse {
  id: string;
  invoiceId: string;
  workItemBudgetId: string | null;
  householdItemBudgetId: string | null;
  itemizedAmount: number;

  // Resolved from the linked budget line
  budgetLineDescription: string | null;
  plannedAmount: number;
  confidence: ConfidenceLevel;

  // Resolved from the linked budget line's category
  categoryId: string | null;
  categoryName: string | null;
  categoryColor: string | null;

  // Resolved from the linked budget line's parent item
  parentItemId: string | null; // work item ID, household item ID, or null if unassigned
  parentItemTitle: string | null; // work item title, household item name, or null if unassigned
  parentItemType: 'work_item' | 'household_item' | 'unassigned'; // 'unassigned' when work_item_id/household_item_id is NULL
  parentItemArea: AreaSummary | null; // non-null only when parentItemType === 'work_item'; always null for household-item-linked or unassigned lines

  createdAt: string;
  updatedAt: string;
}

Invoice Budget Line Endpoints

Budget lines are nested under invoices. These endpoints manage the many-to-many relationship between invoices and budget lines (work item or household item). All endpoints require authentication.

GET /api/invoices/:invoiceId/budget-lines

Returns all budget lines linked to an invoice, with resolved details and the computed remaining amount.

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID

Response (200 OK):

{
  "budgetLines": [
    {
      "id": "ibl-1",
      "invoiceId": "inv-1",
      "workItemBudgetId": "b1",
      "householdItemBudgetId": null,
      "itemizedAmount": 1500.0,
      "budgetLineDescription": "Foundation concrete materials",
      "plannedAmount": 8500.0,
      "confidence": "quote",
      "categoryId": "bc-materials",
      "categoryName": "Materials",
      "categoryColor": "#3B82F6",
      "parentItemId": "wi-1",
      "parentItemTitle": "Foundation Work",
      "parentItemType": "work_item",
      "parentItemArea": {
        "id": "area-1",
        "name": "Foundation",
        "color": "#FF6B6B",
        "ancestors": []
      },
      "createdAt": "2026-03-15T10:00:00.000Z",
      "updatedAt": "2026-03-15T10:00:00.000Z"
    },
    {
      "id": "ibl-2",
      "invoiceId": "inv-1",
      "workItemBudgetId": "b2",
      "householdItemBudgetId": null,
      "itemizedAmount": 1000.0,
      "budgetLineDescription": "Foundation labor",
      "plannedAmount": 5000.0,
      "confidence": "professional_estimate",
      "categoryId": "bc-labor",
      "categoryName": "Labor",
      "categoryColor": "#EF4444",
      "parentItemId": "wi-1",
      "parentItemTitle": "Foundation Work",
      "parentItemType": "work_item",
      "parentItemArea": {
        "id": "area-1",
        "name": "Foundation",
        "color": "#FF6B6B",
        "ancestors": []
      },
      "createdAt": "2026-03-15T10:00:00.000Z",
      "updatedAt": "2026-03-15T10:00:00.000Z"
    }
  ],
  "remainingAmount": 0
}

Notes:

  • remainingAmount is computed: invoice.amount - SUM(budgetLines[].itemizedAmount).
  • Budget lines are sorted by created_at ascending (oldest first).
  • If the linked budget line has been deleted (both budget FKs are NULL), the row is excluded from the response and should be cleaned up.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Invoice ID does not exist

POST /api/invoices/:invoiceId/budget-lines

Links a budget line to an invoice with an itemized amount.

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID

Request body:

{
  "workItemBudgetId": "b1",
  "itemizedAmount": 1500.0
}
Field Type Required Validation
workItemBudgetId string | null No Must be a valid work item budget line UUID. XOR with householdItemBudgetId
householdItemBudgetId string | null No Must be a valid HI budget line UUID. XOR with workItemBudgetId
itemizedAmount number Yes Must be > 0

Response (201 Created):

{
  "budgetLine": {
    "id": "ibl-1",
    "invoiceId": "inv-1",
    "workItemBudgetId": "b1",
    "householdItemBudgetId": null,
    "itemizedAmount": 1500.0,
    "budgetLineDescription": "Foundation concrete materials",
    "plannedAmount": 8500.0,
    "confidence": "quote",
    "categoryId": "bc-materials",
    "categoryName": "Materials",
    "categoryColor": "#3B82F6",
    "parentItemId": "wi-1",
    "parentItemTitle": "Foundation Work",
    "parentItemType": "work_item",
    "createdAt": "2026-03-15T10:00:00.000Z",
    "updatedAt": "2026-03-15T10:00:00.000Z"
  },
  "remainingAmount": 1000.0
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR Missing or invalid fields, itemizedAmount <= 0, neither or both budget IDs provided
400 ITEMIZED_SUM_EXCEEDS_INVOICE Adding this link would cause the sum of itemized amounts to exceed the invoice total
401 UNAUTHORIZED No valid session
404 NOT_FOUND Invoice ID or budget line ID does not exist
409 BUDGET_LINE_ALREADY_LINKED The specified budget line is already linked to a different invoice (exclusive linking)

PATCH /api/invoices/:invoiceId/budget-lines/:id

Updates an existing invoice-budget-line link with full edit + optional parent move. Runs atomically in a single transaction.

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID
id string Invoice budget line UUID

Request body (all fields optional, at least one required):

{
  "itemizedAmount": 2000.0,
  "description": "Materials for kitchen",
  "plannedAmount": 5000.0,
  "confidence": "invoice",
  "budgetCategoryId": "bc-materials",
  "budgetSourceId": null,
  "vendorId": "v-001",
  "quantity": 10,
  "unit": "bags",
  "unitPrice": 250.0,
  "includesVat": true,
  "newWorkItemId": "wi-456",
  "newHouseholdItemId": null
}
Field Type Validation
itemizedAmount number Optional, must be > 0 if provided
description string | null Optional, max 500 characters; null clears the description
plannedAmount number Optional, must be >= 0 if provided
confidence string Optional, one of: own_estimate, professional_estimate, quote, invoice
budgetCategoryId string | null Optional, valid budget category UUID or null to clear
budgetSourceId string | null Optional, valid budget source UUID or null to clear
vendorId string | null Optional, valid vendor UUID or null to clear
quantity number | null Optional, must be >= 0 if provided
unit string | null Optional, max 50 characters; null clears the unit
unitPrice number | null Optional, must be >= 0 if provided
includesVat boolean Optional, true if the unit price includes VAT
newWorkItemId string | null Optional, target work item UUID to move the budget line to; mutually exclusive with newHouseholdItemId
newHouseholdItemId string | null Optional, target household item UUID to move the budget line to; mutually exclusive with newWorkItemId

Response (200 OK):

{
  "budgetLine": {
    "id": "ibl-001",
    "invoiceId": "inv-001",
    "workItemBudgetId": "wib-456",
    "householdItemBudgetId": null,
    "itemizedAmount": 2000.0,
    "budgetLineDescription": "Materials for kitchen",
    "plannedAmount": 5000.0,
    "confidence": "invoice",
    "categoryId": "bc-materials",
    "categoryName": "Materials",
    "categoryColor": "#FF6B6B",
    "categoryTranslationKey": "budgetCategories.materials",
    "parentItemId": "wi-456",
    "parentItemTitle": "Install Kitchen Cabinets",
    "parentItemType": "work_item",
    "parentItemArea": null,
    "createdAt": "2026-05-22T10:00:00Z",
    "updatedAt": "2026-05-22T11:30:00Z"
  },
  "remainingAmount": 500.0
}

Error responses:

HTTP Status Error Code When
400 VALIDATION_ERROR No fields provided, invalid values, or mutually exclusive move fields both provided
400 ITEMIZED_SUM_EXCEEDS_INVOICE Updating this amount would cause the sum of itemized amounts to exceed the invoice total
401 UNAUTHORIZED No valid session
404 NOT_FOUND Invoice ID, budget line link ID, or target work item/household item does not exist

Notes:

  • All changes to the underlying budget line and the invoice-budget-line link are applied in a single transaction. If any constraint is violated, the entire operation is rolled back.
  • The response shape is InvoiceBudgetLineCreateResponse with budgetLine (full InvoiceBudgetLineDetailResponse) and remainingAmount.

DELETE /api/invoices/:invoiceId/budget-lines/:id

Removes a budget line link from an invoice. This unlinks the budget line -- it does not delete the budget line itself.

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID
id string Invoice budget line UUID

Response (204 No Content): Empty body.

Error responses:

HTTP Status Error Code When
401 UNAUTHORIZED No valid session
404 NOT_FOUND Invoice ID or budget line link ID does not exist, or link does not belong to this invoice

POST /api/invoices/:invoiceId/auto-itemize

Extracts line items from a Paperless-ngx document using LLM-based OCR and optional dry-run preview. Supports both dry-run preview (extraction only, no DB writes) and commit mode (save extracted lines as invoice budget lines).

Auth required: Yes

Path parameters:

Parameter Type Description
invoiceId string Invoice UUID

Request body:

{
  "paperlessDocumentId": 42,
  "mode": "append",
  "dryRun": true,
  "lines": [],
  "invoicePatch": {
    "amount": 1250.5,
    "date": "2026-05-12",
    "dueDate": "2026-06-11",
    "invoiceNumber": "INV-2026-042",
    "notes": "Updated from auto-itemize page"
  }
}
Field Type Required Notes
paperlessDocumentId integer Yes Paperless document ID. Must be linked to this invoice via document_links. Returns 404 if not linked.
mode "append" | "replace" Yes append = add to existing lines; replace = first delete the invoice's auto-created budget lines (work_item_budgets with origin = 'auto', cascading their junction rows). Links to any other budget line survive; see assignment modes
dryRun boolean Yes true = LLM extraction only, return { lines, warnings }, no DB writes. false = commit extracted.
lines ExtractedLine[] Yes (commit) When dryRun: false, user-edited extracted line data. Max 200 items. Ignored when dryRun: true.
invoicePatch InvoicePatchForAutoItemize No Optional invoice metadata patch applied transactionally with line creation. Only honored when dryRun: false. Ignored on dry-run. Must contain at least one property when present. Vendor cannot be patched here.

InvoicePatchForAutoItemize shape:

interface InvoicePatchForAutoItemize {
  invoiceNumber?: string | null;
  amount?: number; // exclusiveMinimum: 0
  date?: string; // YYYY-MM-DD
  dueDate?: string | null; // YYYY-MM-DD
  notes?: string | null;
}

The patch is a strict subset of UpdateInvoiceRequest: status and vendorId are NOT patchable through this endpoint. Vendor changes go through the existing PATCH /api/vendors/:vendorId/invoices/:invoiceId endpoint (and re-parenting an invoice to a different vendor is not currently supported by the API). Status changes go through the same PATCH endpoint.

Transactional semantics:

When dryRun: false is sent with both invoicePatch and lines, the patch is applied first and the lines are then created inside the same SQLite transaction. If line creation fails (validation error, ITEMIZED_SUM_EXCEEDS_INVOICE, transient DB error), the patch is rolled back — the invoice remains in its prior state. The endpoint never leaves the database in a half-applied state.

ExtractedLine shape (shared/src/types/budgetExtraction.ts; request schema lineItemSchema in server/src/routes/invoiceAutoItemize.ts, additionalProperties: false):

interface ExtractedLine {
  description: string; // Required. 1-1000 chars
  quantity?: number;
  unit?: string; // Max 100 chars
  unitPrice?: number;
  totalAmount: number; // Required. >= 0
  includesVat?: boolean; // false = totalAmount is net; persisted amounts are grossed up (see below)
  vatRate?: number; // Informational; not consulted by persistence
  vendorName?: string;
  confidence: number; // Required. 0..1
  assignedBudgetLineId?: string; // Existing budget line to link (assign-existing)
  assignedBudgetLineType?: 'work_item' | 'household_item'; // FK family of assignedBudgetLineId
  assignmentMode?: 'create-new' | 'assign-existing'; // Absent = inferred from assignedBudgetLineId
  budgetCategoryId?: string | null; // create-new only. Max 36 chars
  budgetSourceId?: string | null; // create-new only. Absent = discretionary source. Max 36 chars
  category?: string | null; // Dry-run output only: raw LLM category name, mapped server-side to budgetCategoryId
}

All optional fields also accept null on the request.

Assignment modes (commit):

  • create-new (or assignmentMode absent and no assignedBudgetLineId): creates a new work_item_budgets row (origin 'auto') from the line's description, quantity, unit, unitPrice, totalAmount, includesVat, budgetCategoryId and budgetSourceId, then an invoice_budget_lines junction row.
  • assign-existing (or assignmentMode absent and assignedBudgetLineId set): links only. Both assignedBudgetLineId and assignedBudgetLineType are required (400 VALIDATION_ERROR otherwise); the referenced line must exist (404 NOT_FOUND otherwise). The server creates only the invoice_budget_lines junction row, with itemizedAmount = the VAT-effective totalAmount (grossed up when includesVat: false). The referenced budget line is never modified: the line's description, quantity, unit, unitPrice, budgetCategoryId and budgetSourceId are ignored in this mode. A budget line is linked at most once (user decision, #2149): to at most one invoice, and by at most one row per request. The server validates every assign-existing row before any write, inside the commit transaction, and rejects the whole request with 409 BUDGET_LINE_ALREADY_LINKED (nothing is written) when (a) two rows in lines reference the same budget line (reason: 'duplicate_in_request'), (b) the line is linked to a different invoice (reason: 'linked_to_other_invoice'), or (c) in append mode, the line is already linked to this invoice (reason: 'linked_to_this_invoice'). details is { budgetLineId, budgetLineType, reason }. In replace mode, a line still linked to this invoice after the replace deletion (non-auto links survive it) is not rejected: its single existing junction row's itemizedAmount is overwritten with the row's VAT-effective totalAmount and updatedAt is bumped — amounts are never summed, so re-running a replace save is idempotent for re-linked lines. Clients should not offer such lines: the review-page picker hides budget lines linked to any invoice in either mode and lines already chosen by another row of the same draft. Every row's VAT-effective totalAmount counts toward the ITEMIZED_SUM_EXCEEDS_INVOICE check, which compares amount against the invoice's total stored itemizedAmount after the call: all of the invoice's junction rows that survive any replace-mode deletion, plus everything this call inserted or overwrote. (#2149)

VAT-effective amount: includesVat: false multiplies totalAmount by 1 + VAT_RATE (server config vatRate, default 0.19, the same value exposed by GET /api/config), rounded to cents; any other value uses totalAmount as is. The per-line vatRate field is not consulted.

Response (dry-run, 200 OK):

{
  "lines": [
    {
      "description": "Concrete blocks (50 units @ €5.20)",
      "quantity": 50,
      "unit": "blocks",
      "unitPrice": 5.2,
      "totalAmount": 260.0
    }
  ],
  "warnings": [
    {
      "code": "TOTAL_MISMATCH",
      "extractedTotal": 1200.0,
      "invoiceTotal": 1500.0
    }
  ]
}

Response (commit, 200 OK):

Full InvoiceBudgetLineListDetailResponse shape (same as GET /api/invoices/:invoiceId/budget-lines):

{
  "budgetLines": [
    {
      "id": "ibl-1",
      "invoiceId": "inv-1",
      "workItemBudgetId": null,
      "householdItemBudgetId": null,
      "itemizedAmount": 260.0,
      "budgetLineDescription": "Concrete blocks (50 units @ €5.20)",
      "plannedAmount": null,
      "confidence": "unconfirmed",
      "categoryId": null,
      "categoryName": null,
      "categoryColor": null,
      "parentItemId": null,
      "parentItemTitle": null,
      "parentItemType": "unassigned",
 
⚠️ **GitHub.com Fallback** ⚠️