diff options
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | clients/bokftui.c | 214 | ||||
| -rw-r--r-- | docs/COMPLIANCE.md | 3 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 25 | ||||
| -rw-r--r-- | docs/STATE.md | 18 | ||||
| -rw-r--r-- | src/commands.c | 234 | ||||
| -rw-r--r-- | src/sru.c | 711 | ||||
| -rw-r--r-- | src/sru.h | 15 | ||||
| -rw-r--r-- | tests/test_core.c | 120 |
9 files changed, 1262 insertions, 80 deletions
@@ -18,7 +18,7 @@ VENDOR_SRC = vendor/sqlite3.c vendor/yyjson.c vendor/sha256.c \ CORE_SRC = src/util.c src/log.c src/config.c src/db.c src/auth.c \ src/sessions.c src/audit.c src/protocol.c src/commands.c \ - src/seed.c src/ledger.c src/reports.c src/sie.c src/formula.c + src/seed.c src/ledger.c src/reports.c src/sru.c src/sie.c src/formula.c VENDOR_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(VENDOR_SRC)) CORE_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRC)) diff --git a/clients/bokftui.c b/clients/bokftui.c index d446974..6c50b1b 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -373,7 +373,7 @@ static char *date_prompt(const char *label, const char *def) attroff(A_BOLD); size_t pos = (size_t)-1; int fresh = 1; - curs_set(1); + int prev = curs_set(1); for (;;) { move(y, (int)strlen(label) + 3); clrtoeol(); @@ -384,10 +384,14 @@ static char *date_prompt(const char *label, const char *def) move(y, (int)strlen(label) + 3 + (int)disp_width_n(buf, cp)); refresh(); int ch = ui_getch(); - if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) + if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + curs_set(prev == ERR ? 0 : prev); return buf; - if (ch == 27) + } + if (ch == 27) { + curs_set(prev == ERR ? 0 : prev); return NULL; + } date_field_edit(buf, sizeof buf, &pos, ch, &fresh); } } @@ -2599,8 +2603,12 @@ static void vouchers_screen(struct app *a) char *date = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.description", i); char *desc = jstr_dup(resp, path); - snprintf(line, sizeof line, "%s %-*s %s", date ? date : "", - idw, idstr[i], desc ? desc : ""); + snprintf(path, sizeof path, "result.items.%zu.attachment_count", + i); + int atts = (int)jint_val(resp, path, 0); + snprintf(line, sizeof line, "%c %s %-*s %s", + atts ? 'x' : ' ', date ? date : "", idw, idstr[i], + desc ? desc : ""); items[i] = xstrdup(line); free(date); free(desc); @@ -3161,6 +3169,47 @@ static void is_print_group(struct buf *t, yyjson_val *res, yyjson_val *prev, *pre = sp; } +static int is_range_has(yyjson_val *res, int lo, int hi) +{ + for (int k = 0; k < 2; k++) { + yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]); + yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL; + size_t n = yyjson_arr_size(arr); + for (size_t i = 0; i < n; i++) + if (acct_in(vstr(yyjson_arr_get(arr, i), "account"), lo, hi)) + return 1; + } + return 0; +} + +/* Rows in one account range, printed with the previous-year column; the + signed display sums are added to *sum / *sum_pre. */ +static void is_range_rows(struct buf *t, yyjson_val *res, yyjson_val *prev, + int lo, int hi, int64_t *sum, int64_t *sum_pre) +{ + for (int k = 0; k < 2; k++) { + yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]); + yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL; + size_t n = yyjson_arr_size(arr); + for (size_t i = 0; i < n; i++) { + yyjson_val *row = yyjson_arr_get(arr, i); + const char *acc = vstr(row, "account"); + if (!acct_in(acc, lo, hi)) + continue; + int64_t c = k == 0 ? vint(row, "amount_ore") + : -vint(row, "amount_ore"); + int64_t p = is_prev_lookup(prev, acc, k != 0); + char nm[128], a1[48], a2[48]; + pad_label(nm, sizeof nm, vstr(row, "name"), 48); + amt_col(a1, sizeof a1, 15, c); + amt_col(a2, sizeof a2, 15, p); + buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2); + *sum += c; + *sum_pre += p; + } + } +} + static void fmt_resultat(struct buf *t, const struct app *a, yyjson_val *res, yyjson_val *prev) { @@ -3205,45 +3254,14 @@ static void fmt_resultat(struct buf *t, const struct app *a, yyjson_val *res, } is_summary(t, "Summa rörelsekostnader", exp, exp_pre, 0); - /* finansiella poster */ - int64_t fin = 0, fin_pre = 0, booked = 0, booked_pre = 0; - int has_fin = 0, has_booked = 0; - for (int k = 0; k < 2; k++) { - yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]); - yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL; - size_t n = yyjson_arr_size(arr); - for (size_t i = 0; i < n; i++) { - yyjson_val *row = yyjson_arr_get(arr, i); - const char *acc = vstr(row, "account"); - if (acct_in(acc, 8000, 8899)) - has_fin = 1; - else if (acct_in(acc, 8900, 8999)) - has_booked = 1; - } - } - if (has_fin) { + int64_t fin = 0, fin_pre = 0, disp = 0, disp_pre = 0; + int64_t tax = 0, tax_pre = 0, booked = 0, booked_pre = 0; + int has_disp = is_range_has(res, 8800, 8899); + int has_tax = is_range_has(res, 8900, 8989); + int has_booked = is_range_has(res, 8999, 8999); + if (is_range_has(res, 8000, 8599)) { buf_line(t, "\nFinansiella poster\n"); - for (int k = 0; k < 2; k++) { - yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]); - yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL; - size_t n = yyjson_arr_size(arr); - for (size_t i = 0; i < n; i++) { - yyjson_val *row = yyjson_arr_get(arr, i); - const char *acc = vstr(row, "account"); - if (!acct_in(acc, 8000, 8899)) - continue; - int64_t c = k == 0 ? vint(row, "amount_ore") - : -vint(row, "amount_ore"); - int64_t p = is_prev_lookup(prev, acc, k != 0); - char nm[128], a1[48], a2[48]; - pad_label(nm, sizeof nm, vstr(row, "name"), 48); - amt_col(a1, sizeof a1, 15, c); - amt_col(a2, sizeof a2, 15, p); - buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2); - fin += c; - fin_pre += p; - } - } + is_range_rows(t, res, prev, 8000, 8599, &fin, &fin_pre); is_summary(t, "Summa", fin, fin_pre, 0); } int64_t ror = rev + exp, ror_pre = rev_pre + exp_pre; @@ -3252,41 +3270,34 @@ static void fmt_resultat(struct buf *t, const struct app *a, yyjson_val *res, buf_rule(t, IS_RULE_W); is_summary(t, "Rörelseresultat", ror, ror_pre, 0); is_summary(t, "Resultat efter finansiella poster", after, after_pre, 1); - is_summary(t, "Resultat före skatt", after, after_pre, 1); + if (has_disp) { + buf_line(t, "\nBokslutsdispositioner\n"); + is_range_rows(t, res, prev, 8800, 8899, &disp, &disp_pre); + is_summary(t, "Summa", disp, disp_pre, 0); + } + int64_t before_tax = after + disp, before_tax_pre = after_pre + disp_pre; + is_summary(t, "Resultat före skatt", before_tax, before_tax_pre, 1); + if (has_tax) { + buf_line(t, "\nSkatt\n"); + is_range_rows(t, res, prev, 8900, 8989, &tax, &tax_pre); + is_summary(t, "Summa", tax, tax_pre, 0); + } + int64_t ars = before_tax + tax, ars_pre = before_tax_pre + tax_pre; buf_line(t, "\n"); buf_rule(t, IS_RULE_W); - is_summary(t, "Årets resultat", after, after_pre, 0); + is_summary(t, "Årets resultat", ars, ars_pre, 0); if (has_booked) { - buf_line(t, "Bokfört resultat\n"); - for (int k = 0; k < 2; k++) { - yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]); - yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL; - size_t n = yyjson_arr_size(arr); - for (size_t i = 0; i < n; i++) { - yyjson_val *row = yyjson_arr_get(arr, i); - const char *acc = vstr(row, "account"); - if (!acct_in(acc, 8900, 8999)) - continue; - int64_t c = -vint(row, "amount_ore"); - int64_t p = is_prev_lookup(prev, acc, 1); - char nm[128], a1[48], a2[48]; - pad_label(nm, sizeof nm, vstr(row, "name"), 48); - amt_col(a1, sizeof a1, 15, c); - amt_col(a2, sizeof a2, 15, p); - buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2); - booked += c; - booked_pre += p; - } - } + buf_line(t, "\nBokfört resultat\n"); + is_range_rows(t, res, prev, 8999, 8999, &booked, &booked_pre); is_summary(t, "Summa", booked, booked_pre, 0); buf_line(t, "\n"); buf_rule(t, IS_RULE_W); - is_summary(t, "Ej bokfört resultat", after + booked, - after_pre + booked_pre, 0); + is_summary(t, "Ej bokfört resultat", ars + booked, + ars_pre + booked_pre, 0); } else { buf_line(t, "\n"); buf_rule(t, IS_RULE_W); - is_summary(t, "Ej bokfört resultat", after, after_pre, 0); + is_summary(t, "Ej bokfört resultat", ars, ars_pre, 0); } } @@ -3619,6 +3630,64 @@ static void eskd_save(struct app *a, const char *from, const char *to) free(resp); } +static void sru_save(struct app *a) +{ + const char *home = getenv("HOME"); + char def[512]; + snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : "."); + struct stat sb; + if (stat(def, &sb) != 0 || !S_ISDIR(sb.st_mode)) + snprintf(def, sizeof def, "%s", home && *home ? home : "."); + char *dir = prompt("Spara SRU-filer i: ", def, 0); + if (!dir || !*dir) + return; + char full[512]; + if (dir[0] == '~' && (dir[1] == '/' || dir[1] == '\0')) + snprintf(full, sizeof full, "%s%s", home ? home : "", dir + 1); + else + snprintf(full, sizeof full, "%s", dir); + char args[64]; + snprintf(args, sizeof args, "{\"fiscal_year\":%lld}", (long long)a->fy); + char *resp = client_rpc(&a->conn, "sru.export", a->session, a->org, args); + if (!resp || !client_ok(resp)) { + show_error("Kunde inte skapa SRU-filerna", resp); + free(resp); + return; + } + const char *keys[2] = { "result.info.content_base64", + "result.blanketter.content_base64" }; + const char *names[2] = { "INFO.SRU", "BLANKETTER.SRU" }; + char saved[2][600] = { "", "" }; + int ok = 0; + for (int i = 0; i < 2; i++) { + char *b64 = jstr_dup(resp, keys[i]); + unsigned char *data = NULL; + size_t n = 0; + if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) { + free(b64); + continue; + } + snprintf(saved[i], sizeof saved[i], "%s/%s", full, names[i]); + FILE *f = fopen(saved[i], "wb"); + if (f && fwrite(data, 1, n, f) == n) { + fclose(f); + ok++; + } else { + if (f) + fclose(f); + message("SRU", "Kunde inte skriva %s: %s", saved[i], + strerror(errno)); + } + free(data); + free(b64); + } + free(resp); + if (ok == 2) + message("SRU", "Sparat:\n%s\n%s\n\nLadda upp båda i Skatteverkets" + " filöverföring.", + saved[0], saved[1]); +} + static void reports_screen(struct app *a) { static const char *const report_items[] = { @@ -3628,16 +3697,21 @@ static void reports_screen(struct app *a) "Huvudbok", "Verifikationslista", "Momsdeklaration", + "Inkomstdeklaration (INK2/SRU)", "Kontolista (alla konton)", }; static int last_sel = 0; for (;;) { if (g_quit) return; - int sel = menu("Rapporter", report_items, 7, 0, &last_sel); + int sel = menu("Rapporter", report_items, 8, 0, &last_sel); if (sel < 0) return; if (sel == 6) { + sru_save(a); + continue; + } + if (sel == 7) { accounts_report(a); continue; } diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md index abc6247..c318b7f 100644 --- a/docs/COMPLIANCE.md +++ b/docs/COMPLIANCE.md @@ -155,6 +155,7 @@ approval by any authority — none exists. Fortnox/Visma/BL. 3. ~~eSKD file for momsdeklaration~~ (`report.vat_eskd`, upload in Skatteverket's e-service); AGI file for payroll. -4. K2 årsredovisning document generation + SRU files for INK2. +4. K2 årsredovisning document generation; ~~SRU files for INK2~~ + (`sru.export`, INFO.SRU + BLANKETTER.SRU). 5. Invoicing and reskontra; bank import (CSV, then PSD2). 6. Peppol e-invoicing, if still relevant when it is due. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 75f384f..b864d10 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -300,11 +300,19 @@ Years with status `closed` reject all postings. |---|---|---| | `voucher.post` | `date`, `description?`, `rows[]` **or** `template`+`x`, `series?`, `corrects_voucher?`, `attachment_ids?`, `client_ref?` | dry-run supported | | `voucher.get` | `id` | | -| `voucher.list` | `fiscal_year?`, `from?`, `to?`, `series?`, `account?`, `text?`, `limit`, `cursor` | | +| `voucher.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. @@ -374,6 +382,7 @@ key is the voucher/attachment pair); linking the same pair twice is a | `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: @@ -386,7 +395,19 @@ All reports are pure reads, respect locks, and return JSON rows. Amounts are "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. `report.vat_eskd` builds Skatteverket's `eSKDUpload` +`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`. + +`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 diff --git a/docs/STATE.md b/docs/STATE.md index 517e62a..5be11e7 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -86,8 +86,8 @@ server/protocol/ledger only. SHA-256 verified, text inline), attach to an existing voucher (`^F`), remove a link (`d` in the `f` picker) and link an inbox item to a voucher (`k`). -- Next backend milestone: **bokslut automation** vs **K2/SRU** (eSKD is - done: `report.vat_eskd` + `s = spara eSKD` in the momsrapport). +- Next backend milestone: **K2/SRU** (INK2/INK2R/INK2S + årsredovisning); + bokslut.post is done server-side, its TUI screen pending. - Moms `report_rules` seed is a corrected starter mapping (schema v3), but there is still no command/TUI to edit rules per org; add one before filing if the mapping needs adjustments (SCHEMA.md §10 promises owner editing). @@ -96,9 +96,13 @@ server/protocol/ledger only. 1. ~~eSKD file generation for momsdeklaration.~~ `report.vat_eskd` (eSKDUpload 6.0, ISO-8859-1, whole kronor) with a save action in the TUI momsrapport. -2. Bokslut automation (avskrivningar, periodiseringsfond, skatt, - resultatdisposition). -3. K2 årsredovisning document + SRU files (INK2/INK2R/INK2S). +2. Bokslut automation: `bokslut.post` is in (entries/avskrivningar, + periodiseringsfond, skatt at a given rate, resultatdisposition, dry-run + plan, audited) and the resultatrapport separates bokslutsdispositioner + and skatt per K2. TUI screen still to do. +3. K2 årsredovisning document; ~~SRU files (INK2/INK2R/INK2S)~~ done as + `sru.export` (official 2025P4 field tables, BAS mapping, TUI save in + Rapporter -> Inkomstdeklaration). 4. `audit.verify` must also verify the **voucher** hash chain (today only the audit chain is verified). 5. ~~`report.general_ledger` and `report.voucher_list`~~ implemented @@ -113,6 +117,10 @@ server/protocol/ledger only. 9. Password change, user disable, TOTP. 10. Bank import/reconciliation (CSV first, then PSD2), invoicing/reskontra, AGI/payroll if employees. +11a. Imported history years whose SIE contains the source's P&L closings + ("Stäng intäktskonton/kostnadskonton") net to zero in the income + statement (Kapitas 2022-2026). The importer should skip those closing + verifications (keeping the resultatdisposition) or mark them; decide. 11. ~~SIE import only into an empty fiscal year; consider broader import.~~ Chronological multi-year import works (CRLF, `#RAR 0`, zero rows, `#IB` rule handled); each year must still target an empty fiscal year. Note: diff --git a/src/commands.c b/src/commands.c index d7f3860..500c9a3 100644 --- a/src/commands.c +++ b/src/commands.c @@ -20,6 +20,7 @@ #include "reports.h" #include "seed.h" #include "sie.h" +#include "sru.h" #include "util.h" #include "version.h" @@ -3029,7 +3030,9 @@ static yyjson_mut_val *h_voucher_list(struct req *r) "SELECT v.id,v.series,v.number,v.date,v.description,v.source," "v.corrects_voucher_id," "(SELECT count(*) FROM voucher_rows r WHERE r.org_id=v.org_id" - " AND r.voucher_id=v.id)" + " AND r.voucher_id=v.id)," + "(SELECT count(*) FROM voucher_attachments va WHERE va.org_id=v.org_id" + " AND va.voucher_id=v.id)" " FROM vouchers v WHERE v.org_id=?1" " AND (?2=0 OR v.fiscal_year_id=?2)" " AND (?3='' OR v.series=?3)" @@ -3075,6 +3078,8 @@ static yyjson_mut_val *h_voucher_list(struct req *r) sqlite3_column_int64(st, 6)); yyjson_mut_obj_add_int(r->rdoc, o, "row_count", sqlite3_column_int64(st, 7)); + yyjson_mut_obj_add_int(r->rdoc, o, "attachment_count", + sqlite3_column_int64(st, 8)); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); @@ -3635,6 +3640,23 @@ static yyjson_mut_val *h_report_vat(struct req *r) return res; } +static yyjson_mut_val *h_sru_export(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + char *err = NULL; + yyjson_mut_val *res = + sru_export(r->rdoc, r->db, r->org_id, fy, r->args, &err); + if (!res) { + yyjson_mut_val *e = + fail(r, "INVALID_ARGS", err ? err : "could not build the SRU files"); + free(err); + return e; + } + return res; +} + static yyjson_mut_val *h_report_general_ledger(struct req *r) { int64_t fy = 0; @@ -3690,6 +3712,212 @@ static yyjson_mut_val *h_report_vat_eskd(struct req *r) return res; } +/* True for P&L accounts that take part in the taxable result (3xxx-88xx). */ +static int bokslut_pl_account(const char *number) +{ + int n = atoi(number); + return n >= 3000 && n <= 8899; +} + +static yyjson_mut_val *h_bokslut_post(struct req *r) +{ + int64_t fy_id = 0; + if (req_fy(r, &fy_id) != 0) + return NULL; + char fy_label[64] = "", fy_start[16] = "", fy_end[16] = ""; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT label,start_date,end_date FROM fiscal_years" + " WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, fy_id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "fiscal year not found"); + } + snprintf(fy_label, sizeof fy_label, "%s", + (const char *)sqlite3_column_text(st, 0)); + snprintf(fy_start, sizeof fy_start, "%s", + (const char *)sqlite3_column_text(st, 1)); + snprintf(fy_end, sizeof fy_end, "%s", + (const char *)sqlite3_column_text(st, 2)); + sqlite3_finalize(st); + const char *date = arg_str(r->args, "date"); + if (!date) + date = fy_end; + else if (!util_parse_iso_date(date)) + return fail(r, "INVALID_ARGS", "date must be YYYY-MM-DD"); + + yyjson_val *earr = yyjson_obj_get(r->args, "entries"); + if (earr && !yyjson_is_arr(earr)) + return fail(r, "INVALID_ARGS", "entries must be an array"); + size_t nent = earr ? yyjson_arr_size(earr) : 0; + if (nent > 31) + return fail(r, "INVALID_ARGS", "too many entries"); + struct ledger_row erows[64]; + size_t nerows = 0; + memset(erows, 0, sizeof erows); + for (size_t i = 0; i < nent; i++) { + yyjson_val *e = yyjson_arr_get(earr, i); + const char *da = + yyjson_get_str(yyjson_obj_get(e, "debit_account")); + const char *ca = + yyjson_get_str(yyjson_obj_get(e, "credit_account")); + const char *ed = + yyjson_get_str(yyjson_obj_get(e, "description")); + int64_t amt = 0; + yyjson_val *av = yyjson_obj_get(e, "amount_ore"); + if (av && yyjson_is_int(av)) + amt = yyjson_get_sint(av); + if (!da || !*da || !ca || !*ca || amt <= 0) + return fail(r, "INVALID_ARGS", + "each entry needs debit_account, credit_account and" + " amount_ore > 0"); + erows[nerows++] = (struct ledger_row){ da, amt, 0, ed }; + erows[nerows++] = (struct ledger_row){ ca, 0, amt, ed }; + } + int64_t fond = 0; + arg_int(r->args, "periodiseringsfond_ore", &fond); + if (fond < 0) + return fail(r, "INVALID_ARGS", "periodiseringsfond_ore must be >= 0"); + if (fond > 0) { + if (nerows + 2 > 64) + return fail(r, "INVALID_ARGS", "too many entries"); + erows[nerows++] = (struct ledger_row){ "8811", fond, 0, NULL }; + erows[nerows++] = (struct ledger_row){ "2110", 0, fond, NULL }; + } + + int64_t result_before = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT COALESCE(SUM(r.credit_ore)-SUM(r.debit_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 v.fiscal_year_id=?2 AND v.series<>'IB'" + " AND a.type IN ('revenue','expense') AND a.number NOT LIKE '89%'", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, fy_id); + if (sqlite3_step(st) == SQLITE_ROW) + result_before = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + + int64_t delta = 0; + for (size_t i = 0; i < nerows; i++) + if (bokslut_pl_account(erows[i].account)) + delta += erows[i].credit_ore - erows[i].debit_ore; + + double tax_rate = 20.6; + yyjson_val *trv = yyjson_obj_get(r->args, "tax_rate"); + if (trv) { + if (yyjson_is_real(trv)) + tax_rate = yyjson_get_real(trv); + else if (yyjson_is_int(trv)) + tax_rate = (double)yyjson_get_sint(trv); + else + return fail(r, "INVALID_ARGS", "tax_rate must be a number"); + if (tax_rate < 0 || tax_rate > 100) + return fail(r, "INVALID_ARGS", "tax_rate must be 0-100"); + } + long bp = (long)(tax_rate * 100.0 + 0.5); + int64_t taxable = result_before + delta; + int64_t tax = taxable > 0 ? taxable * bp / 10000 : 0; + int64_t after = taxable - tax; + int dispose = 1; + arg_bool(r->args, "dispose", &dispose); + + struct { + const char *desc; + const struct ledger_row *rows; + size_t nrows; + } plan[3]; + size_t np = 0; + if (nerows > 0) + plan[np++] = (typeof(plan[0])){ "Bokslutsdispositioner", erows, + nerows }; + struct ledger_row taxrows[2] = { + { "8910", tax, 0, NULL }, { "2512", 0, tax, NULL } + }; + if (tax != 0) + plan[np++] = (typeof(plan[0])){ "Skatt på årets resultat", taxrows, 2 }; + struct ledger_row drows[2]; + if (dispose && after != 0) { + if (after > 0) { + drows[0] = (struct ledger_row){ "8999", after, 0, NULL }; + drows[1] = (struct ledger_row){ "2099", 0, after, NULL }; + } else { + drows[0] = (struct ledger_row){ "2099", -after, 0, NULL }; + drows[1] = (struct ledger_row){ "8999", 0, -after, NULL }; + } + plan[np++] = (typeof(plan[0])){ "Resultatdisposition", drows, 2 }; + } + + yyjson_mut_val *vouchers = yyjson_mut_arr(r->rdoc); + for (size_t i = 0; i < np; i++) { + struct ledger_post_opts o; + memset(&o, 0, sizeof o); + o.org_id = r->org_id; + o.user_id = r->sess->user_id; + o.token_id = r->sess->token_id; + o.date = date; + o.description = plan[i].desc; + o.rows = plan[i].rows; + o.nrows = plan[i].nrows; + o.dry_run = r->dry_run; + struct ledger_error e; + char *json = NULL; + if (ledger_post(r->db, &o, &e, &json) != 0) { + yyjson_mut_val *res = + fail(r, e.code ? e.code : "INTERNAL", e.msg); + free(json); + return res; + } + yyjson_mut_val *vo = yyjson_mut_arr_add_obj(r->rdoc, vouchers); + yyjson_mut_obj_add_strcpy(r->rdoc, vo, "description", plan[i].desc); + yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); + for (size_t k = 0; k < plan[i].nrows; k++) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, rows); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", + plan[i].rows[k].account); + yyjson_mut_obj_add_int(r->rdoc, ro, "debit_ore", + plan[i].rows[k].debit_ore); + yyjson_mut_obj_add_int(r->rdoc, ro, "credit_ore", + plan[i].rows[k].credit_ore); + } + yyjson_mut_obj_add_val(r->rdoc, vo, "rows", rows); + yyjson_mut_val *pv = json_to_mut(r->rdoc, json); + free(json); + if (pv) + yyjson_mut_obj_add_val(r->rdoc, vo, "voucher", pv); + } + if (!r->dry_run) { + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "bokslut.post", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_val *fy = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, fy, "id", fy_id); + yyjson_mut_obj_add_strcpy(r->rdoc, fy, "label", fy_label); + yyjson_mut_obj_add_strcpy(r->rdoc, fy, "start_date", fy_start); + yyjson_mut_obj_add_strcpy(r->rdoc, fy, "end_date", fy_end); + yyjson_mut_obj_add_val(r->rdoc, o, "fiscal_year", fy); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", date); + yyjson_mut_obj_add_int(r->rdoc, o, "result_before_ore", result_before); + yyjson_mut_obj_add_int(r->rdoc, o, "entries_ore", delta); + yyjson_mut_obj_add_int(r->rdoc, o, "taxable_ore", taxable); + yyjson_mut_obj_add_int(r->rdoc, o, "tax_ore", tax); + yyjson_mut_obj_add_int(r->rdoc, o, "result_after_ore", after); + yyjson_mut_obj_add_val(r->rdoc, o, "vouchers", vouchers); + return o; +} + + static unsigned char *read_file(const char *path, size_t *out_len) { FILE *f = fopen(path, "rb"); @@ -3899,6 +4127,8 @@ const struct command g_commands[] = { { "voucher.list", "List vouchers", PERM_READ, 1, 0, 0, h_voucher_list }, { "voucher.correct", "Post an ändringsverifikat", PERM_WRITE, 1, 1, 1, h_voucher_correct }, + { "bokslut.post", "Year-end: dispositions, tax and result transfer", + PERM_WRITE, 1, 1, 1, h_bokslut_post }, { "settings.get", "Read org settings", PERM_READ, 1, 0, 0, h_settings_get }, { "settings.set", "Change an org setting", PERM_WRITE, 1, 1, 1, @@ -3937,6 +4167,8 @@ const struct command g_commands[] = { h_report_voucher_list }, { "report.vat_eskd", "eSKD XML for the momsdeklaration (ISO-8859-1)", PERM_READ, 1, 0, 0, h_report_vat_eskd }, + { "sru.export", "INK2 SRU files (INFO.SRU + BLANKETTER.SRU)", + PERM_READ, 1, 0, 0, h_sru_export }, { "sie.export", "Export SIE 4 (PC8)", PERM_READ, 1, 0, 0, h_sie_export }, { "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1, 1, h_sie_import }, diff --git a/src/sru.c b/src/sru.c new file mode 100644 index 0000000..98a3c6d --- /dev/null +++ b/src/sru.c @@ -0,0 +1,711 @@ +#include "sru.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "reports.h" +#include "util.h" +#include "version.h" + +static void set_err(char **err, const char *msg) +{ + if (err && !*err) + *err = xstrdup(msg); +} + +static const char *jstr(yyjson_val *o, const char *k) +{ + yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL; + return v && yyjson_is_str(v) ? yyjson_get_str(v) : NULL; +} + +static int64_t jint(yyjson_val *o, const char *k) +{ + yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL; + return v && yyjson_is_int(v) ? (int64_t)yyjson_get_int(v) : 0; +} + +static const char *mjstr(yyjson_mut_val *o, const char *k) +{ + yyjson_mut_val *v = o ? yyjson_mut_obj_get(o, k) : NULL; + return v && yyjson_mut_is_str(v) ? yyjson_mut_get_str(v) : NULL; +} + +static int64_t mjint(yyjson_mut_val *o, const char *k) +{ + yyjson_mut_val *v = o ? yyjson_mut_obj_get(o, k) : NULL; + return v && yyjson_mut_is_int(v) ? (int64_t)yyjson_mut_get_int(v) : 0; +} + +static void add(struct buf *b, const char *s) +{ + buf_append(b, s, strlen(s)); +} + +/* ISO-8859-1 text; only the code points Latin-1 can hold are allowed. */ +static int add_latin1(struct buf *b, const char *utf8, char **err) +{ + for (const unsigned char *p = (const unsigned char *)utf8; *p;) { + uint32_t cp; + int len; + if (*p < 0x80) { + cp = *p; + len = 1; + } else if ((*p & 0xE0) == 0xC0) { + cp = *p & 0x1F; + len = 2; + } else if ((*p & 0xF0) == 0xE0) { + cp = *p & 0x0F; + len = 3; + } else { + set_err(err, "text is not valid UTF-8"); + return -1; + } + for (int i = 1; i < len; i++) { + if ((p[i] & 0xC0) != 0x80) { + set_err(err, "text is not valid UTF-8"); + return -1; + } + cp = (cp << 6) | (uint32_t)(p[i] & 0x3F); + } + if (cp > 0xFF) { + set_err(err, "text has a character outside ISO-8859-1"); + return -1; + } + unsigned char c = (unsigned char)cp; + buf_append(b, &c, 1); + p += len; + } + return 0; +} + +struct fld { + char code[8]; + int64_t ore; +}; + +struct fldset { + struct fld v[64]; + size_t n; +}; + +static void fld_add(struct fldset *s, const char *code, int64_t ore) +{ + if (!ore) + return; + for (size_t i = 0; i < s->n; i++) { + if (strcmp(s->v[i].code, code) == 0) { + s->v[i].ore += ore; + return; + } + } + if (s->n >= sizeof s->v / sizeof s->v[0]) + return; + snprintf(s->v[s->n].code, sizeof s->v[s->n].code, "%s", code); + s->v[s->n].ore = ore; + s->n++; +} + +static int64_t fld_get(const struct fldset *s, const char *code) +{ + for (size_t i = 0; i < s->n; i++) + if (strcmp(s->v[i].code, code) == 0) + return s->v[i].ore; + return 0; +} + +/* ---------------- BAS ranges to INK2R field codes ---------------- */ + +struct bmap { + int lo, hi; + const char *code; +}; + +static const struct bmap BAL_ASSETS[] = { + { 1010, 1079, "7201" }, { 1090, 1099, "7201" }, { 1080, 1089, "7202" }, + { 1100, 1119, "7214" }, { 1130, 1179, "7214" }, { 1190, 1199, "7214" }, + { 1120, 1129, "7216" }, { 1180, 1189, "7217" }, + { 1200, 1299, "7215" }, + { 1311, 1316, "7230" }, { 1330, 1338, "7231" }, { 1350, 1359, "7233" }, + { 1380, 1389, "7233" }, { 1320, 1329, "7232" }, { 1340, 1349, "7232" }, + { 1360, 1369, "7234" }, { 1370, 1379, "7235" }, { 1390, 1399, "7235" }, + { 1410, 1419, "7241" }, { 1440, 1449, "7242" }, { 1450, 1469, "7243" }, + { 1470, 1489, "7244" }, { 1490, 1499, "7245" }, { 1400, 1409, "7246" }, + { 1500, 1519, "7251" }, { 1560, 1579, "7252" }, + { 1620, 1620, "7262" }, + { 1520, 1559, "7261" }, { 1580, 1599, "7261" }, { 1600, 1699, "7261" }, + { 1700, 1799, "7263" }, + { 1860, 1869, "7270" }, { 1800, 1859, "7271" }, { 1870, 1899, "7271" }, + { 1900, 1999, "7281" }, +}; + +static const struct bmap BAL_EL[] = { + { 2010, 2089, "7301" }, { 2090, 2099, "7302" }, + { 2110, 2129, "7321" }, { 2150, 2159, "7322" }, { 2130, 2149, "7323" }, + { 2160, 2199, "7323" }, + { 2210, 2219, "7331" }, { 2220, 2229, "7332" }, { 2230, 2299, "7333" }, + { 2320, 2329, "7350" }, { 2330, 2339, "7351" }, { 2340, 2359, "7352" }, + { 2360, 2379, "7353" }, { 2380, 2399, "7354" }, + { 2410, 2419, "7360" }, { 2420, 2439, "7361" }, { 2400, 2409, "7362" }, + { 2450, 2459, "7363" }, { 2460, 2469, "7364" }, { 2440, 2449, "7365" }, + { 2490, 2490, "7366" }, { 2470, 2479, "7367" }, + { 2480, 2489, "7369" }, { 2491, 2499, "7369" }, { 2600, 2799, "7369" }, + { 2800, 2899, "7369" }, { 2500, 2599, "7368" }, + { 2900, 2999, "7370" }, +}; + +struct imap { + int lo, hi; + const char *plus; + const char *minus; +}; + +static const struct imap INC_REV[] = { + { 3000, 3799, "7410", "7410" }, + { 3800, 3899, "7412", "7412" }, + { 3900, 3999, "7413", "7413" }, + { 4900, 4999, "7411", "7510" }, + { 8000, 8099, "7414", "7518" }, + { 8100, 8199, "7415", "7519" }, + { 8200, 8269, "7423", "7530" }, + { 8270, 8299, "7416", "7520" }, + { 8300, 8399, "7417", "7417" }, + { 8820, 8820, "7419", "7419" }, + { 8819, 8819, "7420", "7420" }, + { 8850, 8850, "7421", "7526" }, + { 8860, 8899, "7422", "7527" }, +}; + +static const struct imap INC_EXP[] = { + { 4000, 4599, "7511", "7511" }, + { 4600, 4699, "7512", "7512" }, + { 5000, 6999, "7513", "7513" }, + { 7000, 7699, "7514", "7514" }, + { 7700, 7799, "7516", "7516" }, + { 7800, 7899, "7515", "7515" }, + { 7900, 7999, "7517", "7517" }, + { 8400, 8499, "7522", "7522" }, + { 8500, 8599, "7521", "7521" }, + { 8830, 8830, "7524", "7524" }, + { 8811, 8811, "7525", "7525" }, + { 8910, 8989, "7528", "7528" }, +}; + +static const char *const INK2R_ORDER[] = { + "7011", "7012", "7201", "7202", "7214", "7215", "7216", "7217", "7230", + "7231", "7233", "7232", "7234", "7235", "7241", "7242", "7243", "7244", + "7245", "7246", "7251", "7252", "7261", "7262", "7263", "7270", "7271", + "7281", "7301", "7302", "7321", "7322", "7323", "7331", "7332", "7333", + "7350", "7351", "7352", "7353", "7354", "7360", "7361", "7362", "7363", + "7364", "7365", "7366", "7367", "7369", "7368", "7370", "7410", "7411", + "7510", "7412", "7413", "7511", "7512", "7513", "7514", "7515", "7516", + "7517", "7414", "7518", "7415", "7519", "7423", "7530", "7416", "7520", + "7417", "7521", "7522", "7524", "7419", "7420", "7525", "7421", "7526", + "7422", "7527", "7528", "7450", "7550", +}; + +static const char *const INK2S_ORDER[] = { + "7011", "7012", "7650", "7750", "7651", "7652", "7653", "7751", "7764", + "7752", "7753", "7754", "7654", "7668", "7655", "7673", "7665", "7755", + "7656", "7756", "7657", "7658", "7757", "7758", "7659", "7660", "7759", + "7666", "7765", "7661", "7760", "7761", "7662", "7663", "7762", "7763", + "7671", "7672", "7670", "7770", "8020", "8021", "8023", "8026", "8022", + "8028", "8040", "8041", "8044", "8045", +}; + +static const char *const INK2_ORDER[] = { + "7011", "7012", "7104", "7114", "7131", "7132", "7133", "7153", + "7154", "7155", "7156", "80", "93", "84", "86", "95", "96", "97", + "98", "90", "1582", +}; + +/* INK2S fields that add to / subtract from the överskott (7670). */ +static const char *const INK2S_PLUS[] = { + "7650", "7651", "7652", "7653", "7654", "7668", "7655", "7673", + "7665", "7656", "7657", "7658", "7659", "7660", "7666", "7661", + "7662", "7663", "7671", "7672", +}; +static const char *const INK2S_MINUS[] = { + "7750", "7751", "7764", "7752", "7753", "7754", "7755", "7756", + "7757", "7758", "7759", "7765", "7760", "7761", "7762", "7763", +}; + +static int in_list(const char *const *list, size_t n, const char *code) +{ + for (size_t i = 0; i < n; i++) + if (strcmp(list[i], code) == 0) + return 1; + return 0; +} + +static void map_balance(yyjson_mut_val *arr, const struct bmap *tab, + size_t ntab, struct fldset *out, + struct fldset *unmapped) +{ + size_t n = yyjson_mut_arr_size(arr); + for (size_t i = 0; i < n; i++) { + yyjson_mut_val *row = yyjson_mut_arr_get(arr, i); + const char *acc = mjstr(row, "account"); + int64_t ore = mjint(row, "amount_ore"); + if (!acc || !ore) + continue; + int num = atoi(acc); + const char *code = NULL; + for (size_t t = 0; t < ntab; t++) { + if (num >= tab[t].lo && num <= tab[t].hi) { + code = tab[t].code; + break; + } + } + if (code) + fld_add(out, code, ore); + else + fld_add(unmapped, acc, ore); + } +} + +static int64_t map_income(yyjson_mut_val *arr, const struct imap *tab, + size_t ntab, struct fldset *out, + struct fldset *unmapped) +{ + int64_t total = 0; + size_t n = yyjson_mut_arr_size(arr); + for (size_t i = 0; i < n; i++) { + yyjson_mut_val *row = yyjson_mut_arr_get(arr, i); + const char *acc = mjstr(row, "account"); + int64_t ore = mjint(row, "amount_ore"); + if (!acc || !ore) + continue; + int num = atoi(acc); + if (num >= 8990 && num <= 8999) + continue; /* the result transfer itself is not a field */ + const struct imap *m = NULL; + for (size_t t = 0; t < ntab; t++) { + if (num >= tab[t].lo && num <= tab[t].hi) { + m = &tab[t]; + break; + } + } + if (!m) { + fld_add(unmapped, acc, ore); + continue; + } + if (strcmp(m->plus, m->minus) == 0) + fld_add(out, m->plus, ore); + else if (ore >= 0) + fld_add(out, m->plus, ore); + else + fld_add(out, m->minus, -ore); + total += ore; + } + return total; +} + +static void emit_fields(struct buf *b, const struct fldset *f, + const char *const *order, size_t norder) +{ + char line[64]; + for (size_t i = 0; i < norder; i++) { + int64_t kr = fld_get(f, order[i]) / 100; + if (!kr) + continue; + snprintf(line, sizeof line, "#UPPGIFT %s %lld\n", order[i], + (long long)kr); + add(b, line); + } +} + +static void now_stamp(char date[16], char hms[16]) +{ + time_t t = time(NULL); + struct tm tm; + localtime_r(&t, &tm); + strftime(date, 16, "%Y%m%d", &tm); + strftime(hms, 16, "%H%M%S", &tm); +} + +/* 10 or 12 digits (hyphens/spaces ignored) to SSSS...; 10 gets a 16 prefix. */ +static int org12(const char *raw, char out[13], char **err) +{ + char digits[16]; + size_t n = 0; + for (const char *p = raw ? raw : ""; *p; p++) + if (*p >= '0' && *p <= '9' && n < sizeof digits - 1) + digits[n++] = *p; + digits[n] = '\0'; + if (n == 10) + snprintf(out, 13, "16%s", digits); + else if (n == 12) + snprintf(out, 13, "%s", digits); + else { + set_err(err, "org_nr must be xxxxxx-xxxx; set it in Företagsuppgifter"); + return -1; + } + return 0; +} + +static void date_compact(const char *iso, char out[9]) +{ + snprintf(out, 9, "%.4s%.2s%.2s", iso, iso + 5, iso + 8); +} + +static yyjson_mut_val *file_json(yyjson_mut_doc *doc, const char *fname, + const struct buf *b) +{ + unsigned char raw[32]; + char sha[65]; + util_sha256(b->p, b->len, raw); + util_hex(raw, sizeof raw, sha); + char *b64 = util_b64(b->p, b->len); + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, o, "filename", fname); + yyjson_mut_obj_add_strcpy(doc, o, "content_base64", b64 ? b64 : ""); + yyjson_mut_obj_add_strcpy(doc, o, "sha256", sha); + yyjson_mut_obj_add_int(doc, o, "size", (int64_t)b->len); + free(b64); + return o; +} + +yyjson_mut_val *sru_export(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, + int64_t fy_id, yyjson_val *args, char **err) +{ + char fy_start[16] = "", fy_end[16] = ""; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "SELECT COALESCE(name,''),COALESCE(org_nr,'')," + "COALESCE(address,''),COALESCE(postal_code,'')," + "COALESCE(city,''),COALESCE(email,'')," + "COALESCE(phone,'') FROM orgs WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) { + set_err(err, "database error"); + return NULL; + } + sqlite3_bind_int64(st, 1, org_id); + char org_name[128] = "", org_nr[32] = "", org_addr[128] = ""; + char org_zip[16] = "", org_city[64] = "", org_mail[128] = ""; + char org_phone[32] = ""; + if (sqlite3_step(st) == SQLITE_ROW) { + snprintf(org_name, sizeof org_name, "%s", + (const char *)sqlite3_column_text(st, 0)); + snprintf(org_nr, sizeof org_nr, "%s", + (const char *)sqlite3_column_text(st, 1)); + snprintf(org_addr, sizeof org_addr, "%s", + (const char *)sqlite3_column_text(st, 2)); + snprintf(org_zip, sizeof org_zip, "%s", + (const char *)sqlite3_column_text(st, 3)); + snprintf(org_city, sizeof org_city, "%s", + (const char *)sqlite3_column_text(st, 4)); + snprintf(org_mail, sizeof org_mail, "%s", + (const char *)sqlite3_column_text(st, 5)); + snprintf(org_phone, sizeof org_phone, "%s", + (const char *)sqlite3_column_text(st, 6)); + } + sqlite3_finalize(st); + + if (sqlite3_prepare_v2(db, + "SELECT label,start_date,end_date FROM fiscal_years" + " WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) { + set_err(err, "database error"); + return NULL; + } + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_int64(st, 2, fy_id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + set_err(err, "fiscal year not found"); + return NULL; + } + snprintf(fy_start, sizeof fy_start, "%s", + (const char *)sqlite3_column_text(st, 1)); + snprintf(fy_end, sizeof fy_end, "%s", + (const char *)sqlite3_column_text(st, 2)); + sqlite3_finalize(st); + + char org12buf[13]; + if (org12(org_nr, org12buf, err) != 0) + return NULL; + + int end_month = atoi(fy_end + 5); + char suffix = end_month <= 4 ? '1' + : end_month <= 6 ? '2' + : end_month <= 8 ? '3' + : '4'; + int income_year = atoi(fy_end); + char type_base[8], type_ink2[16], type_r[16], type_s[16]; + snprintf(type_base, sizeof type_base, "%dP%c", income_year, suffix); + snprintf(type_ink2, sizeof type_ink2, "INK2-%s", type_base); + snprintf(type_r, sizeof type_r, "INK2R-%s", type_base); + snprintf(type_s, sizeof type_s, "INK2S-%s", type_base); + + yyjson_mut_val *bal = report_balance_sheet(doc, db, org_id, fy_id, NULL, + err); + if (!bal) + return NULL; + yyjson_mut_val *inc = report_income_statement(doc, db, org_id, fy_id, + NULL, NULL, err); + if (!inc) + return NULL; + + struct fldset ink2r = { 0 }, ink2s = { 0 }, ink2 = { 0 }; + struct fldset unmapped = { 0 }; + map_balance(yyjson_mut_obj_get(yyjson_mut_obj_get(bal, "assets"), + "accounts"), + BAL_ASSETS, sizeof BAL_ASSETS / sizeof BAL_ASSETS[0], &ink2r, + &unmapped); + map_balance(yyjson_mut_obj_get(yyjson_mut_obj_get(bal, "equity"), + "accounts"), + BAL_EL, sizeof BAL_EL / sizeof BAL_EL[0], &ink2r, &unmapped); + map_balance(yyjson_mut_obj_get(yyjson_mut_obj_get(bal, "liabilities"), + "accounts"), + BAL_EL, sizeof BAL_EL / sizeof BAL_EL[0], &ink2r, &unmapped); + int64_t rev_total = map_income( + yyjson_mut_obj_get(yyjson_mut_obj_get(inc, "revenue"), "accounts"), + INC_REV, sizeof INC_REV / sizeof INC_REV[0], &ink2r, &unmapped); + int64_t exp_total = map_income( + yyjson_mut_obj_get(yyjson_mut_obj_get(inc, "expenses"), "accounts"), + INC_EXP, sizeof INC_EXP / sizeof INC_EXP[0], &ink2r, &unmapped); + int64_t result = rev_total - exp_total; + if (result > 0) + fld_add(&ink2r, "7450", result); + else if (result < 0) + fld_add(&ink2r, "7550", -result); + + if (unmapped.n > 0) { + int ignore = 0; + yyjson_val *iv = yyjson_obj_get(args, "ignore_unmapped"); + if (iv && yyjson_is_bool(iv)) + ignore = yyjson_get_bool(iv); + if (!ignore) { + char msg[512]; + snprintf(msg, sizeof msg, "unmapped accounts: "); + size_t used = strlen(msg); + for (size_t i = 0; i < unmapped.n && i < 10; i++) + used += (size_t)snprintf(msg + used, sizeof msg - used, "%s%s", + i ? ", " : "", unmapped.v[i].code); + snprintf(msg + used, sizeof msg - used, + " (pass ignore_unmapped:true to proceed)"); + set_err(err, msg); + return NULL; + } + } + + /* INK2S: derived result and tax, then the manual adjustments. The + överskott is summed from the whole-krona fields, like the + e-service recomputes it. */ + int64_t result_kr = result / 100; + if (result_kr > 0) + fld_add(&ink2s, "7650", result_kr * 100); + else if (result_kr < 0) + fld_add(&ink2s, "7750", -result_kr * 100); + int64_t tax = fld_get(&ink2r, "7528"); + if (tax) + fld_add(&ink2s, "7651", (tax / 100) * 100); + yyjson_val *adj = yyjson_obj_get(args, "adjustments"); + if (adj) { + if (!yyjson_is_arr(adj)) { + set_err(err, "adjustments must be an array"); + return NULL; + } + size_t n = yyjson_arr_size(adj); + for (size_t i = 0; i < n; i++) { + yyjson_val *a = yyjson_arr_get(adj, i); + const char *code = jstr(a, "code"); + int64_t ore = jint(a, "amount_ore"); + if (!code || + !(in_list(INK2S_PLUS, sizeof INK2S_PLUS / sizeof INK2S_PLUS[0], + code) || + in_list(INK2S_MINUS, + sizeof INK2S_MINUS / sizeof INK2S_MINUS[0], code))) { + set_err(err, "adjustments: unknown INK2S field code"); + return NULL; + } + fld_add(&ink2s, code, (ore / 100) * 100); + } + } + int64_t overskott = 0; + for (size_t i = 0; i < sizeof INK2S_PLUS / sizeof INK2S_PLUS[0]; i++) + overskott += fld_get(&ink2s, INK2S_PLUS[i]) / 100; + for (size_t i = 0; i < sizeof INK2S_MINUS / sizeof INK2S_MINUS[0]; i++) + overskott -= fld_get(&ink2s, INK2S_MINUS[i]) / 100; + if (overskott > 0) + fld_add(&ink2s, "7670", overskott * 100); + else if (overskott < 0) + fld_add(&ink2s, "7770", -overskott * 100); + if (fld_get(&ink2s, "7670")) + fld_add(&ink2, "7104", fld_get(&ink2s, "7670")); + if (fld_get(&ink2s, "7770")) + fld_add(&ink2, "7114", fld_get(&ink2s, "7770")); + + /* Submitter for INFO.SRU; defaults to the org itself. */ + yyjson_val *sub = yyjson_obj_get(args, "submitter"); + const char *s_name = jstr(sub, "name"); + const char *s_org = jstr(sub, "org_nr"); + const char *s_addr = jstr(sub, "address"); + const char *s_zip = jstr(sub, "postnr"); + const char *s_city = jstr(sub, "postort"); + const char *s_contact = jstr(sub, "contact"); + const char *s_mail = jstr(sub, "email"); + const char *s_phone = jstr(sub, "phone"); + if (!s_name) + s_name = org_name; + if (!s_org) + s_org = org_nr; + if (!s_addr) + s_addr = org_addr; + if (!s_zip) + s_zip = org_zip; + if (!s_city) + s_city = org_city; + if (!s_mail) + s_mail = org_mail; + if (!s_phone) + s_phone = org_phone; + char sub12[13]; + if (org12(s_org, sub12, err) != 0) + return NULL; + if (!s_name[0] || !s_zip[0] || !s_city[0]) { + set_err(err, "submitter name, postnr and postort are required" + " (set them in Företagsuppgifter or pass submitter)"); + return NULL; + } + + char dnow[16], tnow[16], dstart[9], dend[9]; + now_stamp(dnow, tnow); + date_compact(fy_start, dstart); + date_compact(fy_end, dend); + + struct buf info; + buf_init(&info); + add(&info, "#DATABESKRIVNING_START\n#PRODUKT SRU\n"); + { + char line[64]; + snprintf(line, sizeof line, "#SKAPAD %s %s\n", dnow, tnow); + add(&info, line); + snprintf(line, sizeof line, "#PROGRAM bokf %s\n", BOKF_VERSION); + add(&info, line); + } + add(&info, "#FILNAMN BLANKETTER.SRU\n#DATABESKRIVNING_SLUT\n"); + add(&info, "#MEDIELEV_START\n"); + { + char line[64]; + snprintf(line, sizeof line, "#ORGNR %s\n", sub12); + add(&info, line); + } + if (add_latin1(&info, "#NAMN ", err) != 0 || + add_latin1(&info, s_name, err) != 0) + goto fail; + add(&info, "\n"); + if (s_addr[0]) { + if (add_latin1(&info, "#ADRESS ", err) != 0 || + add_latin1(&info, s_addr, err) != 0) + goto fail; + add(&info, "\n"); + } + { + char line[64]; + snprintf(line, sizeof line, "#POSTNR %s\n", s_zip); + add(&info, line); + } + if (add_latin1(&info, "#POSTORT ", err) != 0 || + add_latin1(&info, s_city, err) != 0) + goto fail; + add(&info, "\n"); + if (s_contact && s_contact[0]) { + if (add_latin1(&info, "#KONTAKT ", err) != 0 || + add_latin1(&info, s_contact, err) != 0) + goto fail; + add(&info, "\n"); + } + if (s_mail[0]) { + if (add_latin1(&info, "#EMAIL ", err) != 0 || + add_latin1(&info, s_mail, err) != 0) + goto fail; + add(&info, "\n"); + } + if (s_phone[0]) { + if (add_latin1(&info, "#TELEFON ", err) != 0 || + add_latin1(&info, s_phone, err) != 0) + goto fail; + add(&info, "\n"); + } + add(&info, "#MEDIELEV_SLUT\n"); + + int assisted = -1, audited = -1; + yyjson_val *av = yyjson_obj_get(args, "assisted"); + if (av && yyjson_is_bool(av)) + assisted = yyjson_get_bool(av); + yyjson_val *auv = yyjson_obj_get(args, "audited"); + if (auv && yyjson_is_bool(auv)) + audited = yyjson_get_bool(auv); + + struct buf bl; + buf_init(&bl); + struct { + const char *type; + const struct fldset *f; + const char *const *order; + size_t norder; + } blocks[3] = { + { type_ink2, &ink2, INK2_ORDER, + sizeof INK2_ORDER / sizeof INK2_ORDER[0] }, + { type_r, &ink2r, INK2R_ORDER, + sizeof INK2R_ORDER / sizeof INK2R_ORDER[0] }, + { type_s, &ink2s, INK2S_ORDER, + sizeof INK2S_ORDER / sizeof INK2S_ORDER[0] }, + }; + for (size_t i = 0; i < 3; i++) { + char line[128]; + snprintf(line, sizeof line, "#BLANKETT %s\n", blocks[i].type); + add(&bl, line); + snprintf(line, sizeof line, "#IDENTITET %s %s %s\n", org12buf, dnow, + tnow); + add(&bl, line); + if (add_latin1(&bl, "#NAMN ", err) != 0) + goto fail; + if (add_latin1(&bl, org_name, err) != 0) + goto fail; + add(&bl, "\n"); + snprintf(line, sizeof line, "#UPPGIFT 7011 %s\n", dstart); + add(&bl, line); + snprintf(line, sizeof line, "#UPPGIFT 7012 %s\n", dend); + add(&bl, line); + emit_fields(&bl, blocks[i].f, blocks[i].order, blocks[i].norder); + if (i == 2) { + if (assisted >= 0) + add(&bl, assisted ? "#UPPGIFT 8040 X\n" + : "#UPPGIFT 8041 X\n"); + if (audited >= 0) + add(&bl, audited ? "#UPPGIFT 8044 X\n" + : "#UPPGIFT 8045 X\n"); + } + add(&bl, "#BLANKETTSLUT\n"); + } + add(&bl, "#FIL_SLUT\n"); + + yyjson_mut_val *info_file = file_json(doc, "INFO.SRU", &info); + yyjson_mut_val *blanketter_file = file_json(doc, "BLANKETTER.SRU", &bl); + buf_free(&info); + buf_free(&bl); + + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, o, "period", type_base); + yyjson_mut_obj_add_strcpy(doc, o, "blankett", type_ink2); + yyjson_mut_obj_add_val(doc, o, "info", info_file); + yyjson_mut_obj_add_val(doc, o, "blanketter", blanketter_file); + if (unmapped.n > 0) { + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (size_t i = 0; i < unmapped.n; i++) { + yyjson_mut_val *u = yyjson_mut_arr_add_obj(doc, arr); + yyjson_mut_obj_add_strcpy(doc, u, "account", unmapped.v[i].code); + yyjson_mut_obj_add_int(doc, u, "amount_ore", unmapped.v[i].ore); + } + yyjson_mut_obj_add_val(doc, o, "unmapped", arr); + } + return o; + +fail: + buf_free(&info); + return NULL; +} diff --git a/src/sru.h b/src/sru.h new file mode 100644 index 0000000..7b5a57a --- /dev/null +++ b/src/sru.h @@ -0,0 +1,15 @@ +#ifndef BOKF_SRU_H +#define BOKF_SRU_H + +#include <sqlite3.h> +#include <stdint.h> + +#include "yyjson.h" + +/* Builds Skatteverket's SRU files (INFO.SRU + BLANKETTER.SRU with INK2, + INK2R and INK2S) for a fiscal year. Returns a result object with both + files base64-encoded, or NULL with *err set. */ +yyjson_mut_val *sru_export(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, + int64_t fy_id, yyjson_val *args, char **err); + +#endif diff --git a/tests/test_core.c b/tests/test_core.c index 6f114b9..bb31fc7 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -996,6 +996,16 @@ int main(void) CHECK_STR(d, "error.code", "NOT_FOUND"); yyjson_doc_free(d); + /* voucher.list reports the attachment count */ + d = call(reqf("{\"v\":1,\"id\":\"61i\",\"cmd\":\"voucher.list\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"limit\":5}}", + g_session, (int)org_id)); + CHECK_OK(d); + CHECK(jint(d, "result.items.0.attachment_count") >= 1); + CHECK(jint(d, "result.items.1.attachment_count") == 0); + CHECK(jint(d, "result.items.2.attachment_count") >= 1); + yyjson_doc_free(d); + /* correction */ d = call(reqf("{\"v\":1,\"id\":\"62\",\"cmd\":\"voucher.correct\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"voucher\":%lld," @@ -1518,6 +1528,116 @@ int main(void) CHECK(n05 == 1); yyjson_doc_free(d); + /* ---------------- bokslut: dispositions, tax, transfer ---------- */ + d = call(reqf("{\"v\":1,\"id\":\"120\",\"cmd\":\"bokslut.post\"," + "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":" + "{\"tax_rate\":20.6}}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(jint(d, "result.result_before_ore") == 132000); + CHECK(jint(d, "result.taxable_ore") == 132000); + CHECK(jint(d, "result.tax_ore") == 27192); + CHECK(jint(d, "result.result_after_ore") == 104808); + CHECK(yyjson_arr_size(jget(d, "result.vouchers")) == 2); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"121\",\"cmd\":\"bokslut.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"tax_rate\":20.6,\"periodiseringsfond_ore\":50000}}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(jint(d, "result.entries_ore") == -50000); + CHECK(jint(d, "result.taxable_ore") == 82000); + CHECK(jint(d, "result.tax_ore") == 16892); + CHECK(jint(d, "result.result_after_ore") == 65108); + CHECK(yyjson_arr_size(jget(d, "result.vouchers")) == 3); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"122\",\"cmd\":\"report.balance_sheet\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(find_amount(d, "result.equity.accounts", "account", "2110", + "amount_ore") == 50000); + CHECK(find_amount(d, "result.liabilities.accounts", "account", "2512", + "amount_ore") == 16892); + CHECK(find_amount(d, "result.equity.accounts", "account", "2099", + "amount_ore") == 65108); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"123\",\"cmd\":\"bokslut.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"entries\":[{\"debit_account\":\"8910\"," + "\"amount_ore\":100}]}}", + g_session, (int)vat_org)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + /* ---------------- SRU (INK2/INK2R/INK2S) ----------------------- */ + d = call(reqf("{\"v\":1,\"id\":\"124\",\"cmd\":\"org.update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"org_nr\":\"559331-2126\",\"postal_code\":\"192 48\"," + "\"city\":\"Sollentuna\"}}", + g_session, (int)vat_org)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"125\",\"cmd\":\"sru.export\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"adjustments\":[{\"code\":\"7653\"," + "\"amount_ore\":10000}]}}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK_STR(d, "result.period", "2026P4"); + CHECK_STR(d, "result.blankett", "INK2-2026P4"); + char *sb64 = jstr(d, "result.blanketter.content_base64"); + unsigned char *srub = NULL; + size_t srul = 0; + CHECK(sb64 && util_b64_decode(sb64, strlen(sb64), &srub, &srul) == 0); + char *sru = xmalloc(srul + 1); + memcpy(sru, srub, srul); + sru[srul] = '\0'; + free(srub); + CHECK(strstr(sru, "#BLANKETT INK2-2026P4") != NULL); + CHECK(strstr(sru, "#BLANKETT INK2R-2026P4") != NULL); + CHECK(strstr(sru, "#BLANKETT INK2S-2026P4") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7011 20260101") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7410 1500") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7513 80") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7525 500") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7528 168") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7450 651") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7281 1875") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7369 355") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7321 500") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7302 651") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7650 651") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7651 168") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7653 100") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7670 919") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7104 919") != NULL); + CHECK(strstr(sru, "#FIL_SLUT") != NULL); + free(sru); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"126\",\"cmd\":\"sru.export\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{}}", + g_session, (int)vat_org)); + CHECK_OK(d); + sb64 = jstr(d, "result.info.content_base64"); + srub = NULL; + srul = 0; + CHECK(sb64 && util_b64_decode(sb64, strlen(sb64), &srub, &srul) == 0); + sru = xmalloc(srul + 1); + memcpy(sru, srub, srul); + sru[srul] = '\0'; + free(srub); + CHECK(strstr(sru, "#ORGNR 165593312126") != NULL); + CHECK(strstr(sru, "#POSTORT Sollentuna") != NULL); + CHECK(strstr(sru, "#FILNAMN BLANKETTER.SRU") != NULL); + free(sru); + yyjson_doc_free(d); + /* close fiscal year and verify hard stop */ d = call(reqf("{\"v\":1,\"id\":\"73\",\"cmd\":\"fiscal_year.close\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," |
