diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-20 09:56:56 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-20 09:57:20 +0200 |
| commit | 59ef95d3c1f590a05c3bea79834d424200d28250 (patch) | |
| tree | 6fd26b77e3ffb10658c3e3ef029704704118dd0d /src | |
| parent | b5349b52e757399d8ddb6325120a58e7d9e6e1f6 (diff) | |
| download | bokf-59ef95d3c1f590a05c3bea79834d424200d28250.tar.gz bokf-59ef95d3c1f590a05c3bea79834d424200d28250.zip | |
report: per-org moms rule editor commands
Diffstat (limited to 'src')
| -rw-r--r-- | src/commands.c | 380 | ||||
| -rw-r--r-- | src/reports.c | 20 |
2 files changed, 394 insertions, 6 deletions
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); |
