aboutsummaryrefslogtreecommitdiff
path: root/docs/PROTOCOL.md
blob: bbe8166894795400fbd7356f4d5680a91a0ca343 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
# 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:<nonce>:<ciphertext>` 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 (`<inkomstår>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 `<export_dir>/<org>_<fy>.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 = <number><check>`. `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 <number> <customer>.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 <number>` 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 <nr> till <e-post>?". 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),
  plus the invoice bankgiro and "vår referens"; editable in place, only
  owners can save, others see it read-only. The accounting accounts and the
  SMTP transport live in **Inställningar**.
- **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.