API Contract - steilerDev/cornerstone GitHub Wiki
The API contract evolves incrementally as each epic is implemented. This page documents endpoint specifications, request/response shapes, and error patterns for all routes.
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.
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 1against 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
HEALTHCHECKcalls this endpoint every 30 seconds (Dockerfile). The sampledocker-compose.ymlhealthcheck callsGET /api/healthinstead.
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.
llmEnabledandautoItemizeEnabledare always equal — both mean "LLM_BASE_URL,LLM_API_KEY, andLLM_MODELare all set".llmEnabledis the preferred field for any new LLM-backed feature;autoItemizeEnabledis 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.NumberFormatcurrency formatting. -
autoItemizeEnabledisfalseby 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.
Cornerstone supports two authentication methods:
- Local authentication -- Email/password login for the initial admin account (setup flow) and as a fallback
-
OIDC authentication -- OpenID Connect sign-in for existing accounts. By default OIDC never creates accounts (opt-in just-in-time provisioning of
memberaccounts viaOIDC_JIT_PROVISIONING, defaultfalse; 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 (seeGET /api/auth/oidc/callbackand 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.
| 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.
| 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.
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.
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.
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.ipis typedstringbut is genuinelynullwhen the socket carries no address metadata.normalizeIPdereferences its argument (ip.toLowerCase()), so a generator that forwardsrequest.ipunguarded throws and turns every rate-limited endpoint into a500. The?? 'unknown'fallback puts such requests in one shared bucket instead. -
@fastify/rate-limitgates its own normalization on generator identity. It appliesnormalizeIPonly whenkeyGeneratoris the exactdefaultKeyGeneratorreference; every other generator is invoked askeyGenerator(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 callsnormalizeIPitself. That gate is what made CVE-2026-15144 possible.
Invariants any change to rateLimitKeyGenerator MUST preserve:
-
Call
normalizeIPon every return path. Returningrequest.ipverbatim, 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 onnormalizeIP's own parameter default makes theipv6Subnetoption silently ineffective and lets the two values drift apart. -
Keep the null guard.
request.ip'sstringtype is a lie at runtime; removing the fallback reintroduces the500. -
Never parse
X-Forwarded-For/X-Real-IPby hand. Redundant —TRUST_PROXYalready makesrequest.ipreflect those headers — and such code typically bypassesnormalizeIPon 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).
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: truemeans zero users exist in the database; the client should show the setup form. -
oidcEnabledtells the client whether to show the "Login with SSO" button.
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 |
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_CREDENTIALSerror 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_DEACTIVATEDto 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 viaPOST /api/users) attempting local login receiveINVALID_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'sid,role,authProvider, orpassword_hash.
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 |
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_idfrom config -
redirect_urifrom 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 |
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:
- Validates the
stateparameter against the stored value - Exchanges the
codefor tokens using the OIDC token endpoint. Theredirect_urisent 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) - Extracts
sub,email,email_verified,nameandpreferred_usernamefrom the ID token (only a booleantruecounts as verified; an absent claim is unverified). The two name claims are used only by step 8 (provisioning) -
Subject match. Looks up the user by
oidc_subject = sub(anyauth_provider). If found, that account signs in — neither an email claim noremail_verifiedis required on this path (the subject is already bound) -
Email present. No subject match: an empty or missing email claim redirects with
missing_email(error codeOIDC_MISSING_EMAIL) -
Verified-email gate. If
email_verified !== true, redirects withoidc_email_unverified. This check runs before any email lookup, so an unverified token can never probe which emails have accounts - 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
-
No match: reject or provision. With
OIDC_JIT_PROVISIONINGunset orfalse(the default), redirects withoidc_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 flagtrue, the callback creates the account (outcomeprovisioned), except that it still redirects withoidc_no_matching_accountwhen the email was ambiguous in step 7 (fail closed) or theuserstable is empty (initial setup has not run; provisioning a member first would permanently blockPOST /api/auth/setup). The new account hasrole = 'member'(neveradmin),authProvider = 'oidc',oidc_subject = sub, the claim'semail, and no password.displayNameis the first non-empty ofname,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 samesubfinds the account at step 4 and never inserts. If a write from outside the process (another instance or an external tool) already bound thesub, the insert hits the unique index onoidc_subject. The violation is caught, the account is re-read by subject, and it signs in as a subject match. The server logs provisioning atinfowith the newuserIdonly. Continues at step 11 -
Deactivated. If the matched account is deactivated, redirects with
account_deactivatedand 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 -
Link. Sets the matched account's
oidc_subjecttosub. If the account was already linked to a different subject, the re-bind is accepted (gated by step 6) and logged atwarn.id,role,authProvider, andpassword_hashare unchanged - Creates a session and sets the cookie
- 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), andOIDC_NO_MATCHING_ACCOUNT(403) in the Error Codes table; the endpoint surfaces them only as the redirect codes above. - The earlier
email_conflictredirect (and theEMAIL_CONFLICTerror code) no longer exist: an OIDC identity whose verified email matches a local account is linked to it rather than rejected.
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 |
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 |
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
403covers everyauthProvider: "oidc"account: OIDC just-in-time provisioned and admin-created SSO-only (POST /api/userswithauthProvider: "oidc"). It runs in the handler, after body schema validation, so such a user sending an invalid body gets400, not403. - This is the only password endpoint, and it requires the current password. No endpoint sets a password
for an account that has none, and
authProvidernever changes, so an SSO-only account stays SSO-only (#2122).
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
deactivatedAtset) - No pagination needed for <5 users; will be revisited if the user count model changes
- The
password_hashandoidc_subjectfields 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.
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 atpreHandler(403). A non-admin sending an invalid body therefore gets400, not403. 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 getsVALIDATION_ERROReven 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.fieldrather than the schema'sdetails.fields[]. Omittingpasswordnever silently creates an SSO-only account:authProvider: "oidc"must be explicit. -
OIDC_NOT_CONFIGUREDis400here (the request is well-formed but the instance cannot satisfy it), unlikeGET /api/auth/oidc/login, where the route itself is unavailable and it is404. - 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 = NULLandoidc_subject = NULL. They haveoidcLinked: falseuntil 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 answersINVALID_CREDENTIALS(seePOST /api/auth/login), andPOST /api/users/me/passwordanswers403. -
SSO-only is permanent. No endpoint sets a password for an account without one, and
PATCH /api/users/:idcannot changeauthProvider. 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 returns409. Case variants ([email protected]vs[email protected]) can coexist, consistent with ADR-035. - The limits match the rest of the API:
displayName1-100 as onPATCH /api/users/meandPATCH /api/users/:id, and a 12-character password minimum as onPOST /api/users/me/password. This endpoint has no route-specific rate limit.
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 |
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_atset to the current timestamp - All active sessions for the deactivated user are deleted immediately
- Deactivated users cannot log in. Local login returns
ACCOUNT_DEACTIVATEDfor an account with a password andINVALID_CREDENTIALSfor one without (OIDC-provisioned or SSO-only, seePOST /api/auth/login); the OIDC callback redirects withaccount_deactivated - An already-deactivated user can be "re-deleted" without error (idempotent)
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 bumpsupdated_at). - The endpoint does not check
deactivated_at; unlocking a deactivated account returns204but does not reactivate it. - Sessions are not touched.
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.
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 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:
-
areais the spatial location of the work item (nullable). Only id, name, and color are embedded. -
assignedVendoris the vendor assigned to this work item (nullable, mutually exclusive withassignedUser). -
subtasksare always embedded, sorted bysortOrderascending. -
dependencies.predecessorslists work items that this item depends on.dependencies.successorslists work items that depend on this item. Both include a summary of the related work item. -
budgetsare 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/notesto 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.
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 |
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 |
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 |
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
nullto clear the value. -
Actual dates auto-population: When
statuschanges, actual dates are auto-populated as follows:-
not_started->in_progress: setsactualStartDateto today (if not explicitly provided) -
in_progress->completed: setsactualEndDateto today (if not explicitly provided) -
not_started->completed: setsactualEndDateto today (if not explicitly provided and not already set), and sets a missingactualStartDateto the resolved actual end date, theactualEndDatethe work item has after the call (#2209; before, it was always today, which stored start > end for "Mark done" with a past date) - If
actualStartDateoractualEndDateis explicitly included in the request body (even asnull), 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 |
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 |
All tag endpoints have been removed in EPIC-18. Tags are replaced by structured Area and Trade dimensions. See ADR-028.
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.
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 |
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 |
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 |
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 |
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:detailsis not sent: the error is raised withsuppressDetails(#315 hardening), so its counts reach only the server log and the response carriescodeandmessagealone. 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.
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.
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 |
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 |
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) |
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) |
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:detailsis not sent: the error is raised withsuppressDetails(#315 hardening), so its counts reach only the server log and the response carriescodeandmessagealone. 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; the409above stays the authority (another user may have assigned the trade since the list loaded). Trades have noGET /api/delete-impactentry (EPIC-21 contract 19): a delete that succeeds affects nothing else.
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:
-
:idmust exist (404),targetTradeIdmust exist and differ from:id(400). -
UPDATE vendors SET trade_id = :targetTradeId, updated_at = :now WHERE trade_id = :id(the moved companies'updated_atchanges, like any edit of their trade). -
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.
Notes are nested under work items. All note endpoints require the parent work item to exist.
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 |
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 |
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 |
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 |
Subtasks are nested under work items. All subtask endpoints require the parent work item to exist.
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 |
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 |
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 |
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 |
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 |
Dependencies define predecessor/successor relationships between work items for scheduling.
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 |
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 |
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 |
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 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).
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 |
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 |
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/:idto move budget lines between work items and household items.
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:detailsis not sent: the error is raised withsuppressDetails(#315 hardening), so its counts reach only the server log and the response carriescodeandmessagealone. 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.
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). -
lineAmountfor a non-invoiced line is the VAT-effective planned amount: a net line (includesVat: false) is grossed up by1 + VAT_RATE(server configvatRate, default0.19, exposed byGET /api/config) and rounded to cents before the confidence margin and the subsidy percentage apply; a gross line is used as stored. This matchesGET /api/budget/overview(subsidySummary) andGET /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
subsidiesis 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%).
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/:idfor full details on a specific household item.
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).
interface BudgetCategoryResponse {
id: string;
name: string;
description: string | null;
color: string | null;
sortOrder: number;
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}Budget categories are a small, finite collection (typically 10-20 items). They are NOT paginated.
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 |
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) |
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.
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 |
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) |
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:detailsis not sent: the error is raised withsuppressDetails(#315 hardening), so its counts reach only the server log and the response carriescodeandmessagealone. Server-side counts (log only):subsidyProgramCount,budgetLineCount,householdBudgetCount.
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:
-
invoiceCountis the total number of invoices for this vendor (all statuses). -
outstandingBalanceis 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. Returns0if nothing is left to pay. (changed in #2194, D-06; previously summedpending+claimedinvoice amounts)
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).
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_ERRORwithdetails.tradeIds. (Trades are a small list, see Trade Endpoints; 50 bounds theIN (…)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.tradeIdstays the filter and still combines with AND, sotradeId=A&q=x&tradeIds=Breturns companies of trade A that matchxby text (Bcan'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 |
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 |
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 |
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 |
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:detailsis not sent: the error is raised withsuppressDetails(#315 hardening), so its counts reach only the server log and the response carriescodeandmessagealone. Server-side counts (log only):invoiceCount,budgetLineCount. - Vendors with no invoices and no budget line references can be freely deleted.
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:
-
budgetLinesis always included in invoice responses (empty array if no budget lines are linked). -
remainingAmountis computed server-side:invoice.amount - SUM(budgetLines[].itemizedAmount). It represents the portion of the invoice not yet attributed to any budget line. - The previous
workItemBudgetIdfield has been removed from the invoice response (EPIC-15). Budget line linkage is now exclusively managed through theinvoice_budget_linesjunction table.
Notes on deposits and finalPaymentAmount:
-
depositsis ordered bydueDateascending, thencreatedAtascending. In list responses (GET /api/vendors/:vendorId/invoices), this field is always an empty array. OnGET /api/invoices, it is also always an empty array except whenopenOnly=true(Story #2046), in which case it is populated with the invoice's full deposit list (not filtered to pending — see theopenOnlynote above). In all casesfinalPaymentAmountis refund-aware (computed from the full deposits data) regardless of whetherdepositsitself is populated. -
finalPaymentAmountis 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 theopenOnlynote above) is a separate, additive figure — it is not a replacement forfinalPaymentAmount.
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 statuspendingorpaid) withPOST /api/invoices/:invoiceId/convert-quotation(Story #2107).paidis 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.
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.
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 |
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 |
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
vendorIdidentifies the source vendor (used to locate the invoice); the optionalvendorIdin the request body is the target vendor to reassign the invoice to. If the bodyvendorIdreferences a vendor that does not exist, the endpoint returns404(NOT_FOUND, "Vendor not found") and the invoice is left unchanged. Omitting the bodyvendorIdleaves the vendor unchanged (backward compatible). Reassignment preserves the invoice's budget-line links.
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
outstandingBalanceandinvoiceCountfields (visible in subsequentGET /api/vendors/:idresponses). - The invoice must belong to the specified vendor. If the invoice exists but belongs to a different vendor, a 404 is returned.
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).
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 fromallocatedAmount(which is itself this-source-only). Summing it does not reconstructinvoiceAmount, 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 byinvoices[].splitKind. Inferring either frombudgetLinesForSource.length, fromdepositsVisibleToSource, or fromsum(allocatedPortion) < invoiceAmountis 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 thesplitKinddiscriminator (#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 (hasOwnTaggedDepositinbuildReportContent.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_TAGcannot be resolved, all document fields (archiveSerialNumber,title) arenull - With
PAPERLESS_FILTER_TAGconfigured, a document that does not carry the tag getsnullarchiveSerialNumberandtitle, 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 |
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:
- Status is
pendingorpaid, OR - 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
pendingorpaidAND no other-source budget line interest → flipped toclaimed; diary event fired (if enabled) -
Invoices with status
pendingorpaidBUT 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 toclaimed;claimedDate = today()(YYYY-MM-DD);paidDatepreserved (never cleared); diary event fired (if enabled)
Request Validation Rules:
- At least one of
invoiceIdsordepositIdsmust be non-empty; it is an error to provide both as empty arrays. -
sourceIdmust 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. |
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
-
includedInvoiceIdsare filtered to the report's actual invoice IDs server-side - Throws
EMPTY_SELECTIONif 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
CURRENCYenvironment variable (exposed via/api/config) -
descriptionsis keyed by the invoice IDs the server accepted fromincludedInvoiceIds; a client must not assume a key exists for an ID that was filtered out - Length limits:
letterSubject150,letterBody2000, per-invoicedescription200 characters. The limit stated in the prompt and the limit enforced by the response validator are the same value by construction — both derive fromREPORT_CONTENT_LIMITSinserver/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 withLLM_INVALID_RESPONSE -
letterBody,letterSubject, and eachdescriptions[].descriptionare 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\nline 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 lone15. Mai 2026 …or2. 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_PROMPTrule 4 inserver/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
letterBodyexclusions (no salutation, closing formula or signature; German lowercase start) are enforced only by the prompt (REPORT_CONTENT_SYSTEM_PROMPTrule 4 and the output-format line inserver/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 inReportContentEditor.tsxbefore the PDF is generated. - Requires the LLM gateway to be configured. Clients should gate the UI on
llmEnabledfromGET /api/configrather than relying on theLLM_NOT_CONFIGUREDerror path
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.
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 |
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 |
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-setspaidDate = today()unlesspaidDateis supplied in the request. ClearsclaimedDate. -
pending → claimed(Story #1878): Auto-sets bothpaidDate = today()andclaimedDate = today()unless supplied in the request. -
paid → claimed: Auto-setsclaimedDate = today()unlessclaimedDateis supplied in the request. PreservespaidDate. -
paid → pending: Clears bothpaidDateandclaimedDate. -
claimed → paid: ClearsclaimedDate, preservespaidDate.
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 |
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 |
The same deposit operations are also available under vendor scope for consistency:
GET /api/vendors/:vendorId/invoices/:invoiceId/depositsPOST /api/vendors/:vendorId/invoices/:invoiceId/depositsPATCH /api/vendors/:vendorId/invoices/:invoiceId/deposits/:depositIdDELETE /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.
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).
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
summaryfield 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 tosummary.openPayableandsummary.refundsDue(Story #2046) — they do not change whenopenOnlyor any other filter is applied. -
summary.overdueincludes pending invoices with adueDatebefore today (local time). This is a subset ofsummary.pendingand helps highlight invoices requiring immediate attention. - Results are sorted by
datedescending by default (newest invoices first). Exception: whenopenOnly=trueandsortByis omitted, results are instead sorted ascending by earliest open due date — the minimum of the invoice's owndueDate(when the invoice ispending) and thedueDates of itspendingdeposits. Invoices with no dated open item sort last. Passing an explicitsortByoverrides this and behaves as usual. - The
qparameter matches the invoice number, the company (vendor) name, or the invoice description (notes) using case-insensitive substring matching;%,_and\match literally. -
Story #2046 —
openOnly: whentrue, only invoices that are themselvespendingor that carry at least onependingdeposit (eitherentryType) are returned. Pagination (totalItems/totalPages) counts invoices only — deposits never consume page capacity. On these responses only, each invoice'sdepositsarray is populated (see the note ondepositsabove) and each invoice carries an additionalopenAmountfield:-
openAmount=(status === 'pending' ? max(0, amount − Σ deposit-type entries of any status) : 0) + Σ (pending deposit-type entries). Pending refunds are excluded fromopenAmount— they are reported separately viasummary.refundsDue. -
summary.openPayable={ count, totalAmount }over all invoices (not just the current page/filter):totalAmountis the sum of every invoice'sopenAmount,countis the number of invoices withopenAmount > 0. This total is computed from the exact same per-invoice figures returned inopenAmount— summinginvoices[].openAmountacross every page of anopenOnly=trueresult set, with no other filters applied, reproducessummary.openPayable.totalAmountexactly. Ifq,vendorId, or a date/amount range filter narrows the visible invoices, the summedopenAmountvalues are only a subset and will not equal the (always global/filter-independent)summary.openPayable.totalAmount. -
summary.refundsDue={ count, totalAmount }:totalAmountis the sum of pending refund-type deposit amounts (positive),countis the number of invoices carrying at least one pending refund. Refunds are never netted intoopenPayable. - A
quotationinvoice with a pending deposit is returned (its pending deposit is an open item), but the invoice's own amount contributes nothing toopenAmountoropenPayable.
-
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 |
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 |
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 includeremoveAdditional: 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
budgetLineschange. Every other line of the invoice keeps itsitemizedAmount. - Lines cannot be added or removed here. To itemize new lines, use
POST /api/invoices/:invoiceId/budget-linesor 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 tolerantexceedsAmountcomparison. This applies even whenbudgetLinesis omitted: a final amount below the existing itemized total is rejected withITEMIZED_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_statusdiary auto-event (quotation→ the target status) is written after the transaction commits, as forPATCH. It honoursDIARY_AUTO_EVENTStoday; 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:
- 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. - Invoice exists:
404 NOT_FOUND. - Invoice status is
quotation:409 INVOICE_NOT_QUOTATION. - Fields (amount, calendar dates, effective due date ≥
date):400 VALIDATION_ERROR. - Notes (non-blank
conversionNote, combined length ≤ 10000):400 VALIDATION_ERROR. - Budget lines (duplicate ids:
400; id not on this invoice:404). - Itemized sum:
400 ITEMIZED_SUM_EXCEEDS_INVOICE. - 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.
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:
-
usedAmountrepresents the planned allocation perspective -- summed over allwork_item_budgetsandhousehold_item_budgetsrows referencing this source: an invoiced line contributes itsitemized_amount, a non-invoiced line its VAT-effectiveplanned_amount(a net line,includes_vat = 0, is grossed up by1 + VAT_RATE(server configvatRate, default0.19, exposed byGET /api/config), rounded to cents, exactly likeprojectedAmount). It reflects how much of the source has been earmarked for budget lines, on the same gross basis astotalAmount. -
availableAmountistotalAmount - usedAmount, showing how much planned capacity remains (VAT-effective, becauseusedAmountis). -
claimedAmountrepresents the actual drawdown perspective -- the sum ofclaimedcontributions from invoices linked (viainvoice_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). -
unclaimedAmountrepresents the sum ofpaidcontributions (non-claimed, non-pending) from linked invoices, using the same proportional split. -
paidAmountequalsclaimedAmount + unclaimedAmount(i.e., all non-pending contributions). -
actualAvailableAmountistotalAmount - claimedAmount, showing how much of the source's funds remain after actual drawdowns. -
projectedAmountis a blended projection: invoiced budget lines use actual cost, non-invoiced lines apply confidence margins toplanned_amount. A net line (includes_vat = 0) has its planned amount grossed up by1 + VAT_RATE(server configvatRate, default0.19, exposed byGET /api/config) before the margin is applied, the same as the budget overview and breakdown. -
isDiscretionaryistrueonly 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 itssourceTypechanged.
Deposit-aware aggregation: When an invoice has deposits, each invoice_budget_lines row's contribution is split proportionally:
- Each deposit
dcontributesibl.itemizedAmount × (d.amount / invoice.amount)underd.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'sstatus. - Zero-deposit invoices behave identically to before: 100% of
ibl.itemizedAmountcontributes under the parent invoice'sstatus. - 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_linesrow's contribution for the source uses the pro-rata split above, BUT deposits with non-nullbudget_source_iddo 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.amountover 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_depositsrows wherebudget_source_id = sourceId, signed byentryType(deposit= +,refund= -), filtered to the report's target status slice (e.g.,claimed,paid, orpendingdepending 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).lineKindderives from the combined sign.unallocatedInvoicesexcludes invoices with ≥1 tagged deposit to the source. -
Reconciliation invariant: for a single invoice fully covered by budget lines, summing
allocatedAmountacross 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 gets600 × 0.85 + 150 = 660and B gets400 × 0.85 = 340, totalling 1000.
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).
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 |
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 |
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 |
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 |
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_SOURCEerror 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:detailsis not sent: the error is raised withsuppressDetails(#315 hardening), so its counts reach only the server log and the response carriescodeandmessagealone. Server-side counts (log only):budgetLineCount. - Budget sources with no budget line references can be freely deleted.
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:
-
BaseBudgetLineextends 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 —trueif any linked invoice hasstatus = 'claimed'OR any deposit on a linked invoice hasstatus = 'claimed'. This is a status-existence check (not an amount threshold): a pending invoice with a claimed deposit returnstrue; a claimed invoice whose residual is fully absorbed by non-claimed deposits also returnstrue. 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 foractualCost". -
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 denormalizedvendorId/vendorNameso callers do not need a separate vendor fetch. - The
areafield 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:
- Area name (ascending, nulls last)
- Parent item name (ascending)
- Created at (ascending)
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Budget source ID does not exist |
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:
- Source budget source exists (404 if missing)
- Target source ID is different from source ID (400 SAME_SOURCE if equal)
- Target budget source exists (404 if missing)
- At least one budget line ID is provided (400 EMPTY_SELECTION if both arrays empty)
- 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_idandupdated_atare changed. - Both empty arrays are allowed only if at least one is non-empty; providing two empty arrays triggers EMPTY_SELECTION.
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 |
All endpoints require a valid session (member or admin).
Lists all subsidy programs, sorted by name ascending. Not paginated.
Response (200 OK): { "subsidyPrograms": SubsidyProgram[] }
Error responses: 401 UNAUTHORIZED.
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.
Response (200 OK): { "subsidyProgram": SubsidyProgram }
Error responses: 401 UNAUTHORIZED; 404 NOT_FOUND (no program with that 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
applicationStatuschanges, asubsidy_statussystem event is written (ADR-020; de-duplicated per ADR-040 W1 and W3). Sending the current status again writes nothing. - When
applicationStatuschanges, the body carries an Undo token (contract 11); Undo restoresapplicationStatusandupdatedAtonly.
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 |
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) |
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
projectedMinandprojectedMaxfields 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 by1 + VAT_RATE(server configvatRate, default0.19, exposed byGET /api/config) before margins and subsidies apply. Gross lines are used as stored.GET /api/budget/breakdownand the budget-sourceprojectedAmountuse 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:
-
remainingVsMinPlannedWithPaybackandremainingVsMaxPlannedWithPaybackaccount for expected subsidy payback amounts. Formula:availableFunds + totalPayback - totalPlanned. This answers the question "how much remains if we receive the expected subsidy reimbursements?" -
subsidySummary.minTotalPaybackandsubsidySummary.maxTotalPaybackaggregate 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-itemGET /api/work-items/:workItemId/subsidy-paybackendpoint, including the gross-up of net lines by1 + VAT_RATEbefore the subsidy percentage applies).
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.
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.
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 thechildrenarray. - 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 ASCthenname ASCat 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
BreakdownBudgetLineincludes abudgetSourceId(string or null) indicating its assigned budget source, anoriginfield ('manual' | 'auto') indicating how the line was created, and anisQuotationboolean flag. Lines withbudgetSourceId === nullare not assigned to any source. - The
budgetSourcesarray contains all budget sources that have at least one budget line assigned to them. Aggregate figures (projectedMinandprojectedMax) represent the perspective-dependent cost range across all lines assigned to that source. The frontend computesallocatedCostat the active perspective and derivesremaining = totalAmount - allocatedCost.
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 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;
}Milestones represent major project progress points. The milestone list is expected to remain small (fewer than ~50 milestones) so no pagination is used.
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 |
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 |
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 |
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
isCompletedis set totrue, thecompletedAtfield is automatically set to the current timestamp. - When
isCompletedis set tofalse, thecompletedAtfield is automatically cleared tonull.
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 |
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 |
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 |
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 |
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.
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 |
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 |
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 |
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
}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 |
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 |
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 |
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 |
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 |
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_daysvalue. Orphan work items with no dates and no dependencies are excluded. - Items with status
completedare included in the schedule calculation (their dates are fixed), but a warning of typealready_completedis generated if their dates would change. - The
criticalPatharray contains work item IDs in topological order (from project start to project end). - The
isLateflag indicates that the scheduling engine clamped an item's dates to today because they fell in the past. Rule 2: anot_starteditem whose computed start is before today has its start floored to today. Rule 3: anin_progressitem whose computed end is before today has its end floored to today. When actual dates are set (Rule 1), they take precedence andisLateisfalse. -
autoReschedule(server-side, after writes) persists the result of the same engine without Rules 2/3;isLatehere is the preview with the floor. The floor is a read-time projection on the timeline and work-item reads (contract 4, #2199).
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,projectedStartDateorprojectedEndDatenon-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/schedulebut 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/endDateare the stored planned dates and are no longer replaced for not-started items (D-38). Forecast dates,isLate,lateDaysandisHeldUpcome from the one read-time projection (engine run without and with the today floor; contract 4, #2199). The critical path and milestoneprojectedDate/isLate/isEarlyuse the forecast run. -
dateRangeis computed from the returned work items over planned and forecast dates. If no work items have dates set,dateRangeisnull. - This endpoint does not include budget information -- it is purpose-built for the Gantt chart and calendar views. Use
GET /api/work-items/:idfor full work item details including budgets. - The
householdItemsarray 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. TheisLateflag is true when the computed earliest delivery date is in the past and was floored to today by the scheduler.
All API endpoints are prefixed with /api/.
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.
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".
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)
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.
| 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) |
All requests and responses use application/json, except:
- OIDC redirect endpoints (
GET /api/auth/oidc/loginandGET /api/auth/oidc/callback) which return HTTP redirects (302) -
204 No Contentresponses which have no body
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
totalPagesreturns an emptyitemsarray with valid pagination metadata. - The
GET /api/usersendpoint does not use pagination (fewer than 5 users). TheGET /api/areasendpoint does not use pagination (areas are expected to be fewer than ~100). TheGET /api/tradesendpoint does not use pagination (trades are expected to be fewer than ~50). TheGET /api/budget-categoriesendpoint does not use pagination (budget categories are a small, finite collection). TheGET /api/budget-sourcesendpoint does not use pagination (budget sources are a small, finite collection). TheGET /api/work-items/:id/budgetsendpoint does not use pagination (budget lines per work item are a small collection). TheGET /api/vendors/:id/invoicesendpoint does not use pagination (invoices per vendor are typically fewer than ~50). TheGET /api/milestonesendpoint does not use pagination (milestones are expected to be fewer than ~50). - The
GET /api/invoicesendpoint (standalone, cross-vendor) uses standard pagination since it spans all vendors and may return many items.
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
qparameter for case-insensitive substring matching
Sorting:
-
sortByspecifies the field to sort by (default varies per endpoint) -
sortOrderspecifies direction:ascordesc(default varies per endpoint) - Only one sort field is supported per request
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 adds two groups of endpoints:
-
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. -
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).
| 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) |
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.
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
reachablecheck makes a lightweight call to the Paperless-ngx API to verify connectivity.
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
searchHitfield is only present when aqueryparameter is provided. It isnullfor non-search listings. - The
contentfield 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_TAGset, results are restricted to that tag; if the tag cannot be resolved the request fails with502 PAPERLESS_ERRORand never returns unfiltered results (see Filter tag above). -
Page stability (#2101): for listings without
query, the proxy sends Paperless-ngxordering=<sortBy>,id(both keys in the same direction assortOrder). Theidtiebreaker makes the order total, so consecutivepagerequests neither repeat nor skip documents. This matters becausecreatedis date-only (API v9+), so ties are common. Full-text searches (queryset) 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 byid.
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 |
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 |
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 |
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 |
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
documentCountfield 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 |
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
idascending. - Returns
{ "correspondents": [] }when the instance has no correspondents (empty-state, not an error). - Mirrors the
GET /api/paperless/tagspattern. Not cached — fetched on demand when the picker opens. - The returned
idis passed verbatim to thecorrespondentquery parameter onGET /api/paperless/documentsto 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 |
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
documentmultipart field. Missing file returns 400VALIDATION_ERROR. - Title must be provided in the
titlemultipart field. Missing title returns 400VALIDATION_ERROR. - If
PAPERLESS_FILTER_TAGis configured, the document is automatically tagged with that tag. If the tag cannot be resolved, the upload fails with502 PAPERLESS_ERRORand 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) |
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.
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 |
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
documentfield will benull. The link itself is preserved. - With
PAPERLESS_FILTER_TAGconfigured, a linked document that does not carry the tag is treated the same way:documentisnulland the link is preserved. If the configured tag cannot be resolved, everydocumentisnull(see Paperless-ngx › Filter tag). - The
contentfield is set tonullin list responses to reduce payload size. UseGET /api/paperless/documents/:idfor 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
documentfields arenull).
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing or invalid entityType or entityId
|
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 |
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 |
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 |
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 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;
}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, theplannedCostMin/plannedCostMaxfilters andfilterMeta.plannedCostall sum each budget line'splanned_amountwith net lines (includes_vat = 0) grossed up by1 + VAT_RATE(server configvatRate, default0.19, exposed byGET /api/config) and rounded to cents per line. This is the same figure the household-item detail page andGET /api/budget/breakdownshow, so filtering by planned cost matches what the user sees.
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 |
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 |
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
statusis changed to "arrived" andactualDeliveryDateis not provided in the request and is currently null, the server auto-populatesactualDeliveryDatewith today's date (YYYY-MM-DD). - Explicit
actualDeliveryDatevalues 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 |
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, andhousehold_item_subsidiesvia FK ON DELETE CASCADE. -
document_linksentries must be cleaned up in application code (no FK constraint on polymorphic table).
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).
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 |
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 |
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 |
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 |
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).
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, andinvoiceCountare computed frominvoice_budget_linesfor household item budget lines, using the same logic as work item budget lines. TheinvoiceLinkfield is also populated when a link exists. -
confidenceMarginis 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 |
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:
-
budgetCategoryIdis automatically assigned tobc-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 |
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/:idto move budget lines between work items and household items.
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 |
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.
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 |
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 |
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 |
Manages the M:N relationship between household items and subsidy programs. Not paginated.
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 |
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 |
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 |
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 by1 + VAT_RATE(server configvatRate, default0.19, exposed byGET /api/config) first. -
Fixed subsidies:
minPayback === maxPayback === reductionValue. - Confidence margin ranges: own_estimate (+-20%), professional_estimate (+-10%), quote (+-5%), invoice (+-0%).
-
Percentage subsidies:
- When no subsidies are linked, all totals are 0 and
subsidiesis an empty array.
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.
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 |
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) |
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).
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 |
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) |
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:detailsis not sent: the error is raised withsuppressDetails(#315 hardening), so its counts reach only the server log and the response carriescodeandmessagealone. Server-side counts (log only):householdItemCount.
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:
-
totalPlannedincludes household item budget lines in addition to work item budget lines. -
byCategorybreakdowns include household item budget line contributions per category. -
bySourcebreakdowns include household item budget line contributions per source. -
subsidySummaryincludes household items that have linked, non-rejected subsidies. - The response shape does not change; the backend aggregates across both
work_item_budgetsandhousehold_item_budgetsvia UNION queries.
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.
/** 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
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
preferencesarray 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 |
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 |
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 |
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).
/** 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
nullclears 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-userlocalepreference, Settings › Account) and not a server env setting (so not onGET /api/config). Stored inapp_settingsunder the keyentries_languageasdeoren; 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 validentries_languagerow exists (no row, or a stored value outsideENTRIES_LANGUAGES), the server derives the value on every read and never writes it:-
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 accountPOST /api/auth/setupcreated (OIDC provisioning refuses an emptyuserstable, so nothing can precede it). - If the founder's
user_preferencesrow withkey = 'locale'holdsdeoren, that is the value. - Otherwise (no founder yet, no stored locale,
system, or anything else) it isFALLBACK_ENTRIES_LANGUAGE(en). The server cannot see a browser language, sosystemis 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_atandidnever change, and the query ignoresrole, so promoting, demoting, adding or deactivating users does not change who the founder is. Only the founder's ownlocalepreference 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 firstPATCH /api/settingsthat carriesentriesLanguagewrites 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 sendsentriesLanguageonly when the user changed the select), so loading the page does not freeze it by accident.Rejected alternatives. Freezing the value on the first
GETraces 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 duringPOST /api/auth/setupwould 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. -
The founder is the earliest-created user:
-
Client use of
entriesLanguage: user-entered text is rendered withlang={entriesLanguage}on its element so the browser'shyphens: autohyphenates 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.
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.
entriesLanguageis always present: the stored value, else the founder's storedde/enlocale, else"en"(see "Unset value ofentriesLanguage") - Does not require prior writes (settings are lazily initialized)
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
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 |
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 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.
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;
}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.
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:
-
remainingAmountis computed:invoice.amount - SUM(budgetLines[].itemizedAmount). - Budget lines are sorted by
created_atascending (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 |
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) |
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
InvoiceBudgetLineCreateResponsewithbudgetLine(fullInvoiceBudgetLineDetailResponse) andremainingAmount.
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 |
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(orassignmentModeabsent and noassignedBudgetLineId): creates a newwork_item_budgetsrow (origin'auto') from the line'sdescription,quantity,unit,unitPrice,totalAmount,includesVat,budgetCategoryIdandbudgetSourceId, then aninvoice_budget_linesjunction row. -
assign-existing(orassignmentModeabsent andassignedBudgetLineIdset): links only. BothassignedBudgetLineIdandassignedBudgetLineTypeare required (400VALIDATION_ERRORotherwise); the referenced line must exist (404NOT_FOUNDotherwise). The server creates only theinvoice_budget_linesjunction row, withitemizedAmount= the VAT-effectivetotalAmount(grossed up whenincludesVat: false). The referenced budget line is never modified: the line'sdescription,quantity,unit,unitPrice,budgetCategoryIdandbudgetSourceIdare 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 everyassign-existingrow before any write, inside the commit transaction, and rejects the whole request with 409BUDGET_LINE_ALREADY_LINKED(nothing is written) when (a) two rows inlinesreference the same budget line (reason: 'duplicate_in_request'), (b) the line is linked to a different invoice (reason: 'linked_to_other_invoice'), or (c) inappendmode, the line is already linked to this invoice (reason: 'linked_to_this_invoice').detailsis{ budgetLineId, budgetLineType, reason }. Inreplacemode, a line still linked to this invoice after thereplacedeletion (non-auto links survive it) is not rejected: its single existing junction row'sitemizedAmountis overwritten with the row's VAT-effectivetotalAmountandupdatedAtis bumped — amounts are never summed, so re-running areplacesave 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-effectivetotalAmountcounts toward theITEMIZED_SUM_EXCEEDS_INVOICEcheck, which comparesamountagainst the invoice's total storeditemizedAmountafter the call: all of the invoice's junction rows that survive anyreplace-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",