summaryrefslogtreecommitdiff
path: root/docs/PROTOCOL.md
blob: 75f1990f9ae5b9b5262aa78b66b8e511afa4938b (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
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
# 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` | | | ● | |
| `employee.create/update/archive`, `payroll.run_post`, `payroll.pay_tax`, `payroll.payslip_mail`, `payroll.settings_set` | | ● | ● | |
| `payroll.agi` (decrypted personnummer), `payroll.tax_tables_fetch/import` | | | ● | |
| `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`, `FETCH_FAILED`, `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`, `series_voucher`, `series_invoice`, `series_payroll`, `series_bokslut`, `series_ib`, `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` |

`series_voucher`, `series_invoice`, `series_payroll`, `series_bokslut` and
`series_ib` (1–8 characters, e.g. `A`, `V-`, `Å`) are the number series each
feature posts into: manual vouchers, `invoice.issue`, payroll runs and
payments, `bokslut.post` and ingående balans. They default to `A`, `F`, `L`,
`Å` and `IB`; `series_voucher` also falls back to the legacy
`default_series` (1–8 characters) when unset, and new templates default to
it. Reports and SIE count both the configured `series_ib` and the
historical `IB` series as ingående balans. `attachment_dir` (a path, up to
255 characters) is accepted for
compatibility, but the TUI file browser now remembers the directory of the
last picked attachment client-side. `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) must be e-mail addresses
(one `@`, no spaces), `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. The sender's display name is the
organization name; `smtp_from` is the address.

`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`, `paid_date`, `payment_voucher_id`, `last_sent_at`, `last_sent_to` |
| `invoice.list` | `customer_id?`, `status?` (`issued`/`credited`), `limit?` | `items[]`, newest first, with `paid_date` |
| `invoice.pdf` | `id` | stored PDF as `content_base64` |
| `invoice.send` | `id`, `to?` | `id`, `sent_to`, `at`; `dry_run` returns `to`, `subject` |
| `invoice.pay` | `id`, `voucher_id`, `dry_run?` | `id`, `number`, `paid_date`, `payment_voucher_id`, `voucher_series`, `voucher_number` |

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`.

A row with `"text": true` is a free-text line: only `description` is used,
it has no quantity, unit, price or VAT, contributes nothing to the totals
and only prints its description in the document. Every draft still needs at
least one priced row (`INVALID_ARGS` otherwise, "invoice total must be
greater than zero"). `invoice.get` and `invoice.list` return rows with
`is_text`.

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.

`invoice.pay` links a payment voucher (created by the client, normally from
the TUI's **Kvittera betalning** action, which prefills debit `bank_account`
and credit `invoice_receivable_account` in the ordinary voucher form) and
stamps `paid_date` with the voucher's date. The voucher must credit the
`invoice_receivable_account` (default `1510`) with exactly the invoice
total, else `INVALID_ARGS`; an already paid invoice and a `credited` one are
rejected (`CONFLICT` and `INVALID_ARGS`). `dry_run` validates without
writing, and the command is audited.

### 7.11 Anställda (employees)

The employee register. `personal_no` is checked for shape (10 or 12 digits,
with or without a hyphen at the usual position) but not for checksum. It is
encrypted at rest with AES-256-GCM under `BOKFD_SECRET_KEY`; a write without
a valid key is `INTERNAL`. Every response except `payroll.agi` masks it,
keeping only the last four characters (`********-1234`); when the key is
missing, reads return an all-masked `********` instead of failing.

| Command | Args | Result |
|---|---|---|
| `employee.list` | `active_only?` | `items[]` ordered by name, personnummer masked |
| `employee.get` | `id` | one employee, personnummer masked |
| `employee.create` | `name`, `personal_no`, `address?`, `postal_code?`, `city?`, `bank_account?`, `email?`, `salary_account?`, `monthly_salary_ore?`, `tax_table?`, `tax_column?` | the employee |
| `employee.update` | `id` plus any field (merged) | the effective employee |
| `employee.archive` | `id`, `active` | `id`, `active` |

`salary_account` (digits only) defaults to the `payroll_salary_account`
setting (default `7210`), `tax_table` is 29–42 and `tax_column` is 1–6.
`email` (at most 254 characters, no control characters) is the recipient for
`payroll.payslip_mail`. Creating a second employee with the same
personnummer is a `CONFLICT`. All
mutations support `dry_run` and are audited (`employee.create`,
`employee.update`, `employee.archive`); the audit entry redacts the
personnummer.

### 7.12 Lön (payroll)

| Command | Args | Result |
|---|---|---|
| `payroll.tax_tables_fetch` | `year?` | `year`, `rows`, `bytes`, `sha256`, `source_url` (owner; audited) |
| `payroll.tax_tables_import` | `year`, `content_base64` | same parse from a supplied file (owner; audited) |
| `payroll.tax_tables_status` | — | `stored_years[]`, `current_year`, `stale`, `fetched_at`, `source_url` |
| `payroll.run_preview` | `period` (YYYY-MM) | per-employee gross/tax/avgifter/net and totals; nothing written |
| `payroll.run_post` | `period`, `pay_date`, `dry_run?` | `id`, `voucher_id`, totals; posts the run voucher |
| `payroll.run_list` | `limit?` | runs, newest period first |
| `payroll.run_get` | `id` | run with `lines[]` |
| `payroll.payslip` | `run_id`, `employee_id?` | one employee's payslip PDF as `content_base64`, `filename` |
| `payroll.payslip_mail` | `run_id`, `employee_id?`, `to?` | sends the payslip; `sent_to`, `at`, `attachment_id`, `filename` |
| `payroll.agi` | `period` | per-employee AGI underlag incl. the personnummer in clear (owner) |
| `payroll.pay_tax` | `run_id`, `date?`, `dry_run?` | payment voucher; marks the run `paid` |
| `payroll.settings_get` | — | effective payroll settings (defaults included) |
| `payroll.settings_set` | `key`, `value` | digits-only write of one `payroll_*` key |

`payroll.tax_tables_fetch` GETs Skatteverket's "Specialversioner för
programföretag" page over HTTPS (system trust store), takes the year's
`allmanna-tabeller-manad.txt` link in document order (current year first,
so `year` defaults to the current calendar year), downloads and parses the
fixed-width file, and replaces the year's rows plus `tax_table_meta`
(`source_url`, SHA-256, `fetched_at`) in one transaction. The file's B
records (income up to 80,000 kr/month) give the withholding in whole kronor
per table, column and income range; the % records above 80,000 kr are stored
(`pct` is percent × 100) but wave 1 does not implement them: a lookup above
the top B range fails with `INVALID_ARGS` and the message "income above the
tabulated range is not supported yet". SKV 433 (Teknisk beskrivning)
states only that the deduction for income over 80,000 kr "anges i procent"
and that it "får beräknas på samma sätt som beskrivits ovan"; no formula
for applying the percentage is given, so the safe reading is not guessed.
`payroll.tax_tables_import` parses a supplied file the same way
(`source_url` is `import`) for air-gapped installs. A network or TLS failure
is `FETCH_FAILED`. `stale` is true when no table is stored for the current
calendar year.

`payroll.run_preview` computes, per active employee with a salary, gross =
`monthly_salary_ore`, the table tax for their `tax_table`/`tax_column`
(period year; `run_post` uses the pay date's year), employer contributions
`round(gross × payroll_avgift_rate_bp / 10000)` and net = gross − tax.
Annual income below 1,000 kr is not taxed (SKV rule). `run_post` validates
that `pay_date` lies in a fiscal year whose months include `period`, then in
one transaction posts the `source:"payroll"` voucher — debit each employee's
salary account, debit `payroll_avgift_account`, credit `payroll_tax_account`,
credit `1930` for the total net and credit `payroll_avgift_liability` — and
inserts `payroll_runs` plus one `payroll_run_lines` row per employee. One
run per period: a second `run_post` for the same period is a `CONFLICT`.
Nothing is written on `dry_run`.

`payroll.agi` returns the posted run's per-employee gross, tax, contributions
and net together with the decrypted `personal_no`; it is owner-only and the
only command that reveals the number. `payroll.pay_tax` books
`source:"payroll_tax"` (debit `payroll_tax_account` and
`payroll_avgift_liability`, credit `payroll_tax_payment_account`) on the
given date (default: today) and sets the run's `status` to `paid` with
`payment_voucher_id`; paying twice is a `CONFLICT`.

`payroll.payslip` renders one A4 lönebesked for an employee line of a posted
run: the employer header and footer, the employee name, the masked
personnummer (`********-1234`, or `********` when the key is unavailable),
period, pay date, tax table and column, then Bruttolön, the negative
Preliminärskatt, a rule and Nettolön, and the note `Arbetsgivaravgifter
<rate> % betalas av arbetsgivaren.` (from `payroll_avgift_rate_bp`). The
personnummer is never written in clear. `employee_id` may be omitted only
when the run has a single line; an unknown run, employee or line is
`NOT_FOUND`. Nothing is stored.

`payroll.payslip_mail` renders the same PDF, stores it as an
`application/pdf` attachment named `Lönebesked <period> <name>.pdf`, links
it to the run's voucher with `voucher_attachments` (the run must have one;
otherwise `INTERNAL`) and mails it with subject `Lönebesked <period>` and a
short Swedish body with the net amount. The recipient is `to`, or the
employee's `email`; an empty address is `INVALID_ARGS`. SMTP configuration
follows `invoice.send`; missing configuration is `SMTP_NOT_CONFIGURED` and a
failed delivery `SMTP_FAILED` with the stored attachment left linked. A
successful send is audited as `payroll.payslip_mail`. `dry_run` checks the
recipient and the configuration, renders the PDF and returns the recipient
and file name without storing or sending anything.

The settings `payroll_salary_account` (`7210`, the default for new
employees), `payroll_tax_account` (`2710`), `payroll_avgift_account`
(`7510`), `payroll_avgift_liability` (`2731`),
`payroll_tax_payment_account` (`1630`) and `payroll_avgift_rate_bp` (`3142`)
are digits-only and are read with `payroll.settings_get` /
`payroll.settings_set`; stored keys also appear in `settings.get`.

<!-- generated:commands begin -->
## Command catalogue (generated)

Generated by `make gen-protocol` from the command tables; do not edit by hand.
Args: `name:type(values)[!][=default]`, `!` = required.

| Command | Permission | Org | Mutating | Dry run | Args |
|---|---|---|---|---|---|
| `health` | public | no | no | no | — |
| `meta` | public | no | no | no | — |
| `session.open` | public | no | no | no | `method:enum(password\|token)!`, `username:string`, `password:string`, `token:string` |
| `session.close` | viewer | no | no | no | — |
| `session.whoami` | viewer | no | no | no | — |
| `session.list_orgs` | viewer | no | no | no | — |
| `session.use_org` | viewer | no | no | no | `org:int!` |
| `org.create` | viewer | no | yes | yes | `name:string!`, `org_nr:string`, `fiscal_year_start_month:int=1`, `moms_period:enum(month\|quarter\|year)=month`, `framework:enum(K2\|K3)=K2` |
| `org.list` | viewer | no | no | no | — |
| `org.get` | viewer | yes | no | no | — |
| `org.update` | owner | yes | yes | yes | `name:string`, `org_nr:string`, `vat_nr:string`, `address:string`, `postal_code:string`, `city:string`, `country:string`, `email:string`, `phone:string`, `moms_period:enum(month\|quarter\|year)`, `framework:enum(K2\|K3)`, `description:string`, `fiscal_year_start_month:int`, `shares:int` |
| `org.member_list` | viewer | yes | no | no | — |
| `org.member_add` | owner | yes | yes | yes | `username:string!`, `role:enum(owner\|bookkeeper\|viewer)!` |
| `org.member_set_role` | owner | yes | yes | yes | `username:string!`, `role:enum(owner\|bookkeeper\|viewer)!` |
| `org.member_remove` | owner | yes | yes | yes | `username:string!` |
| `board.list` | viewer | yes | no | no | — |
| `board.add` | owner | yes | yes | yes | `name:string!`, `title:string=Styrelseledamot` |
| `board.update` | owner | yes | yes | yes | `id:int!`, `name:string`, `title:string` |
| `board.remove` | owner | yes | yes | yes | `id:int!` |
| `user.create` | admin | no | yes | yes | `username:string!`, `password:string!`, `display_name:string`, `is_admin:bool=false` |
| `user.list` | admin | no | no | no | — |
| `token.create` | viewer | yes | yes | yes | `label:string!`, `scopes:json`, `expires_at:date` |
| `token.list` | viewer | yes | no | no | — |
| `token.revoke` | viewer | yes | yes | yes | `id:int!` |
| `describe` | viewer | no | no | no | `cmd:string` |
| `agent.instructions` | viewer | no | no | no | — |
| `audit.list` | viewer | yes | no | no | `cursor:int`, `limit:int=100`, `action:string` |
| `audit.verify` | viewer | no | no | no | `full:bool=false` |
| `backup.snapshot` | admin | no | yes | no | `dest:string` |
| `account.list` | viewer | yes | no | no | `active_only:bool` |
| `account.get` | viewer | yes | no | no | `id:int`, `number:string` |
| `account.create` | bookkeeper | yes | yes | yes | `number:string!`, `name:string!`, `type:enum(asset\|liability\|equity\|revenue\|expense)!`, `sru_code:string`, `vat_code:string` |
| `account.update` | bookkeeper | yes | yes | yes | `id:int!`, `name:string`, `sru_code:string`, `vat_code:string`, `active:bool` |
| `fiscal_year.list` | viewer | yes | no | no | — |
| `fiscal_year.get` | viewer | yes | no | no | `id:int` |
| `fiscal_year.open` | owner | yes | yes | yes | `label:string!`, `start_date:date!`, `end_date:date!` |
| `fiscal_year.close` | owner | yes | yes | no | `id:int!`, `confirm:bool!` |
| `fiscal_year.reopen` | owner | yes | yes | yes | `id:int!`, `confirm:bool!` |
| `fiscal_year.update` | bookkeeper | yes | yes | yes | `id:int!`, `dividend_ore:int`, `events:string`, `agm_date:string`, `dividend_date:string`, `employees:string`, `notes:string` |
| `period.lock` | owner | yes | yes | yes | `fiscal_year:int!`, `until:date!`, `reason:string` |
| `period.unlock` | owner | yes | yes | yes | `fiscal_year:int!`, `reason:string` |
| `voucher.post` | bookkeeper | yes | yes | yes | `date:date!`, `description:string`, `series:string`, `client_ref:string`, `corrects_voucher:int`, `rows:json`, `template:json`, `x:json`, `attachment_ids:json` |
| `voucher.get` | viewer | yes | no | no | `id:int!` |
| `voucher.list` | viewer | yes | no | no | `fiscal_year:int`, `from:date`, `to:date`, `series:string`, `account:string`, `text:string`, `cursor:int`, `limit:int=100` |
| `voucher.correct` | bookkeeper | yes | yes | yes | `voucher:int!`, `description:string!`, `date:date`, `client_ref:string` |
| `bokslut.post` | bookkeeper | yes | yes | yes | `fiscal_year:int`, `entries:json`, `periodiseringsfond_ore:int`, `tax_rate:json=20.6`, `dispose:bool=true`, `date:date` |
| `settings.get` | viewer | yes | no | no | — |
| `settings.set` | bookkeeper | yes | yes | yes | `key:string!`, `value:string` |
| `bank.import` | bookkeeper | yes | yes | yes | `format:enum(seb)!`, `content_base64:string`, `path:string`, `account:string` |
| `bank.list` | viewer | yes | no | no | `status:enum(all\|unmatched\|matched)=all`, `from:date`, `to:date`, `account:string`, `limit:int=200` |
| `bank.match` | bookkeeper | yes | yes | yes | `transaction_id:int!`, `voucher_id:int!` |
| `bank.unmatch` | bookkeeper | yes | yes | yes | `transaction_id:int!`, `voucher_id:int!` |
| `report.rule_list` | viewer | yes | no | no | `report:enum(vat)` |
| `report.rule_create` | owner | yes | yes | yes | `report:enum(vat)!`, `box:string!`, `match_type:enum(account\|range\|type)!`, `pattern:string!`, `sign:int=1`, `sort_order:int=0` |
| `report.rule_update` | owner | yes | yes | yes | `id:int!`, `box:string`, `match_type:enum(account\|range\|type)`, `pattern:string`, `sign:int`, `sort_order:int` |
| `report.rule_delete` | owner | yes | yes | yes | `id:int!` |
| `template.list` | viewer | yes | no | no | `active_only:bool` |
| `template.get` | viewer | yes | no | no | `id:int`, `name:string` |
| `template.create` | bookkeeper | yes | yes | yes | `name:string!`, `series:string`, `description:string`, `rows:json!` |
| `template.update` | bookkeeper | yes | yes | yes | `id:int`, `name:string`, `series:string`, `description:string`, `active:bool`, `rows:json` |
| `template.archive` | bookkeeper | yes | yes | yes | `id:int`, `name:string` |
| `attachment.put` | bookkeeper | yes | yes | yes | `filename:string!`, `mime:string=application/octet-stream`, `content_base64:string!`, `voucher_id:int` |
| `attachment.link` | bookkeeper | yes | yes | yes | `id:int!`, `voucher_id:int!` |
| `attachment.unlink` | bookkeeper | yes | yes | yes | `id:int!`, `voucher_id:int!` |
| `attachment.get` | viewer | yes | no | no | `id:int!` |
| `attachment.list` | viewer | yes | no | no | `voucher_id:int`, `unlinked:bool`, `cursor:int`, `limit:int=100` |
| `report.trial_balance` | viewer | yes | no | no | `fiscal_year:int`, `from:date`, `to:date`, `include_zero:bool=false` |
| `report.income_statement` | viewer | yes | no | no | `fiscal_year:int`, `from:date`, `to:date` |
| `report.balance_sheet` | viewer | yes | no | no | `fiscal_year:int`, `to:date` |
| `report.vat` | viewer | yes | no | no | `from:date!`, `to:date!` |
| `report.general_ledger` | viewer | yes | no | no | `fiscal_year:int`, `accounts:json`, `from:date`, `to:date` |
| `report.voucher_list` | viewer | yes | no | no | `fiscal_year:int`, `series:string` |
| `report.vat_eskd` | viewer | yes | no | no | `from:date!`, `to:date!`, `upplysning:string` |
| `sru.export` | viewer | yes | no | no | `fiscal_year:int`, `adjustments:json`, `submitter:json`, `assisted:bool`, `audited:bool`, `ignore_unmapped:bool=false` |
| `sie.export` | viewer | yes | no | no | `fiscal_year:int`, `inline:bool=false` |
| `sie.import` | bookkeeper | yes | yes | yes | `content_base64:string`, `path:string` |
| `customer.list` | viewer | yes | no | no | `active_only:bool` |
| `customer.get` | viewer | yes | no | no | `id:int!` |
| `customer.create` | bookkeeper | yes | yes | yes | `name:string!`, `address:string`, `postal_code:string`, `city:string`, `country:string=SE`, `vat_nr:string`, `email:string`, `your_ref:string`, `notes:string`, `payment_days:int=30` |
| `customer.update` | bookkeeper | yes | yes | yes | `id:int!`, `name:string`, `address:string`, `postal_code:string`, `city:string`, `country:string`, `vat_nr:string`, `email:string`, `your_ref:string`, `notes:string`, `payment_days:int`, `active:bool` |
| `customer.archive` | bookkeeper | yes | yes | yes | `id:int!`, `active:bool!` |
| `invoice.sequence_get` | viewer | yes | no | no | — |
| `invoice.sequence_set` | owner | yes | yes | yes | `next_number:int!` |
| `invoice.preview` | viewer | yes | no | no | `customer_id:int!`, `invoice_date:date!`, `due_date:date!`, `delivery_date:string`, `your_ref:string`, `our_ref:string`, `notes:string`, `rows:json!` |
| `invoice.issue` | bookkeeper | yes | yes | yes | `customer_id:int!`, `invoice_date:date!`, `due_date:date!`, `delivery_date:string`, `your_ref:string`, `our_ref:string`, `notes:string`, `rows:json!` |
| `invoice.get` | viewer | yes | no | no | `id:int!` |
| `invoice.list` | viewer | yes | no | no | `customer_id:int`, `status:enum(issued\|credited)`, `limit:int=200` |
| `invoice.pdf` | viewer | yes | no | no | `id:int!` |
| `invoice.send` | bookkeeper | yes | yes | yes | `id:int!`, `to:string` |
| `invoice.pay` | bookkeeper | yes | yes | yes | `id:int!`, `voucher_id:int!` |
| `employee.list` | viewer | yes | no | no | `active_only:bool` |
| `employee.get` | viewer | yes | no | no | `id:int!` |
| `employee.create` | bookkeeper | yes | yes | yes | `name:string!`, `personal_no:string!`, `address:string`, `postal_code:string`, `city:string`, `bank_account:string`, `email:string`, `salary_account:string`, `monthly_salary_ore:int=0`, `tax_table:int=30`, `tax_column:int=1` |
| `employee.update` | bookkeeper | yes | yes | yes | `id:int!`, `name:string`, `personal_no:string`, `address:string`, `postal_code:string`, `city:string`, `bank_account:string`, `email:string`, `salary_account:string`, `monthly_salary_ore:int`, `tax_table:int`, `tax_column:int`, `active:bool` |
| `employee.archive` | bookkeeper | yes | yes | yes | `id:int!`, `active:bool!` |
| `payroll.tax_tables_fetch` | owner | yes | yes | yes | `year:int` |
| `payroll.tax_tables_import` | owner | yes | yes | yes | `year:int!`, `content_base64:string!` |
| `payroll.tax_tables_status` | viewer | yes | no | no | — |
| `payroll.run_preview` | bookkeeper | yes | no | no | `period:string!` |
| `payroll.run_post` | bookkeeper | yes | yes | yes | `period:string!`, `pay_date:date!` |
| `payroll.run_list` | viewer | yes | no | no | `limit:int=100` |
| `payroll.run_get` | viewer | yes | no | no | `id:int!` |
| `payroll.payslip` | viewer | yes | no | no | `run_id:int!`, `employee_id:int` |
| `payroll.payslip_mail` | bookkeeper | yes | yes | yes | `run_id:int!`, `employee_id:int`, `to:string` |
| `payroll.agi` | owner | yes | no | no | `period:string!` |
| `payroll.pay_tax` | bookkeeper | yes | yes | yes | `run_id:int!`, `date:date` |
| `payroll.settings_get` | viewer | yes | no | no | — |
| `payroll.settings_set` | bookkeeper | yes | yes | yes | `key:string!`, `value:string!` |
<!-- generated:commands end -->

## 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 and
  Enter opens **Granska** (text in a pager, other files in the desktop
  viewer) or **Ladda ned…**; the voucher detail's underlag list (`f`) works
  the same way.
- **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` imports a SEB CSV.
  `Ctrl+N` (or the last row `Skapa nytt verifikat…` in the match list) opens
  **Nytt verifikat** prefilled with the transaction's date, text, signed
  amount and bank account; posting it auto-matches the new voucher and
  returns to the list. A failed auto-match keeps the posted voucher and
  shows the server error.
- **Fakturor** — invoice list (`invoice.list`, newest first) with number,
  date, customer, total and status (`utfärdad`/`krediterad`/`betald
  <datum>`). 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); a row with only beskrivning is a free-text line
  (`text` rows, no amount). `F5` previews the real PDF (`invoice.preview`,
  nothing stored, no number consumed), `F9` issues (`invoice.issue`) and
  then asks "Skicka faktura <nr> till <e-post>?". The detail shows header
  and rows and offers `p = visa PDF` (`invoice.pdf`), `s = skicka`
  (`invoice.send`), `u = duplicera` (a new draft with the same rows and
  today's dates) and, on unpaid invoices, `b = kvittera betalning`: a
  prefilled payment voucher (debit `bank_account`, credit
  `invoice_receivable_account`) is opened in the ordinary voucher form and,
  once posted, linked with `invoice.pay`. 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.
- **Lönekörningar** — the monthly payroll runs (`payroll.run_list`, newest
  period first) with status (`bokförd`/`betald`), gross and net. Ctrl+N
  starts a new run (period YYYY-MM and pay date, defaulting into the
  selected fiscal year) when an active employee with a monthly salary
  exists, Enter opens an existing run. The run screen shows the status and
  totals, `F5` previews (`payroll.run_preview` for a new run, the stored
  lines for a posted one), `Ctrl+Enter` posts after confirmation
  (`payroll.run_post`, showing the voucher), and the manual steps are the
  action rows **Lönebesked** (PDF via `payroll.payslip`), **AGI-underlag**
  (`payroll.agi`, owner only) and **Betala skatt & avgifter**
  (`payroll.pay_tax`); a disabled step names its reason. The list title
  warns when the current year's tax tables are missing.
- **Anställda** — the employee register (name, masked personnummer, salary,
  tax table/column, e-mail). Ctrl+N creates, Enter edits (an empty
  personnummer keeps the stored one), `d` archives/reactivates; F5
  validates with a dry run and Ctrl+Enter saves.
- **Skattetabeller** (via **System**) — stored tax table years, the current
  year's status, fetch time and source. Owners fetch Skatteverket's official
  monthly table (`payroll.tax_tables_fetch`) or import a file offline
  (`payroll.tax_tables_import`).
- **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.
- **Bolaget** — the dashboard's hub for the org's master data:
  **Företagsuppgifter** (name, org number, VAT number, address, e-mail,
  phone, moms period, framework, fiscal-year start month), editable in
  place by owners, others see it read-only; **Fakturauppgifter** (nästa
  fakturanummer (owner), fordringskonto, intäktskonto, bankgiro, vår
  referens); **Verifikationsserier** (the per-feature series); and
  **E-post (SMTP)** (host, port, user, sender, reply-to, security,
  password), the last three `settings.set` and open to bookkeepers;
  **Styrelseledamöter**; and the registers **Anställda**, **Kunder** and
  **Momsregler**.
- **System** — the hub with **Skattetabeller** and **Revision**. The file
  browser starts in the directory of the last picked attachment (remembered
  in the client's `tui.conf`) and falls back to `$HOME` when it is gone.
- **Revision** (via **System**) — 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 customer or employee from their registers; a new payroll
run from Lönekörningar; 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.