aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 09:56:56 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 09:57:20 +0200
commit59ef95d3c1f590a05c3bea79834d424200d28250 (patch)
tree6fd26b77e3ffb10658c3e3ef029704704118dd0d
parentb5349b52e757399d8ddb6325120a58e7d9e6e1f6 (diff)
report: per-org moms rule editor commands
-rw-r--r--docs/PROTOCOL.md24
-rw-r--r--src/commands.c380
-rw-r--r--src/reports.c20
-rw-r--r--tests/test_core.c202
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
@@ -2680,6 +2680,347 @@ static yyjson_mut_val *h_settings_set(struct req *r)
}
/* ------------------------------------------------------------------ */
+/* 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\":"