summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-21 21:43:44 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-21 21:43:44 +0200
commit62a92f0af0442c78277c3c97c9efe6fef8145625 (patch)
tree3802a62904880da22bf84a44e9ece3cdff9cd8aa
parentf1eeb15781a044a9451ee08b91c63feb395b4767 (diff)
downloadbokf-0.1.60.tar.gz
bokf-0.1.60.zip
settings: per-feature voucher series and console invoice number; tui: F9 hintsv0.1.60
-rw-r--r--clients/screens_bokslut.c2
-rw-r--r--clients/screens_ib.c4
-rw-r--r--clients/screens_invoices.c2
-rw-r--r--clients/screens_payroll.c4
-rw-r--r--clients/screens_settings.c89
-rw-r--r--clients/screens_templates.c2
-rw-r--r--clients/screens_vouchers.c19
-rw-r--r--clients/tui.c4
-rw-r--r--clients/ui.c7
-rw-r--r--clients/ui.h1
-rw-r--r--docs/DECISIONS.md13
-rw-r--r--docs/PROTOCOL.md21
-rw-r--r--docs/STATE.md9
-rw-r--r--docs/TUI-GUIDELINES.md4
-rwxr-xr-xscripts/tui-golden.py22
-rw-r--r--src/cmd_bokslut.c9
-rw-r--r--src/cmd_invoices.c4
-rw-r--r--src/cmd_payroll.c8
-rw-r--r--src/cmd_settings.c44
-rw-r--r--src/db.c8
-rw-r--r--src/db.h3
-rw-r--r--src/ledger.c6
-rw-r--r--src/reports.c29
-rw-r--r--src/sie.c16
-rw-r--r--tests/test_core.c140
25 files changed, 406 insertions, 64 deletions
diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c
index 3f69982..96c6fe9 100644
--- a/clients/screens_bokslut.c
+++ b/clients/screens_bokslut.c
@@ -242,7 +242,7 @@ void bokslut_screen(struct app *a)
ff[7].cap = sizeof rate;
ff[7].kind = TUI_F_TEXT;
tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför F5 = visa"
- " bokslutsplan ^Enter/F9 = bokför planen (frågar"
+ " bokslutsplan F9 = bokför planen (frågar"
" först) Esc/q = tillbaka ^C = avsluta");
int r = tui_form_run_actions("Bokslut", ff, nf + 2, can_edit, acts, 5,
NULL, &sel);
diff --git a/clients/screens_ib.c b/clients/screens_ib.c
index 8950667..2d62297 100644
--- a/clients/screens_ib.c
+++ b/clients/screens_ib.c
@@ -167,7 +167,7 @@ static int ib_post_delta(struct ibform *ib)
yyjson_mut_doc_set_root(d, o);
yyjson_mut_obj_add_strcpy(d, o, "date", a->fy_start);
yyjson_mut_obj_add_strcpy(d, o, "description", "Ingående balans");
- yyjson_mut_obj_add_strcpy(d, o, "series", "IB");
+ yyjson_mut_obj_add_strcpy(d, o, "series", a->ib_series);
char *ref = util_random_id("tui-", 8);
yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref);
free(ref);
@@ -272,7 +272,7 @@ static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold)
for (;;) {
int rr = tui_rt_run("Ingående balans", &ib.rt,
"Tab = byta fält F5 = validera ^X = rensa rad"
- " ^Enter/F9 = spara Esc = avbryt");
+ " F9 = spara Esc = avbryt");
if (rr == -1)
return 0;
char msg[256];
diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c
index ca59eef..8fb32eb 100644
--- a/clients/screens_invoices.c
+++ b/clients/screens_invoices.c
@@ -1092,7 +1092,7 @@ static int64_t invoices_new(struct app *a)
tui_rt_set_fields(&f.rt, ff, 7);
const char *hint =
"Enter = välj/ändra Tab = byta fält F5 = förhandsvisa"
- " ^Enter/F9 = utfärda Esc = avbryt";
+ " F9 = utfärda Esc = avbryt";
int64_t out = 0;
for (;;) {
int rr = tui_rt_run("Ny faktura", &f.rt, hint);
diff --git a/clients/screens_payroll.c b/clients/screens_payroll.c
index 23d765f..234778c 100644
--- a/clients/screens_payroll.c
+++ b/clients/screens_payroll.c
@@ -1101,7 +1101,7 @@ static int64_t payroll_run_screen(struct app *a, int64_t id)
{ status, -1, NULL },
{ totals, -1, NULL },
{ "Förhandsvisa (F5)", 1, NULL },
- { "Bokför körning (^Enter/F9)", post_reason ? 0 : 1,
+ { "Bokför körning (F9)", post_reason ? 0 : 1,
post_reason },
{ "Lönebesked (PDF)", payslip_reason ? 0 : 1, payslip_reason },
{ "AGI-underlag", agi_reason ? 0 : 1, agi_reason },
@@ -1124,7 +1124,7 @@ static int64_t payroll_run_screen(struct app *a, int64_t id)
snprintf(title, sizeof title, "Ny lönekörning");
int editable = can_write && !posted;
tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför"
- " F5 = förhandsvisa ^Enter/F9 = bokför"
+ " F5 = förhandsvisa F9 = bokför"
" Esc/q = tillbaka ^C = avsluta");
int ret = tui_form_run_actions(title, ff, 2, editable, acts, 7, NULL,
&focus);
diff --git a/clients/screens_settings.c b/clients/screens_settings.c
index 8a31620..7730ff4 100644
--- a/clients/screens_settings.c
+++ b/clients/screens_settings.c
@@ -199,6 +199,7 @@ struct setting_field {
const char *const *choices;
int nchoices;
int mask;
+ int invoice_number;
};
static void setting_save(struct app *a, const struct setting_field *f,
@@ -218,9 +219,11 @@ static void setting_save(struct app *a, const struct setting_field *f,
char *rr = client_rpc(&a->conn, "settings.set", a->session, a->org, args);
free(args);
if (rr && client_ok(rr)) {
- if (strcmp(f->key, "default_series") == 0)
+ if (strcmp(f->key, "series_voucher") == 0)
snprintf(a->default_series, sizeof a->default_series, "%s",
value);
+ else if (strcmp(f->key, "series_ib") == 0)
+ snprintf(a->ib_series, sizeof a->ib_series, "%s", value);
else if (strcmp(f->key, "attachment_dir") == 0)
snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s",
value);
@@ -283,12 +286,42 @@ static void settings_form(struct app *a, const char *title,
acts[0].enabled = -1;
}
free(resp);
+ for (int i = 0; i < n; i++) {
+ if (!fields[i].invoice_number)
+ continue;
+ char *seq = client_rpc(&a->conn, "invoice.sequence_get",
+ a->session, a->org, "{}");
+ if (seq && client_ok(seq))
+ snprintf(vals[i], 512, "%lld",
+ (long long)jint_val(seq, "result.next_number", 1));
+ free(seq);
+ }
tui_form_hint("upp/ned/Home/End Enter = ändra F5 = uppdatera"
" Esc/q = tillbaka ^C = avsluta");
int r = tui_form_run_actions(title, ff, n, can_edit, acts,
show_password_status ? 1 : 0, NULL,
focus);
- if (r >= 0 && r < n) {
+ if (r >= 0 && r < n && fields[r].invoice_number) {
+ if (!(a->role[0] && strcmp(a->role, "owner") == 0)) {
+ tui_message(title, "Kräver ägarbehörighet.");
+ } else {
+ char *end = NULL;
+ long v = strtol(vals[r], &end, 10);
+ if (!end || *end || v < 1) {
+ tui_message(title,
+ "Nästa fakturanummer måste vara ett positivt"
+ " heltal.");
+ } else {
+ char args[64];
+ snprintf(args, sizeof args, "{\"next_number\":%ld}", v);
+ char *rr = client_rpc(&a->conn, "invoice.sequence_set",
+ a->session, a->org, args);
+ if (!rr || !client_ok(rr))
+ show_error(title, rr);
+ free(rr);
+ }
+ }
+ } else if (r >= 0 && r < n) {
const char *val = vals[r];
if (fields[r].choices)
val = fields[r].choices[choice[r]];
@@ -304,21 +337,29 @@ static void settings_form(struct app *a, const char *title,
static const char *const SECURITY_CHOICES[] = { "starttls", "tls", "plain" };
static const struct setting_field INVOICE_SETTINGS[] = {
- { "default_series", "Standardserie", NULL, 0, 0 },
- { "invoice_receivable_account", "Fordringskonto", NULL, 0, 0 },
- { "invoice_revenue_account", "Intäktskonto", NULL, 0, 0 },
- { "invoice_bankgiro", "Bankgiro", NULL, 0, 0 },
- { "invoice_our_ref", "Vår referens (faktura)", NULL, 0, 0 },
+ { "invoice_next_number", "Nästa fakturanummer", NULL, 0, 0, 1 },
+ { "invoice_receivable_account", "Fordringskonto", NULL, 0, 0, 0 },
+ { "invoice_revenue_account", "Intäktskonto", NULL, 0, 0, 0 },
+ { "invoice_bankgiro", "Bankgiro", NULL, 0, 0, 0 },
+ { "invoice_our_ref", "Vår referens (faktura)", NULL, 0, 0, 0 },
+};
+
+static const struct setting_field SERIES_SETTINGS[] = {
+ { "series_voucher", "Manuella verifikat", NULL, 0, 0, 0 },
+ { "series_invoice", "Fakturor", NULL, 0, 0, 0 },
+ { "series_payroll", "Lön", NULL, 0, 0, 0 },
+ { "series_bokslut", "Bokslut", NULL, 0, 0, 0 },
+ { "series_ib", "Ingående balans", NULL, 0, 0, 0 },
};
static const struct setting_field SMTP_SETTINGS[] = {
- { "smtp_host", "SMTP-server", NULL, 0, 0 },
- { "smtp_port", "SMTP-port", NULL, 0, 0 },
- { "smtp_user", "SMTP-användare", NULL, 0, 0 },
- { "smtp_from", "SMTP-avsändare", NULL, 0, 0 },
- { "smtp_reply_to", "SMTP-svarsadress", NULL, 0, 0 },
- { "smtp_security", "SMTP-säkerhet", SECURITY_CHOICES, 3, 0 },
- { "smtp_password", "SMTP-lösenord", NULL, 0, 1 },
+ { "smtp_host", "SMTP-server", NULL, 0, 0, 0 },
+ { "smtp_port", "SMTP-port", NULL, 0, 0, 0 },
+ { "smtp_user", "SMTP-användare", NULL, 0, 0, 0 },
+ { "smtp_from", "SMTP-avsändare", NULL, 0, 0, 0 },
+ { "smtp_reply_to", "SMTP-svarsadress", NULL, 0, 0, 0 },
+ { "smtp_security", "SMTP-säkerhet", SECURITY_CHOICES, 3, 0, 0 },
+ { "smtp_password", "SMTP-lösenord", NULL, 0, 1, 0 },
};
static int settings_can_write(struct app *a)
@@ -343,6 +384,14 @@ static void smtp_settings_screen(struct app *a)
settings_can_write(a), 1, &sel);
}
+static void series_settings_screen(struct app *a)
+{
+ static int sel = 0;
+ settings_form(a, "Verifikationsserier", SERIES_SETTINGS,
+ (int)(sizeof SERIES_SETTINGS / sizeof SERIES_SETTINGS[0]),
+ settings_can_write(a), 0, &sel);
+}
+
static const char *const SYSTEM_ITEMS[] = {
"Skattetabeller",
"Revision",
@@ -435,6 +484,7 @@ static void company_data_screen(struct app *a)
static const char *const COMPANY_ITEMS[] = {
"Företagsuppgifter",
"Fakturauppgifter",
+ "Verifikationsserier",
"E-post (SMTP)",
"Styrelseledamöter",
MK_SECTION "Register",
@@ -462,18 +512,21 @@ void company_screen(struct app *a)
invoice_settings_screen(a);
break;
case 2:
- smtp_settings_screen(a);
+ series_settings_screen(a);
break;
case 3:
+ smtp_settings_screen(a);
+ break;
+ case 4:
board_screen(a);
break;
- case 5:
+ case 6:
employees_screen(a);
break;
- case 6:
+ case 7:
customers_screen(a);
break;
- case 7:
+ case 8:
rules_screen(a);
break;
default:
diff --git a/clients/screens_templates.c b/clients/screens_templates.c
index 5777742..e55028d 100644
--- a/clients/screens_templates.c
+++ b/clients/screens_templates.c
@@ -234,7 +234,7 @@ static int template_form(struct app *a, const char *load_name)
tui_rt_set_fields(&tf.rt, ff, 3);
const char *title = load_name ? "Redigera mall" : "Ny mall";
const char *hint = "Enter = ändra fält Tab = byta fält F5 = validera"
- " ^X = rensa rad ^Enter/F9 = spara Esc = avbryt";
+ " ^X = rensa rad F9 = spara Esc = avbryt";
for (;;) {
int rr = tui_rt_run(title, &tf.rt, hint);
if (rr == -1)
diff --git a/clients/screens_vouchers.c b/clients/screens_vouchers.c
index 5e1b006..d884b6e 100644
--- a/clients/screens_vouchers.c
+++ b/clients/screens_vouchers.c
@@ -743,6 +743,23 @@ static int64_t vform_post(struct vform *vf, int dry)
replayed ? " — redan bokfört (idempotent)" : "");
free(ser);
free(r);
+ if (strcmp(vf->series, a->default_series) != 0) {
+ yyjson_mut_doc *sd = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *so = yyjson_mut_obj(sd);
+ yyjson_mut_doc_set_root(sd, so);
+ yyjson_mut_obj_add_strcpy(sd, so, "key", "series_voucher");
+ yyjson_mut_obj_add_strcpy(sd, so, "value", vf->series);
+ char *sargs = yyjson_mut_write(sd, 0, NULL);
+ yyjson_mut_doc_free(sd);
+ if (sargs) {
+ char *sr = client_rpc(&a->conn, "settings.set",
+ a->session, a->org, sargs);
+ free(sr);
+ free(sargs);
+ }
+ snprintf(a->default_series, sizeof a->default_series, "%s",
+ vf->series);
+ }
free(args);
return id;
}
@@ -836,7 +853,7 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p)
tui_rt_normalize(&vf.rt);
const char *hint = "Enter = ändra fält Tab = byta fält F4 = mall"
" ^F = bifoga fil F5 = validera ^X = rensa rad"
- " ^Enter/F9 = bokför Esc = avbryt";
+ " F9 = bokför Esc = avbryt";
for (;;) {
int rr = tui_rt_run("Nytt verifikat", &vf.rt, hint);
if (rr == -1)
diff --git a/clients/tui.c b/clients/tui.c
index 30761a8..db5599d 100644
--- a/clients/tui.c
+++ b/clients/tui.c
@@ -1615,7 +1615,7 @@ static int form_run(const char *title, struct tui_form_field *f, int nf,
h = na > 0
? (can_edit
? "upp/ned/Tab = flytta Enter = ändra/utför"
- " F5 = uppdatera ^Enter/F9 = spara"
+ " F5 = uppdatera F9 = spara"
" Esc/q = tillbaka ^C = avsluta"
: "upp/ned/Tab = flytta Enter = utför"
" F5 = uppdatera Esc/q = tillbaka"
@@ -1623,7 +1623,7 @@ static int form_run(const char *title, struct tui_form_field *f, int nf,
" (endast behöriga kan ändra)")
: (can_edit
? "upp/ned/Home/End Enter = ändra"
- " F5 = uppdatera ^Enter/F9 = spara"
+ " F5 = uppdatera F9 = spara"
" Esc/q = tillbaka ^C = avsluta"
: "upp/ned/Home/End F5 = uppdatera"
" Esc/q = tillbaka ^C = avsluta"
diff --git a/clients/ui.c b/clients/ui.c
index ca6906f..e6f6e07 100644
--- a/clients/ui.c
+++ b/clients/ui.c
@@ -217,12 +217,17 @@ void app_refresh_context(struct app *a)
free(resp);
snprintf(a->default_series, sizeof a->default_series, "%s", "A");
+ snprintf(a->ib_series, sizeof a->ib_series, "%s", "IB");
resp = client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
if (resp && client_ok(resp)) {
- char *ser = jstr_dup(resp, "result.default_series");
+ char *ser = jstr_dup(resp, "result.series_voucher");
if (ser && *ser)
snprintf(a->default_series, sizeof a->default_series, "%s", ser);
free(ser);
+ char *ib = jstr_dup(resp, "result.series_ib");
+ if (ib && *ib)
+ snprintf(a->ib_series, sizeof a->ib_series, "%s", ib);
+ free(ib);
}
free(resp);
diff --git a/clients/ui.h b/clients/ui.h
index b37eb4f..b4ce5f5 100644
--- a/clients/ui.h
+++ b/clients/ui.h
@@ -42,6 +42,7 @@ struct app {
int64_t employee_sel; /* last selected employee id in the list view */
int64_t payroll_sel; /* last selected payroll run id in the list view */
char default_series[16];
+ char ib_series[16];
char attachment_dir[256];
long max_attachment_bytes;
char fy_label[64];
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index a108f48..b52f8ec 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -201,7 +201,18 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21).
employed now but everything is multi-employee; no semester accrual;
tax tables from day one; manual step-buttons (Bokför, Lönebesked,
AGI-underlag, Betala skatt & avgifter); bank phase 3 (`bank_rule.*`) is
- dropped.
+ dropped.
+25. **Per-feature voucher series (2026-09-21)**: the single standardserie
+ setting is replaced by one setting per feature that posts vouchers:
+ `series_voucher` (manual vouchers and new templates, default `A`,
+ falling back to the legacy `default_series`), `series_invoice` (`F`),
+ `series_payroll` (`L`), `series_bokslut` (`Å`) and `series_ib` (`IB`).
+ All are editable (Bolaget → Verifikationsserier), 1–8 characters with
+ no control characters. The manual voucher form remembers the last
+ series it posted as `series_voucher`. Reports and SIE treat both the
+ configured `series_ib` and the historical `IB` series as ingående
+ balans, so old books keep working. "Nästa fakturanummer" is editable
+ in Bolaget → Fakturauppgifter (owner only).
## Completed work formerly listed under "Pending decisions"
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 76197d9..6d99225 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -374,11 +374,17 @@ resolved rows in a dry run.
| 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` |
+| `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` |
-`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 accepted for
+`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
@@ -963,10 +969,11 @@ commands. Implemented screens (0.1.0-dev):
- **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**
- (standardserie, fordringskonto, intäktskonto, bankgiro, vår referens) and
+ 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), both `settings.set` and open to bookkeepers;
+ 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
diff --git a/docs/STATE.md b/docs/STATE.md
index 929ddf1..56a23ed 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -43,8 +43,9 @@ unit tests and the docs consistency check.
headings: Verifikat, Underlag, Bankavstämning, Mallar, Fakturor,
Lönekörningar, Rapporter, Bokslut, Bolaget, System, Ingående balans,
Räkenskapsår, Logga ut. **Bolaget** is the master-data hub
- (Företagsuppgifter, Fakturauppgifter, E-post (SMTP), Styrelseledamöter
- and the registers Anställda, Kunder, Momsregler); **System** holds
+ (Företagsuppgifter, Fakturauppgifter with Nästa fakturanummer,
+ **Verifikationsserier**, E-post (SMTP), Styrelseledamöter and the
+ registers Anställda, Kunder, Momsregler); **System** holds
Skattetabeller and Revision. Inställningar is gone, and bilagornas mapp
is no longer a setting: the file browser remembers the last pick
directory in `tui.conf` and falls back to `$HOME`. `--screen settings`
@@ -134,6 +135,10 @@ Original entries for the struck items are in `docs/DECISIONS.md`.
## Known caveats
+- Saving with Ctrl+Enter needs a terminal that speaks xterm
+ `modifyOtherKeys` level 2 or the Kitty keyboard protocol; gnome-terminal/VTE
+ sends neither, so the hints advertise `F9`, which works everywhere.
+
- Developer tooling: the `g_cmd_<domain>[]` tables in `src/cmd_*.c` carry
declarative argument schemas (`CMD_ARGS`); `describe` emits them and the
dispatcher validates before the handler runs. `make check` (part of
diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md
index 190a5c5..470f6d8 100644
--- a/docs/TUI-GUIDELINES.md
+++ b/docs/TUI-GUIDELINES.md
@@ -33,7 +33,7 @@ there.
| `Ctrl+F` | Attach a file via the file browser (voucher form and voucher detail) |
| `k` | Underlag: link the highlighted attachment to a voucher picked from a list |
| `Ctrl+X` | Clear the current row — only inside row editors (never "new") |
-| `Ctrl+Enter` | Save/post the current form. Enabled via xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol; terminals that send neither keep `F9` working, and the hints show `^Enter/F9` |
+| `Ctrl+Enter` | Save/post the current form. Needs xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol (xterm, kitty, foot, WezTerm); gnome-terminal/VTE sends neither, so the hints advertise `F9`, which works everywhere |
Every screen prints its keys in the footer via `hints()`. If a key exists, the
footer shows it; if the footer shows it, the key works. Control keys are
@@ -43,7 +43,7 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width.
- Rows are numbered `NN. text`, right-aligned so 2- and 3-digit numbers line up.
- Verifikation ids are shown concatenated as `series+number` (`V-8`, `A8`),
- using the org's `default_series` (Bolaget → Fakturauppgifter) for new
+ using the org's `series_voucher` (Bolaget → Verifikationsserier) for new
vouchers.
- The last row may be an action (e.g. `+ Nytt verifikat (Ctrl+N)`); selecting it
runs the action instead of opening a detail view.
diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py
index f5d5b7f..db8a18e 100755
--- a/scripts/tui-golden.py
+++ b/scripts/tui-golden.py
@@ -291,10 +291,28 @@ SCENARIOS = [
"name": "company",
"screen": "company",
"expect": ["Bolaget", "Företagsuppgifter", "Fakturauppgifter",
- "E-post (SMTP)", "Anställda", "Kunder", "Momsregler"],
+ "Verifikationsserier", "E-post (SMTP)", "Anställda",
+ "Kunder", "Momsregler"],
"steps": [
{
+ "keys": ["2"],
+ "expect": ["Fakturauppgifter", "Nästa fakturanummer"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["Bolaget", "Verifikationsserier"],
+ },
+ {
"keys": ["3"],
+ "expect": ["Verifikationsserier", "Manuella verifikat",
+ "Ingående balans"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["Bolaget", "Fakturauppgifter"],
+ },
+ {
+ "keys": ["4"],
"expect": ["E-post (SMTP)", "SMTP-server",
"lämna tomt för oförändrat"],
},
@@ -303,7 +321,7 @@ SCENARIOS = [
"expect": ["Bolaget", "Företagsuppgifter"],
},
{
- "keys": ["5"],
+ "keys": ["6"],
"expect": ["Anställda", "Testanställd"],
},
{
diff --git a/src/cmd_bokslut.c b/src/cmd_bokslut.c
index c813923..f0ddee9 100644
--- a/src/cmd_bokslut.c
+++ b/src/cmd_bokslut.c
@@ -88,6 +88,9 @@ static yyjson_mut_val *h_bokslut_post(struct req *r)
}
int64_t result_before = 0;
+ char ib_series[16];
+ db_setting_copy(r->db, r->org_id, "series_ib", "IB", ib_series,
+ sizeof ib_series);
if (sqlite3_prepare_v2(
r->db,
"SELECT COALESCE(SUM(r.credit_ore)-SUM(r.debit_ore),0)"
@@ -95,11 +98,13 @@ static yyjson_mut_val *h_bokslut_post(struct req *r)
" JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id"
" JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
" WHERE r.org_id=?1 AND v.fiscal_year_id=?2 AND v.series<>'IB'"
+ " AND v.series<>?3"
" AND a.type IN ('revenue','expense') AND a.number NOT LIKE '89%'",
-1, &st, NULL) != SQLITE_OK)
return db_error(r);
sqlite3_bind_int64(st, 1, r->org_id);
sqlite3_bind_int64(st, 2, fy_id);
+ sqlite3_bind_text(st, 3, ib_series, -1, SQLITE_TRANSIENT);
if (sqlite3_step(st) == SQLITE_ROW)
result_before = sqlite3_column_int64(st, 0);
sqlite3_finalize(st);
@@ -154,6 +159,9 @@ static yyjson_mut_val *h_bokslut_post(struct req *r)
plan[np++] = (typeof(plan[0])){ "Resultatdisposition", drows, 2 };
}
+ char series[16];
+ db_setting_copy(r->db, r->org_id, "series_bokslut", "Å", series,
+ sizeof series);
yyjson_mut_val *vouchers = yyjson_mut_arr(r->rdoc);
for (size_t i = 0; i < np; i++) {
struct ledger_post_opts o;
@@ -165,6 +173,7 @@ static yyjson_mut_val *h_bokslut_post(struct req *r)
o.description = plan[i].desc;
o.rows = plan[i].rows;
o.nrows = plan[i].nrows;
+ o.series = series;
o.dry_run = r->dry_run;
struct ledger_error e;
char *json = NULL;
diff --git a/src/cmd_invoices.c b/src/cmd_invoices.c
index 0892ac8..73273dc 100644
--- a/src/cmd_invoices.c
+++ b/src/cmd_invoices.c
@@ -821,6 +821,9 @@ static yyjson_mut_val *h_invoice_issue(struct req *r)
invoice_store_attachment(r, &v, pdf, pdf_len, &attachment_id) != 0)
goto done;
+ char series[16];
+ db_setting_copy(r->db, r->org_id, "series_invoice", "F", series,
+ sizeof series);
struct ledger_post_opts o;
memset(&o, 0, sizeof o);
o.org_id = r->org_id;
@@ -831,6 +834,7 @@ static yyjson_mut_val *h_invoice_issue(struct req *r)
o.rows = vrows;
o.nrows = vn;
o.source = "invoice";
+ o.series = series;
o.dry_run = r->dry_run;
o.already_in_tx = 1;
struct ledger_error e;
diff --git a/src/cmd_payroll.c b/src/cmd_payroll.c
index b5db4f1..84ae323 100644
--- a/src/cmd_payroll.c
+++ b/src/cmd_payroll.c
@@ -491,6 +491,9 @@ static yyjson_mut_val *h_payroll_run_post(struct req *r)
char description[64];
snprintf(description, sizeof description, "Lönekörning %s", period);
+ char series[16];
+ db_setting_copy(r->db, r->org_id, "series_payroll", "L", series,
+ sizeof series);
struct ledger_post_opts o;
memset(&o, 0, sizeof o);
o.org_id = r->org_id;
@@ -501,6 +504,7 @@ static yyjson_mut_val *h_payroll_run_post(struct req *r)
o.rows = vrows;
o.nrows = vn;
o.source = "payroll";
+ o.series = series;
o.dry_run = r->dry_run;
o.already_in_tx = 1;
struct ledger_error e;
@@ -1341,6 +1345,9 @@ static yyjson_mut_val *h_payroll_pay_tax(struct req *r)
if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0)
return fail(r, "DB_BUSY", "could not start transaction");
in_tx = 1;
+ char series[16];
+ db_setting_copy(r->db, r->org_id, "series_payroll", "L", series,
+ sizeof series);
struct ledger_post_opts o;
memset(&o, 0, sizeof o);
o.org_id = r->org_id;
@@ -1351,6 +1358,7 @@ static yyjson_mut_val *h_payroll_pay_tax(struct req *r)
o.rows = rows;
o.nrows = vn;
o.source = "payroll_tax";
+ o.series = series;
o.dry_run = r->dry_run;
o.already_in_tx = 1;
if (ledger_post(r->db, &o, &e, &voucher_json) != 0) {
diff --git a/src/cmd_settings.c b/src/cmd_settings.c
index 066dc55..28167de 100644
--- a/src/cmd_settings.c
+++ b/src/cmd_settings.c
@@ -20,6 +20,9 @@ static yyjson_mut_val *h_settings_get(struct req *r)
yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
int have_default = 0, have_bank = 0, have_receivable = 0, have_revenue = 0;
int have_password = 0, have_security = 0;
+ int have_voucher = 0, have_invoice = 0, have_payroll = 0;
+ int have_bokslut = 0, have_ib = 0;
+ char legacy_series[16] = "A";
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(
r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st,
@@ -35,22 +38,46 @@ static yyjson_mut_val *h_settings_get(struct req *r)
}
yyjson_mut_obj_add(o, yyjson_mut_strcpy(r->rdoc, k),
yyjson_mut_strcpy(r->rdoc, v ? v : ""));
- if (strcmp(k, "default_series") == 0)
+ if (strcmp(k, "default_series") == 0) {
have_default = 1;
- if (strcmp(k, "bank_account") == 0)
+ if (v && *v)
+ snprintf(legacy_series, sizeof legacy_series, "%s", v);
+ } else if (strcmp(k, "series_voucher") == 0) {
+ have_voucher = 1;
+ } else if (strcmp(k, "series_invoice") == 0) {
+ have_invoice = 1;
+ } else if (strcmp(k, "series_payroll") == 0) {
+ have_payroll = 1;
+ } else if (strcmp(k, "series_bokslut") == 0) {
+ have_bokslut = 1;
+ } else if (strcmp(k, "series_ib") == 0) {
+ have_ib = 1;
+ } else if (strcmp(k, "bank_account") == 0) {
have_bank = 1;
- if (strcmp(k, "invoice_receivable_account") == 0)
+ } else if (strcmp(k, "invoice_receivable_account") == 0) {
have_receivable = 1;
- if (strcmp(k, "invoice_revenue_account") == 0)
+ } else if (strcmp(k, "invoice_revenue_account") == 0) {
have_revenue = 1;
- if (strcmp(k, "smtp_security") == 0)
+ } else if (strcmp(k, "smtp_security") == 0) {
have_security = 1;
+ }
}
}
sqlite3_finalize(st);
}
if (!have_default)
yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A");
+ if (!have_voucher)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_voucher",
+ legacy_series);
+ if (!have_invoice)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_invoice", "F");
+ if (!have_payroll)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_payroll", "L");
+ if (!have_bokslut)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_bokslut", "Å");
+ if (!have_ib)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series_ib", "IB");
if (!have_bank)
yyjson_mut_obj_add_strcpy(r->rdoc, o, "bank_account", "1930");
if (!have_receivable)
@@ -135,7 +162,12 @@ static yyjson_mut_val *h_settings_set(struct req *r)
}
size_t maxlen;
int digits_only = 0, bankgiro = 0, port = 0, security = 0;
- if (strcmp(key, "default_series") == 0)
+ if (strcmp(key, "default_series") == 0 ||
+ strcmp(key, "series_voucher") == 0 ||
+ strcmp(key, "series_invoice") == 0 ||
+ strcmp(key, "series_payroll") == 0 ||
+ strcmp(key, "series_bokslut") == 0 ||
+ strcmp(key, "series_ib") == 0)
maxlen = 8;
else if (strcmp(key, "attachment_dir") == 0 ||
strcmp(key, "smtp_host") == 0 ||
diff --git a/src/db.c b/src/db.c
index 95ecf3f..4618a73 100644
--- a/src/db.c
+++ b/src/db.c
@@ -1215,6 +1215,14 @@ char *db_setting(sqlite3 *db, int64_t org_id, const char *key)
return value;
}
+void db_setting_copy(sqlite3 *db, int64_t org_id, const char *key,
+ const char *def, char *out, size_t n)
+{
+ char *v = db_setting(db, org_id, key);
+ snprintf(out, n, "%s", v && *v ? v : def);
+ free(v);
+}
+
char *db_membership_role(sqlite3 *db, int64_t org_id, int64_t user_id)
{
sqlite3_stmt *st = NULL;
diff --git a/src/db.h b/src/db.h
index 4e16f62..e8d891b 100644
--- a/src/db.h
+++ b/src/db.h
@@ -2,6 +2,7 @@
#define BOKF_DB_H
#include <sqlite3.h>
+#include <stddef.h>
#include <stdint.h>
#define BOKF_SCHEMA_VERSION 11
@@ -14,6 +15,8 @@ int db_create_user(sqlite3 *db, const char *username, const char *display_name,
char **err);
char *db_membership_role(sqlite3 *db, int64_t org_id, int64_t user_id);
char *db_setting(sqlite3 *db, int64_t org_id, const char *key);
+void db_setting_copy(sqlite3 *db, int64_t org_id, const char *key,
+ const char *def, char *out, size_t n);
int64_t db_count(sqlite3 *db, const char *sql);
int64_t db_last_id(sqlite3 *db);
diff --git a/src/ledger.c b/src/ledger.c
index a22e15e..b9aad47 100644
--- a/src/ledger.c
+++ b/src/ledger.c
@@ -360,7 +360,11 @@ int ledger_post(sqlite3 *db, const struct ledger_post_opts *o,
char *series_owned = NULL;
const char *series = o->series;
if (!series || !*series) {
- series_owned = db_setting(db, o->org_id, "default_series");
+ series_owned = db_setting(db, o->org_id, "series_voucher");
+ if (!series_owned || !*series_owned) {
+ free(series_owned);
+ series_owned = db_setting(db, o->org_id, "default_series");
+ }
series = series_owned && *series_owned ? series_owned : "A";
}
if (!util_parse_iso_date(o->date)) {
diff --git a/src/reports.c b/src/reports.c
index 1d4efdd..2ca7e45 100644
--- a/src/reports.c
+++ b/src/reports.c
@@ -4,6 +4,7 @@
#include <stdlib.h>
#include <string.h>
+#include "db.h"
#include "util.h"
struct fy_info {
@@ -65,19 +66,22 @@ static yyjson_mut_val *balance_query(yyjson_mut_doc *doc, sqlite3 *db,
const char *from, const char *to,
const char *types_filter, char **err)
{
+ char ib_series[16];
+ db_setting_copy(db, org_id, "series_ib", "IB", ib_series,
+ sizeof ib_series);
char sql[1280];
snprintf(sql, sizeof sql,
"SELECT a.number,a.name,a.type,"
- " COALESCE(SUM(CASE WHEN v.series <> 'IB'"
+ " COALESCE(SUM(CASE WHEN v.series <> 'IB' AND v.series <> ?5"
" AND v.date BETWEEN ?2 AND ?3 THEN r.debit_ore END),0),"
- " COALESCE(SUM(CASE WHEN v.series <> 'IB'"
+ " COALESCE(SUM(CASE WHEN v.series <> 'IB' AND v.series <> ?5"
" AND v.date BETWEEN ?2 AND ?3 THEN r.credit_ore END),0),"
- " COALESCE(SUM(CASE WHEN (v.series = 'IB'"
+ " COALESCE(SUM(CASE WHEN ((v.series = 'IB' OR v.series = ?5)"
" AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'"
- " AND v.date < ?2) THEN r.debit_ore END),0),"
- " COALESCE(SUM(CASE WHEN (v.series = 'IB'"
+ " AND v.series <> ?5 AND v.date < ?2) THEN r.debit_ore END),0),"
+ " COALESCE(SUM(CASE WHEN ((v.series = 'IB' OR v.series = ?5)"
" AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'"
- " AND v.date < ?2) THEN r.credit_ore END),0)"
+ " AND v.series <> ?5 AND v.date < ?2) THEN r.credit_ore END),0)"
" FROM accounts a"
" LEFT JOIN (voucher_rows r JOIN vouchers v"
" ON v.org_id=r.org_id AND v.id=r.voucher_id)"
@@ -94,6 +98,7 @@ static yyjson_mut_val *balance_query(yyjson_mut_doc *doc, sqlite3 *db,
sqlite3_bind_text(st, 2, from, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 3, to, -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(st, 4, fy_id);
+ sqlite3_bind_text(st, 5, ib_series, -1, SQLITE_TRANSIENT);
yyjson_mut_val *arr = yyjson_mut_arr(doc);
while (sqlite3_step(st) == SQLITE_ROW) {
const char *number = (const char *)sqlite3_column_text(st, 0);
@@ -377,6 +382,9 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
int64_t amount;
} acc[VAT_MAX_BOXES];
size_t nacc = 0;
+ char ib_series[16];
+ db_setting_copy(db, org_id, "series_ib", "IB", ib_series,
+ sizeof ib_series);
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(
db,
@@ -401,21 +409,22 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
" JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id"
" JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
" WHERE r.org_id=?1 AND a.number=?2 AND v.series <> 'IB'"
- " AND v.date BETWEEN ?3 AND ?4";
+ " AND v.series <> ?5 AND v.date BETWEEN ?3 AND ?4";
else if (strcmp(mt, "type") == 0)
sql = "SELECT COALESCE(SUM(r.debit_ore),0),"
"COALESCE(SUM(r.credit_ore),0) FROM voucher_rows r"
" JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id"
" JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
" WHERE r.org_id=?1 AND a.type=?2 AND v.series <> 'IB'"
- " AND v.date BETWEEN ?3 AND ?4";
+ " AND v.series <> ?5 AND v.date BETWEEN ?3 AND ?4";
else
sql = "SELECT COALESCE(SUM(r.debit_ore),0),"
"COALESCE(SUM(r.credit_ore),0) FROM voucher_rows r"
" JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id"
" JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
" WHERE r.org_id=?1 AND CAST(a.number AS INTEGER) BETWEEN ?2"
- " AND ?3 AND v.series <> 'IB' AND v.date BETWEEN ?4 AND ?5";
+ " AND ?3 AND v.series <> 'IB' AND v.series <> ?6"
+ " AND v.date BETWEEN ?4 AND ?5";
if (sqlite3_prepare_v2(db, sql, -1, &qs, NULL) != SQLITE_OK)
break;
sqlite3_bind_int64(qs, 1, org_id);
@@ -423,6 +432,7 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
sqlite3_bind_text(qs, 2, pattern, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(qs, 3, from, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(qs, 4, to, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(qs, 5, ib_series, -1, SQLITE_TRANSIENT);
} else {
long lo = 0, hi = 0;
if (sscanf(pattern, "%ld-%ld", &lo, &hi) != 2) {
@@ -433,6 +443,7 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
sqlite3_bind_int64(qs, 3, hi);
sqlite3_bind_text(qs, 4, from, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(qs, 5, to, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(qs, 6, ib_series, -1, SQLITE_TRANSIENT);
}
if (sqlite3_step(qs) == SQLITE_ROW)
amount = (sqlite3_column_int64(qs, 0) -
diff --git a/src/sie.c b/src/sie.c
index ba4c1a8..6e0eab4 100644
--- a/src/sie.c
+++ b/src/sie.c
@@ -8,6 +8,7 @@
#include <string.h>
#include <time.h>
+#include "db.h"
#include "ledger.h"
#include "log.h"
#include "util.h"
@@ -135,18 +136,22 @@ int sie_export_file(sqlite3 *db, int64_t org_id, int64_t fy_id,
put_date(f, end);
put(f, "\n");
- /* accounts and balances: IB = this year's series "IB" voucher(s) plus
- all non-IB history before the year; UB = everything through the end;
+ /* accounts and balances: IB = this year's IB-series voucher(s) plus all
+ non-IB history before the year; UB = everything through the end;
RES = the year's non-IB movements. */
+ char ib_series[16];
+ db_setting_copy(db, org_id, "series_ib", "IB", ib_series,
+ sizeof ib_series);
if (sqlite3_prepare_v2(
db,
"SELECT a.number,a.name,"
- " COALESCE(SUM(CASE WHEN (v.series = 'IB'"
+ " COALESCE(SUM(CASE WHEN ((v.series = 'IB' OR v.series = ?5)"
" AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'"
- " AND v.date < ?2) THEN r.debit_ore - r.credit_ore END),0),"
+ " AND v.series <> ?5 AND v.date < ?2)"
+ " THEN r.debit_ore - r.credit_ore END),0),"
" COALESCE(SUM(CASE WHEN v.date <= ?3"
" THEN r.debit_ore - r.credit_ore END),0),"
- " COALESCE(SUM(CASE WHEN v.series <> 'IB'"
+ " COALESCE(SUM(CASE WHEN v.series <> 'IB' AND v.series <> ?5"
" AND v.date >= ?2 AND v.date <= ?3"
" THEN r.debit_ore - r.credit_ore END),0)"
" FROM accounts a LEFT JOIN (voucher_rows r JOIN vouchers v"
@@ -163,6 +168,7 @@ int sie_export_file(sqlite3 *db, int64_t org_id, int64_t fy_id,
sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 3, end, -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(st, 4, fy_id);
+ sqlite3_bind_text(st, 5, ib_series, -1, SQLITE_TRANSIENT);
while (sqlite3_step(st) == SQLITE_ROW) {
const char *number = (const char *)sqlite3_column_text(st, 0);
const char *name = (const char *)sqlite3_column_text(st, 1);
diff --git a/tests/test_core.c b/tests/test_core.c
index 12d42e5..a0b2468 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -2317,6 +2317,132 @@ static void test_settings(struct tctx *t)
yyjson_doc_free(d);
}
+static void test_series(struct tctx *t)
+{
+ yyjson_doc *d;
+ (void)t;
+
+ /* own org: the series settings and their vouchers must not disturb the
+ shared test org's counts and defaults */
+ d = call(reqf("{\"v\":1,\"id\":\"sv0\",\"cmd\":\"org.create\","
+ "\"session\":\"%s\",\"args\":{\"name\":\"Serie AB\","
+ "\"org_nr\":\"5560001111\"}}",
+ g_session));
+ CHECK_OK(d);
+ int org = (int)jint(d, "result.id");
+ CHECK(org > 0);
+ yyjson_doc_free(d);
+
+ /* the legacy default_series seeds series_voucher; the other features
+ have their own conventional defaults */
+ d = call(reqf("{\"v\":1,\"id\":\"sv1a\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"default_series\",\"value\":\"V-\"}}",
+ g_session, org));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"sv1\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, org));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.series_voucher", "V-");
+ CHECK_STR(d, "result.series_invoice", "F");
+ CHECK_STR(d, "result.series_payroll", "L");
+ CHECK_STR(d, "result.series_bokslut", "Å");
+ CHECK_STR(d, "result.series_ib", "IB");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"sv2\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"series_foo\",\"value\":\"X\"}}",
+ g_session, org));
+ CHECK_STR(d, "error.code", "UNSUPPORTED");
+ yyjson_doc_free(d);
+
+ /* an explicit per-feature voucher series wins over the legacy key */
+ d = call(reqf("{\"v\":1,\"id\":\"sv3\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"series_voucher\",\"value\":\"S-\"}}",
+ g_session, org));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "S-");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"sv4\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-09-20\",\"description\":\"Serieserie\",\"rows\":"
+ "[{\"account\":\"1930\",\"debit_ore\":100},{\"account\":"
+ "\"3001\",\"credit_ore\":100}]}}",
+ g_session, org));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.series", "S-");
+ yyjson_doc_free(d);
+
+ /* invoices post in the invoice series */
+ d = call(reqf("{\"v\":1,\"id\":\"sv5\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"series_invoice\",\"value\":\"F2\"}}",
+ g_session, org));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"sv6\",\"cmd\":\"customer.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":"
+ "\"Serie Test AB\"}}",
+ g_session, org));
+ CHECK_OK(d);
+ int64_t cust = jint(d, "result.id");
+ CHECK(cust > 0);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"sv7\",\"cmd\":\"invoice.issue\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"Konsult\","
+ "\"quantity\":\"1\",\"unit_price_ore\":100000,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, org, (long long)cust));
+ CHECK_OK(d);
+ int64_t voucher = jint(d, "result.voucher_id");
+ CHECK(voucher > 0);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"sv8\",\"cmd\":\"voucher.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, org, (long long)voucher));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.series", "F2");
+ yyjson_doc_free(d);
+
+ /* the configured IB series counts as ingående balans */
+ d = call(reqf("{\"v\":1,\"id\":\"sv9\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"series_ib\",\"value\":\"IBX\"}}",
+ g_session, org));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"sv10\",\"cmd\":\"report.trial_balance\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, org));
+ CHECK_OK(d);
+ int64_t ib_before = find_amount(d, "result.accounts", "account", "1930",
+ "ib_ore");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"sv11\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-01-01\",\"description\":\"IB serietest\","
+ "\"series\":\"IBX\",\"rows\":[{\"account\":\"1930\","
+ "\"debit_ore\":700},{\"account\":\"2010\","
+ "\"credit_ore\":700}]}}",
+ g_session, org));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.series", "IBX");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"sv12\",\"cmd\":\"report.trial_balance\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, org));
+ CHECK_OK(d);
+ CHECK(find_amount(d, "result.accounts", "account", "1930", "ib_ore") ==
+ ib_before + 700);
+ yyjson_doc_free(d);
+}
+
static void test_smtp_settings(struct tctx *t)
{
yyjson_doc *d;
@@ -4554,6 +4680,12 @@ static void test_payroll(struct tctx *t)
yyjson_doc_free(d);
/* post: one balanced voucher, the run and its lines */
+ d = call(reqf("{\"v\":1,\"id\":\"p13a\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"series_payroll\",\"value\":\"L9\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
d = call(reqf("{\"v\":1,\"id\":\"p13\",\"cmd\":\"payroll.run_post\","
"\"session\":\"%s\",\"org\":%d,\"args\":"
"{\"period\":\"2026-08\",\"pay_date\":\"2026-08-25\"}}",
@@ -4575,6 +4707,13 @@ static void test_payroll(struct tctx *t)
g_session, (int)t->org_id, (long long)voucher));
CHECK_OK(d);
CHECK_STR(d, "result.source", "payroll");
+ CHECK_STR(d, "result.series", "L9");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p14b\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"series_payroll\",\"value\":\"L\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
yyjson_doc_free(d);
char sql[512];
@@ -5124,6 +5263,7 @@ static const struct ttest TESTS[] = {
{ "sie_export", test_sie_export, "ib" },
{ "sie_import", test_sie_import, "attachments sie_export" },
{ "settings", test_settings, "org_members" },
+ { "series", test_series, "settings" },
{ "smtp_settings", test_smtp_settings, "org_members" },
{ "bank", test_bank, "settings" },
{ "invoices", test_invoices, "org_members" },