* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) Why the problem occurred: the legal form was modelled as a binary flag in ~300 files. `EntityType` was a two-member union, but nothing dispatched on it exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize, match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut, MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B` ternary. Widening the union compiled everywhere and changed nothing, so a förening would have booked as an enskild firma in the app and as an aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused föreningar at the door (mapEntityType returned null), which is what the tester hit. What was removed or simplified: the silent defaults. One module, lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser (never defaults), the resolver (settings hint, then companies.entity_type, then throw) and `byEntityType`, whose Record arms make the compiler refuse the next widening until each site has an answer. The form-dependent facts (closing account, owner settlement account, calendar-year lock, default method, K1/K2 label, personnummer vs 16-prefix) live there once instead of in the ternaries. On the SQL side supported_entity_types() replaces four copies of the literal list in the create RPCs. Why this shape and not the proposed one: the tracker asked for the enum widening plus a chart; that alone was the dangerous version (compiles, books wrong). Bundling stiftelse was considered and dropped: identical plumbing but no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so the CHECK, RPCs and seed can ship now and the first partner is switched on without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut, Swish) lands on the tracker. Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner accounts, member settlement on 2890; accrual default; brutet räkenskapsår allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix. Migration 20260908110835 widens the three CHECK constraints, adds supported_entity_types(), re-creates the three create RPCs with the widened guard and adds the förening block to seed_chart_of_accounts. Applied to staging and covered by ideell-forening-entity-type.pg.test.ts. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * fix(company): close the förening paths the skeptic refuted (#2072) Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the shared definition rather than the reported site: 1. Privately paid supplier invoices and the utlägg dialog resolved the owner account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening member's invoice was built on 2893 and then refused by the expense-claim service (which already said 2890), burning an ankomstnummer. The helper now uses ownerSettlementAccount. 2. Booking templates substitute their `_ab` accounts only for an aktiebolag; the `private_expense` template kept its base 2013 for a förening. Template accounts now resolve through templateAccountForForm: EF base, AB override, förening base with owner accounts translated to 2890 (booking-templates.ts and proposal-lines.ts share it). 3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline: the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule now keys on fiscalYearLockedToCalendar instead of the two literals; same in the MCP VAT report. 4. 2069 would have accumulated across years: the year-open omföring was AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068) and skips forms with no carry (EF). 5. With the flag off, a registry lookup that returned "Ideell förening" was prefilled into the onboarding journey, the form picker was skipped and the create step answered "Ogiltig företagsform" with no way back. The journey, the BankID picker, the onboarding page and the MCP lookup now use mapSetupEntityType, which maps only creatable forms, so a flagged-off form falls through to the picker as before. Also: form picker keeps its AB-first order; tests for each fix. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(migrations): move ideell förening migration after main's latest version (20260908143051) Two migrations landed on main after the branch forked; a lower version would be skipped by the merge-time apply. Staging history row renamed to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(skills): regenerate accounted-api reference for the widened entity_type enum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
13 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[],
coverage?: Record<string, unknown>
}
}
Example response 200:
{
"data": [
{
"id": "8fd5b1f4-…",
"name": "Acme AB",
"org_number": "556677-8899",
"entity_type": "aktiebolag",
"role": "owner",
"created_at": "2025-01-04T08:00:00Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
POST /api/v1/companies
Create a company and set it up for bookkeeping.
scope:companies:write · risk:medium · dry-run
Creates a new company owned by the API key user (or attached to one of their teams) and sets it up in one call: owner membership, BAS chart of accounts for the company form, compliance settings, the first fiscal period and the automatic tax deadlines. A 30-day trial with every paid capability starts immediately. Intended for partner platforms provisioning client companies (byrå/vertical SaaS) and for agents onboarding a user.
Use when: A platform or agent needs to provision a company that does not exist in Accounted yet. The caller becomes its owner; invite the end customer afterwards. Do not use for: Companies that already exist (list them with GET /api/v1/companies), or changing settings on an existing company (PATCH /api/v1/companies/{companyId}/settings).
Pitfalls:
- A VAT-registered company MUST send moms_period (monthly / quarterly / yearly); the request is refused otherwise, because a missing period silently produces zero VAT deadlines.
- Bookkeeping duty under BFL starts when the company exists with a fiscal period: do not create companies to try things out. Use a test-mode key (dry run) for that.
- Enskild firma always runs on the calendar year; fiscal_year_start_month is ignored for it.
- first_fiscal_year is only for a company in its first year (BFL 3 kap.: up to 18 months). Omit it for an established company.
- Not idempotent, and Idempotency-Key is not honoured on this company-less route: a retry after a network failure creates a second company. List GET /api/v1/companies before retrying.
- org_number is required for a VAT-registered company (the invoice momsregistreringsnummer derives from it), and f_skatt must be stated explicitly: F-skatt approval is never assumed.
- accounting_method may be omitted: it then defaults by form (aktiebolag accrual, enskild firma cash) and the response shows the resolved value. The cash default is only legal when turnover normally stays under 3 MSEK (BFL 4 kap 4 §): send accrual explicitly for a larger enskild firma.
Request body:
{
name: string,
entity_type: "enskild_firma" | "aktiebolag" | "ideell_forening",
org_number?: string,
vat_registered: boolean,
moms_period?: "monthly" | "quarterly" | "yearly",
accounting_method?: "accrual" | "cash",
f_skatt: boolean,
fiscal_year_start_month?: number,
first_fiscal_year?: { start: string, end: string },
address_line1?: string,
postal_code?: string,
city?: string,
team_id?: string
}
Example request:
{
"name": "Acme AB",
"entity_type": "aktiebolag",
"org_number": "5566778899",
"vat_registered": true,
"moms_period": "quarterly",
"accounting_method": "accrual",
"f_skatt": true
}
Response 200:
{
data: {
id: string,
name: string,
entity_type: "enskild_firma" | "aktiebolag" | "ideell_forening",
org_number: string,
vat_registered: boolean,
moms_period: "monthly" | "quarterly" | "yearly",
accounting_method: "accrual" | "cash",
fiscal_period: { start_date: string, end_date: string, name: string },
team_id: 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[],
coverage?: Record<string, unknown>
}
}
Example response 200:
{
"data": {
"id": "8fd5b1f4-…",
"name": "Acme AB",
"entity_type": "aktiebolag",
"org_number": "5566778899",
"vat_registered": true,
"moms_period": "quarterly",
"accounting_method": "accrual",
"fiscal_period": {
"start_date": "2026-01-01",
"end_date": "2026-12-31",
"name": "Räkenskapsår 2026"
},
"team_id": null
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
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 }
}
}
Example request:
{
"bankgiro": "991-2346",
"contact_person": "Anna Andersson"
}
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[],
coverage?: Record<string, unknown>
}
}
Example response 200:
{
"data": {
"company_id": "aaaa1111-2222-4333-8444-555566667777",
"bank_name": "Testbanken",
"clearing_number": null,
"account_number": null,
"bankgiro": "991-2346",
"plusgiro": null,
"swish": null,
"iban": null,
"bic": null,
"contact_person": "Anna Andersson",
"email": "faktura@acme.example",
"phone": null,
"website": null,
"invoice_email_texts": null
},
"meta": {
"request_id": "req_...",
"api_version": "2026-05-12"
}
}
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[],
coverage?: Record<string, unknown>
}
}
Example response 200:
{
"data": {
"status": "ok",
"service": "gnubok",
"api_version": "2026-05-12",
"timestamp": "2026-05-12T16:25:06Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
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[],
coverage?: Record<string, unknown>
}
}
Example response 200:
{
"data": {
"operation_id": "0e9c-…",
"type": "fiscal_periods.year_end",
"status": "succeeded",
"progress": {
"phase": "committed",
"current": 142,
"total": 142
},
"result": {
"journal_entries_created": 4,
"opening_balances_set": 138
},
"error": null,
"started_at": "2026-05-12T10:01:23Z",
"completed_at": "2026-05-12T10:01:48Z",
"poll_url": "/api/v1/operations/0e9c-…",
"webhook_event": "operation.completed"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}