# bokf protocol v1 Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later `bokf` is a self-hosted bookkeeping system for Swedish organizations (aktiebolag, enskild firma, handelsbolag, föreningar). One daemon owns the ledger. Every client — the CLI (`bokfctl`), the ncurses UI (`bokftui`) and any LLM agent — speaks exactly this protocol. There is no privileged client and no second API. ## 1. Design principles 1. **One API.** TUI, CLI and agent are peers. Anything one can do, all can do. 2. **Self-describing.** A client can discover every command, argument and permission at runtime (`describe`), plus prose workflow rules (`agent.instructions`). 3. **Dry run everywhere.** Every mutating command accepts `dry_run: true` and must have no side effects when it is set. 4. **The server owns the invariants.** Number series, balance, period locks, append-only vouchers, hash chains and tenant isolation are enforced inside the daemon, never trusted to clients. 5. **Machine-first errors.** Stable error codes and machine-readable `details`. ## 2. Components | Component | Role | |---|---| | `bokfd` | Daemon. Sole owner of the SQLite database. Serializes writes. | | `bokfctl` | Scriptable CLI client. First client built; used for tests and automation. | | `bokftui` | ncurses client for interactive reviewing and entry. | | agent | Any process using a token; a token is its own audit actor. | No client reads the database file or the filesystem directly. Backups are made by the daemon (`backup.snapshot`), not by clients. ## 3. Transport ### 3.1 Unix domain socket (default) - Default path: `/run/bokfd/bokfd.sock` (configurable). - File mode `0660`, owned by a dedicated group. Access to the socket is the first line of defense. - Intended for `bokftui`, `bokfctl` and agents running on the same host. ### 3.2 TCP and TLS (optional) - Plain TCP is disabled by default. When enabled it binds `127.0.0.1` unless explicitly configured otherwise. It is intended for loopback, an SSH tunnel (`ssh -L`) or a private overlay network (Tailscale/WireGuard). - A separate TLS listener (`tls`, e.g. `0.0.0.0:8788`) serves exactly the same protocol over TLS 1.2+ using a PEM certificate chain and key (`tls_cert`, `tls_key`). The daemon reloads the certificate when the files change, so an ACME renewer can replace them without a restart. - Clients select the transport with `BOKFD_SOCKET`/`--socket`: a Unix socket path, `tcp:host:port` or `tls:host:port`. The TLS client verifies the certificate chain and host name against the system trust store; `BOKFD_TLS_CA` adds a PEM file for private CAs. - Every TCP/TLS command requires authentication, including read commands. `meta` and `health` are the only unauthenticated commands. ### 3.3 Framing - UTF-8, one JSON object per line, `\n` terminated (JSON Lines / NDJSON). - One response line per request line, in order. Requests may be pipelined; responses preserve order. Match on `id`. - Default maximum request line: 1 MiB. Because attachments travel base64 in one line, the daemon accepts lines large enough for `max_attachment_bytes` (default 10 MiB decoded, ~13.4 MiB encoded plus envelope). - Idle connections are closed after 10 minutes. Sessions have their own TTL (default 8 h, sliding). ## 4. Authentication and authorization ### 4.1 Sessions `session.open` authenticates with either a username/password or an API token and returns an opaque, high-entropy session id: ```json {"v":1,"id":"1","cmd":"session.open","args":{"method":"password","username":"anders","password":"..."}} {"id":"1","ok":true,"result":{"session":"s_9f3...","user":{"id":1,"username":"anders","display_name":"Anders"},"orgs":[{"id":1,"name":"AB Ett","role":"owner"}],"active_org":1}} ``` - Sessions live in memory only. Restarting `bokfd` logs everyone out; that is intentional. API tokens survive restarts. - Sliding TTL, `session_ttl` default 8 h. `session.close` ends one explicitly. - Passwords are stored as Argon2id hashes. Failed logins are rate limited per peer (default: 5 failures per 15 minutes, then `RATE_LIMITED`). - Token lookups compare SHA-256 hashes in constant time. Token values are shown exactly once at creation and are never logged. ### 4.2 Tokens - Format: `bokf_` + 32 random bytes, base64url. Stored as SHA-256 hash. - A token is bound to one user and one org, has scopes (`read`, `write`, `admin`) and is an independent audit actor (label shown in history). - Tokens are the intended mechanism for agents and for accountant/viewer access. They can be revoked immediately (`token.revoke`). Scopes are enforced for every command, including admin commands: `backup.snapshot` and `user.*` need a token with the `admin` scope. ### 4.3 Roles and permissions Roles are per membership (org, user): `owner`, `bookkeeper`, `viewer`. Scopes on a token can narrow but never widen the user's role. | Capability | viewer | bookkeeper | owner | system admin | |---|---|---|---|---| | Read: vouchers, reports, audit, accounts | ● | ● | ● | ● | | `voucher.post`, `voucher.correct`, `attachment.put` | | ● | ● | | | `bank.import`, `bank.match`, `bank.unmatch` | | ● | ● | | | `sie.import`, `account.create`, `account.update` | | ● | ● | | | `period.lock`, `fiscal_year.open/close/reopen`, `org.update` | | | ● | | | `org.member_*`, `token.create` for others | | | ● | | | `user.create`, any org | | | | ● | | `backup.snapshot` | | | ● | ● | Any authenticated user may create a new org (config `allow_org_create`, default true) and becomes its owner. ### 4.4 Org scoping Every command executes in exactly one org context. The `org` field in the request selects it; without it the session's `active_org` is used. The server checks membership and role before touching data. Isolation is additionally enforced at the database level via composite keys (see `SCHEMA.md`). ## 5. Message format ### 5.1 Request | Field | Type | Notes | |---|---|---| | `v` | int | Required. Protocol version, currently `1`. | | `id` | string | Required. Client-generated; echoed in the response. | | `cmd` | string | Required. Command name, e.g. `voucher.post`. | | `session` | string | Session id from `session.open`. | | `org` | int | Optional org id. Overrides `active_org`. | | `dry_run` | bool | Optional, default false. No side effects when true. | | `args` | object | Command arguments. Required for commands that take any. | ### 5.2 Response ```json {"id":"42","ok":true,"result":{...},"warnings":[{"code":"FY_ENDING","message":"..."}]} {"id":"43","ok":false,"error":{"code":"UNBALANCED","message":"Debit and credit differ","details":{"difference_ore":1250}}} ``` `ok:true` always has `result` (may be `{}`). `ok:false` always has `error`. `warnings` is optional and only appears on success. ### 5.3 Error codes `PARSE_ERROR`, `UNSUPPORTED_VERSION`, `UNKNOWN_COMMAND`, `INVALID_ARGS`, `AUTH_REQUIRED`, `AUTH_FAILED`, `SESSION_EXPIRED`, `RATE_LIMITED`, `ORG_REQUIRED`, `ORG_FORBIDDEN`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `UNBALANCED`, `ACCOUNT_NOT_FOUND`, `ACCOUNT_INACTIVE`, `FISCAL_YEAR_NOT_FOUND`, `FISCAL_YEAR_CLOSED`, `PERIOD_LOCKED`, `DATE_OUT_OF_RANGE`, `IMMUTABLE`, `SEQUENCE_GAP`, `TOO_LARGE`, `UNSUPPORTED`, `SMTP_NOT_CONFIGURED`, `SMTP_FAILED`, `DB_BUSY`, `INTERNAL`. Codes are stable; `message` is human-readable and may change. `details` is machine-readable where offered. ### 5.4 Value conventions - **Money** is always integer **öre** (1/100 SEK) in JSON. Never floats. - **Dates** are `YYYY-MM-DD`. **Timestamps** are RFC 3339 UTC with `Z`. - **Account numbers** are strings (`"1930"`) to preserve leading zeros. - **Org numbers** are strings (`"5560123456"`), not integers. ### 5.5 Idempotency Any mutating command may carry `args.client_ref`, a client-generated string (≤ 64 chars, unique per org). If the same `client_ref` is seen again, the server returns the original result with `"replayed":true` instead of posting twice. Agents should always set it on writes. ### 5.6 Dry run `dry_run: true` performs the full validation path — role check, period lock, balance, account existence, VAT computation, number assignment preview — and returns exactly what would be written, with `"dry_run":true` in the result. Nothing is persisted, including audit entries. ### 5.7 Batching ```json {"v":1,"id":"7","cmd":"batch","session":"s_...","args":{"atomic":true,"requests":[ {"cmd":"voucher.post","args":{...}}, {"cmd":"attachment.put","args":{...}} ]}} ``` Executes requests in order. `atomic:true` wraps them in one transaction. Each element's response is returned in `result.results`; execution stops at the first error unless `"continue_on_error":true`. ### 5.8 Pagination List results have the shape `{"items":[...],"next_cursor":"..."}`. Pass `args.cursor` back to continue. `limit` defaults to 100, maximum 1000. Cursors are opaque. ## 6. Discovery The discovery commands themselves: | Command | Auth | Result | |---|---|---| | `health` | public | `{"status":"ok"}` | | `meta` | public | server version, protocol version `1`, features, limits, `tcp_enabled`, `time` | | `describe` | authenticated | full command catalogue; `{"cmd":"voucher.post"}` returns one entry | | `agent.instructions` | authenticated | Markdown workflow rules (see 6.3) | ### 6.1 `meta` / `health` (unauthenticated) `meta` returns server version, protocol version `1`, enabled features (`checks`, `sie`, `vat_report`), configured limits and whether TCP is enabled. `health` returns `{"status":"ok"}`. ### 6.2 `describe` Returns the full command catalogue. `describe {"cmd":"voucher.post"}` returns one entry. Each entry carries a declarative argument schema: ```json { "name":"voucher.post","summary":"Post an immutable voucher", "permission":{"role":"bookkeeper","require_org":true}, "mutating":true,"dry_run":true, "args":[ {"name":"date","type":"date","required":true, "description":"Voucher date (YYYY-MM-DD)"}, {"name":"description","type":"string","required":false}, {"name":"rows","type":"json","required":false, "description":"Array of {account,debit_ore,credit_ore,description?}"} ] } ``` `args` is an array in validation order. `type` is one of `string`, `int`, `bool`, `enum`, `date` or `json`; `required` tells whether the argument must be present (and, for `string`/`enum`, non-empty); `default` gives the documented default; `values` lists the allowed values of an `enum`; and `description` is a one-line summary. `json` covers structured values (rows, entries, ids, selections). The daemon validates every present argument against this schema before the handler runs and answers `INVALID_ARGS` for a missing required argument or a wrong type; unknown arguments are ignored for forward compatibility. Commands without arguments emit an empty array. This is the primary integration surface for agents: call `describe`, then act. ### 6.3 `agent.instructions` Returns Markdown workflow rules served by the daemon itself, so operational policy versions with the software. It covers, at minimum: - authenticate with a token, pick org, never store passwords - always `dry_run` first, then post with the same `client_ref` - amounts are öre; accounts are strings; dates `YYYY-MM-DD` - never attempt to edit or delete: corrections are new vouchers (`voucher.correct`) - receipts: `attachment.put` before or together with posting - locked periods and closed years are hard stops — ask the human - `fiscal_year.close` can be undone with `fiscal_year.reopen`; both are owner-only and audited. `period.lock` and `sie.import` are irreversible: confirm with the human first - how to read `report.vat` boxes and `report.balance_sheet` - on `CONFLICT`/`replayed`, fetch the existing object instead of retrying ## 7. Commands Arguments are shown abbreviated; `describe` is authoritative. ### 7.1 Session, orgs, users, tokens | Command | Args | Result | |---|---|---| | `session.open` | `method`, `username`, `password`, `token` | `session`, `user`, `orgs[]`, `active_org` | | `session.close` | — | `{}` | | `session.whoami` | — | `user`, `active_org`, `role`, `scopes` | | `session.list_orgs` | — | `items[{id,name,role}]` | | `session.use_org` | `org` | `active_org`, `role` | | `org.create` | `name`, `org_nr?`, `fiscal_year_start_month?`, `moms_period?`, `framework?` | `org` | | `org.get` / `org.list` | `org?` | `org` / `items[]` | | `org.update` | `org`, fields | `org` (owner) | | `org.member_list` | `org` | `items[{user,role}]` | | `org.member_add` | `org`, `username`, `role` | `{}` | | `org.member_set_role` | `org`, `username`, `role` | `{}` | | `org.member_remove` | `org`, `username` | `{}` | | `board.list` | — | `items[{id,name,title}]` (årsredovisning signatures) | | `board.add` / `board.update` / `board.remove` | `name`,`title?` / `id`,`name?`,`title?` / `id` | owner; audited | | `user.create` | `username`, `password`, `display_name`, `is_admin?` | `user` (system admin) | | `user.list` | — | `items[]` (system admin) | | `token.create` | `label`, `scopes[]`, `org`, `expires_at?` | `token` (shown once), `id` | | `token.list` / `token.revoke` | — / `id` | `items[]` / `{}` | ### 7.2 Kontoplan | Command | Args | Notes | |---|---|---| | `account.list` | `active_only?` | BAS 2026 seeded at org creation | | `account.get` | `id` or `number` | | | `account.create` | `number`, `name`, `type`, `sru_code?`, `vat_code?` | bookkeeper | | `account.update` | `id`, `name?`, `sru_code?`, `vat_code?`, `active?` | bookkeeper, audited | Account `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`. ### 7.3 Fiscal years and locks | Command | Args | Notes | |---|---|---| | `fiscal_year.list` / `fiscal_year.get` | `org?` / `id` | | | `fiscal_year.open` | `label`, `start_date`, `end_date` | owner; inherits events/employees/notes from the previous year | | `fiscal_year.close` | `id`, `confirm:true` | owner; audited | | `fiscal_year.reopen` | `id`, `confirm:true` | owner; undoes a close | | `fiscal_year.update` | `id`, any of `dividend_ore`, `events`, `agm_date`, `dividend_date`, `employees`, `notes` | bookkeeper; "Information om året", audited | | `period.lock` | `fiscal_year`, `until`, `reason?` | owner; `until` inclusive | | `period.unlock` | `fiscal_year`, `reason` | owner; audited with reason | Postings dated on or before `locked_until` are rejected with `PERIOD_LOCKED`. Years with status `closed` reject all postings. ### 7.4 Vouchers | Command | Args | Notes | |---|---|---| | `voucher.post` | `date`, `description?`, `rows[]` **or** `template`+`x`, `series?`, `corrects_voucher?`, `attachment_ids?`, `client_ref?` | dry-run supported | | `voucher.get` | `id` | | | `voucher.list` | `fiscal_year?`, `from?`, `to?`, `series?`, `account?`, `text?`, `limit`, `cursor` | items carry `row_count` and `attachment_count` | | `voucher.correct` | `voucher`, `description`, `date?`, `client_ref?` | creates ändringsverifikat | | `bokslut.post` | `fiscal_year`, `entries[]{debit_account,credit_account,amount_ore,description?}`, `periodiseringsfond_ore?`, `tax_rate?`, `dispose?`, `date?` | year-end bookings; `dry_run` shows the plan | A row is `{"account":"1930","debit_ore":125000,"credit_ore":0,"description":"..."}`. Exactly one of `debit_ore`/`credit_ore` may be non-zero; at least two rows; `bokslut.post` computes the result before tax (3xxx-88xx movements), adds the given entries (avskrivningar etc.; each entry names its debit and credit account and must be positive), computes `tax_rate` (default 20.6 %, öre truncated) and posts one voucher per part: dispositions, "Skatt på årets resultat" (8910/2512) and "Resultatdisposition" (8999/2099, reversed for a loss). `dry_run:true` validates and returns the plan without writing. sum debit = sum credit. `voucher.correct` mirrors the original rows, links the new voucher to the original via `corrects_voucher`, and posts it as a normal immutable voucher. The original is never touched. ### 7.4.1 Templates (konteringsmallar) Instead of `rows`, a post may name a template and supply its variable: `{"template":"Försäljning 25%","x":"1250","date":"2026-01-15"}`. | Command | Args | Notes | |---|---|---| | `template.list` | `active_only?` | id, name, series, row_count | | `template.get` | `id` or `name` | includes rows with account, formula | | `template.create` | `name`, `series?`, `description?`, `rows[]{account,formula,description?}` | bookkeeper | | `template.update` | `id` or `name`, plus fields/`rows[]` to replace | bookkeeper | | `template.archive` | `id` or `name` | soft delete; bookkeeper | Formulas use `x`, decimal numbers and `+ - * /` with parentheses, evaluated in kronor. A positive result is a debit, a negative result a credit, a zero result drops the row. Rows are rounded to whole öre and the rounding remainder is assigned to the largest row so the voucher balances. `{x}` in the template description (used when the request carries none) is replaced by the amount. Unknown accounts and invalid formulas are rejected when the template is saved; `voucher.post` re-validates at apply time and reports the resolved rows in a dry run. ### 7.4.2 Settings | Command | Args | Notes | |---|---|---| | `settings.get` | — | effective org settings (defaults included); secret values replaced by `_set` flags | | `settings.set` | `key`, `value?` | known keys: `default_series`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account`, `invoice_bankgiro`, `invoice_our_ref`, `smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, `smtp_reply_to`, `smtp_security`, `smtp_password` | `default_series` (1–8 characters, e.g. `A`, `V-`, `A `) is used when `voucher.post` carries no `series` and as the default series for new templates. `attachment_dir` (a path, up to 255 characters) is the folder the TUI file browser opens in when attaching underlag. `bank_account` (digits only, up to 10 characters, default `1930`) is the account `bank.import` uses when the request carries no `account`. `invoice_receivable_account` (default `1510`) and `invoice_revenue_account` (default `3001`) are the receivable and default revenue account of invoice postings, digits only, up to 10 characters. Verification ids are the concatenation of series and number (`V-8`), and series are free-form: only an unbroken numbering per series is required. `smtp_host` (up to 255 characters, no control characters), `smtp_user` (up to 255), `smtp_from` and `smtp_reply_to` (up to 254), `smtp_port` (digits, 1–65535) and `smtp_security` (`starttls`, `tls` or `plain`, default `starttls` when unset) configure the outgoing mail used when invoices are sent. `smtp_password` is a secret setting. `settings.set` encrypts the value with AES-256-GCM under the key in the `BOKFD_SECRET_KEY` environment variable (32 bytes as 64 hex characters or standard base64, padding optional) and stores only the `enc:v1::` form; a plaintext password is never written. `settings.get` never returns the value. When the setting exists it returns the boolean `smtp_password_set:true` and omits `smtp_password`; when it is absent it returns `smtp_password_set:false`. Setting `value` to the empty string deletes the setting. A dry run and the success response both report `"value":"[redacted]"`, and the audit entry is `{"key":"smtp_password","value":"[redacted]"}`. Setting or clearing the password when `BOKFD_SECRET_KEY` is missing or does not decode to 32 bytes fails with `INTERNAL`. ### 7.5 Attachments (underlag) | Command | Args | Notes | |---|---|---| | `attachment.put` | `filename`, `mime`, `content_base64`, `voucher_id?` | ≤ max_attachment_bytes | | `attachment.get` | `id` | returns base64 + sha256 | | `attachment.link` | `id`, `voucher_id` | write; audited | | `attachment.unlink` | `id`, `voucher_id` | write; audited | | `attachment.list` | `voucher_id?`, `unlinked?`, `limit`, `cursor` | inbox = `unlinked:true` | Attachments are immutable and content-addressed by SHA-256. They are linked to vouchers at posting time (`attachment_ids`) or afterwards via `attachment.put` with `voucher_id` or `attachment.link`; links are audited rows, and `attachment.unlink` removes one (an unlinked attachment returns to the inbox). One attachment may be linked to several vouchers (the link key is the voucher/attachment pair); linking the same pair twice is a `CONFLICT`. `attachment.list` with `voucher_id` returns every attachment linked to that voucher and each item carries that `voucher_id`. ### 7.6 Reports | Command | Args | Result | |---|---|---| | `report.trial_balance` | `fiscal_year`, `from?`, `to?`, `include_zero?` | saldo per account, IB/UB | | `report.income_statement` | `fiscal_year`, `from?`, `to?` | resultaträkning, K2-ish grouping | | `report.balance_sheet` | `fiscal_year`, `to?` | balansräkning | | `report.general_ledger` | `fiscal_year`, `accounts?`, `from?`, `to?` | huvudbok | | `report.voucher_list` | `fiscal_year`, `series?` | grundbok/verifikationslista | | `report.vat` | `from`, `to`, `period_type?` | momsdeklaration ruta för ruta | | `report.vat_eskd` | `from`, `to`, `upplysning?` | eSKD XML (ISO-8859-1) as `content_base64` | | `sru.export` | `fiscal_year`, `adjustments?`, `submitter?`, `assisted?`, `audited?`, `ignore_unmapped?` | `INFO.SRU` + `BLANKETTER.SRU` (ISO-8859-1, base64) | All reports are pure reads, respect locks, and return JSON rows. Amounts are öre. `report.general_ledger` (huvudbok) returns account blocks: `{"fiscal_year","from","to","last_voucher":{...},"accounts":[{"account", "name","ib_ore","debit_ore","credit_ore","ub_ore","rows":[{"series", "number","date","description","row_description","debit_ore","credit_ore", "saldo_ore"}]}]}`; accounts without IB or period movement are omitted, and `accounts` (array of account numbers) filters the list. `report.voucher_list` (verifikationslista) returns `{"fiscal_year","from", "to","last_voucher":{...},"vouchers":[{"id","series","number","date", "description","rows":[{"account","name","debit_ore","credit_ore", "description"}]}],"totals":{"debit_ore","credit_ore"}}` and takes an `series` filter. `sru.export` builds the two SRU files for Skatteverket's filöverföring: `INFO.SRU` (submitter, defaults to the org) and `BLANKETTER.SRU` with one INK2, INK2R and INK2S block each. The blankett type is derived from the fiscal year end (`P1`-`P4`), the org number is written as 12 digits, amounts are whole kronor with öre truncated and the blankett's printed sign, and zero fields are omitted. INK2R is mapped from the ledger via the official BAS ranges; INK2S takes the derived årets resultat and skatt plus `adjustments[]{code,amount_ore}` for manual tax adjustments and computes 7670/7770; INK2 carries 7104/7114 and the optional 8040-8045 flags. Non-zero accounts without a mapping abort with `INVALID_ARGS` unless `ignore_unmapped:true`. The result also carries `from`/`to` and the emitted whole-krona fields for display: `ink2[]`, `ink2r[]`, `ink2s[]` with `{code,amount}`, plus `unmapped[]` when `ignore_unmapped` was used. `report.vat_eskd` builds Skatteverket's `eSKDUpload` Version 6.0 XML for the period ending at `to` (whole kronor, öre truncated like the blankett; box 48 positive as filed) and returns `{"org_nr", "period","from","to","filename","sha256","size","content_base64"}`; the bytes are ISO-8859-1, so write them verbatim to a `.xml` file. Both cover the whole fiscal year; the ledger's period can be narrowed with `from`/`to`. `report.vat` returns `{"from","to","boxes":[{"box":"05","label":"...","amount_ore":...}],"note"}`. Rules sharing a box are summed into a single entry. `box 49` is the sum of the moms boxes (`10`,`11`,`12`,`30`,`31`,`32`,`48`,`60`,`61`,`62`), so box 48 is signed like the blankett (ingående moms negative); underlag boxes do not change what is payable. ### 7.6.1 Reporting rules (moms mapping) `report_rules` maps accounts to blankett boxes. The rules are per-org configuration, not part of the ledger, and owners edit them when Skatteverket changes the blankett; only `report` `vat` is consumed today (`report.vat` and `report.vat_eskd`), other report values are reserved. Several rules may target the same box and `report.vat` sums them into one entry. | Command | Args | Notes | |---|---|---| | `report.rule_list` | `report?` | ordered by report, sort_order, box, id | | `report.rule_create` | `report`, `box`, `match_type`, `pattern`, `sign?`, `sort_order?` | owner; audited (`report_rule.create`) | | `report.rule_update` | `id`, plus any of `box`, `match_type`, `pattern`, `sign`, `sort_order` | owner; audited (`report_rule.update`) | | `report.rule_delete` | `id` | owner; audited (`report_rule.delete`) | `box` is 1–3 digits. An `account` pattern is 1–10 digits; a `range` pattern is `LO-HI` with digits and `LO` ≤ `HI`; a `type` pattern is one of `asset`, `liability`, `equity`, `revenue`, `expense` and matches `accounts.type`. `sign` is `1` or `-1`; each matched account contributes `(debit − credit) × sign` to the box. `report.rule_update` merges the given fields into the existing row and validates the effective rule. All mutations support `dry_run`, which validates without writing. ### 7.7 SIE 4 | Command | Args | Result | |---|---|---| | `sie.export` | `fiscal_year`, `inline?` | by default writes `/_.se` and returns `path`, `sha256`, `size`; `inline:true` also returns `content_base64` | | `sie.import` | `content_base64` or `path`, `dry_run?` | one file per call; creates missing accounts and posts #VER as `source:"sie_import"`; only into an empty org fiscal year; `#IB` becomes an `IB` voucher when the year has no earlier history, otherwise the earlier vouchers carry the balances | SIE 4 files are written in CP437 with PC8 format, `#SIETYP 4`, `#FNR`, `#ORGNR`, `#KONTO`, `#IB`, `#UB`, `#RES`, `#VER`, `#TRANS`. Import is the migration path from Fortnox/Visma/BL and must be dry-run first; it reports exactly what would be created. ### 7.8 Backup and audit | Command | Args | Result | |---|---|---| | `backup.snapshot` | `dest?` | `path`, `sha256`, `size`, `at` — uses SQLite `VACUUM INTO`, no downtime | | `audit.list` | `from?`, `to?`, `action?`, `actor?`, `limit`, `cursor` | behandlingshistorik | | `audit.verify` | `full?` | recomputes the voucher and audit hash chains, flags unbalanced vouchers, and with `full:true` re-hashes attachments; `ok`, `checked` (audit entries), `vouchers_checked`, `attachments_checked`, `unbalanced_vouchers` and the first bad `first_bad_voucher_id` / `first_bad_seq` / `first_bad_attachment_id` / `first_unbalanced_voucher_id` on failure | `audit.verify` is cheap enough to run after every import and before every backup; `full:true` includes attachment hashes. The voucher chain is verified per org in posting order (SCHEMA.md §7.1); the audit chain globally. ### 7.9 Bank reconciliation (statement import) Phase 1 is mechanical reconciliation only: statements are imported as read-only evidence and already-booked vouchers are matched against them. **It never books anything** — no voucher is created or changed by these commands. Suggestions are advisory; a human (or an agent) must post any missing voucher with `voucher.post` and then match it. | Command | Args | Result | |---|---|---| | `bank.import` | `format`, `content_base64` or `path`, `account?` | `format`, `account`, `total`, `imported`, `duplicates`, `first_date`, `last_date`; `dry_run?` | | `bank.list` | `status?`, `from?`, `to?`, `account?`, `limit` | `items[{id,account,booked_at,value_date,text,type,amount_ore,balance_ore,matches[],suggestions[]}]`, `summary{unmatched,matched,unmatched_amount_ore}` | | `bank.match` | `transaction_id`, `voucher_id` | `transaction_id`, `voucher_id`, `difference_ore` | | `bank.unmatch` | `transaction_id`, `voucher_id` | `transaction_id`, `voucher_id`, `unmatched:true` | `bank.import` takes one SEB CSV export (`format:"seb"`): UTF-8 with an optional BOM, `;`-separated, the header exactly `Bokförd;Valutadatum;Text;Typ;Insättningar;Uttag;Bokfört saldo`. Quoted fields use `""` for an embedded quote; amounts use decimal comma and may group thousands with spaces or `.`; the `Insättningar` and `Uttag` columns are mutually exclusive and exactly one must be non-empty (a withdrawal becomes a negative amount); the balance may be empty. Files larger than 64 MiB are rejected with `TOO_LARGE`, an unknown account with `ACCOUNT_NOT_FOUND`, a header mismatch or malformed row (reported with its line number) with `INVALID_ARGS`. Each row is hashed over its canonical field encoding incl. the account, so re-importing the same export only reports duplicates (`imported:0`). `account` defaults to the `bank_account` setting, else `1930`. The result's `first_date`/`last_date` span every row in the file. `bank.list` items carry their `matches` (`voucher_id`, `series`, `number`, `date`, `bank_amount_ore` = the voucher's signed movement on the transaction's account) and, for unmatched transactions, up to three advisory `suggestions` (`voucher_id`, `series`, `number`, `date`, `amount_ore`): posted vouchers that touch the account, whose movement on it equals the transaction amount exactly, are dated within ±5 days and are not yet matched to any transaction. `summary` always counts all transactions for the org (optionally narrowed by `account`), ignoring `status`, `from` and `to`. `bank.match` links one transaction to one voucher. The voucher must post to the transaction's account; matching the same pair twice is a `CONFLICT`. Several vouchers may match one transaction (partial matching) and `difference_ore` is the transaction amount minus the summed bank legs after the insert, so `0` means the transaction is fully reconciled. `bank.unmatch` removes one link and is a `NOT_FOUND` when it does not exist. Both mutate `bank_matches` only and are audited (`bank.match`, `bank.unmatch`); `bank.import` is audited as `bank.import`. ### 7.10 Invoicing (fakturering) | Command | Args | Result | |---|---|---| | `customer.list` | `active_only?` | `items[]` ordered by name | | `customer.get` | `id` | one customer | | `customer.create` | `name`; `address`, `postal_code`, `city`, `country`, `vat_nr`, `email`, `your_ref`, `notes`, `payment_days?` | the customer | | `customer.update` | `id` plus any field (merged) | the effective customer | | `customer.archive` | `id`, `active` | `id`, `active` | | `invoice.sequence_get` | — | `next_number` (1 when no row) | | `invoice.sequence_set` | `next_number` (owner) | `next_number` | | `invoice.preview` | draft (below) | `content_base64`, `number`, `ocr`, `net_ore`, `vat_ore`, `total_ore` | | `invoice.issue` | draft, `dry_run?` | `id`, `number`, `ocr`, `document_id`, `voucher_id`, totals | | `invoice.get` | `id` | header, `rows[]`, `document_id`, `voucher_id`, `last_sent_at`, `last_sent_to` | | `invoice.list` | `customer_id?`, `status?` (`issued`/`credited`), `limit?` | `items[]`, newest first | | `invoice.pdf` | `id` | stored PDF as `content_base64` | | `invoice.send` | `id`, `to?` | `id`, `sent_to`, `at`; `dry_run` returns `to`, `subject` | The draft object is the argument set shared by `invoice.preview` and `invoice.issue`: ```json {"customer_id":2,"invoice_date":"2026-09-20","due_date":"2026-10-20", "delivery_date":"2026-09-20","your_ref":"Lars","our_ref":"Anders", "notes":"", "rows":[{"article_no":"","description":"Utvecklingsarbete", "quantity":"61","unit":"tim","unit_price_ore":120000,"note":"", "vat_code":"25","account":""}]} ``` `quantity` is a decimal string with at most three decimals (`61`, `61,5`, `0,25`); it must be greater than zero. `amount_ore = (quantity_milli * unit_price_ore + 500) / 1000` (round half up), and `unit_price_ore` must be a non-negative integer. `vat_code` is one of `25`, `12`, `6`, `0`, `rc`, `eu` (default `25`). An empty `account` uses the setting `invoice_revenue_account` (default `3001`); unknown or inactive accounts are `ACCOUNT_NOT_FOUND`/`ACCOUNT_INACTIVE`. The customer must exist and be active (`NOT_FOUND`). A draft whose rows do not fit the single page is rejected with `TOO_LARGE`. Numbering is a per-org, global series: `invoice_sequence.next_number` starts at 1, is set by the owner and is incremented by exactly one per issued invoice. The OCR reference is the number followed by its MOD10 (Luhn) check digit: `OCR = `. `invoice.preview` renders the document with the next number but consumes nothing; `invoice.issue` takes the number, renders the PDF, stores it as an immutable `application/pdf` attachment (named `Faktura .pdf`), posts the voucher and links invoice, document and voucher in one transaction. The voucher debits `invoice_receivable_account` (default `1510`) with the total and credits `2610`/`2620`/`2630` with the VAT per rate plus each row's revenue account with its net; its source is `invoice`. The renderer's totals equal the voucher rows exactly. `invoice.issue` ignores `client_ref` (the number series is the idempotency key). `invoice.preview` is a read. `customer.create/update/archive`, `invoice.sequence_set` and `invoice.issue` are audited; `invoice.issue` supports `dry_run`, which validates and renders but takes no number and writes nothing. `invoice.pdf` returns the stored document as base64 (`JVBERi0` after decoding is the PDF magic). When the setting `invoice_bankgiro` is present it is printed in the document's Bankgiro field; `invoice_our_ref` (up to 64 characters) prefills the invoice form's "Vår referens". `invoice.send` mails the stored PDF to the customer's `email` (or the `to` override) with subject `Faktura ` and a Swedish body. It needs the settings `smtp_host` and `smtp_from`; `smtp_port` defaults to 587 and `smtp_security` to `starttls`. When `smtp_user` is set, the secret `smtp_password` must be present and decryptable with the daemon's key, else the command is `SMTP_NOT_CONFIGURED`. The password is decrypted from the encrypted setting, handed to the SMTP client and never written to the audit log or returned in an error. A refused or failed delivery is `SMTP_FAILED` with the client's error text. On success `last_sent_at`/`last_sent_to` are updated and the `invoice.send` audit entry stores `{id,to,subject}` only. `dry_run` validates configuration, recipient and stored document and returns the recipient and subject without sending or updating anything. ## 8. The TUI is just a client `bokftui` logs in over the same socket, picks an org and issues the same commands. Implemented screens (0.1.0-dev): - **Inloggning** — server, user, password; org picker when several exist. - **Dashboard** — status line with org, fiscal year, role and user. - **Verifikat** — list and detail view (rows with column headers, an underlag section separated by a rule, hash, link to corrected voucher); `c` posts an ändringsverifikat. - **Nytt verifikat** — row editor with live balance display, F5 dry-run validation and F9 posting; one `client_ref` per form makes retries safe. F4 applies a konteringsmall (prompts for template and `x`). - **Ingående balans** — the effective opening balances of the selected fiscal year (carry-forward plus any `IB` vouchers, as the reports compute them); enter accounts with signed amounts (positive debit, negative credit); the editor posts `IB` deltas so nothing is ever edited. - **Välj organisation att representera** — login step two: pick which org the session works in. `--org ID` skips the picker for scripts. - **Byt räkenskapsår** (dashboard) — pick from the org's fiscal years; shown as `YYYY-MM-DD - YYYY-MM-DD` (plus label and open/closed) so broken fiscal years are visible. All screens then work in the selected year. - **Mallar** — list, create and edit templates in the same form style as vouchers (Tab, dynamic rows, F7 clear row, F5 validate, F9 save); archive keeps the template but hides it from the list. - **Underlag** — inbox of unlinked attachments; `a` uploads a file. - **Bankavstämning** — imported bank transactions (`bank.import`) matched against vouchers on the bank account, with suggestions; Enter matches the suggested voucher (or picks another), `u` unmatches, `a`/`Ctrl+N` imports a SEB CSV. Phase 1 never books anything. - **Fakturor** — invoice list (`invoice.list`, newest first) with number, date, customer, total and status (`utfärdad`/`krediterad`). Ctrl+N opens the form, Enter the detail. The form has the customer picker, invoice/due (due defaults from the customer's payment days) and delivery dates, er/var referens and rows (beskrivning, antal, enhet, à-pris, moms, anm); `F5` previews the real PDF (`invoice.preview`, nothing stored, no number consumed), `Ctrl+Enter` issues (`invoice.issue`) and then asks "Skicka faktura till ?". The detail shows header and rows; `p` fetches the stored PDF (`invoice.pdf`) and `s` sends it (`invoice.send`). In the list, `n` sets the next invoice number (`invoice.sequence_get`/`sequence_set`, owner-only). - **Kunder** — the customer register (name, address, postal code, city, VAT number, e-mail, your reference, payment days, notes). Ctrl+N creates, Enter edits (F5 validates with a dry run, Ctrl+Enter saves), `d` archives/reactivates. - **Rapporter** — saldobalans, resultaträkning, balansräkning, moms, inkomstdeklaration (INK2/SRU), årsredovisning (K2 text draft) and kontolista (all accounts with type, moms treatment, SRU and status). `s` saves the SRU files, eSKD XML and the årsredovisning respectively. Report tables keep their column-header row pinned while the body scrolls. The draft asks for the board's proposed dividend, kept per fiscal year with `fiscal_year.update`. - **Bokslut** — periodiseringsfond and tax rate fields; F5 shows the posting plan as a `bokslut.post` dry run, `^Enter` (or F9) asks for confirmation and posts the plan. - **Information om året** — the per-year årsredovisning details (material events, AGM and payment dates, proposed dividend, employees, other notes), edited per field; a new fiscal year inherits the stable fields. The årsredovisning draft reads them without prompting. - **Företagsuppgifter** — the org record (name, org number, VAT number, address, e-mail, phone, moms period, framework, fiscal-year start month), editable in place; only owners can save, others see it read-only. - **Revision** — chain verification and behandlingshistorik. **Ctrl+N is the universal "add" key**: it starts a new verifikat from the dashboard, the voucher list and the voucher detail view; a new mall from the Mallar menu; a new fiscal year from the year picker; and maps to the editor in Ingående balans and to file upload in Underlag. F5 is the universal refresh. Hints show the keys per screen. **Ctrl+R reloads the client in place** (for hot-reloading after a rebuild): it re-execs the installed binary with `--org`, `--fy` and `--screen NAME` and carries the open session in `BOKFD_SESSION`, so the session, fiscal year and current top-level view come back without a new login. The same flags can be passed manually (`--session ID` is also accepted). Still missing from the UI (API already supports): SIE export/import, period locks, member/token administration. The TUI holds no local state beyond the session and calls nothing but public commands. ## 9. Versioning - `v` is the protocol major version. The server rejects unknown majors with `UNSUPPORTED_VERSION`. - v1 evolves additively only: new commands, new optional fields, new error codes. Removals or semantic changes require v2. - `meta.capabilities` lets clients feature-detect without version sniffing. ## 10. Security notes - Bind nothing publicly by default. Loopback or Unix socket unless the operator opts in; use the TLS listener when clients connect from outside the LAN and forward only that port. Each person or agent gets their own account or token, never VPN access to the host network. - Passwords: Argon2id (vendored reference implementation). Tokens: 256-bit random, stored hashed, revocable, never logged. Sessions: memory only. - Audit and logs redact secrets: `session.open` records username and outcome, never the password or token value. `settings.set smtp_password` is audited as `[redacted]`. - Settings secrets (`smtp_password`) are encrypted at rest with AES-256-GCM under `BOKFD_SECRET_KEY` (32 bytes, hex or base64, read from the environment); the key itself is never stored in the database, returned by any command or written to a log. - Socket and database files are `0600`/`0660`; backups inherit the same discipline. - For data at rest, prefer LUKS on the host. SQLCipher support is a possible later option; not in v1. - The server answers `DB_BUSY` rather than blocking indefinitely when a writer holds the database; clients should retry with backoff. ## 11. Server configuration `/etc/bokfd/bokfd.conf`, overridable by `BOKFD_*` environment variables: | Key | Default | Meaning | |---|---|---| | `socket` | `/run/bokfd/bokfd.sock` | Unix socket path | | `tcp` | off | `host:port` to enable plain TCP | | `tls` | off | `host:port` to enable the TLS listener | | `tls_cert` | `/var/lib/bokfd/certs/fullchain.pem` | PEM certificate chain | | `tls_key` | `/var/lib/bokfd/certs/privkey.pem` | PEM private key | | `db` | `/var/lib/bokfd/bokfd.db` | SQLite database | | `backup_dir` | `/var/lib/bokfd/backup` | destination for `backup.snapshot` | | `export_dir` | `/var/lib/bokfd/export` | SIE exports | | `session_ttl` | `8h` | sliding session lifetime | | `max_line_bytes` | `1048576` | NDJSON line limit | | `max_attachment_bytes` | `10485760` (10 MiB) | decoded attachment limit | | `auth_fail_limit` | `5/15m` | login rate limit per peer | | `synchronous` | `FULL` | SQLite durability (`FULL`/`NORMAL`) | | `audit_reads` | `false` | log read commands too | | `allow_org_create` | `true` | any user may create an org | Container deployments mount the socket directory, database directory, backup and export directories as volumes; the daemon is otherwise stateless. On `SIGHUP` the daemon closes its listeners and database and re-executes its own binary in place (used by `scripts/deploy.sh --dev`); in-memory sessions are reset and clients reconnect.