* fix(suppliers): stop requiring standardkonto that was never meant to be required The supplier form initializes every optional field to '' and sent them as-is, while CreateSupplierSchema validates default_expense_account with the 4-digit account rule behind .optional(): an empty string is a present string, so saving a supplier with the field untouched failed with "Kontonummer måste vara 4 siffror" even though the field carries no required mark (reported by Björn with a screen recording; the edit page failed the same way for any supplier without a default account). Schemas now own the normalization, split by verb: on create '' becomes undefined (key dropped, column NULL), on update '' becomes null, because update routes pass fields straight into .update() where undefined means "leave unchanged" and clearing must actually write NULL. Email gets the same treatment and the form's old client-side email strip is removed; stripping empty strings client-side would break exactly the clear path. The free-text Standardkonto input is replaced with the shared AccountCombobox (browsable list filtered to cost classes 4-7, the same rule the agent-path expenseAccountField enforces), with the selected account name shown under the field and a clear button when set. Standardkonto itself stays optional: it only prefills supplier-invoice lines and the ledger-context suggestion covers the empty case. Verified end to end against the running app: saving a supplier without a default account succeeds on the update path, and the combobox search/select/clear cycle works inside the create dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api-spec): render preprocess pipes by output side, required-ness by undefined-acceptance The minimal Zod-to-JSON-schema walker described every pipe by its input side. For .transform() that is right (the caller sends the input), but z.preprocess() is the mirror image: the callable sits on the input side, so the supplier schemas' new empty-string normalization rendered email and default_expense_account as required untyped fields in the OpenAPI spec and the generated accounted-api skill. Describe the output side when the input is a transform. Required-ness now derives from schema.safeParse(undefined) instead of a top-level discriminator check: a field may be omitted exactly when the schema accepts undefined. Besides the preprocess pipes, this corrects several fields the old check misrendered as required (z.unknown() bodies, union-with-empty-string settings fields, preprocessed personal_number), so the regenerated skill references only flip required to optional where runtime validation already allowed omission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7.4 KiB
Core endpoints
Connectivity, company discovery, async-operation polling, and company settings. Every session starts with GET /companies to resolve the companyId that all other URLs need.
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) are in SKILL.md and are not repeated per endpoint.
GET /api/v1/companies
List companies the API key can access.
scope:companies:read · risk:low · idempotent
Returns every non-archived company the API key user is a member of, together with their role. Use the returned id as {companyId} in subsequent endpoints.
Use when: You need to discover which company IDs an API key has access to before calling company-scoped endpoints. Do not use for: Fetching a single company you already know the id of: use GET /api/v1/companies/{companyId} for that.
Pitfalls:
- Multi-company keys (e.g. consultants) will see >1 result. Always pass the correct companyId in subsequent paths.
- Archived companies are excluded; if a company disappears the user has been removed from it or it was archived.
Response 200:
{
data: { id: string, name: string, org_number: string, entity_type: string, role: "owner" | "admin" | "member" | "viewer", created_at: string }[],
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
PATCH /api/v1/companies/{companyId}/settings
Partially update company settings.
scope:companies:write · risk:medium · idempotent · dry-run · reversible
Patches the company payment details (bank account, Bankgiro, Plusgiro, Swish, IBAN/BIC), the contact details shown on invoices (contact_person, email, phone, website), and the custom invoice email texts. All fields optional; at least one must be supplied. Idempotent (mandatory Idempotency-Key). Dry-runnable. The same validation as the MCP staging tool applies: Bankgiro/Plusgiro numbers are Luhn-checked and invoice email texts only accept a fixed placeholder set.
Use when: You need to change the payment or contact details that appear on invoices, or override the invoice email texts, directly over REST instead of the staged MCP flow. Do not use for: Legal or tax profile changes (org number, VAT registration, fiscal year, accounting method): those are not exposed on the public API. Reading settings (no GET endpoint yet; use the MCP tool gnubok_get_company_settings).
Pitfalls:
- Idempotency-Key is mandatory; calls without it return 400.
- contact_person is stored as default_our_reference: the default "Our reference" value on new invoices.
- bankgiro and plusgiro must carry a valid Luhn check digit; null or empty string clears them.
- invoice_email_texts only accepts the placeholders {fakturanummer} {kundnamn} {förnamn} {företag} {förfallodatum} {belopp}; any other {token} is rejected. Null clears every override.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Request body:
{
bank_name?: string,
clearing_number?: string | "",
account_number?: string | "",
bankgiro?: string | "",
plusgiro?: string | "",
swish?: string,
iban?: string | "",
bic?: string | "",
contact_person?: string,
email?: string | "",
phone?: string,
website?: string | "",
invoice_email_texts?: {
sv?: { subject?: string, greeting?: string, body?: string, signoff?: string },
en?: { subject?: string, greeting?: string, body?: string, signoff?: string }
}
}
Response 200:
{
data: {
company_id: string,
bank_name: string,
clearing_number: string,
account_number: string,
bankgiro: string,
plusgiro: string,
swish: string,
iban: string,
bic: string,
contact_person: string,
email: string,
phone: string,
website: string,
invoice_email_texts: { sv?: { subject?: string, greeting?: string, body?: string, signoff?: string }, en?: { subject?: string, greeting?: string, body?: string, signoff?: string } }
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
GET /api/v1/health
Health check.
risk:low · idempotent
Reports the API is reachable and what version is currently served. Public; no auth required.
Use when: You want to verify connectivity, latency, or which API version is live before issuing other requests. Do not use for: Anything that needs authenticated data. This endpoint returns no company-specific information.
Pitfalls:
- A 200 here only means the API process responds: downstream Postgres/Supabase may still be degraded.
Response 200:
{
data: { status: "ok" | "degraded", service: "gnubok", api_version: string, timestamp: string },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
GET /api/v1/operations/{id}
Poll a long-running operation by id.
scope:operations:read · risk:low · idempotent
Returns the current snapshot of a v1 async operation: status (queued / running / succeeded / failed / cancelled), progress (jsonb, free-form), result (on success), and error (on failure). The operation_id is returned by the POST endpoints that initiate async work (period close, year-end, currency revaluation, SIE import).
Use when: You started an async operation and need to know whether it has finished. Poll every 5-30 seconds until a terminal status. (The 202 response advertises operation.completed as the eventual push signal, but that webhook event is not deliverable yet — polling is the only supported completion signal today.)
Do not use for: Fetching the resource the operation produced: once status=succeeded, read the result field or call the resource-specific GET endpoint. Cancelling a running operation (no cancel endpoint exists in v1).
Pitfalls:
- Terminal statuses (
succeeded,failed,cancelled) are final; the row never transitions out of them. - progress is free-form jsonb; agents should treat it as opaque except for the documented fields
phase(string),current/total(numbers for percent calculation). - started_at is null while status=queued (the work has not begun yet); completed_at is null until a terminal status is reached.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
id |
path | string |
yes |
Response 200:
{
data: {
operation_id: string,
type: string,
status: "queued" | "running" | "succeeded" | "failed" | "cancelled",
progress?: Record<string, unknown>,
result?: unknown,
error: { code?: string, message?: string, details?: unknown },
started_at: string,
completed_at: string,
poll_url: string,
webhook_event: "operation.completed"
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}