From 59ef95d3c1f590a05c3bea79834d424200d28250 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Sun, 20 Sep 2026 09:56:56 +0200 Subject: report: per-org moms rule editor commands --- docs/PROTOCOL.md | 24 ++++ src/commands.c | 380 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/reports.c | 20 ++- tests/test_core.c | 202 +++++++++++++++++++++++++++++ 4 files changed, 620 insertions(+), 6 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 00f5200..8405ca5 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -448,6 +448,30 @@ the moms boxes (`10`,`11`,`12`,`30`,`31`,`32`,`48`,`60`,`61`,`62`), so box 48 is signed like the blankett (ingående moms negative); underlag boxes do not change what is payable. +### 7.6.1 Reporting rules (moms mapping) + +`report_rules` maps accounts to blankett boxes. The rules are per-org +configuration, not part of the ledger, and owners edit them when +Skatteverket changes the blankett; only `report` `vat` is consumed today +(`report.vat` and `report.vat_eskd`), other report values are reserved. +Several rules may target the same box and `report.vat` sums them into one +entry. + +| Command | Args | Notes | +|---|---|---| +| `report.rule_list` | `report?` | ordered by report, sort_order, box, id | +| `report.rule_create` | `report`, `box`, `match_type`, `pattern`, `sign?`, `sort_order?` | owner; audited (`report_rule.create`) | +| `report.rule_update` | `id`, plus any of `box`, `match_type`, `pattern`, `sign`, `sort_order` | owner; audited (`report_rule.update`) | +| `report.rule_delete` | `id` | owner; audited (`report_rule.delete`) | + +`box` is 1–3 digits. An `account` pattern is 1–10 digits; a `range` pattern +is `LO-HI` with digits and `LO` ≤ `HI`; a `type` pattern is one of `asset`, +`liability`, `equity`, `revenue`, `expense` and matches `accounts.type`. +`sign` is `1` or `-1`; each matched account contributes +`(debit − credit) × sign` to the box. `report.rule_update` merges the given +fields into the existing row and validates the effective rule. All mutations +support `dry_run`, which validates without writing. + ### 7.7 SIE 4 | Command | Args | Result | diff --git a/src/commands.c b/src/commands.c index d639183..2abe0d4 100644 --- a/src/commands.c +++ b/src/commands.c @@ -2679,6 +2679,347 @@ static yyjson_mut_val *h_settings_set(struct req *r) return o; } +/* ------------------------------------------------------------------ */ +/* report rules (per-org moms mapping) */ +/* ------------------------------------------------------------------ */ + +struct rule_row { + int64_t id; + char report[16]; + char box[8]; + char match_type[16]; + char pattern[64]; + int sign; + int64_t sort_order; +}; + +static int rule_digits(const char *s, size_t min, size_t max) +{ + size_t n = s ? strlen(s) : 0; + if (n < min || n > max) + return 0; + for (const char *p = s; *p; p++) + if (*p < '0' || *p > '9') + return 0; + return 1; +} + +static int rule_validate(const char *box, const char *match_type, + const char *pattern, int64_t sign, char *err, + size_t errlen) +{ + if (!rule_digits(box, 1, 3)) { + snprintf(err, errlen, "box must be 1-3 digits"); + return -1; + } + if (strcmp(match_type, "account") == 0) { + if (!rule_digits(pattern, 1, 10)) { + snprintf(err, errlen, + "pattern must be 1-10 digits when match_type is account"); + return -1; + } + } else if (strcmp(match_type, "range") == 0) { + const char *dash = strchr(pattern, '-'); + if (!dash || dash == pattern || !dash[1] || strchr(dash + 1, '-')) { + snprintf(err, errlen, + "pattern must be LO-HI when match_type is range"); + return -1; + } + char lo[16], hi[16]; + size_t lon = (size_t)(dash - pattern); + size_t hin = strlen(dash + 1); + if (lon >= sizeof lo || hin >= sizeof hi) { + snprintf(err, errlen, "pattern must be LO-HI with digits"); + return -1; + } + memcpy(lo, pattern, lon); + lo[lon] = '\0'; + memcpy(hi, dash + 1, hin + 1); + if (!rule_digits(lo, 1, 10) || !rule_digits(hi, 1, 10)) { + snprintf(err, errlen, + "pattern must be LO-HI with 1-10 digits each"); + return -1; + } + size_t llo = strlen(lo), lhi = strlen(hi); + if (llo > lhi || (llo == lhi && strcmp(lo, hi) > 0)) { + snprintf(err, errlen, "range start must not exceed range end"); + return -1; + } + } else if (strcmp(match_type, "type") == 0) { + if (strcmp(pattern, "asset") != 0 && + strcmp(pattern, "liability") != 0 && + strcmp(pattern, "equity") != 0 && + strcmp(pattern, "revenue") != 0 && + strcmp(pattern, "expense") != 0) { + snprintf(err, errlen, + "pattern must be asset, liability, equity, revenue or" + " expense when match_type is type"); + return -1; + } + } else { + snprintf(err, errlen, "match_type must be account, range or type"); + return -1; + } + if (sign != 1 && sign != -1) { + snprintf(err, errlen, "sign must be 1 or -1"); + return -1; + } + return 0; +} + +static int rule_load(sqlite3 *db, int64_t org_id, int64_t id, + struct rule_row *row) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT id,report,box,match_type,pattern,sign,sort_order" + " FROM report_rules WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_int64(st, 2, id); + int found = 0; + if (sqlite3_step(st) == SQLITE_ROW) { + memset(row, 0, sizeof *row); + row->id = sqlite3_column_int64(st, 0); + snprintf(row->report, sizeof row->report, "%s", + sq(sqlite3_column_text(st, 1))); + snprintf(row->box, sizeof row->box, "%s", + sq(sqlite3_column_text(st, 2))); + snprintf(row->match_type, sizeof row->match_type, "%s", + sq(sqlite3_column_text(st, 3))); + snprintf(row->pattern, sizeof row->pattern, "%s", + sq(sqlite3_column_text(st, 4))); + row->sign = sqlite3_column_int(st, 5); + row->sort_order = sqlite3_column_int64(st, 6); + found = 1; + } + sqlite3_finalize(st); + return found ? 0 : -1; +} + +static void rule_to_json(yyjson_mut_doc *doc, yyjson_mut_val *o, + const struct rule_row *row) +{ + yyjson_mut_obj_add_int(doc, o, "id", row->id); + yyjson_mut_obj_add_strcpy(doc, o, "report", row->report); + yyjson_mut_obj_add_strcpy(doc, o, "box", row->box); + yyjson_mut_obj_add_strcpy(doc, o, "match_type", row->match_type); + yyjson_mut_obj_add_strcpy(doc, o, "pattern", row->pattern); + yyjson_mut_obj_add_int(doc, o, "sign", row->sign); + yyjson_mut_obj_add_int(doc, o, "sort_order", row->sort_order); +} + +static yyjson_mut_val *h_report_rule_list(struct req *r) +{ + const char *report = arg_str(r->args, "report"); + sqlite3_stmt *st = NULL; + const char *sql = + report ? "SELECT id,report,box,match_type,pattern,sign,sort_order" + " FROM report_rules WHERE org_id=?1 AND report=?2" + " ORDER BY report,sort_order,box,id" + : "SELECT id,report,box,match_type,pattern,sign,sort_order" + " FROM report_rules WHERE org_id=?1" + " ORDER BY report,sort_order,box,id"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (report) + sqlite3_bind_text(st, 2, report, -1, SQLITE_TRANSIENT); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + while (sqlite3_step(st) == SQLITE_ROW) { + struct rule_row row; + memset(&row, 0, sizeof row); + row.id = sqlite3_column_int64(st, 0); + snprintf(row.report, sizeof row.report, "%s", + sq(sqlite3_column_text(st, 1))); + snprintf(row.box, sizeof row.box, "%s", + sq(sqlite3_column_text(st, 2))); + snprintf(row.match_type, sizeof row.match_type, "%s", + sq(sqlite3_column_text(st, 3))); + snprintf(row.pattern, sizeof row.pattern, "%s", + sq(sqlite3_column_text(st, 4))); + row.sign = sqlite3_column_int(st, 5); + row.sort_order = sqlite3_column_int64(st, 6); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + rule_to_json(r->rdoc, o, &row); + } + sqlite3_finalize(st); + yyjson_mut_val *res = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, res, "items", items); + return res; +} + +static yyjson_mut_val *h_report_rule_create(struct req *r) +{ + const char *report = arg_str(r->args, "report"); + const char *box = arg_str(r->args, "box"); + const char *match_type = arg_str(r->args, "match_type"); + const char *pattern = arg_str(r->args, "pattern"); + int64_t sign = 1, sort_order = 0; + arg_int(r->args, "sign", &sign); + arg_int(r->args, "sort_order", &sort_order); + if (!report || !box || !match_type || !pattern) + return fail(r, "INVALID_ARGS", + "report, box, match_type and pattern are required"); + if (strcmp(report, "vat") != 0) + return fail(r, "INVALID_ARGS", "report must be \"vat\""); + char verr[256]; + if (rule_validate(box, match_type, pattern, sign, verr, sizeof verr) != 0) + return fail(r, "INVALID_ARGS", verr); + if (r->dry_run) { + struct rule_row row; + memset(&row, 0, sizeof row); + snprintf(row.report, sizeof row.report, "%s", report); + snprintf(row.box, sizeof row.box, "%s", box); + snprintf(row.match_type, sizeof row.match_type, "%s", match_type); + snprintf(row.pattern, sizeof row.pattern, "%s", pattern); + row.sign = (int)sign; + row.sort_order = sort_order; + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + rule_to_json(r->rdoc, o, &row); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO report_rules(org_id,report,box,match_type,pattern," + "sign,sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7)", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, report, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, box, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, match_type, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, pattern, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 6, sign); + sqlite3_bind_int64(st, 7, sort_order); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + int64_t id = db_last_id(r->db); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "report_rule.create", reqjson, "OK", NULL); + free(reqjson); + struct rule_row row; + memset(&row, 0, sizeof row); + row.id = id; + snprintf(row.report, sizeof row.report, "%s", report); + snprintf(row.box, sizeof row.box, "%s", box); + snprintf(row.match_type, sizeof row.match_type, "%s", match_type); + snprintf(row.pattern, sizeof row.pattern, "%s", pattern); + row.sign = (int)sign; + row.sort_order = sort_order; + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + rule_to_json(r->rdoc, o, &row); + return o; +} + +static yyjson_mut_val *h_report_rule_update(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + if (id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + struct rule_row row; + if (rule_load(r->db, r->org_id, id, &row) != 0) + return fail(r, "NOT_FOUND", "report rule not found"); + const char *box = arg_str(r->args, "box"); + const char *match_type = arg_str(r->args, "match_type"); + const char *pattern = arg_str(r->args, "pattern"); + int64_t sign = row.sign, sort_order = row.sort_order; + arg_int(r->args, "sign", &sign); + arg_int(r->args, "sort_order", &sort_order); + if (box) + snprintf(row.box, sizeof row.box, "%s", box); + if (match_type) + snprintf(row.match_type, sizeof row.match_type, "%s", match_type); + if (pattern) + snprintf(row.pattern, sizeof row.pattern, "%s", pattern); + row.sign = (int)sign; + row.sort_order = sort_order; + char verr[256]; + if (rule_validate(row.box, row.match_type, row.pattern, row.sign, verr, + sizeof verr) != 0) + return fail(r, "INVALID_ARGS", verr); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + rule_to_json(r->rdoc, o, &row); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE report_rules SET box=?3,match_type=?4,pattern=?5,sign=?6," + "sort_order=?7 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, row.id); + sqlite3_bind_text(st, 3, row.box, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, row.match_type, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, row.pattern, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 6, row.sign); + sqlite3_bind_int64(st, 7, row.sort_order); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "report rule not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "report_rule.update", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + rule_to_json(r->rdoc, o, &row); + return o; +} + +static yyjson_mut_val *h_report_rule_delete(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + if (id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + struct rule_row row; + if (rule_load(r->db, r->org_id, id, &row) != 0) + return fail(r, "NOT_FOUND", "report rule not found"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_bool(r->rdoc, o, "deleted", true); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, "DELETE FROM report_rules 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, id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "report rule not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "report_rule.delete", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_bool(r->rdoc, o, "deleted", true); + return o; +} + /* ------------------------------------------------------------------ */ /* voucher templates (konteringsmallar) */ /* ------------------------------------------------------------------ */ @@ -4869,6 +5210,37 @@ static const struct cmd_arg args_settings_set[] = { { "value", ARG_STR, 1, NULL, NULL, "Setting value" }, }; +static const struct cmd_arg args_report_rule_list[] = { + { "report", ARG_ENUM, 0, NULL, "vat", + "Report filter; omit for all reports" }, +}; + +static const struct cmd_arg args_report_rule_create[] = { + { "report", ARG_ENUM, 1, NULL, "vat", "Report the rule belongs to" }, + { "box", ARG_STR, 1, NULL, NULL, "Blankett box, 1-3 digits" }, + { "match_type", ARG_ENUM, 1, NULL, "account,range,type", + "How pattern selects accounts" }, + { "pattern", ARG_STR, 1, NULL, NULL, + "Account, account range or account type" }, + { "sign", ARG_INT, 0, "1", NULL, "1 or -1" }, + { "sort_order", ARG_INT, 0, "0", NULL, "Evaluation order" }, +}; + +static const struct cmd_arg args_report_rule_update[] = { + { "id", ARG_INT, 1, NULL, NULL, "Rule id" }, + { "box", ARG_STR, 0, NULL, NULL, "Blankett box, 1-3 digits" }, + { "match_type", ARG_ENUM, 0, NULL, "account,range,type", + "How pattern selects accounts" }, + { "pattern", ARG_STR, 0, NULL, NULL, + "Account, account range or account type" }, + { "sign", ARG_INT, 0, NULL, NULL, "1 or -1" }, + { "sort_order", ARG_INT, 0, NULL, NULL, "Evaluation order" }, +}; + +static const struct cmd_arg args_report_rule_delete[] = { + { "id", ARG_INT, 1, NULL, NULL, "Rule id" }, +}; + static const struct cmd_arg args_template_list[] = { { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active templates" }, }; @@ -5104,6 +5476,14 @@ const struct command g_commands[] = { h_settings_get, NULL, 0 }, { "settings.set", "Change an org setting", PERM_WRITE, 1, 1, 1, h_settings_set, CMD_ARGS(args_settings_set) }, + { "report.rule_list", "List per-org reporting rules", PERM_READ, 1, 0, 0, + h_report_rule_list, CMD_ARGS(args_report_rule_list) }, + { "report.rule_create", "Create a reporting rule (owner)", PERM_OWNER, 1, 1, + 1, h_report_rule_create, CMD_ARGS(args_report_rule_create) }, + { "report.rule_update", "Update a reporting rule (owner)", PERM_OWNER, 1, 1, + 1, h_report_rule_update, CMD_ARGS(args_report_rule_update) }, + { "report.rule_delete", "Delete a reporting rule (owner)", PERM_OWNER, 1, 1, + 1, h_report_rule_delete, CMD_ARGS(args_report_rule_delete) }, { "template.list", "List voucher templates", PERM_READ, 1, 0, 0, h_template_list, CMD_ARGS(args_template_list) }, { "template.get", "Get a voucher template with its rows", PERM_READ, 1, 0, diff --git a/src/reports.c b/src/reports.c index 4368eb0..1d4efdd 100644 --- a/src/reports.c +++ b/src/reports.c @@ -394,15 +394,23 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, int sign = sqlite3_column_int(st, 3); int64_t amount = 0; sqlite3_stmt *qs = NULL; - const char *sql = - strcmp(mt, "account") == 0 - ? "SELECT COALESCE(SUM(r.debit_ore),0)," + const char *sql; + if (strcmp(mt, "account") == 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.number=?2 AND v.series <> 'IB'" - " AND v.date BETWEEN ?3 AND ?4" - : "SELECT COALESCE(SUM(r.debit_ore),0)," + " 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"; + 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" @@ -411,7 +419,7 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, if (sqlite3_prepare_v2(db, sql, -1, &qs, NULL) != SQLITE_OK) break; sqlite3_bind_int64(qs, 1, org_id); - if (strcmp(mt, "account") == 0) { + if (strcmp(mt, "account") == 0 || strcmp(mt, "type") == 0) { 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); diff --git a/tests/test_core.c b/tests/test_core.c index ee0d3b9..56c70dd 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -960,6 +960,16 @@ int main(void) CHECK(arg_field_is(find_arg(d, "number"), "type", "string")); yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"30f\",\"cmd\":\"describe\"," + "\"session\":\"%s\",\"args\":{\"cmd\":\"report.rule_create\"}}", + g_session)); + CHECK_OK(d); + CHECK_STR(d, "result.command.name", "report.rule_create"); + CHECK(arg_required(find_arg(d, "report"))); + CHECK(arg_field_is(find_arg(d, "match_type"), "type", "enum")); + CHECK(!arg_required(find_arg(d, "sign"))); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"30c\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"description\":\"Saknar datum\",\"rows\":[" @@ -1996,6 +2006,198 @@ int main(void) CHECK(n05 == 1); yyjson_doc_free(d); + /* ---------------- moms rules: per-org editor -------------------- */ + d = call(reqf("{\"v\":1,\"id\":\"116a\",\"cmd\":\"report.rule_list\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)vat_org)); + CHECK_OK(d); + int nrules = (int)yyjson_arr_size(jget(d, "result.items")); + CHECK(nrules >= 15); + { + yyjson_val *items = jget(d, "result.items"); + for (size_t i = 0; i < yyjson_arr_size(items); i++) { + yyjson_val *rv = yyjson_obj_get(yyjson_arr_get(items, i), "report"); + CHECK(rv && yyjson_is_str(rv) && + strcmp(yyjson_get_str(rv), "vat") == 0); + } + } + CHECK_STR(d, "result.items.0.box", "05"); + CHECK(jint(d, "result.items.0.sort_order") == 10); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116b\",\"cmd\":\"report.rule_list\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"report\":\"vat\"}}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(yyjson_arr_size(jget(d, "result.items")) == (size_t)nrules); + yyjson_doc_free(d); + + /* dry run validates but must not create a rule */ + d = call(reqf("{\"v\":1,\"id\":\"116c\",\"cmd\":\"report.rule_create\"," + "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":" + "{\"report\":\"vat\",\"box\":\"05\",\"match_type\":\"type\"," + "\"pattern\":\"revenue\",\"sign\":-1,\"sort_order\":1}}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(jbool(d, "result.dry_run")); + CHECK(jint(d, "result.id") == 0); + CHECK_STR(d, "result.match_type", "type"); + CHECK_STR(d, "result.pattern", "revenue"); + CHECK(jint(d, "result.sign") == -1); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116d\",\"cmd\":\"report.rule_list\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(yyjson_arr_size(jget(d, "result.items")) == (size_t)nrules); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116e\",\"cmd\":\"report.rule_create\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"report\":\"vat\",\"box\":\"05\",\"match_type\":\"type\"," + "\"pattern\":\"revenue\",\"sign\":-1,\"sort_order\":1}}", + g_session, (int)vat_org)); + CHECK_OK(d); + int64_t rule_id = jint(d, "result.id"); + CHECK(rule_id > 0); + CHECK_STR(d, "result.report", "vat"); + CHECK_STR(d, "result.box", "05"); + CHECK(jint(d, "result.sign") == -1); + CHECK(jint(d, "result.sort_order") == 1); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116f\",\"cmd\":\"report.rule_list\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(yyjson_arr_size(jget(d, "result.items")) == (size_t)nrules + 1); + { + int found = 0; + yyjson_val *items = jget(d, "result.items"); + for (size_t i = 0; i < yyjson_arr_size(items); i++) { + yyjson_val *iv = yyjson_obj_get(yyjson_arr_get(items, i), "id"); + if (iv && yyjson_is_int(iv) && yyjson_get_int(iv) == rule_id) + found = 1; + } + CHECK(found); + } + yyjson_doc_free(d); + + /* the rule adds the revenue type on top of the seeded ranges */ + d = call(reqf("{\"v\":1,\"id\":\"116g\",\"cmd\":\"report.vat\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"from\":\"2026-02-01\",\"to\":\"2026-02-28\"}}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(vat_box(d, "05") == 300000); + CHECK(vat_box(d, "49") == 35500); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116h\",\"cmd\":\"report.rule_update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"id\":%lld,\"sign\":1,\"sort_order\":2}}", + g_session, (int)vat_org, (long long)rule_id)); + CHECK_OK(d); + CHECK_STR(d, "result.match_type", "type"); + CHECK_STR(d, "result.pattern", "revenue"); + CHECK(jint(d, "result.sign") == 1); + CHECK(jint(d, "result.sort_order") == 2); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116i\",\"cmd\":\"report.vat\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"from\":\"2026-02-01\",\"to\":\"2026-02-28\"}}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(vat_box(d, "05") == 0); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116j\",\"cmd\":\"report.rule_update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"match_type\":\"range\",\"pattern\":\"3100-3000\"}}", + g_session, (int)vat_org, (long long)rule_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116k\",\"cmd\":\"report.rule_update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%d}}", + g_session, (int)vat_org, 999999)); + CHECK_STR(d, "error.code", "NOT_FOUND"); + yyjson_doc_free(d); + + /* dry-run delete keeps the rule */ + d = call(reqf("{\"v\":1,\"id\":\"116l\",\"cmd\":\"report.rule_delete\"," + "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," + "\"args\":{\"id\":%lld}}", + g_session, (int)vat_org, (long long)rule_id)); + CHECK_OK(d); + CHECK(jbool(d, "result.deleted")); + CHECK(jbool(d, "result.dry_run")); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116m\",\"cmd\":\"report.rule_delete\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, (int)vat_org, (long long)rule_id)); + CHECK_OK(d); + CHECK(jint(d, "result.id") == rule_id); + CHECK(jbool(d, "result.deleted")); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116n\",\"cmd\":\"report.rule_list\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(yyjson_arr_size(jget(d, "result.items")) == (size_t)nrules); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116o\",\"cmd\":\"report.vat\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"from\":\"2026-02-01\",\"to\":\"2026-02-28\"}}", + g_session, (int)vat_org)); + CHECK_OK(d); + CHECK(vat_box(d, "05") == 150000); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116p\",\"cmd\":\"report.rule_delete\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, (int)vat_org, (long long)rule_id)); + CHECK_STR(d, "error.code", "NOT_FOUND"); + yyjson_doc_free(d); + + /* invalid rules are rejected before any write */ + d = call(reqf("{\"v\":1,\"id\":\"116q\",\"cmd\":\"report.rule_create\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"report\":\"vat\",\"box\":\"05\"," + "\"match_type\":\"bogus\",\"pattern\":\"3001\"}}", + g_session, (int)vat_org)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116r\",\"cmd\":\"report.rule_create\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"report\":\"vat\",\"box\":\"05\"," + "\"match_type\":\"range\",\"pattern\":\"3100-3000\"}}", + g_session, (int)vat_org)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116s\",\"cmd\":\"report.rule_create\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"report\":\"vat\",\"box\":\"05\"," + "\"match_type\":\"account\",\"pattern\":\"30a1\"}}", + g_session, (int)vat_org)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"116t\",\"cmd\":\"report.rule_create\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"report\":\"vat\",\"box\":\"05\"," + "\"match_type\":\"type\",\"pattern\":\"revenue\",\"sign\":0}}", + g_session, (int)vat_org)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + 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\":" -- cgit v1.3