diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-21 21:43:44 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-21 21:43:44 +0200 |
| commit | 62a92f0af0442c78277c3c97c9efe6fef8145625 (patch) | |
| tree | 3802a62904880da22bf84a44e9ece3cdff9cd8aa | |
| parent | f1eeb15781a044a9451ee08b91c63feb395b4767 (diff) | |
| download | bokf-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.c | 2 | ||||
| -rw-r--r-- | clients/screens_ib.c | 4 | ||||
| -rw-r--r-- | clients/screens_invoices.c | 2 | ||||
| -rw-r--r-- | clients/screens_payroll.c | 4 | ||||
| -rw-r--r-- | clients/screens_settings.c | 89 | ||||
| -rw-r--r-- | clients/screens_templates.c | 2 | ||||
| -rw-r--r-- | clients/screens_vouchers.c | 19 | ||||
| -rw-r--r-- | clients/tui.c | 4 | ||||
| -rw-r--r-- | clients/ui.c | 7 | ||||
| -rw-r--r-- | clients/ui.h | 1 | ||||
| -rw-r--r-- | docs/DECISIONS.md | 13 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 21 | ||||
| -rw-r--r-- | docs/STATE.md | 9 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 4 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 22 | ||||
| -rw-r--r-- | src/cmd_bokslut.c | 9 | ||||
| -rw-r--r-- | src/cmd_invoices.c | 4 | ||||
| -rw-r--r-- | src/cmd_payroll.c | 8 | ||||
| -rw-r--r-- | src/cmd_settings.c | 44 | ||||
| -rw-r--r-- | src/db.c | 8 | ||||
| -rw-r--r-- | src/db.h | 3 | ||||
| -rw-r--r-- | src/ledger.c | 6 | ||||
| -rw-r--r-- | src/reports.c | 29 | ||||
| -rw-r--r-- | src/sie.c | 16 | ||||
| -rw-r--r-- | tests/test_core.c | 140 |
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 || @@ -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; @@ -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) - @@ -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" }, |
