diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-17 19:55:36 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-17 19:55:36 +0200 |
| commit | 380195f7cd5e57acf2c1cf2bc41069e6b0b979ed (patch) | |
| tree | 32a88fb22a7fbe8f1fd5c105156d1f928c93950d /docs | |
| download | bokf-0.1.0.tar.gz bokf-0.1.0.zip | |
Initial commit: daemon, clients, docs, Docker deploy pipelinev0.1.0
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/COMPLIANCE.md | 159 | ||||
| -rw-r--r-- | docs/DEPLOY.md | 156 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 469 | ||||
| -rw-r--r-- | docs/SCHEMA.md | 490 | ||||
| -rw-r--r-- | docs/STATE.md | 112 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 108 |
6 files changed, 1494 insertions, 0 deletions
diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md new file mode 100644 index 0000000..a152cd4 --- /dev/null +++ b/docs/COMPLIANCE.md @@ -0,0 +1,159 @@ +# bokf compliance notes + +Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later + +This document maps Swedish bookkeeping requirements to concrete `bokf` +features, and states plainly what the software does **not** do. It is a design +document, not legal advice. Statutory references are indicative — verify the +current wording, and engage a redovisningskonsult or revisor before relying on +any of it. + +The short version: no Swedish authority certifies bookkeeping software. +Compliance is always the responsibility of the bokföringsskyldige. `bokf`'s goal +is to make the formal requirements hard to violate by construction: append-only +data, enforced number series, verifiable history and locked periods. + +## 1. Framework + +| Source | What it governs | +|---|---| +| Bokföringslagen (1999:1078), BFL | Bookkeeping duty, löpande bokföring, verifikationer, bevarande | +| BFNAR 2013:2, vägledning om räkenskapsinformation | Systemdokumentation, behandlingshistorik, rättelser, säkerhet | +| Årsredovisningslagen (1995:1554), ÅRL | Årsredovisning, Bolagsverket filing | +| BFNAR 2016:10 (K2), BFNAR 2012:1 (K3) | Accounting frameworks for AB | +| Mervärdesskattelagen (2023:200), ML | Moms reporting | +| SIE 4 | De facto exchange standard between systems, banks, accountants, auditors | +| BAS-kontoplanen (FAR) | Standard chart of accounts; separately attributed seed data | +| GDPR | Personal data in the ledger and in user management | + +## 2. Requirement → feature map + +| Requirement | Where in bokf | Status | +|---|---|---| +| **Löpande bokföring** (BFL 4 kap) — entries made continuously, in Swedish kronor, in chronological order | Every voucher has a date within its fiscal year; listings are ordered by date, series, number; server rejects out-of-range dates | MVP | +| **Verifikationer** (BFL 5 kap) — every entry has a dated verification with underlag, amount, counterpart, and identifies the counterparty | `voucher.post` requires description, ≥ 2 balanced rows, account references, actor identity; underlag via immutable `attachments` linked to the voucher | MVP | +| **Obruten nummerserie** per räkenskapsår | `sequences` incremented in the same transaction as the insert; a failed post consumes nothing, a committed post skips nothing; `UNIQUE(org, year, series, number)` | MVP | +| **Oföränderlighet** — a verifikation may not be altered after the fact | `BEFORE UPDATE/DELETE` triggers abort on vouchers, rows, attachments and audit. No application command can change ledger rows | MVP | +| **Rättelser** — errors corrected by a new verifikation that references the old (ändringsverifikat), never by editing | `voucher.correct` mirrors and posts a new voucher carrying `corrects_voucher_id`; original remains visible with both linked in reports | MVP | +| **Behandlingshistorik** (BFNAR 2013:2) — who did what, when, with what result | `audit_log`, append-only, SHA-256 chained, one entry per mutation with actor user **and** token; `audit.verify` recomputes the chain | MVP | +| **Systemdokumentation** (BFNAR 2013:2) | `docs/` (this file, `PROTOCOL.md`, `SCHEMA.md`), `meta.schema_version`, runtime `describe`; the operator must keep it current for their installation | MVP, operator duty | +| **Bevarande i 7 år** (BFL 7 kap) | Append-only storage; no delete commands for ledger data; `archive`/disable instead of delete; `backup.snapshot` + restic pattern produces dated, restorable archives | MVP, operator duty | +| **Tillgänglighet och läsbarhet** | JSON reports, SIE 4 export in CP437, SIE import; the archive is readable by any tool that reads SQLite or SIE | MVP | +| **Huvudbok, saldon** (god redovisningssed) | `report.general_ledger`, `report.trial_balance`, `report.balance_sheet`, `report.income_statement` | MVP | +| **Moms** (ML 2023:200) | `report.vat` computes the boxes from `report_rules`; declaration is filed with Skatteverket by the operator | MVP in-system; eSKD file generation roadmap | +| **SIE 4** | `sie.export` (PC8/CP437) and `sie.import` (migration from Fortnox/Visma/BL) | MVP | +| **Årsredovisning / INK2 / SRU** (ÅRL, K2) | Reports supply the figures; document generation and SRU files are roadmap | Roadmap | +| **AGI, arbetsgivaravgifter, löner** | Not present | Roadmap | +| **Fakturering, kund- och leverantörsreskontra** | Not present | Roadmap | +| **Kontrolluppgifter, periodiska sammanställningar** | Not present | Roadmap / manual | +| **GDPR** — lawful basis for ledger personal data, retention overrides erasure | Role-based access, revocable tokens, audit trail, local hosting; see §5 | MVP in-system, operator duty | + +## 3. How invariants are enforced + +The interesting detail is that compliance here is not a feature that can be +switched off, or bypassed by a clever client: + +1. **The server owns the invariants.** Clients — including agents — cannot + construct a voucher that skips a number, exceeds a lock, or references + another org's account. +2. **The database enforces tenancy.** Composite keys make cross-org references + impossible at the storage layer (`SCHEMA.md` §1, §4–7). +3. **The database enforces immutability.** Triggers abort updates and deletes + on ledger and audit tables. This also holds for manual `sqlite3` access as + root. +4. **History is verifiable.** Voucher and audit chains are recomputable by + `audit.verify`; tampering is detectable, not just forbidden. +5. **Locks are explicit.** `period.lock` is owner-only and every unlock is + logged with a reason. `fiscal_year.close` is irreversible from the API. + +## 4. Verifikationer and the correction flow + +- A voucher = verifikation: unique number in an unbroken series, date, + description, rows, creator, timestamp, hash, linked underlag. +- Corrections per BFNAR 2013:2: post an ändringsverifikat with + `voucher.correct`, referencing the original. Both remain in the ledger and in + every report; the link is machine-readable. +- Deleting or hiding a posted voucher is not possible; there is no command for + it and no SQL path to it under normal operation. +- Underlag (receipts, invoices) live in the same database as immutable blobs, + linked to exactly the vouchers they support. Missing-underlag is a + first-class query (`attachment.list {unlinked:...}` and per-voucher coverage). + +## 5. Data location, backups and the 7-year archive + +- Plan to host within the EES and keep the data available for inspection. + BFL's rules on where räkenskapsinformation may be kept and how it must be + presentable are precise — check the current 7 kap. wording for your case. + `bokf` makes no transfers anywhere; there is no telemetry and no cloud + dependency. +- **Never back up live SQLite files with a file-copy tool.** Use + `backup.snapshot` (`VACUUM INTO`), which produces a consistent single file + with no downtime, then let restic pick that up. Exclude the live + `*.db`/`-wal`/`-shm` from restic. +- Test restores on a schedule; a backup that has never been restored is not an + archive. The restore procedure is also the retrieval path for inspections. +- 7 years = seven years after the calendar year in which the fiscal year + ended. Restic retention is not a legal retention policy by itself: keep at + least annual snapshots for the full period and store them somewhere you can + still read in a decade. + +## 6. Roles, audit and access + +| Actor | Typical holder | Sees | +|---|---|---| +| `owner` | You | Everything, including unlocks, closing and member management | +| `bookkeeper` | Accounting help | Books and reports, posts and corrects, no irreversible ops | +| `viewer` | Revisor, auditor, board member | Read-only, including audit history | +| token | Agent or integration | Narrowed scope, its own audit identity, revocable instantly | + +Every mutation records the acting user **and** token, so machine-written +entries are attributable to the machine and to the human who authorized it. +Passwords and tokens never reach the audit log or the application log. + +## 7. GDPR notes + +- Ledger personal data (customer/supplier names, employee data later) is + processed under legal obligation (BFL), not consent; retention periods + override erasure requests. A data subject request must be answered within + those constraints, not by deleting bookkeeping. +- User accounts (name, username, password hash, token metadata) are kept to + operate the service; disable instead of delete when history references them. +- Because everything is single-host, data subject access requests can be + answered from reports and audit listings; there is no third party to notify. +- Keep the host physically and network secured: LUKS at rest, no public + listener by default, tokens revocable, sessions short-lived. + +## 8. What the operator still must do + +The software does not file anything, sign anything, or know your business: + +- File momsdeklaration, AGI, INK2 and other declarations with Skatteverket + (upload files once the eSKD/SRU roadmap items land, or via the e-service). +- Prepare and sign the årsredovisning and file it with Bolagsverket; K2 + generation is roadmap, and the figures still need review. +- Keep a current systemdokumentation for the installation (hardware, OS, + backup, access) — `docs/` is the starting point. +- Decide the moms period, fiscal year and accounting framework per org + (`org.create` args) and keep them correct. +- Test restores; hold two independent backup copies. +- Have a redovisningskonsult or revisor review at least the first year. +- Remember that only humans can exercise judgement: VAT treatment, fringe + benefits, cut-off, going concern. + +## 9. Honest status + +This is a draft specification. Nothing here has been audited, and the system +has no production history yet. Treat generated reports and declaration +figures as you would any new tool's output: review before filing. The +compliance value of `bokf` rests on the enforced invariants above, not on +approval by any authority — none exists. + +## 10. Roadmap, in rough order + +1. Ledger core + protocol + CLI + TUI (MVP). +2. SIE round-trip test suite and migration dry-run against files exported from + Fortnox/Visma/BL. +3. eSKD file for momsdeklaration; AGI file for payroll. +4. K2 årsredovisning document generation + SRU files for INK2. +5. Invoicing and reskontra; bank import (CSV, then PSD2). +6. Peppol e-invoicing, if still relevant when it is due. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..5def5df --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,156 @@ +# bokf — running in Docker + +Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later + +Two roles: + +- **host** — runs Docker + the compose plugin only. No toolchain, no registry. +- **dev machine** — has the source and builds the image; deploys with + `scripts/deploy.sh` over SSH. + +There is no forge or registry in this flow. Releases are git tags, images are +transferred directly with `docker save | ssh docker load`. + +State lives in two bind mounts next to `compose.yaml`: + +| Host path | Container | Contents | +|---|---|---| +| `var/db` | `/var/lib/bokfd` | SQLite database, `backup/`, `export/` | +| `var/run` | `/run/bokfd` | Unix socket (mode 0660, owned by uid 10001) | + +The database is a single SQLite file. Back up with `backup.snapshot` +(`VACUUM INTO`) and point restic at `var/db/backup` — never at the live file. + +## First run + +On the dev machine, put the SSH target in `.env`: + +```sh +cp .env.example .env +# set BOKF_HOST=user@host and BOKF_REMOTE_DIR=/srv/bokf +scripts/deploy.sh v0.1.0 +``` + +The first run stops after shipping the image and compose.yaml, and prints the +one-time init command. Run it on the host: + +```sh +cd /srv/bokf +docker compose run --rm -e BOKFD_PASSWORD='<admin-password>' bokfd init +docker compose up -d +docker compose ps # wait for "healthy" +``` + +`init` creates the admin user and must run before the first `up`; it refuses +to touch an already initialized database. Subsequent `scripts/deploy.sh` +runs update the image, tag and compose file, restart the daemon and wait for +the healthcheck. + +## Deploying upgrades + +```sh +git tag v0.1.1 +scripts/deploy.sh # tag defaults to git describe +scripts/deploy.sh v0.1.1 # or pass one explicitly +``` + +The script: + +1. `make` + `make test` on the dev machine, +2. builds `bokf:<tag>`: locally and ships it with `docker save | gzip | ssh + docker load`, or — when the host runs a different CPU architecture — + builds it natively on the host from a source tar, +3. copies `compose.yaml` and writes `BOKF_IMAGE`/`BOKF_TAG` into the host's + `.env` (other keys are preserved), +4. `docker compose up -d --no-build`, then polls the container healthcheck, +5. on failure, puts the previous `BOKF_TAG` back and rolls back to the image + that is still loaded on the host. + +Cross-architecture builds are automatic: `uname -m` is compared over SSH and +a mismatch switches to a remote build. Override with `BOKF_BUILD=local` or +`BOKF_BUILD=remote` (also settable in `.env`). A remote build pulls the +Debian base image inside a container, so the host needs outbound network +access but still no toolchain. + +Tags are `git describe` output unless passed. Tag releases (`v*`) so rollback +and support have meaningful versions. The rollback image must still exist on +the host; don't prune before the new version has proven itself. + +Manual rollback — set `BOKF_TAG` on the host and restart: + +```sh +cd /srv/bokf +sed -i 's/^BOKF_TAG=.*/BOKF_TAG=v0.1.0/' .env +docker compose up -d --no-build +``` + +## Optional: a bare repository on the host + +For an off-machine copy of the source and an optional auto-deploy hook: + +```sh +ssh host 'git init --bare /srv/git/bokf.git' +git remote add host ssh://host/srv/git/bokf.git +git push host main +``` + +If the host also has the toolchain and a checkout whose origin is that bare +repo, `deploy/post-receive.sample` can build, test and restart on every push +to `main`. The normal `scripts/deploy.sh` flow does not need any of this. + +## Clients + +Run clients inside the container — no host toolchain needed: + +```sh +docker compose exec bokfd bokftui # interactive TUI +docker compose exec -e BOKFD_PASSWORD='<pw>' bokfd \ + bokfctl --user admin fiscal_year.list +``` + +The clients honor `BOKFD_SOCKET`; a host-installed client can also point at +`var/run/bokfd.sock`, but that file is owned by uid 10001, so the host user +must be in that group (or use `sudo`). + +## Mock company + +```sh +docker compose exec -e BOKFD_PASSWORD='<pw>' bokfd \ + bokfctl --user admin org.create \ + '{"name":"Mock AB","org_nr":"556000-0000","fiscal_year_start_month":1}' +``` + +Develop against this org; agents get their own API token +(`bokfctl token.create ...`, shown once). + +## Backup and restore + +```sh +docker compose exec -e BOKFD_PASSWORD='<pw>' bokfd \ + bokfctl --user admin backup.snapshot +ls var/db/backup # <db>-<timestamp>.db + .sha256 +``` + +Restore: + +```sh +docker compose stop +cp var/db/backup/<snapshot>.db var/db/bokfd.db +rm -f var/db/bokfd.db-wal var/db/bokfd.db-shm +docker compose start +``` + +To replace the mock with a real database from another host, restore its +snapshot the same way (same or newer bokf version; older schemas migrate +forward). SIE import is the alternative once the importer's CRLF/`#RAR` +fixes land. + +## Local development + +```sh +make -j"$(nproc)" && make test # no Docker required +docker compose up --build # same image, local var/ data dir +``` + +`scripts/deploy.sh` always runs the tests before shipping. `var/`, `.env`, +`*.db` and `*.se` are gitignored — real books never enter the repository. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..6dc8c7b --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,469 @@ +# 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 (optional) + +- Disabled by default. When enabled it binds `127.0.0.1` unless explicitly + configured otherwise. +- TLS is **not implemented in v1**. The intended deployments are: + - loopback + SSH tunnel (`ssh -L`), or + - a private overlay network (Tailscale/WireGuard), or + - a reverse proxy that terminates TLS in front of `bokfd`. +- Every command on TCP 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`). + +### 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` | | ● | ● | | +| `sie.import`, `account.create`, `account.update` | | ● | ● | | +| `period.lock`, `fiscal_year.open/close`, `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`, `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 + +### 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: + +```json +{ + "name":"voucher.post","summary":"Post an immutable voucher", + "permission":{"role":"bookkeeper","require_org":true}, + "mutating":true,"dry_run":true, + "args":{"date":{"type":"date","required":true}, + "rows":{"type":"array","min":2,"of":{...}}}, + "result":{...}, + "examples":[{"args":{...},"result":{...}}] +} +``` + +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`, `period.lock` and `sie.import` are irreversible + operations: 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.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` | `{}` | +| `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 | +| `fiscal_year.close` | `id`, `confirm:true` | owner; irreversible | +| `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` | | +| `voucher.correct` | `voucher`, `description`, `date?`, `client_ref?` | creates ändringsverifikat | + +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; +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) | +| `settings.set` | `key`, `value` | known keys: `default_series`, `attachment_dir` | + +`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. Verification ids are the concatenation of series and number +(`V-8`), and series are free-form: only an unbroken numbering per series is +required. + +### 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.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`; links are separate insert-only rows. + +### 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 | + +All reports are pure reads, respect locks, and return JSON rows. Amounts are +öre. `report.vat` returns `{"boxes":[{"box":"05","label":"...","amount_ore":...}],"period":{...}}`. + +### 7.7 SIE 4 + +| Command | Args | Result | +|---|---|---| +| `sie.export` | `fiscal_year`, `inline?` | by default writes `<export_dir>/<org>_<fy>.se` and returns `path`, `sha256`, `size`; `inline:true` also returns `content_base64` | +| `sie.import` | `content_base64` or `path`, `dry_run?` | creates missing accounts and posts #VER as `source:"sie_import"`; only into an empty org fiscal year | + +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 voucher and audit hash chains; `ok`, `checked`, first/last mismatch if any | + +`audit.verify` is cheap enough to run after every import and before every +backup; `full:true` includes attachment hashes. + +## 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, attachments, 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 series `IB` voucher for the selected fiscal year; + enter accounts with signed amounts (positive debit, negative credit), the + editor posts deltas so existing entries are never 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. +- **Rapporter** — saldobalans, resultaträkning, balansräkning, moms and + kontolista (all accounts with type, moms treatment, SRU and status). +- **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. + +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. +- 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. +- 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 TCP | +| `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. diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md new file mode 100644 index 0000000..4b22271 --- /dev/null +++ b/docs/SCHEMA.md @@ -0,0 +1,490 @@ +# bokf database schema + +Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later + +Storage is a single SQLite database (WAL mode) owned exclusively by `bokfd`. +Clients never see it. This document defines the schema, the invariants it +enforces, and the canonical hash encodings that make the ledger tamper-evident. + +## 1. Principles + +1. **STRICT tables.** Type errors are bugs, not data. +2. **Integer öre.** All amounts are `INTEGER` öre. No floats anywhere. +3. **Append-only ledger.** Vouchers, rows, attachments and audit entries can be + inserted but never updated or deleted. Corrections are new vouchers. +4. **Tenant isolation in the engine.** Every tenant table has + `UNIQUE(org_id, id)` and every reference is a composite foreign key + `(org_id, target_id)`. A row cannot physically reference another org's data, + even if application code is wrong. +5. **One writer.** `bokfd` serializes all writes; SQLite's single-writer model + is therefore never contended between threads. +6. **Hash chains.** Voucher history and audit history are independently + SHA-256 chained and verifiable (`audit.verify`). + +## 2. Connection pragmas + +Every connection: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +PRAGMA busy_timeout = 5000; +PRAGMA synchronous = FULL; -- configurable: NORMAL for speed, FULL default +PRAGMA wal_autocheckpoint = 1000; +``` + +Writes use `BEGIN IMMEDIATE`. This is what makes `DB_BUSY` a transient error +instead of silent corruption. + +## 3. Overview + +``` +orgs ─┬─ memberships ── users ── api_tokens + ├─ accounts + ├─ fiscal_years ── sequences + ├─ vouchers ─┬─ voucher_rows + │ └─ voucher_attachments ── attachments + ├─ audit_log (global chain, org_id nullable) + ├─ idempotency + ├─ report_rules + └─ settings +``` + +`org_id` is present on every tenant row. `audit_log` is a global chain with a +nullable `org_id`, because user and system events (logins, org creation) are +not org-scoped. + +## 4. Identity and tenancy + +```sql +CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + org_nr TEXT, -- "5560123456", leading zeros preserved + vat_nr TEXT, -- "SE556012345601" + address TEXT, + postal_code TEXT, + city TEXT, + country TEXT NOT NULL DEFAULT 'SE', + email TEXT, + phone TEXT, + fiscal_year_start_month INTEGER NOT NULL DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12), + moms_period TEXT NOT NULL DEFAULT 'month' CHECK (moms_period IN ('month','quarter','year')), + framework TEXT NOT NULL DEFAULT 'K2' CHECK (framework IN ('K2','K3')), + created_at TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id), + archived_at TEXT +) STRICT; + +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT NOT NULL, + pw_hash TEXT NOT NULL, -- Argon2id PHC string + is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0,1)), + created_at TEXT NOT NULL, + disabled_at TEXT +) STRICT; + +CREATE TABLE memberships ( + org_id INTEGER NOT NULL REFERENCES orgs(id), + user_id INTEGER NOT NULL REFERENCES users(id), + role TEXT NOT NULL CHECK (role IN ('owner','bookkeeper','viewer')), + created_at TEXT NOT NULL, + PRIMARY KEY (org_id, user_id) +) STRICT; + +CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id), + user_id INTEGER NOT NULL REFERENCES users(id), + label TEXT NOT NULL, + token_hash BLOB NOT NULL UNIQUE CHECK (length(token_hash) = 32), + scopes TEXT NOT NULL, -- JSON array of 'read','write','admin' + created_at TEXT NOT NULL, + expires_at TEXT, + last_used_at TEXT, + revoked_at TEXT +) STRICT; +``` + +Sessions are deliberately absent from the schema: they live in `bokfd` memory +and die with the process. + +## 5. Kontoplan + +```sql +CREATE TABLE accounts ( + org_id INTEGER NOT NULL REFERENCES orgs(id), + id INTEGER PRIMARY KEY, + number TEXT NOT NULL CHECK (number GLOB '[0-9]*' AND length(number) BETWEEN 1 AND 10), + name TEXT NOT NULL, -- kontobeteckning (BFL 3 kap) + type TEXT NOT NULL CHECK (type IN ('asset','liability','equity','revenue','expense')), + sru_code TEXT, -- for SRU/INK2 mapping later + vat_code TEXT, -- default momskod for reporting + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT, + UNIQUE (org_id, id), + UNIQUE (org_id, number) +) STRICT; + +CREATE INDEX idx_accounts_type ON accounts(org_id, type); +``` + +Accounts are seeded from BAS 2026 at org creation (separately attributed seed +data, see `THIRD_PARTY_NOTICES`). They are mutable configuration, not ledger +data: renaming or deactivating an account is allowed and audited; posting to an +inactive account is rejected unless the fiscal year is closed. + +## 6. Fiscal years, sequences and locks + +```sql +CREATE TABLE fiscal_years ( + org_id INTEGER NOT NULL REFERENCES orgs(id), + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, -- "2026", "2025/2026" + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')), + locked_until TEXT, -- inclusive last locked date + created_at TEXT NOT NULL, + closed_at TEXT, + closed_by INTEGER REFERENCES users(id), + UNIQUE (org_id, id), + UNIQUE (org_id, label), + CHECK (start_date < end_date), + CHECK (locked_until IS NULL OR locked_until <= end_date) +) STRICT; + +CREATE TABLE sequences ( + org_id INTEGER NOT NULL, + fiscal_year_id INTEGER NOT NULL, + series TEXT NOT NULL, -- "A", "B", "IB", ... + next_number INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (org_id, fiscal_year_id, series), + FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_years(org_id, id) +) STRICT; +``` + +`next_number` is incremented in the same transaction as the voucher insert, so +a failed post never consumes a number and a committed post never skips one. +Numbering is per fiscal year and series, matching BFL's requirement of an +unbroken series (obruten nummerserie). + +## 7. Vouchers (append-only) + +```sql +CREATE TABLE vouchers ( + org_id INTEGER NOT NULL REFERENCES orgs(id), + id INTEGER PRIMARY KEY, + fiscal_year_id INTEGER NOT NULL, + series TEXT NOT NULL, + number INTEGER NOT NULL CHECK (number > 0), + date TEXT NOT NULL CHECK (date LIKE '____-__-__'), + description TEXT NOT NULL CHECK (length(description) > 0), + source TEXT NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual','agent','sie_import','system','ib')), + client_ref TEXT, + corrects_voucher_id INTEGER, + created_at TEXT NOT NULL, + created_by_user INTEGER NOT NULL REFERENCES users(id), + created_by_token INTEGER REFERENCES api_tokens(id), + hash_prev BLOB NOT NULL CHECK (length(hash_prev) = 32), + hash BLOB NOT NULL CHECK (length(hash) = 32), + UNIQUE (org_id, id), + UNIQUE (org_id, fiscal_year_id, series, number), + UNIQUE (org_id, client_ref), + FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_years(org_id, id), + FOREIGN KEY (org_id, corrects_voucher_id) REFERENCES vouchers(org_id, id) +) STRICT; + +CREATE TABLE voucher_rows ( + org_id INTEGER NOT NULL, + id INTEGER PRIMARY KEY, + voucher_id INTEGER NOT NULL, + line_no INTEGER NOT NULL, + account_id INTEGER NOT NULL, + debit_ore INTEGER NOT NULL DEFAULT 0 CHECK (debit_ore >= 0), + credit_ore INTEGER NOT NULL DEFAULT 0 CHECK (credit_ore >= 0), + description TEXT, + CHECK ((debit_ore = 0) <> (credit_ore = 0)), -- exactly one side non-zero + CHECK (debit_ore > 0 OR credit_ore > 0), + UNIQUE (org_id, id), + UNIQUE (org_id, voucher_id, line_no), + FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id), + FOREIGN KEY (org_id, account_id) REFERENCES accounts(org_id, id) +) STRICT; + +CREATE INDEX idx_vouchers_date ON vouchers(org_id, date); +CREATE INDEX idx_vouchers_fy ON vouchers(org_id, fiscal_year_id, series, number); +CREATE INDEX idx_rows_voucher ON voucher_rows(org_id, voucher_id, line_no); +CREATE INDEX idx_rows_account ON voucher_rows(org_id, account_id); +``` + +Immutability is enforced by triggers, not convention: + +```sql +CREATE TRIGGER vouchers_no_update BEFORE UPDATE ON vouchers +BEGIN SELECT RAISE(ABORT, 'vouchers are append-only'); END; + +CREATE TRIGGER vouchers_no_delete BEFORE DELETE ON vouchers +BEGIN SELECT RAISE(ABORT, 'vouchers are append-only'); END; + +CREATE TRIGGER voucher_rows_no_update BEFORE UPDATE ON voucher_rows +BEGIN SELECT RAISE(ABORT, 'voucher rows are append-only'); END; + +CREATE TRIGGER voucher_rows_no_delete BEFORE DELETE ON voucher_rows +BEGIN SELECT RAISE(ABORT, 'voucher rows are append-only'); END; +``` + +Consequences worth stating explicitly: + +- There is no `updated_at` on a voucher. There is no way to change one, + including through `sqlite3` as root, without dropping the trigger first. +- `corrects_voucher_id` points old → new is the reverse of the wording: the + **new** voucher carries `corrects_voucher_id = <original>`. The original stays + untouched, and reports show both. +- Balance is enforced by the posting algorithm inside a transaction; `audit.verify` + additionally scans for any unbalanced voucher as a integrity backstop. + +### 7.1 Voucher hash (canonical bytes) + +``` +SHA256( "bokf-v1-voucher\0" + prev_hash[32] + org_id u64be + fiscal_year label u16len + utf8 bytes + series u16len + utf8 bytes + number u64be + date "YYYY-MM-DD" (10 bytes) + description u32len + utf8 bytes + row_count u32be + for each row, line_no order: + account number u16len + utf8 bytes + debit_ore u64be + credit_ore u64be + row description u32len + utf8 bytes ) +``` + +`prev_hash` is the `hash` of the previous voucher **by `id`** (insertion +order = posting order) in the same org, or 32 zero bytes for the first voucher +of the org. The chain therefore spans fiscal years, and any retroactive edit, +insertion or deletion breaks it at a detectable point. + +## 8. Attachments (underlag) + +```sql +CREATE TABLE attachments ( + org_id INTEGER NOT NULL REFERENCES orgs(id), + id INTEGER PRIMARY KEY, + sha256 BLOB NOT NULL CHECK (length(sha256) = 32), + filename TEXT NOT NULL, + mime TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + content BLOB NOT NULL, -- stored in-DB; backups stay trivial + created_at TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id), + UNIQUE (org_id, id), + UNIQUE (org_id, sha256, filename) +) STRICT; + +CREATE TABLE voucher_attachments ( + org_id INTEGER NOT NULL, + voucher_id INTEGER NOT NULL, + attachment_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (org_id, voucher_id, attachment_id), + FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id), + FOREIGN KEY (org_id, attachment_id) REFERENCES attachments(org_id, id) +) STRICT; +``` + +Attachments are content-addressed and immutable; linking is an insert into +`voucher_attachments` and is itself audited. Unlinked attachments form the +inbox the TUI shows. The 7-year archive rule means content must never be +garbage-collected; deduplication by hash keeps repeated receipts cheap. + +## 9. Audit log and idempotency + +```sql +CREATE TABLE audit_log ( + seq INTEGER PRIMARY KEY, + org_id INTEGER REFERENCES orgs(id), + at TEXT NOT NULL, -- RFC 3339 UTC + actor_user_id INTEGER REFERENCES users(id), + actor_token_id INTEGER REFERENCES api_tokens(id), + action TEXT NOT NULL, -- "voucher.post", "auth.fail", ... + request_json TEXT NOT NULL DEFAULT '{}', -- redacted, exact bytes are hashed + result_code TEXT NOT NULL, -- "OK", "UNBALANCED", ... + hash_prev BLOB NOT NULL CHECK (length(hash_prev) = 32), + hash BLOB NOT NULL CHECK (length(hash) = 32) +) STRICT; + +CREATE TRIGGER audit_no_update BEFORE UPDATE ON audit_log +BEGIN SELECT RAISE(ABORT, 'audit log is append-only'); END; +CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_log +BEGIN SELECT RAISE(ABORT, 'audit log is append-only'); END; + +CREATE TABLE idempotency ( + org_id INTEGER NOT NULL, + client_ref TEXT NOT NULL, + cmd TEXT NOT NULL, + response_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (org_id, client_ref), + FOREIGN KEY (org_id) REFERENCES orgs(id) +) STRICT; +``` + +### 9.1 Audit hash (canonical bytes) + +``` +SHA256( "bokf-v1-audit\0" + prev_hash[32] + seq u64be + at u16len + utf8 + actor_user_id u64be -- 0 = none + actor_token_id u64be -- 0 = none + org_id u64be -- 0 = none + action u16len + utf8 + request_json u32len + exact stored bytes + result_code u16len + utf8 ) +``` + +Hashing the stored bytes verbatim avoids canonicalization disputes. Secrets are +redacted **before** the bytes are stored and hashed: `session.open` records +`{"method":"password","username":"anders"}` and the outcome, never the +password or token. + +Actions written to the log include: `auth.open`, `auth.fail`, `session.close`, +`org.create`, `org.update`, `member.add`, `member.set_role`, `member.remove`, +`user.create`, `token.create`, `token.revoke`, `account.create`, +`account.update`, `fiscal_year.open`, `fiscal_year.close`, `period.lock`, +`period.unlock`, `voucher.post`, `voucher.correct`, `attachment.put`, +`attachment.link`, `sie.import`, `sie.export`, `backup.snapshot`, +`settings.update`. Reads are logged only when `audit_reads = true`. + +## 10. Reporting rules and settings + +```sql +CREATE TABLE report_rules ( + org_id INTEGER NOT NULL REFERENCES orgs(id), + id INTEGER PRIMARY KEY, + report TEXT NOT NULL, -- "vat", "income_statement", ... + box TEXT NOT NULL, -- "05", "10", ... + match_type TEXT NOT NULL CHECK (match_type IN ('account','range','type')), + pattern TEXT NOT NULL, -- "2610" | "2610-2619" | "revenue" + sign INTEGER NOT NULL DEFAULT 1 CHECK (sign IN (1,-1)), + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (org_id, id) +) STRICT; + +CREATE TABLE settings ( + org_id INTEGER NOT NULL REFERENCES orgs(id), + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (org_id, key) +) STRICT; +``` + +`report_rules` is seeded per org with a moms mapping over BAS account ranges and +is editable by owners when Skatteverket changes the blankett. The seed rules +are data, not code: the system ships a reviewed default set per fiscal year and +keeps older sets for older years. + +### 10.1 Voucher templates (schema v2) + +Konteringsmallar: named sets of rows with a formula over the variable `x`. +Configuration data, mutable and audited, never part of the ledger. + +```sql +CREATE TABLE voucher_templates ( + org_id INTEGER NOT NULL REFERENCES orgs(id), + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + series TEXT NOT NULL DEFAULT 'A', + description TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT, + UNIQUE (org_id, id), + UNIQUE (org_id, name) +) STRICT; + +CREATE TABLE voucher_template_rows ( + org_id INTEGER NOT NULL, + id INTEGER PRIMARY KEY, + template_id INTEGER NOT NULL, + line_no INTEGER NOT NULL, + account_id INTEGER NOT NULL, + formula TEXT NOT NULL CHECK (length(formula) > 0), + description TEXT, + UNIQUE (org_id, id), + UNIQUE (org_id, template_id, line_no), + FOREIGN KEY (org_id, template_id) + REFERENCES voucher_templates(org_id, id), + FOREIGN KEY (org_id, account_id) REFERENCES accounts(org_id, id) +) STRICT; +``` + +Formulas are evaluated in kronor, rounded per row to whole öre, and the +rounding remainder is assigned to the largest row before posting, so the +resulting voucher always balances. See `PROTOCOL.md` §7.4.1. + +## 11. The posting algorithm + +Every write path (`voucher.post`, `voucher.correct`, `sie.import`) runs the same +steps inside one `BEGIN IMMEDIATE` transaction: + +1. Authenticate, resolve org, check role/scope. +2. Validate arguments; resolve accounts by number; reject inactive. +3. Resolve fiscal year by date; reject `FISCAL_YEAR_CLOSED`, + `DATE_OUT_OF_RANGE`, `PERIOD_LOCKED`. +4. Rows: ≥ 2, exactly one non-zero side per row, `sum(debit) == sum(credit)`, + else `UNBALANCED` with `difference_ore`. +5. Reserve and increment `sequences.next_number`; the assigned number is the + only number the voucher can have (`SEQUENCE_GAP` on mismatch). +6. Read `hash_prev` = last voucher hash by id for the org; compute `hash`. +7. Insert voucher, rows, and `voucher_attachments` links. +8. Insert `audit_log` entry with its own chain hash. +9. Insert `idempotency` row if `client_ref` was given. +10. Commit. On any failure, roll back everything — no number is consumed. + +`dry_run` runs steps 1–5 and 7's validation, computes the hash with the +*candidate* number and a temporary `id`, and returns the preview without +writing. The preview hash is not the final hash (the number could change if +another voucher is posted in between) — clients must not persist it. + +## 12. Migrations and versioning + +- `meta(key TEXT PRIMARY KEY, value TEXT)` holds `schema_version` (integer) + and `created_at`. Current version: **2** (v2 adds the two template tables). +- Migrations are forward-only, applied automatically at daemon start, each in + one transaction, and require an automatic `VACUUM INTO` snapshot next to the + database before starting (`bokfd.db.pre-migration-<version>`). +- `audit.verify` must pass before and after any migration; migrations never + rewrite ledger rows. + +## 13. Integrity and operations + +- Daily sanity checks by the daemon: `PRAGMA quick_check`, `audit.verify`, + unbalanced-voucher scan. Failures are logged loudly and surfaced in `meta`. +- `backup.snapshot` uses `VACUUM INTO` to a timestamped file: consistent, no + downtime, and safe for restic to pick up. The live `*.db`/`-wal`/`-shm` files + must never be handed to a file-copy backup tool. +- Restore procedure: stop `bokfd`, replace the database with a snapshot, start, + run `audit.verify` and a report smoke test. Restores are tested on a + schedule; the same procedure is the 7-year archive retrieval path. +- Size: SQLite with `page_size` default and WAL suits the load easily + (thousands of vouchers per year are trivial). Attachments dominate growth; + they live in the same file so a snapshot remains a single artifact. + +## 14. Seeds + +| Data | Source | Notes | +|---|---|---| +| BAS 2026 kontoplan | `data/bas2026.csv` | separately attributed (FAR); loaded at org creation | +| Moms report rules | `data/vat_rules_<year>.json` | account → ruta mapping, reviewed per year | +| Standard series | code | `A` (normal), `IB` (opening balances), `SIE` (import) | +| SRU codes | `data/sru_map.csv` | for later INK2/SRU export | diff --git a/docs/STATE.md b/docs/STATE.md new file mode 100644 index 0000000..d9edcb0 --- /dev/null +++ b/docs/STATE.md @@ -0,0 +1,112 @@ +# bokf — project state + +Snapshot for resuming work in a new session. Read with `AGENTS.md` (rules) +and `docs/TUI-GUIDELINES.md` (UI conventions). Dated 2026-09-17. + +## Status + +Working self-hosted bookkeeping system, not production-proven. Backend ledger +core is complete; filing/year-end/payroll are not. TUI is usable and +exercised via pty smoke tests; the test suite (`make test`) covers the +server/protocol/ledger only. + +## Locked decisions + +1. **Name/license**: `bokf`, daemon `bokfd`, clients `bokfctl` (scriptable) + and `bokftui` (ncurses); GPL-3.0-or-later; repo `~/work/bokf`. +2. **Stack**: C11, Makefile, vendored SQLite 3.53.4 / yyjson 0.13.0 / Argon2 + 20190702 / SHA-256 (public domain). Only system dep: libncursesw. +3. **Storage**: SQLite WAL, `synchronous=FULL`, STRICT tables, composite-key + tenant isolation, append-only triggers, `VACUUM INTO` snapshots. Postgres + deliberately rejected for now; keep DB access behind one layer for a later + port. +4. **Protocol**: NDJSON over Unix socket (+ optional token TCP), protocol v1. + `dry_run` on every mutation, `client_ref` idempotency, stable error codes, + `describe` + `agent.instructions`, money in integer öre. +5. **Auth**: multi-org; memberships owner/bookkeeper/viewer; API tokens bound + to user+org with scopes, shown once, revocable; sessions in memory; + Argon2id. Server messages English, UI Swedish. +6. **Compliance design**: SHA-256 audit chain, SHA-256 voucher chain + (canonical encoding in `SCHEMA.md` §7.1/§9.1), period locks, fiscal year + close, corrections only as ändringsverifikat, SIE 4 (CP437) in/out. +7. **Verifikat ids**: series is free text (`A`, `V-`, `A ` …); unbroken + numbering per fiscal year+series; id displayed as `series+number` + (`V-8`). Org setting `default_series` (Inställningar) for new vouchers and + new templates. +8. **Templates** (`konteringsmallar`): server-side; formula language over `x` + with `+ - * /` and parentheses, positive=debit, negative=credit, zero rows + dropped; rounding remainder assigned to the largest row; `{x}` in the + description; `template.*` commands + `voucher.post {template,x}`; archive + instead of delete. +9. **Ingående balans**: one series `IB` voucher per fiscal year (dated at + year start). Reports treat series IB as IB, not period movement; SIE + export/import round-trips without double counting; TUI editor posts only + deltas so nothing is ever edited. +10. **Attachments**: content stored in the DB (BLOB), immutable, linked via + append-only `voucher_attachments`. Default limit 10 MiB + (`max_attachment_bytes`); the socket line limit is derived from it + (base64). TUI `Ctrl+F` attaches via file browser; the client checks the + size from `meta` first. Setting `attachment_dir` (tilde expanded). +11. **UI keys**: Ctrl+N = add, F5 = refresh, Esc/q = back (never exits), + Ctrl+C = quit, 1–9/g = jump, F7 = clear row in editors, F9 = save. + Shared line editor and date field; see TUI-GUIDELINES.md. +12. **Login**: two steps — credentials, then org picker ("Välj organisation + att representera"). No org switch in the dashboard; fiscal year switch is + on the dashboard. `--org ID` bypasses the picker. +13. **Settings**: `settings.get`/`settings.set`; keys `default_series`, + `attachment_dir`. + +## Pending decisions + +- Link attachments after posting (API already supports + `attachment.put {voucher_id}`): proposed TUI actions — `Ctrl+F` in the + voucher detail to upload+link, and selecting an inbox item and pressing a + key to link it to a voucher picked from a list. Waiting for a go-ahead. +- Priority between **eSKD moms filing** and **bokslut/K2+SRU** for the next + backend milestone (eSKD was suggested first). +- Moms `report_rules` seed is a reviewed starter mapping only; must be + checked against the current Skatteverket blankett before filing. + +## Backlog (prioritized, from COMPLIANCE.md §10 and the audit) + +1. eSKD file generation for momsdeklaration. +2. Bokslut automation (avskrivningar, periodiseringsfond, skatt, + resultatdisposition). +3. K2 årsredovisning document + SRU files (INK2/INK2R/INK2S). +4. `audit.verify` must also verify the **voucher** hash chain (today only the + audit chain is verified). +5. `report.general_ledger` and `report.voucher_list` (documented, not + implemented). +6. `describe` argument schemas (currently name/summary/permission only). +7. Pre-migration `VACUUM INTO` snapshot (promised in SCHEMA.md, not built). +8. Docker image + compose (multi-arch amd64/arm64, GHCR) and systemd unit. +9. Password change, user disable, TOTP. +10. Bank import/reconciliation (CSV first, then PSD2), invoicing/reskontra, + AGI/payroll if employees. +11. SIE import only into an empty fiscal year; consider broader import. +12. TUI polish: horizontal scrolling in long text fields, bracketed paste. + +## Environment / how to run + +- Demo: db `~/bokf-demo/bokfd.db`, socket `~/bokf-demo/bokfd.sock`, + pid file `~/bokf-demo/bokfd.pid`; login `admin` / `demo1234`. + Start TUI: `cd ~/work/bokf && BOKFD_SOCKET=$HOME/bokf-demo/bokfd.sock \ + BOKFD_USER=admin BOKFD_PASSWORD=demo1234 ./build/bokftui` +- Restart daemon: kill the pid file's process, then + `BOKFD_BACKUP_DIR=$HOME/bokf-demo/backup \ + BOKFD_EXPORT_DIR=$HOME/bokf-demo/export setsid nohup \ + ./build/bokfd --db $HOME/bokf-demo/bokfd.db \ + --socket $HOME/bokf-demo/bokfd.sock > $HOME/bokf-demo/daemon.log 2>&1 &` +- The user's own early instance was `/tmp/x.db` + `/tmp/bokfd.sock` + (schema v1, old binary) — recreate or migrate it with the current build if + it is still wanted. +- TUI smoke tests: drive over a pty with `script -qec`; function-key escape + sequences are timing-sensitive there (not an app bug). `Ctrl+N/C/F` are + single bytes and reliable. + +## Known caveats + +- Never commit unless the human asks. +- SQLite files must not be backed up live with restic; use + `backup.snapshot` (`VACUUM INTO`) and point restic at the snapshots. +- Schema version is 2; forward migrations are in `db.c`. diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md new file mode 100644 index 0000000..180c72b --- /dev/null +++ b/docs/TUI-GUIDELINES.md @@ -0,0 +1,108 @@ +# bokftui guidelines + +Rules for the ncurses client so every view behaves the same. When in doubt, +copy the behaviour of the voucher list / voucher form; they are the reference +implementations. Inspired by Midnight Commander, htop, mutt and calcurse. + +## Session start + +After login the org picker ("Välj organisation att representera") is always +shown; the selected org is fixed for the session (`--org ID` bypasses it for +scripts). The fiscal year is chosen from the dashboard and is changeable +during the session. + +## Universal keys + +| Key | Meaning | +|---|---| +| `Ctrl+N` | Add: new verifikat (dashboard, voucher list/detail), new mall (Mallar), new fiscal year (year picker), open editor (IB), upload file (Underlag) | +| `F5` | Refresh the view | +| `Esc` / `q` | Back one level. At the dashboard it does nothing — Esc never exits the app | +| `Ctrl+C` | Quit the application (closes the session). The only key that exits | +| `1`–`9` | In lists: jump to that row. In menus: activate that item | +| `g` | Live goto: "Gå till rad/nummer:" updates the selection as you type digits (backspace steps back); Enter closes the prompt without opening anything | +| arrows, PgUp/PgDn, Home/End | Move/scroll; selection always stays visible | +| `e` | Edit the shown object (IB, where applicable) | +| `a` | Add/upload (Underlag) | +| `c` | Correct (voucher detail) | +| `Ctrl+F` | Attach a file (voucher form) / pick a file (Underlag) via the file browser | +| `F7` | Clear the current row — only inside row editors (never "new") | +| `F9` | Save/post the current form | + +Every screen prints its keys in the footer via `hints()`. If a key exists, the +footer shows it; if the footer shows it, the key works. + +## Lists (`select_list`, `menu`) + +- Rows are numbered `NN. text`, right-aligned so 2- and 3-digit numbers line up. +- Verifikation ids are shown concatenated as `series+number` (`V-8`, `A8`), + using the org's `default_series` (Inställningar) for new vouchers. +- The last row may be an action (e.g. `+ Nytt verifikat (Ctrl+N)`); selecting it + runs the action instead of opening a detail view. +- Selection memory: lists remember the selected row by identity (voucher id), + not index, across detail round-trips, refreshes and screen re-entry. +- Digits move the highlight; only Enter activates. Menus are the exception: + digits activate directly (they are shortcuts). +- Empty lists are never a dead end: show a message or keep the add-row. + +## Forms + +- Layout: bold header fields at the top, then a bold column header, then rows; + footer hints carry the keys. +- Field navigation: `Tab` / `Shift-Tab` forward/back, arrows Up/Down between + rows. `Enter` advances in simple forms; it never saves unless the screen is + a one-line prompt. +- The active field is drawn reverse-video and holds the hardware cursor at the + caret. The caret is a real position: ←/→/Home/End/Del work inside the field, + `Ctrl+U` clears it (see `field_edit`). +- First keystroke in a freshly focused field replaces its content + (`field_fresh`), so prefilled values like dates can be typed over. +- Date fields (`date_field_edit`, `date_prompt`) accept digits only and insert + the dashes themselves: type `20260315` and the field shows `2026-03-15`. + Backspace deletes a digit (with its separator), ←/→/Home/End move by digit, + and the caret renders like in any other field. Validation (`util_parse_iso_date`) + happens on F5/F9. +- Derived values (account names, balances) are dim and non-editable. +- Row tables: always exactly one empty trailing row; entering data appends a + new empty row; an empty row followed by another empty row collapses. + `F7` clears the selected row. +- Validation: `F5` validates without writing and reports exactly what is wrong + (field, row number). Server `dry_run` is used where available. +- Saving: `F9` writes, shows a confirmation message, and returns to the + previous view. `Esc` cancels without saving. +- File browser (`file_browser`): starts in the org's `attachment_dir` (or + `$HOME`; a leading `~` is expanded to `$HOME`), `.. (uppåt)` is the first row, directories sort first with a + trailing `/`, hidden files are skipped. Enter enters a directory or picks a + file; `Esc` cancels. Selected files are uploaded immediately as unlinked + underlag and linked when the voucher is posted. + +## Messages + +- Info/confirmation: `message(title, ...)` box, dismissed with Enter. +- Quitting: only `Ctrl+C` (from anywhere) or "Logga ut / avsluta" ends the + app; `Esc`/`q` only navigate. After `Ctrl+C` every screen unwinds, the + session is closed and the terminal restored. +- Errors: `show_error(title, resp)` prints `CODE: message` from the server + error object; if the response is missing/empty it says + "Inget svar från servern (kör daemonen?)". Never render an empty error. +- Irreversible actions (close year, lock, archive) ask first. + +## Layout and text + +- Screen frame: title top-left, dim status line under it + (`org | label start - end | role | user`), hints in the last line. +- Columns are padded with `pad_field()` (UTF-8 display width) and truncated to + their column; amounts right-aligned via `kr_format()`; never pad with `%s` + widths directly on user text. +- Selection = reverse video, headers = bold, derived data = dim. +- All user-visible TUI text is Swedish; server messages are English. + +## Adding a view — checklist + +1. Data comes from public protocol commands only. +2. Wrap the screen in `frame()`/`hints()`; return `Esc`/`q` to the parent. +3. Use `menu()` or `select_list()` instead of writing a new loop; pass + `allow_new`/`allow_refresh` so the universal keys apply. +4. Forms use the shared editor (`field_edit`) and the row-normalising helpers. +5. Support `F5` if the data can change elsewhere. +6. Update `PROTOCOL.md` §8 and this file if you add a new key or interaction. |
