diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cmd_accounts.c | 246 | ||||
| -rw-r--r-- | src/cmd_attachments.c | 414 | ||||
| -rw-r--r-- | src/cmd_audit.c | 288 | ||||
| -rw-r--r-- | src/cmd_auth.c | 435 | ||||
| -rw-r--r-- | src/cmd_bank.c | 887 | ||||
| -rw-r--r-- | src/cmd_bokslut.c | 241 | ||||
| -rw-r--r-- | src/cmd_customers.c | 517 | ||||
| -rw-r--r-- | src/cmd_fiscal.c | 484 | ||||
| -rw-r--r-- | src/cmd_invoices.c | 1422 | ||||
| -rw-r--r-- | src/cmd_org.c | 753 | ||||
| -rw-r--r-- | src/cmd_reports.c | 209 | ||||
| -rw-r--r-- | src/cmd_rules.c | 398 | ||||
| -rw-r--r-- | src/cmd_settings.c | 234 | ||||
| -rw-r--r-- | src/cmd_sie.c | 171 | ||||
| -rw-r--r-- | src/cmd_sru.c | 50 | ||||
| -rw-r--r-- | src/cmd_templates.c | 539 | ||||
| -rw-r--r-- | src/cmd_users.c | 287 | ||||
| -rw-r--r-- | src/cmd_util.c | 412 | ||||
| -rw-r--r-- | src/cmd_util.h | 52 | ||||
| -rw-r--r-- | src/cmd_vouchers.c | 625 | ||||
| -rw-r--r-- | src/commands.c | 8341 | ||||
| -rw-r--r-- | src/commands.h | 9 |
22 files changed, 8719 insertions, 8295 deletions
diff --git a/src/cmd_accounts.c b/src/cmd_accounts.c new file mode 100644 index 0000000..900558f --- /dev/null +++ b/src/cmd_accounts.c @@ -0,0 +1,246 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "db.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* kontoplan */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *account_json(yyjson_mut_doc *doc, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(doc, o, "number", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(doc, o, "type", sq(sqlite3_column_text(st, 3))); + if (sqlite3_column_type(st, 4) == SQLITE_NULL) + yyjson_mut_obj_add_null(doc, o, "sru_code"); + else + yyjson_mut_obj_add_strcpy(doc, o, "sru_code", + sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) == SQLITE_NULL) + yyjson_mut_obj_add_null(doc, o, "vat_code"); + else + yyjson_mut_obj_add_strcpy(doc, o, "vat_code", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_bool(doc, o, "active", sqlite3_column_int(st, 6) != 0); + return o; +} + +#define ACCOUNT_COLUMNS "id,number,name,type,sru_code,vat_code,active" + +static yyjson_mut_val *h_account_list(struct req *r) +{ + int active_only = 0; + arg_bool(r->args, "active_only", &active_only); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + const char *sql = active_only + ? "SELECT " ACCOUNT_COLUMNS " FROM accounts" + " WHERE org_id=?1 AND active=1 ORDER BY number" + : "SELECT " ACCOUNT_COLUMNS " FROM accounts" + " WHERE org_id=?1 ORDER BY number"; + 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); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(items, account_json(r->rdoc, st)); + sqlite3_finalize(st); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, o, "items", items); + return o; +} + +static yyjson_mut_val *h_account_get(struct req *r) +{ + int64_t id = 0; + const char *number = arg_str(r->args, "number"); + arg_int(r->args, "id", &id); + if (!id && !number) + return fail(r, "INVALID_ARGS", "id or number is required"); + sqlite3_stmt *st = NULL; + const char *sql = id ? "SELECT " ACCOUNT_COLUMNS " FROM accounts" + " WHERE org_id=?1 AND id=?2" + : "SELECT " ACCOUNT_COLUMNS " FROM accounts" + " WHERE org_id=?1 AND number=?2"; + 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 (id) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = account_json(r->rdoc, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "account not found"); + return o; +} + +static int valid_account_type(const char *t) +{ + return t && (!strcmp(t, "asset") || !strcmp(t, "liability") || + !strcmp(t, "equity") || !strcmp(t, "revenue") || + !strcmp(t, "expense")); +} + +static yyjson_mut_val *h_account_create(struct req *r) +{ + const char *number = arg_str(r->args, "number"); + const char *name = arg_str(r->args, "name"); + const char *type = arg_str(r->args, "type"); + const char *sru = arg_str(r->args, "sru_code"); + const char *vat = arg_str(r->args, "vat_code"); + if (!is_digits(number) || strlen(number) > 10) + return fail(r, "INVALID_ARGS", "number must be 1-10 digits"); + if (!name || !*name) + return fail(r, "INVALID_ARGS", "name is required"); + if (!valid_account_type(type)) + return fail(r, "INVALID_ARGS", + "type must be asset, liability, equity, revenue or expense"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO accounts(org_id,number,name,type,sru_code,vat_code," + "created_at) 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, number, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, type, -1, SQLITE_TRANSIENT); + if (sru) + sqlite3_bind_text(st, 5, sru, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 5); + if (vat) + sqlite3_bind_text(st, 6, vat, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 6); + sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + if ((rc & 0xff) == SQLITE_CONSTRAINT) + return fail(r, "CONFLICT", "account number already exists"); + 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, + "account.create", 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_strcpy(r->rdoc, o, "number", number); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "type", type); + return o; +} + +static yyjson_mut_val *h_account_update(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + const char *name = arg_str(r->args, "name"); + const char *sru = arg_str(r->args, "sru_code"); + const char *vat = arg_str(r->args, "vat_code"); + int active = -1; + arg_bool(r->args, "active", &active); + if (!name && !sru && !vat && active < 0) + return fail(r, "INVALID_ARGS", "nothing to update"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE accounts SET" + " name=COALESCE(?2,name), sru_code=COALESCE(?3,sru_code)," + " vat_code=COALESCE(?4,vat_code)," + " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6" + " WHERE org_id=?1 AND id=?7", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (name) + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 2); + if (sru) + sqlite3_bind_text(st, 3, sru, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 3); + if (vat) + sqlite3_bind_text(st, 4, vat, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 4); + sqlite3_bind_int(st, 5, active); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, 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", "account not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "account.update", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + + +static const struct cmd_arg args_account_list[] = { + { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active accounts" }, +}; + +static const struct cmd_arg args_account_get[] = { + { "id", ARG_INT, 0, NULL, NULL, "Account id" }, + { "number", ARG_STR, 0, NULL, NULL, "Account number (string)" }, +}; + +static const struct cmd_arg args_account_create[] = { + { "number", ARG_STR, 1, NULL, NULL, "Account number, 1-10 digits" }, + { "name", ARG_STR, 1, NULL, NULL, "Account name" }, + { "type", ARG_ENUM, 1, NULL, "asset,liability,equity,revenue,expense", + "Account type" }, + { "sru_code", ARG_STR, 0, NULL, NULL, "SRU code" }, + { "vat_code", ARG_STR, 0, NULL, NULL, "VAT code" }, +}; + +static const struct cmd_arg args_account_update[] = { + { "id", ARG_INT, 1, NULL, NULL, "Account id" }, + { "name", ARG_STR, 0, NULL, NULL, "Account name" }, + { "sru_code", ARG_STR, 0, NULL, NULL, "SRU code" }, + { "vat_code", ARG_STR, 0, NULL, NULL, "VAT code" }, + { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, +}; + +const struct command g_cmd_accounts[] = { + { "account.list", "List the chart of accounts", PERM_READ, 1, 0, 0, + h_account_list, CMD_ARGS(args_account_list) }, + { "account.get", "Get one account", PERM_READ, 1, 0, 0, h_account_get, + CMD_ARGS(args_account_get) }, + { "account.create", "Add an account", PERM_WRITE, 1, 1, 1, + h_account_create, CMD_ARGS(args_account_create) }, + { "account.update", "Rename or deactivate an account", PERM_WRITE, 1, 1, 1, + h_account_update, CMD_ARGS(args_account_update) }, +}; + +const struct cmd_table g_cmd_table_accounts = { + g_cmd_accounts, sizeof g_cmd_accounts / sizeof g_cmd_accounts[0] +}; diff --git a/src/cmd_attachments.c b/src/cmd_attachments.c new file mode 100644 index 0000000..b1590fd --- /dev/null +++ b/src/cmd_attachments.c @@ -0,0 +1,414 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "config.h" +#include "db.h" +#include "sha256.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* attachments */ +/* ------------------------------------------------------------------ */ + +static int link_attachment(struct req *r, int64_t aid, int64_t vid, + char **errmsg, const char **errcode) +{ + *errcode = "INTERNAL"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT (SELECT count(*) FROM vouchers WHERE org_id=?1 AND id=?2)," + "(SELECT count(*) FROM voucher_attachments WHERE org_id=?1" + " AND voucher_id=?2 AND attachment_id=?3)," + "(SELECT count(*) FROM attachments WHERE org_id=?1 AND id=?3)", + -1, &st, NULL) != SQLITE_OK) { + *errmsg = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, vid); + sqlite3_bind_int64(st, 3, aid); + int64_t vexists = 0, linked = 0, aexists = 0; + if (sqlite3_step(st) == SQLITE_ROW) { + vexists = sqlite3_column_int64(st, 0); + linked = sqlite3_column_int64(st, 1); + aexists = sqlite3_column_int64(st, 2); + } + sqlite3_finalize(st); + if (!aexists) { + *errmsg = xstrdup("attachment not found"); + *errcode = "NOT_FOUND"; + return -1; + } + if (!vexists) { + *errmsg = xstrdup("voucher not found"); + *errcode = "NOT_FOUND"; + return -1; + } + if (linked) { + *errmsg = xstrdup("attachment is already linked"); + *errcode = "CONFLICT"; + return -1; + } + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO voucher_attachments(org_id,voucher_id,attachment_id," + "created_at) VALUES(?1,?2,?3,?4)", + -1, &st, NULL) != SQLITE_OK) { + *errmsg = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, vid); + sqlite3_bind_int64(st, 3, aid); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + *errmsg = xstrdup("could not link attachment"); + return -1; + } + return 0; +} + +static yyjson_mut_val *h_attachment_put(struct req *r) +{ + const char *filename = arg_str(r->args, "filename"); + const char *mime = arg_str(r->args, "mime"); + const char *b64 = arg_str(r->args, "content_base64"); + int64_t voucher_id = 0; + arg_int(r->args, "voucher_id", &voucher_id); + if (!filename || !*filename || !b64) + return fail(r, "INVALID_ARGS", + "filename and content_base64 are required"); + if (!mime || !*mime) + mime = "application/octet-stream"; + unsigned char *content = NULL; + size_t len = 0; + if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) + return fail(r, "INVALID_ARGS", "content_base64 is not valid base64"); + if ((long)len > g_cfg.max_attachment_bytes) { + free(content); + return failf(r, "TOO_LARGE", "attachment exceeds %ld bytes", + g_cfg.max_attachment_bytes); + } + unsigned char hash[32]; + util_sha256(content, len, hash); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id FROM attachments WHERE org_id=?1 AND sha256=?2" + " AND filename=?3", + -1, &st, NULL) != SQLITE_OK) { + free(content); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); + int64_t existing = 0; + if (sqlite3_step(st) == SQLITE_ROW) + existing = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + + if (existing) { + free(content); + if (voucher_id) { + char *emsg = NULL; + const char *ecode = NULL; + if (link_attachment(r, existing, voucher_id, &emsg, &ecode) != 0) { + yyjson_mut_val *res = + fail(r, ecode ? ecode : "INTERNAL", emsg); + free(emsg); + return res; + } + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.link", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", existing); + yyjson_mut_obj_add_bool(r->rdoc, o, "replayed", true); + return o; + } + + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes," + "content,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + -1, &st, NULL) != SQLITE_OK) { + free(content); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, mime, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 5, (int64_t)len); + sqlite3_bind_blob(st, 6, content, (int)len, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 8, r->sess->user_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + free(content); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + int64_t id = db_last_id(r->db); + + if (voucher_id) { + char *emsg = NULL; + const char *ecode = NULL; + if (link_attachment(r, id, voucher_id, &emsg, &ecode) != 0) { + yyjson_mut_val *res = fail(r, ecode ? ecode : "INTERNAL", emsg); + free(emsg); + return res; + } + } + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.put", 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_int(r->rdoc, o, "size_bytes", (int64_t)len); + return o; +} + +static yyjson_mut_val *h_attachment_link(struct req *r) +{ + int64_t id = 0, voucher_id = 0; + arg_int(r->args, "id", &id); + arg_int(r->args, "voucher_id", &voucher_id); + if (id <= 0 || voucher_id <= 0) + return fail(r, "INVALID_ARGS", "id and voucher_id are required"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + char *emsg = NULL; + const char *ecode = NULL; + if (link_attachment(r, id, voucher_id, &emsg, &ecode) != 0) { + yyjson_mut_val *e = fail(r, ecode ? ecode : "INTERNAL", emsg); + free(emsg); + return e; + } + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.link", 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_int(r->rdoc, o, "voucher_id", voucher_id); + return o; +} + +static yyjson_mut_val *h_attachment_unlink(struct req *r) +{ + int64_t id = 0, voucher_id = 0; + arg_int(r->args, "id", &id); + arg_int(r->args, "voucher_id", &voucher_id); + if (id <= 0 || voucher_id <= 0) + return fail(r, "INVALID_ARGS", "id and voucher_id are required"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + 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 voucher_attachments WHERE org_id=?1" + " AND voucher_id=?2 AND attachment_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_int64(st, 3, 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", "link not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.unlink", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_attachment_get(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT filename,mime,size_bytes,content,sha256,created_at" + " FROM attachments 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); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "attachment not found"); + } + const void *content = sqlite3_column_blob(st, 3); + size_t len = (size_t)sqlite3_column_bytes(st, 3); + char *b64 = util_b64(content ? content : (const unsigned char *)"", len); + char hex[65]; + const void *hb = sqlite3_column_blob(st, 4); + if (hb && sqlite3_column_bytes(st, 4) == 32) + util_hex((const unsigned char *)hb, 32, hex); + else + hex[0] = '\0'; + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", + sq(sqlite3_column_text(st, 0))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", + sqlite3_column_int64(st, 2)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); + free(b64); + sqlite3_finalize(st); + return o; +} + +static yyjson_mut_val *h_attachment_list(struct req *r) +{ + int64_t voucher_id = 0, cursor = 0, limit = 100; + int unlinked = 0; + arg_int(r->args, "voucher_id", &voucher_id); + arg_int(r->args, "cursor", &cursor); + arg_int(r->args, "limit", &limit); + arg_bool(r->args, "unlinked", &unlinked); + if (limit < 1) + limit = 100; + if (limit > 1000) + limit = 1000; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT a.id,a.filename,a.mime,a.size_bytes,a.created_at,a.sha256," + "(SELECT va.voucher_id FROM voucher_attachments va" + " WHERE va.org_id=a.org_id AND va.attachment_id=a.id" + " AND (?2=0 OR va.voucher_id=?2)" + " ORDER BY va.voucher_id LIMIT 1)" + " FROM attachments a WHERE a.org_id=?1" + " AND (?2=0 OR EXISTS(SELECT 1 FROM voucher_attachments va2" + " WHERE va2.org_id=a.org_id AND va2.attachment_id=a.id" + " AND va2.voucher_id=?2))" + " AND (?3=0 OR NOT EXISTS(SELECT 1 FROM voucher_attachments va3" + " WHERE va3.org_id=a.org_id AND va3.attachment_id=a.id))" + " AND a.id>?4 ORDER BY a.id LIMIT ?5", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_int(st, 3, unlinked); + sqlite3_bind_int64(st, 4, cursor); + sqlite3_bind_int64(st, 5, limit); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + int64_t n = 0, last = cursor; + while (sqlite3_step(st) == SQLITE_ROW) { + n++; + last = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", last); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", + sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 4))); + char hex[65]; + const void *hb = sqlite3_column_blob(st, 5); + if (hb && sqlite3_column_bytes(st, 5) == 32) + util_hex((const unsigned char *)hb, 32, hex); + else + hex[0] = '\0'; + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + if (sqlite3_column_type(st, 6) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", + sqlite3_column_int64(st, 6)); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + if (n == limit) + yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last); + else + yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); + return out; +} + + +static const struct cmd_arg args_attachment_put[] = { + { "filename", ARG_STR, 1, NULL, NULL, "File name" }, + { "mime", ARG_STR, 0, "application/octet-stream", NULL, "MIME type" }, + { "content_base64", ARG_STR, 1, NULL, NULL, "File content, base64" }, + { "voucher_id", ARG_INT, 0, NULL, NULL, "Link to this voucher" }, +}; + +static const struct cmd_arg args_attachment_link[] = { + { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, + { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, +}; + +static const struct cmd_arg args_attachment_unlink[] = { + { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, + { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, +}; + +static const struct cmd_arg args_attachment_get[] = { + { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, +}; + +static const struct cmd_arg args_attachment_list[] = { + { "voucher_id", ARG_INT, 0, NULL, NULL, + "Only attachments on this voucher" }, + { "unlinked", ARG_BOOL, 0, NULL, NULL, + "Only unlinked attachments (inbox)" }, + { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, + { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, +}; + +const struct command g_cmd_attachments[] = { + { "attachment.put", "Store an underlag (receipt, invoice)", PERM_WRITE, 1, + 1, 1, h_attachment_put, CMD_ARGS(args_attachment_put) }, + { "attachment.link", "Link an existing attachment to a voucher", + PERM_WRITE, 1, 1, 1, h_attachment_link, CMD_ARGS(args_attachment_link) }, + { "attachment.unlink", "Remove an attachment link", PERM_WRITE, 1, 1, 1, + h_attachment_unlink, CMD_ARGS(args_attachment_unlink) }, + { "attachment.get", "Fetch an attachment", PERM_READ, 1, 0, 0, + h_attachment_get, CMD_ARGS(args_attachment_get) }, + { "attachment.list", "List attachments / inbox", PERM_READ, 1, 0, 0, + h_attachment_list, CMD_ARGS(args_attachment_list) }, +}; + +const struct cmd_table g_cmd_table_attachments = { + g_cmd_attachments, sizeof g_cmd_attachments / sizeof g_cmd_attachments[0] +}; diff --git a/src/cmd_audit.c b/src/cmd_audit.c new file mode 100644 index 0000000..183e7e6 --- /dev/null +++ b/src/cmd_audit.c @@ -0,0 +1,288 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <time.h> +#include <unistd.h> + +#include "audit.h" +#include "config.h" +#include "db.h" +#include "ledger.h" +#include "sha256.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* audit and backup */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *h_audit_list(struct req *r) +{ + int64_t cursor = 0, limit = 100; + arg_int(r->args, "cursor", &cursor); + arg_int(r->args, "limit", &limit); + if (limit < 1) + limit = 100; + if (limit > 1000) + limit = 1000; + const char *action = arg_str(r->args, "action"); + + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + const char *sql = action + ? "SELECT seq,at,actor_user_id,actor_token_id,action," + "request_json,result_code FROM audit_log" + " WHERE org_id=?1 AND seq>?2 AND action=?3" + " ORDER BY seq LIMIT ?4" + : "SELECT seq,at,actor_user_id,actor_token_id,action," + "request_json,result_code FROM audit_log" + " WHERE org_id=?1 AND seq>?2 ORDER BY seq LIMIT ?4"; + 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); + sqlite3_bind_int64(st, 2, cursor); + if (action) + sqlite3_bind_text(st, 3, action, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 4, limit); + int64_t last_seq = cursor; + int64_t n = 0; + while (sqlite3_step(st) == SQLITE_ROW) { + n++; + last_seq = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "seq", last_seq); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", + sq(sqlite3_column_text(st, 1))); + if (sqlite3_column_type(st, 2) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "actor_user_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "actor_user_id", + sqlite3_column_int64(st, 2)); + if (sqlite3_column_type(st, 3) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "actor_token_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "actor_token_id", + sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "action", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "result_code", + sq(sqlite3_column_text(st, 6))); + const char *req = (const char *)sqlite3_column_text(st, 5); + yyjson_doc *parsed = req ? yyjson_read(req, strlen(req), 0) : NULL; + if (parsed && yyjson_is_obj(yyjson_doc_get_root(parsed))) + yyjson_mut_obj_add_val(r->rdoc, o, "request", + yyjson_val_mut_copy(r->rdoc, + yyjson_doc_get_root(parsed))); + if (parsed) + yyjson_doc_free(parsed); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + if (n == limit) + yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last_seq); + else + yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); + return out; +} + +static int verify_attachments(sqlite3 *db, int64_t *checked, int64_t *bad_id, + char **err) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "SELECT id,sha256,size_bytes,content FROM attachments" + " ORDER BY id", + -1, &st, NULL) != SQLITE_OK) { + if (err && !*err) + *err = xstrdup(sqlite3_errmsg(db)); + return -1; + } + int ret = 0; + for (;;) { + int step = sqlite3_step(st); + if (step == SQLITE_DONE) + break; + if (step != SQLITE_ROW) { + if (err && !*err) + *err = xstrdup(sqlite3_errmsg(db)); + ret = -1; + break; + } + int64_t id = sqlite3_column_int64(st, 0); + const void *sha = sqlite3_column_blob(st, 1); + int sha_n = sqlite3_column_bytes(st, 1); + int64_t size = sqlite3_column_int64(st, 2); + const void *content = sqlite3_column_blob(st, 3); + int content_n = sqlite3_column_bytes(st, 3); + unsigned char digest[32]; + util_sha256(content, (size_t)content_n, digest); + (*checked)++; + if (sha_n != 32 || memcmp(sha, digest, 32) != 0 || + size != content_n) { + if (!*bad_id) + *bad_id = id; + ret = 1; + } + } + sqlite3_finalize(st); + return ret; +} + +static yyjson_mut_val *h_audit_verify(struct req *r) +{ + int full = 0; + arg_bool(r->args, "full", &full); + + int64_t audit_checked = 0, audit_bad = 0; + char *err = NULL; + int audit_rc = audit_verify(r->db, &audit_checked, &audit_bad, &err); + if (audit_rc < 0) { + yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); + free(err); + return res; + } + free(err); + err = NULL; + + struct ledger_verify_result lv; + int ledger_rc = ledger_verify(r->db, &lv, &err); + if (ledger_rc < 0) { + yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); + free(err); + return res; + } + free(err); + err = NULL; + + int64_t att_checked = 0, att_bad = 0; + int att_rc = 0; + if (full) { + att_rc = verify_attachments(r->db, &att_checked, &att_bad, &err); + if (att_rc < 0) { + yyjson_mut_val *res = + fail(r, "INTERNAL", err ? err : "verify failed"); + free(err); + return res; + } + free(err); + } + + int ok = audit_rc == 0 && ledger_rc == 0 && att_rc == 0; + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "ok", ok); + yyjson_mut_obj_add_int(r->rdoc, o, "checked", audit_checked); + yyjson_mut_obj_add_int(r->rdoc, o, "vouchers_checked", + lv.vouchers_checked); + yyjson_mut_obj_add_int(r->rdoc, o, "unbalanced_vouchers", + lv.unbalanced_vouchers); + if (audit_bad) + yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", audit_bad); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_seq"); + if (lv.first_bad_voucher_id) + yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_voucher_id", + lv.first_bad_voucher_id); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_voucher_id"); + if (lv.first_unbalanced_voucher_id) + yyjson_mut_obj_add_int(r->rdoc, o, "first_unbalanced_voucher_id", + lv.first_unbalanced_voucher_id); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_unbalanced_voucher_id"); + if (full) { + yyjson_mut_obj_add_int(r->rdoc, o, "attachments_checked", + att_checked); + if (att_bad) + yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_attachment_id", + att_bad); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_attachment_id"); + } + return o; +} + +static yyjson_mut_val *h_backup_snapshot(struct req *r) +{ + const char *dest = arg_str(r->args, "dest"); + if (mkdir_p(g_cfg.backup_dir, 0700) != 0) + return fail(r, "INTERNAL", "cannot create backup directory"); + char path[4096]; + if (dest && *dest) { + snprintf(path, sizeof path, "%s", dest); + } else { + char stamp[32]; + time_t t = (time_t)util_now(); + struct tm tm; + gmtime_r(&t, &tm); + strftime(stamp, sizeof stamp, "%Y%m%dT%H%M%SZ", &tm); + snprintf(path, sizeof path, "%s/bokfd-%s.db", g_cfg.backup_dir, stamp); + } + if (access(path, F_OK) == 0) + return fail(r, "CONFLICT", "destination already exists"); + char *sql = sqlite3_mprintf("VACUUM INTO %Q", path); + int rc = db_exec(r->db, sql, NULL); + sqlite3_free(sql); + if (rc != 0) + return fail(r, "INTERNAL", "VACUUM INTO failed"); + + struct stat sb; + if (stat(path, &sb) != 0) + return fail(r, "INTERNAL", "backup file not found after snapshot"); + FILE *f = fopen(path, "rb"); + if (!f) + return fail(r, "INTERNAL", "cannot read backup file"); + SHA256_CTX ctx; + sha256_init(&ctx); + unsigned char chunk[65536]; + size_t rn; + while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) + sha256_update(&ctx, (const BYTE *)chunk, rn); + fclose(f); + unsigned char digest[32]; + sha256_final(&ctx, digest); + char hex[65]; + util_hex(digest, 32, hex); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + + audit_append(r->db, r->org_id, r->sess->user_id, 0, "backup.snapshot", + "{}", "OK", NULL); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + yyjson_mut_obj_add_int(r->rdoc, o, "size", (int64_t)sb.st_size); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts); + return o; +} + +static const struct cmd_arg args_audit_list[] = { + { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, + { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, + { "action", ARG_STR, 0, NULL, NULL, "Filter by action" }, +}; + +static const struct cmd_arg args_audit_verify[] = { + { "full", ARG_BOOL, 0, "false", NULL, "Also re-hash attachments" }, +}; + +static const struct cmd_arg args_backup_snapshot[] = { + { "dest", ARG_STR, 0, NULL, NULL, + "Destination path; defaults to backup_dir with a timestamp" }, +}; + +const struct command g_cmd_audit[] = { + { "audit.list", "Read the audit log for the active org", PERM_READ, 1, 0, + 0, h_audit_list, CMD_ARGS(args_audit_list) }, + { "audit.verify", "Verify the voucher and audit hash chains", PERM_READ, 0, + 0, 0, h_audit_verify, CMD_ARGS(args_audit_verify) }, + { "backup.snapshot", "Create a consistent database snapshot", PERM_ADMIN, + 0, 1, 0, h_backup_snapshot, CMD_ARGS(args_backup_snapshot) }, +}; + +const struct cmd_table g_cmd_table_audit = { + g_cmd_audit, sizeof g_cmd_audit / sizeof g_cmd_audit[0] +}; diff --git a/src/cmd_auth.c b/src/cmd_auth.c new file mode 100644 index 0000000..be140f4 --- /dev/null +++ b/src/cmd_auth.c @@ -0,0 +1,435 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +#include "audit.h" +#include "auth.h" +#include "config.h" +#include "db.h" +#include "sessions.h" +#include "util.h" +#include "version.h" + +/* ------------------------------------------------------------------ */ +/* login rate limiting (in-memory, per key) */ +/* ------------------------------------------------------------------ */ + +#define RL_MAX_KEYS 16 +#define RL_MAX_FAILS 5 +#define RL_WINDOW 900 + +struct rl_entry { + char key[64]; + int fails; + int64_t window_end; +}; + +static struct rl_entry g_rl[RL_MAX_KEYS]; + +static struct rl_entry *rl_get(const char *key, int create) +{ + struct rl_entry *slot = NULL; + for (size_t i = 0; i < RL_MAX_KEYS; i++) { + if (g_rl[i].key[0] && strcmp(g_rl[i].key, key) == 0) + return &g_rl[i]; + if (create && !g_rl[i].key[0] && !slot) + slot = &g_rl[i]; + } + if (create && slot) { + snprintf(slot->key, sizeof slot->key, "%s", key); + slot->fails = 0; + slot->window_end = 0; + } + return slot; +} + +static int rl_blocked(const char *key, int64_t *retry_after) +{ + struct rl_entry *e = rl_get(key, 0); + if (!e || e->fails < RL_MAX_FAILS) + return 0; + int64_t now = util_now(); + if (e->window_end <= now) + return 0; + if (retry_after) + *retry_after = e->window_end - now; + return 1; +} + +static void rl_fail(const char *key) +{ + struct rl_entry *e = rl_get(key, 1); + if (!e) + return; + int64_t now = util_now(); + if (e->fails == 0 || e->window_end <= now) + e->window_end = now + RL_WINDOW; + e->fails++; +} + +static void rl_ok(const char *key) +{ + struct rl_entry *e = rl_get(key, 0); + if (e) { + e->fails = 0; + e->window_end = 0; + } +} + +/* ------------------------------------------------------------------ */ +/* public commands */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *h_health(struct req *r) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "ok"); + return o; +} + +static yyjson_mut_val *h_meta(struct req *r) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "server", "bokfd"); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "version", BOKF_VERSION); + yyjson_mut_obj_add_int(r->rdoc, o, "protocol", BOKF_PROTOCOL_VERSION); + yyjson_mut_val *features = yyjson_mut_arr(r->rdoc); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "describe"); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "agent.instructions"); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "orgs"); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "tokens"); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "backup"); + yyjson_mut_obj_add_val(r->rdoc, o, "features", features); + yyjson_mut_val *limits = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, limits, "max_line_bytes", + g_cfg.max_line_bytes); + yyjson_mut_obj_add_int(r->rdoc, limits, "max_attachment_bytes", + g_cfg.max_attachment_bytes); + yyjson_mut_obj_add_int(r->rdoc, limits, "session_ttl_seconds", + g_cfg.session_ttl); + yyjson_mut_obj_add_val(r->rdoc, o, "limits", limits); + yyjson_mut_obj_add_bool(r->rdoc, o, "tcp_enabled", g_cfg.tcp_enabled != 0); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "time", ts); + return o; +} + +static yyjson_mut_val *orgs_for_user(sqlite3 *db, yyjson_mut_doc *doc, + int64_t user_id, int64_t only_org, + int64_t *first_org) +{ + yyjson_mut_val *arr = yyjson_mut_arr(doc); + const char *sql = only_org + ? "SELECT o.id,o.name,m.role FROM memberships m" + " JOIN orgs o ON o.id=m.org_id" + " WHERE m.user_id=?1 AND o.id=?2 ORDER BY o.id" + : "SELECT o.id,o.name,m.role FROM memberships m" + " JOIN orgs o ON o.id=m.org_id" + " WHERE m.user_id=?1 ORDER BY o.id"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) + return arr; + sqlite3_bind_int64(st, 1, user_id); + if (only_org) + sqlite3_bind_int64(st, 2, only_org); + while (sqlite3_step(st) == SQLITE_ROW) { + int64_t id = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr); + yyjson_mut_obj_add_int(doc, o, "id", id); + yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(doc, o, "role", sq(sqlite3_column_text(st, 2))); + if (first_org && *first_org == 0) + *first_org = id; + } + sqlite3_finalize(st); + return arr; +} + +static yyjson_mut_val *session_payload(struct req *r, struct session *s, + int64_t user_id, const char *username, + const char *display, + yyjson_mut_val *orgs, + int64_t active_org) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "session", s->id); + yyjson_mut_val *u = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, u, "id", user_id); + yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : ""); + yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name", + display ? display : ""); + yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", s->is_admin != 0); + yyjson_mut_obj_add_val(r->rdoc, o, "user", u); + yyjson_mut_obj_add_val(r->rdoc, o, "orgs", orgs); + if (active_org) + yyjson_mut_obj_add_int(r->rdoc, o, "active_org", active_org); + else + yyjson_mut_obj_add_null(r->rdoc, o, "active_org"); + return o; +} + +static yyjson_mut_val *h_session_open(struct req *r) +{ + const char *method = arg_str(r->args, "method"); + if (!method) + return fail(r, "INVALID_ARGS", "method is required"); + char *reqjson = audit_args_json(r->args); + + if (strcmp(method, "password") == 0) { + const char *username = arg_str(r->args, "username"); + const char *password = arg_str(r->args, "password"); + if (!username || !password) { + free(reqjson); + return fail(r, "INVALID_ARGS", "username and password are required"); + } + int64_t retry = 0; + if (rl_blocked("local", &retry)) { + free(reqjson); + return failf(r, "RATE_LIMITED", + "too many failed logins, retry in %lld seconds", + (long long)retry); + } + char *pwhash = NULL, *display = NULL; + int is_admin = 0, disabled = 0; + int64_t uid = auth_user_lookup(r->db, username, &pwhash, &display, + &is_admin, &disabled); + int ok = uid > 0 && !disabled && pwhash && + auth_verify_password(pwhash, password) == 0; + free(pwhash); + if (!ok) { + rl_fail("local"); + audit_append(r->db, 0, uid > 0 ? uid : 0, 0, "auth.fail", reqjson, + "AUTH_FAILED", NULL); + free(display); + free(reqjson); + return fail(r, "AUTH_FAILED", "invalid credentials"); + } + rl_ok("local"); + int64_t active = 0; + yyjson_mut_val *orgs = + orgs_for_user(r->db, r->rdoc, uid, 0, &active); + struct session *s = + sessions_create(uid, is_admin, active, 0, "read,write,admin"); + audit_append(r->db, active, uid, 0, "auth.open", reqjson, "OK", NULL); + yyjson_mut_val *out = session_payload(r, s, uid, username, display, + orgs, active); + free(display); + free(reqjson); + return out; + } + + if (strcmp(method, "token") == 0) { + const char *token = arg_str(r->args, "token"); + if (!token) { + free(reqjson); + return fail(r, "INVALID_ARGS", "token is required"); + } + unsigned char th[32]; + auth_hash_token(token, th); + sqlite3_stmt *st = NULL; + int rc = sqlite3_prepare_v2( + r->db, + "SELECT t.id,t.user_id,t.org_id,t.scopes,t.expires_at,t.revoked_at," + " t.label,u.username,u.display_name,u.is_admin,u.disabled_at" + " FROM api_tokens t JOIN users u ON u.id=t.user_id" + " WHERE t.token_hash=?1", + -1, &st, NULL); + if (rc != SQLITE_OK) { + free(reqjson); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_blob(st, 1, th, 32, SQLITE_TRANSIENT); + int found = sqlite3_step(st) == SQLITE_ROW; + int64_t token_id = 0, uid = 0, org_id = 0; + char *scopes = NULL, *expires = NULL, *revoked = NULL; + char *username = NULL, *display = NULL; + int is_admin = 0, disabled = 0; + if (found) { + token_id = sqlite3_column_int64(st, 0); + uid = sqlite3_column_int64(st, 1); + org_id = sqlite3_column_int64(st, 2); + scopes = xstrdup(sq(sqlite3_column_text(st, 3))); + if (sqlite3_column_type(st, 4) != SQLITE_NULL) + expires = xstrdup(sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) != SQLITE_NULL) + revoked = xstrdup(sq(sqlite3_column_text(st, 5))); + username = xstrdup(sq(sqlite3_column_text(st, 7))); + display = xstrdup(sq(sqlite3_column_text(st, 8))); + is_admin = sqlite3_column_int(st, 9); + disabled = sqlite3_column_type(st, 10) != SQLITE_NULL; + } + sqlite3_finalize(st); + + int expired = 0; + if (expires) { + char today[16]; + time_t t = (time_t)util_now(); + struct tm tm; + gmtime_r(&t, &tm); + strftime(today, sizeof today, "%Y-%m-%d", &tm); + expired = strcmp(expires, today) < 0; + } + if (!found || revoked || disabled || expired) { + audit_append(r->db, 0, uid, 0, "auth.fail", reqjson, "AUTH_FAILED", + NULL); + free(scopes); + free(expires); + free(revoked); + free(username); + free(display); + free(reqjson); + return fail(r, "AUTH_FAILED", "invalid token"); + } + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *up = NULL; + if (sqlite3_prepare_v2( + r->db, "UPDATE api_tokens SET last_used_at=?1 WHERE id=?2", -1, + &up, NULL) == SQLITE_OK) { + sqlite3_bind_text(up, 1, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(up, 2, token_id); + sqlite3_step(up); + sqlite3_finalize(up); + } + int64_t active = org_id; + yyjson_mut_val *orgs = + orgs_for_user(r->db, r->rdoc, uid, org_id, &active); + struct session *s = sessions_create(uid, is_admin, org_id, org_id, + scopes ? scopes : "read"); + s->active_org = org_id; + s->token_id = token_id; + audit_append(r->db, org_id, uid, token_id, "auth.open", reqjson, "OK", + NULL); + yyjson_mut_val *out = session_payload(r, s, uid, username, display, + orgs, org_id); + free(scopes); + free(expires); + free(revoked); + free(username); + free(display); + free(reqjson); + return out; + } + + free(reqjson); + return fail(r, "UNSUPPORTED", "unsupported auth method"); +} + +static yyjson_mut_val *h_session_close(struct req *r) +{ + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->sess->active_org, r->sess->user_id, 0, + "session.close", reqjson, "OK", NULL); + free(reqjson); + sessions_destroy(r->sess->id); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_session_whoami(struct req *r) +{ + char *display = NULL, *username = NULL; + int is_admin = 0; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT username,display_name,is_admin FROM users" + " WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->sess->user_id); + if (sqlite3_step(st) == SQLITE_ROW) { + username = xstrdup(sq(sqlite3_column_text(st, 0))); + display = xstrdup(sq(sqlite3_column_text(st, 1))); + is_admin = sqlite3_column_int(st, 2); + } + sqlite3_finalize(st); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_val *u = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, u, "id", r->sess->user_id); + yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : ""); + yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name", display ? display : ""); + yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", is_admin != 0); + yyjson_mut_obj_add_val(r->rdoc, o, "user", u); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", r->sess->scopes); + if (r->sess->active_org) { + yyjson_mut_obj_add_int(r->rdoc, o, "active_org", r->sess->active_org); + char *role = db_membership_role(r->db, r->sess->active_org, + r->sess->user_id); + if (role) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role); + else + yyjson_mut_obj_add_null(r->rdoc, o, "role"); + free(role); + } else { + yyjson_mut_obj_add_null(r->rdoc, o, "active_org"); + yyjson_mut_obj_add_null(r->rdoc, o, "role"); + } + free(username); + free(display); + return o; +} + +static yyjson_mut_val *h_session_list_orgs(struct req *r) +{ + int64_t first = 0; + yyjson_mut_val *orgs = orgs_for_user(r->db, r->rdoc, r->sess->user_id, 0, + &first); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, o, "items", orgs); + return o; +} + +static yyjson_mut_val *h_session_use_org(struct req *r) +{ + int64_t org = 0; + if (!arg_int(r->args, "org", &org) || org <= 0) + return fail(r, "INVALID_ARGS", "org is required"); + if (r->sess->bound_org && org != r->sess->bound_org) + return fail(r, "ORG_FORBIDDEN", "token is bound to another org"); + char *role = db_membership_role(r->db, org, r->sess->user_id); + if (!role) + return fail(r, "ORG_FORBIDDEN", "not a member of this org"); + r->sess->active_org = org; + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "active_org", org); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role); + free(role); + return o; +} + + +static const struct cmd_arg args_session_open[] = { + { "method", ARG_ENUM, 1, NULL, "password,token", + "Open with a password or an API token" }, + { "username", ARG_STR, 0, NULL, NULL, "Username for method=password" }, + { "password", ARG_STR, 0, NULL, NULL, "Password for method=password" }, + { "token", ARG_STR, 0, NULL, NULL, "API token for method=token" }, +}; + +static const struct cmd_arg args_session_use_org[] = { + { "org", ARG_INT, 1, NULL, NULL, "Org id" }, +}; + +const struct command g_cmd_auth[] = { + { "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health, NULL, 0 }, + { "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta, NULL, + 0 }, + { "session.open", "Open a session (password or token)", PERM_PUBLIC, 0, 0, + 0, h_session_open, CMD_ARGS(args_session_open) }, + { "session.close", "Close the current session", PERM_READ, 0, 0, 0, + h_session_close, NULL, 0 }, + { "session.whoami", "Current user, org, role and scopes", PERM_READ, 0, 0, + 0, h_session_whoami, NULL, 0 }, + { "session.list_orgs", "Orgs the current user is a member of", PERM_READ, + 0, 0, 0, h_session_list_orgs, NULL, 0 }, + { "session.use_org", "Switch active org", PERM_READ, 0, 0, 0, + h_session_use_org, CMD_ARGS(args_session_use_org) }, +}; + +const struct cmd_table g_cmd_table_auth = { + g_cmd_auth, sizeof g_cmd_auth / sizeof g_cmd_auth[0] +}; diff --git a/src/cmd_bank.c b/src/cmd_bank.c new file mode 100644 index 0000000..b1ab2e3 --- /dev/null +++ b/src/cmd_bank.c @@ -0,0 +1,887 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "db.h" +#include "ledger.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* bank reconciliation (phase 1: import and match, never book) */ +/* ------------------------------------------------------------------ */ + +struct seb_row { + const char *booked_at; + const char *value_date; + const char *text; + const char *type; + int64_t amount_ore; + int has_balance; + int64_t balance_ore; +}; + +static int csv_split(char *line, char **fields, int max) +{ + int nf = 0, in_quotes = 0; + char *dst = line; + fields[nf++] = dst; + for (char *p = line; *p; p++) { + if (*p == '"') { + if (in_quotes && p[1] == '"') { + *dst++ = '"'; + p++; + } else { + in_quotes = !in_quotes; + } + } else if (*p == ';' && !in_quotes) { + *dst++ = '\0'; + if (nf >= max) + return -1; + fields[nf++] = dst; + } else { + *dst++ = *p; + } + } + *dst = '\0'; + return in_quotes ? -1 : nf; +} + +static int seb_amount(const char *s, int64_t *out) +{ + while (*s == ' ') + s++; + int neg = 0; + if (*s == '-') { + neg = 1; + s++; + } else if (*s == '+') { + s++; + } + int64_t whole = 0, frac = 0; + int digits = 0, fdigits = 0, comma = 0; + for (; *s; s++) { + if (*s == ' ' || *s == '.') + continue; + if (*s == ',') { + if (comma) + return -1; + comma = 1; + continue; + } + if (*s < '0' || *s > '9') + return -1; + if (comma) { + if (fdigits >= 2) + return -1; + frac = frac * 10 + (*s - '0'); + fdigits++; + } else { + if (whole > (INT64_MAX - 9) / 10) + return -1; + whole = whole * 10 + (*s - '0'); + digits++; + } + } + if (digits == 0) + return -1; + while (fdigits < 2) { + frac *= 10; + fdigits++; + } + if (whole > (INT64_MAX - frac) / 100) + return -1; + int64_t v = whole * 100 + frac; + *out = neg ? -v : v; + return 0; +} + +static int seb_parse(char *csv, struct seb_row **out, size_t *out_n, + char *err, size_t errlen) +{ + static const char *header[7] = { + "Bokförd", "Valutadatum", "Text", "Typ", + "Insättningar", "Uttag", "Bokfört saldo", + }; + struct seb_row *rows = NULL; + size_t n = 0, cap = 0; + char *cur = csv; + int lineno = 0; + while (*cur) { + lineno++; + char *nl = strchr(cur, '\n'); + if (nl) + *nl = '\0'; + size_t llen = strlen(cur); + if (llen && cur[llen - 1] == '\r') + cur[--llen] = '\0'; + char *fields[8]; + int nf = csv_split(cur, fields, 8); + if (nf < 0) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + if (lineno == 1) { + int ok = nf == 7; + for (int i = 0; ok && i < 7; i++) + if (strcmp(fields[i], header[i]) != 0) + ok = 0; + if (!ok) { + snprintf(err, errlen, "not a SEB CSV export: header mismatch"); + goto bad; + } + if (!nl) + break; + cur = nl + 1; + continue; + } + if (llen == 0) { + if (!nl) + break; + cur = nl + 1; + continue; + } + if (nf != 7) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + struct seb_row row; + row.booked_at = fields[0]; + row.value_date = fields[1]; + row.text = fields[2]; + row.type = fields[3]; + row.has_balance = fields[6][0] != '\0'; + row.balance_ore = 0; + int has_dep = fields[4][0] != '\0'; + int has_wd = fields[5][0] != '\0'; + if (!util_date_valid(fields[0]) || !util_date_valid(fields[1]) || + has_dep + has_wd != 1) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + if (has_dep) { + if (seb_amount(fields[4], &row.amount_ore) != 0) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + } else { + int64_t w = 0; + if (seb_amount(fields[5], &w) != 0) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + row.amount_ore = w > 0 ? -w : w; + } + if (row.has_balance && seb_amount(fields[6], &row.balance_ore) != 0) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + if (n == cap) { + cap = cap ? cap * 2 : 64; + rows = xrealloc(rows, cap * sizeof *rows); + } + rows[n++] = row; + if (!nl) + break; + cur = nl + 1; + } + *out = rows; + *out_n = n; + return 0; +bad: + free(rows); + return -1; +} + +static void bank_field(struct buf *b, const char *s, int wide) +{ + size_t n = strlen(s); + if (wide) + buf_append_u32be(b, (uint32_t)n); + else + buf_append_u16be(b, (uint16_t)n); + buf_append(b, s, n); +} + +static void bank_row_hash(const char *account, const struct seb_row *row, + unsigned char out[32]) +{ + static const char tag[] = "bokf-v1-bank-tx"; + struct buf b; + buf_init(&b); + buf_append(&b, tag, sizeof tag); + bank_field(&b, account, 0); + bank_field(&b, row->booked_at, 0); + bank_field(&b, row->value_date, 0); + bank_field(&b, row->text, 1); + bank_field(&b, row->type, 1); + buf_append_u64be(&b, (uint64_t)row->amount_ore); + unsigned char flag = row->has_balance ? 1 : 0; + buf_append(&b, &flag, 1); + if (row->has_balance) + buf_append_u64be(&b, (uint64_t)row->balance_ore); + util_sha256(b.p, b.len, out); + buf_free(&b); +} + +static int bank_hash_cmp(const void *a, const void *b) +{ + return memcmp(a, b, 32); +} + +static int bank_account_exists(struct req *r, const char *account) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, "SELECT 1 FROM accounts WHERE org_id=?1 AND number=?2", -1, + &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); + int found = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + return found; +} + +static yyjson_mut_val *h_bank_import(struct req *r) +{ + const char *format = arg_str(r->args, "format"); + const char *b64 = arg_str(r->args, "content_base64"); + const char *path = arg_str(r->args, "path"); + const char *account = arg_str(r->args, "account"); + if (!format || strcmp(format, "seb") != 0) + return fail(r, "INVALID_ARGS", "format must be \"seb\""); + unsigned char *content = NULL; + size_t len = 0; + if (b64) { + if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) + return fail(r, "INVALID_ARGS", + "content_base64 is not valid base64"); + } else if (path) { + content = read_file(path, &len); + if (!content) + return fail(r, "NOT_FOUND", "could not read file"); + } else { + return fail(r, "INVALID_ARGS", "content_base64 or path is required"); + } + if (len > 64u * 1024u * 1024u) { + free(content); + return fail(r, "TOO_LARGE", "bank statement is too large"); + } + if (memchr(content, '\0', len) != NULL) { + free(content); + return fail(r, "INVALID_ARGS", "not a SEB CSV export: header mismatch"); + } + char *csv = xmalloc(len + 1); + memcpy(csv, content, len); + csv[len] = '\0'; + free(content); + char *start = csv; + if (len >= 3 && memcmp(start, "\xEF\xBB\xBF", 3) == 0) + start += 3; + char perr[128]; + struct seb_row *rows = NULL; + size_t nrows = 0; + if (seb_parse(start, &rows, &nrows, perr, sizeof perr) != 0) { + free(csv); + return fail(r, "INVALID_ARGS", perr); + } + + char *setting = account && *account + ? NULL + : db_setting(r->db, r->org_id, "bank_account"); + const char *acct = account && *account + ? account + : (setting ? setting : "1930"); + int found = bank_account_exists(r, acct); + if (found < 0) { + free(setting); + free(rows); + free(csv); + return fail(r, "INTERNAL", "database error"); + } + if (!found) { + yyjson_mut_val *res = + failf(r, "ACCOUNT_NOT_FOUND", "account %s not found", acct); + free(setting); + free(rows); + free(csv); + return res; + } + + unsigned char(*hashes)[32] = xmalloc((nrows ? nrows : 1) * 32); + for (size_t i = 0; i < nrows; i++) + bank_row_hash(acct, &rows[i], hashes[i]); + + int64_t imported = 0; + if (r->dry_run) { + unsigned char(*sorted)[32] = xmalloc((nrows ? nrows : 1) * 32); + memcpy(sorted, hashes, nrows * 32); + qsort(sorted, nrows, 32, bank_hash_cmp); + for (size_t i = 0; i < nrows;) { + size_t j = i + 1; + while (j < nrows && memcmp(sorted[i], sorted[j], 32) == 0) + j++; + sqlite3_stmt *q = NULL; + int exists = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT 1 FROM bank_transactions WHERE org_id=?1" + " AND source_hash=?2", + -1, &q, NULL) != SQLITE_OK) { + free(sorted); + free(hashes); + free(setting); + free(rows); + free(csv); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(q, 1, r->org_id); + sqlite3_bind_blob(q, 2, sorted[i], 32, SQLITE_TRANSIENT); + exists = sqlite3_step(q) == SQLITE_ROW; + sqlite3_finalize(q); + if (!exists) + imported++; + i = j; + } + free(sorted); + } else { + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + int failed = db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0; + for (size_t i = 0; i < nrows && !failed; i++) { + sqlite3_stmt *ins = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO bank_transactions(org_id,account,booked_at," + "value_date,text,type,amount_ore,balance_ore,source," + "source_hash,imported_at,imported_by)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,'seb-csv',?9,?10,?11)" + " ON CONFLICT(org_id,source_hash) DO NOTHING", + -1, &ins, NULL) != SQLITE_OK) { + failed = 1; + break; + } + sqlite3_bind_int64(ins, 1, r->org_id); + sqlite3_bind_text(ins, 2, acct, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 3, rows[i].booked_at, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 4, rows[i].value_date, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 5, rows[i].text, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 6, rows[i].type, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(ins, 7, rows[i].amount_ore); + if (rows[i].has_balance) + sqlite3_bind_int64(ins, 8, rows[i].balance_ore); + else + sqlite3_bind_null(ins, 8); + sqlite3_bind_blob(ins, 9, hashes[i], 32, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 10, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(ins, 11, r->sess->user_id); + int rc = sqlite3_step(ins); + sqlite3_finalize(ins); + if (rc != SQLITE_DONE) { + failed = 1; + break; + } + if (sqlite3_changes(r->db) > 0) + imported++; + } + if (failed) { + db_exec(r->db, "ROLLBACK", NULL); + free(hashes); + free(setting); + free(rows); + free(csv); + return fail(r, "INTERNAL", "database error"); + } + if (db_exec(r->db, "COMMIT", NULL) != 0) { + db_exec(r->db, "ROLLBACK", NULL); + free(hashes); + free(setting); + free(rows); + free(csv); + return fail(r, "INTERNAL", "database error"); + } + } + int64_t duplicates = (int64_t)nrows - imported; + const char *first = NULL, *last = NULL; + for (size_t i = 0; i < nrows; i++) { + if (!first || strcmp(rows[i].booked_at, first) < 0) + first = rows[i].booked_at; + if (!last || strcmp(rows[i].booked_at, last) > 0) + last = rows[i].booked_at; + } + 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, + "bank.import", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "format", "seb"); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", acct); + yyjson_mut_obj_add_int(r->rdoc, o, "total", (int64_t)nrows); + yyjson_mut_obj_add_int(r->rdoc, o, "imported", imported); + yyjson_mut_obj_add_int(r->rdoc, o, "duplicates", duplicates); + if (first) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "first_date", first); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_date"); + if (last) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_date", last); + else + yyjson_mut_obj_add_null(r->rdoc, o, "last_date"); + if (r->dry_run) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + free(hashes); + free(setting); + free(rows); + free(csv); + return o; +} + +static yyjson_mut_val *h_bank_list(struct req *r) +{ + const char *status = arg_str(r->args, "status"); + const char *from = arg_str(r->args, "from"); + const char *to = arg_str(r->args, "to"); + const char *account = arg_str(r->args, "account"); + int64_t limit = 200; + arg_int(r->args, "limit", &limit); + if (limit < 1) + limit = 200; + if (limit > 1000) + limit = 1000; + int status_code = 0; + if (status && strcmp(status, "unmatched") == 0) + status_code = 1; + else if (status && strcmp(status, "matched") == 0) + status_code = 2; + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT t.id,t.account,t.booked_at,t.value_date,t.text,t.type," + "t.amount_ore,t.balance_ore FROM bank_transactions t" + " WHERE t.org_id=?1" + " AND (?2 IS NULL OR t.account=?2)" + " AND (?3 IS NULL OR t.booked_at>=?3)" + " AND (?4 IS NULL OR t.booked_at<=?4)" + " AND (?5=0" + " OR (?5=1 AND NOT EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=t.org_id AND m.transaction_id=t.id))" + " OR (?5=2 AND EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)))" + " ORDER BY t.booked_at,t.id LIMIT ?6", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (account) + sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 2); + if (from) + sqlite3_bind_text(st, 3, from, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 3); + if (to) + sqlite3_bind_text(st, 4, to, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 4); + sqlite3_bind_int64(st, 5, status_code); + sqlite3_bind_int64(st, 6, limit); + + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + while (sqlite3_step(st) == SQLITE_ROW) { + int64_t tx_id = sqlite3_column_int64(st, 0); + char tx_account[16]; + snprintf(tx_account, sizeof tx_account, "%s", + sq(sqlite3_column_text(st, 1))); + const char *booked_at = sq(sqlite3_column_text(st, 2)); + yyjson_mut_val *item = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, item, "id", tx_id); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "account", tx_account); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "booked_at", booked_at); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "value_date", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "text", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "type", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_int(r->rdoc, item, "amount_ore", + sqlite3_column_int64(st, 6)); + if (sqlite3_column_type(st, 7) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, item, "balance_ore"); + else + yyjson_mut_obj_add_int(r->rdoc, item, "balance_ore", + sqlite3_column_int64(st, 7)); + + yyjson_mut_val *matches = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *ms = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT v.id,v.series,v.number,v.date," + "COALESCE(SUM(r.debit_ore-r.credit_ore),0)" + " FROM bank_matches m" + " JOIN vouchers v ON v.org_id=m.org_id AND v.id=m.voucher_id" + " LEFT JOIN accounts a ON a.org_id=v.org_id AND a.number=?2" + " LEFT JOIN voucher_rows r ON r.org_id=v.org_id" + " AND r.voucher_id=v.id AND r.account_id=a.id" + " WHERE m.org_id=?1 AND m.transaction_id=?3" + " GROUP BY v.id,v.series,v.number,v.date ORDER BY v.id", + -1, &ms, NULL) != SQLITE_OK) { + sqlite3_finalize(st); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(ms, 1, r->org_id); + sqlite3_bind_text(ms, 2, tx_account, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(ms, 3, tx_id); + while (sqlite3_step(ms) == SQLITE_ROW) { + yyjson_mut_val *m = yyjson_mut_arr_add_obj(r->rdoc, matches); + yyjson_mut_obj_add_int(r->rdoc, m, "voucher_id", + sqlite3_column_int64(ms, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, m, "series", + sq(sqlite3_column_text(ms, 1))); + yyjson_mut_obj_add_int(r->rdoc, m, "number", + sqlite3_column_int64(ms, 2)); + yyjson_mut_obj_add_strcpy(r->rdoc, m, "date", + sq(sqlite3_column_text(ms, 3))); + yyjson_mut_obj_add_int(r->rdoc, m, "bank_amount_ore", + sqlite3_column_int64(ms, 4)); + } + sqlite3_finalize(ms); + yyjson_mut_obj_add_val(r->rdoc, item, "matches", matches); + + yyjson_mut_val *suggestions = yyjson_mut_arr(r->rdoc); + if (yyjson_mut_arr_size(matches) == 0) { + sqlite3_stmt *ss = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT v.id,v.series,v.number,v.date," + "SUM(r.debit_ore-r.credit_ore)" + " FROM vouchers v" + " JOIN voucher_rows r ON r.org_id=v.org_id" + " AND r.voucher_id=v.id" + " JOIN accounts a ON a.org_id=r.org_id" + " AND a.id=r.account_id" + " WHERE v.org_id=?1 AND a.number=?2" + " AND ABS(julianday(v.date)-julianday(?3))<=5" + " AND NOT EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=v.org_id AND m.voucher_id=v.id)" + " GROUP BY v.id,v.series,v.number,v.date" + " HAVING SUM(r.debit_ore-r.credit_ore)=?4" + " ORDER BY ABS(julianday(v.date)-julianday(?3)),v.date," + "v.id LIMIT 3", + -1, &ss, NULL) != SQLITE_OK) { + sqlite3_finalize(st); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(ss, 1, r->org_id); + sqlite3_bind_text(ss, 2, tx_account, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ss, 3, booked_at, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(ss, 4, sqlite3_column_int64(st, 6)); + while (sqlite3_step(ss) == SQLITE_ROW) { + yyjson_mut_val *s = + yyjson_mut_arr_add_obj(r->rdoc, suggestions); + yyjson_mut_obj_add_int(r->rdoc, s, "voucher_id", + sqlite3_column_int64(ss, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, s, "series", + sq(sqlite3_column_text(ss, 1))); + yyjson_mut_obj_add_int(r->rdoc, s, "number", + sqlite3_column_int64(ss, 2)); + yyjson_mut_obj_add_strcpy(r->rdoc, s, "date", + sq(sqlite3_column_text(ss, 3))); + yyjson_mut_obj_add_int(r->rdoc, s, "amount_ore", + sqlite3_column_int64(ss, 4)); + } + sqlite3_finalize(ss); + } + yyjson_mut_obj_add_val(r->rdoc, item, "suggestions", suggestions); + } + sqlite3_finalize(st); + + sqlite3_stmt *sum = NULL; + int64_t unmatched = 0, matched = 0, unmatched_amount = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT COUNT(*)," + "COALESCE(SUM(EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)),0)," + "COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)" + " THEN 0 ELSE t.amount_ore END),0)" + " FROM bank_transactions t" + " WHERE t.org_id=?1 AND (?2 IS NULL OR t.account=?2)", + -1, &sum, NULL) == SQLITE_OK) { + sqlite3_bind_int64(sum, 1, r->org_id); + if (account) + sqlite3_bind_text(sum, 2, account, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(sum, 2); + if (sqlite3_step(sum) == SQLITE_ROW) { + int64_t total = sqlite3_column_int64(sum, 0); + matched = sqlite3_column_int64(sum, 1); + unmatched = total - matched; + unmatched_amount = sqlite3_column_int64(sum, 2); + } + sqlite3_finalize(sum); + } + yyjson_mut_val *res = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, res, "items", items); + yyjson_mut_val *s = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, s, "unmatched", unmatched); + yyjson_mut_obj_add_int(r->rdoc, s, "matched", matched); + yyjson_mut_obj_add_int(r->rdoc, s, "unmatched_amount_ore", + unmatched_amount); + yyjson_mut_obj_add_val(r->rdoc, res, "summary", s); + return res; +} + +static int bank_legs_sum(sqlite3 *db, int64_t org_id, int64_t tx_id, + const char *account, int64_t *out) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT COALESCE(SUM(r.debit_ore-r.credit_ore),0)" + " FROM bank_matches m" + " JOIN voucher_rows r ON r.org_id=m.org_id" + " AND r.voucher_id=m.voucher_id" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE m.org_id=?1 AND m.transaction_id=?2 AND a.number=?3", + -1, &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_text(st, 3, account, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + if (rc == SQLITE_ROW) + *out = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return rc == SQLITE_ROW ? 0 : -1; +} + +static yyjson_mut_val *h_bank_match(struct req *r) +{ + int64_t tx_id = 0, voucher_id = 0; + if (!arg_int(r->args, "transaction_id", &tx_id) || tx_id <= 0 || + !arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) + return fail(r, "INVALID_ARGS", + "transaction_id and voucher_id are required"); + sqlite3_stmt *st = NULL; + char account[16]; + int64_t amount = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT account,amount_ore FROM bank_transactions" + " 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, tx_id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "bank transaction not found"); + } + snprintf(account, sizeof account, "%s", sq(sqlite3_column_text(st, 0))); + amount = sqlite3_column_int64(st, 1); + sqlite3_finalize(st); + + int found = 0; + if (sqlite3_prepare_v2( + r->db, "SELECT 1 FROM vouchers 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, voucher_id); + found = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + if (!found) + return fail(r, "NOT_FOUND", "voucher not found"); + + int touches = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT 1 FROM voucher_rows r" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE r.org_id=?1 AND r.voucher_id=?2 AND a.number=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_text(st, 3, account, -1, SQLITE_TRANSIENT); + touches = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + if (!touches) + return failf(r, "INVALID_ARGS", + "voucher does not post to account %s", account); + + int linked = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT 1 FROM bank_matches WHERE org_id=?1 AND transaction_id=?2" + " AND voucher_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_int64(st, 3, voucher_id); + linked = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + if (linked) + return fail(r, "CONFLICT", + "transaction is already matched to this voucher"); + + int64_t legs = 0; + if (bank_legs_sum(r->db, r->org_id, tx_id, account, &legs) != 0) + return fail(r, "INTERNAL", "database error"); + if (r->dry_run) { + sqlite3_stmt *vs = NULL; + int64_t voucher_leg = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT COALESCE(SUM(r.debit_ore-r.credit_ore),0)" + " FROM voucher_rows r" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE r.org_id=?1 AND r.voucher_id=?2 AND a.number=?3", + -1, &vs, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(vs, 1, r->org_id); + sqlite3_bind_int64(vs, 2, voucher_id); + sqlite3_bind_text(vs, 3, account, -1, SQLITE_TRANSIENT); + if (sqlite3_step(vs) == SQLITE_ROW) + voucher_leg = sqlite3_column_int64(vs, 0); + sqlite3_finalize(vs); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); + yyjson_mut_obj_add_int(r->rdoc, o, "difference_ore", + amount - (legs + voucher_leg)); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO bank_matches(org_id,transaction_id,voucher_id," + "matched_at,matched_by,kind) VALUES(?1,?2,?3,?4,?5,'manual')", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_int64(st, 3, voucher_id); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 5, r->sess->user_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", "database error"); + + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "bank.match", reqjson, "OK", NULL); + free(reqjson); + + if (bank_legs_sum(r->db, r->org_id, tx_id, account, &legs) != 0) + return fail(r, "INTERNAL", "database error"); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); + yyjson_mut_obj_add_int(r->rdoc, o, "difference_ore", amount - legs); + return o; +} + +static yyjson_mut_val *h_bank_unmatch(struct req *r) +{ + int64_t tx_id = 0, voucher_id = 0; + if (!arg_int(r->args, "transaction_id", &tx_id) || tx_id <= 0 || + !arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) + return fail(r, "INVALID_ARGS", + "transaction_id and voucher_id are required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT 1 FROM bank_matches WHERE org_id=?1 AND transaction_id=?2" + " AND voucher_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_int64(st, 3, voucher_id); + int found = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + if (!found) + return fail(r, "NOT_FOUND", "match not found"); + if (!r->dry_run) { + if (sqlite3_prepare_v2( + r->db, + "DELETE FROM bank_matches WHERE org_id=?1" + " AND transaction_id=?2 AND voucher_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_int64(st, 3, voucher_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", "database error"); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "match not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "bank.unmatch", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); + yyjson_mut_obj_add_bool(r->rdoc, o, "unmatched", true); + if (r->dry_run) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; +} + + +static const struct cmd_arg args_bank_import[] = { + { "format", ARG_ENUM, 1, NULL, "seb", "Statement format" }, + { "content_base64", ARG_STR, 0, NULL, NULL, + "Statement content, base64; alternative to path" }, + { "path", ARG_STR, 0, NULL, NULL, + "Server-side path; alternative to content_base64" }, + { "account", ARG_STR, 0, NULL, NULL, + "Bank account number; defaults to settings.bank_account" }, +}; + +static const struct cmd_arg args_bank_list[] = { + { "status", ARG_ENUM, 0, "all", "all,unmatched,matched", + "Match status filter" }, + { "from", ARG_DATE, 0, NULL, NULL, "Earliest booked date" }, + { "to", ARG_DATE, 0, NULL, NULL, "Latest booked date" }, + { "account", ARG_STR, 0, NULL, NULL, "Bank account number filter" }, + { "limit", ARG_INT, 0, "200", NULL, "Page size, 1-1000" }, +}; + +static const struct cmd_arg args_bank_match[] = { + { "transaction_id", ARG_INT, 1, NULL, NULL, "Bank transaction id" }, + { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, +}; + +const struct command g_cmd_bank[] = { + { "bank.import", "Import a bank statement (SEB CSV)", PERM_WRITE, 1, 1, 1, + h_bank_import, CMD_ARGS(args_bank_import) }, + { "bank.list", "List bank transactions, matches and suggestions", + PERM_READ, 1, 0, 0, h_bank_list, CMD_ARGS(args_bank_list) }, + { "bank.match", "Match a bank transaction to a voucher", PERM_WRITE, 1, 1, + 1, h_bank_match, CMD_ARGS(args_bank_match) }, + { "bank.unmatch", "Remove a transaction/voucher match", PERM_WRITE, 1, 1, + 1, h_bank_unmatch, CMD_ARGS(args_bank_match) }, +}; + +const struct cmd_table g_cmd_table_bank = { + g_cmd_bank, sizeof g_cmd_bank / sizeof g_cmd_bank[0] +}; diff --git a/src/cmd_bokslut.c b/src/cmd_bokslut.c new file mode 100644 index 0000000..429ada4 --- /dev/null +++ b/src/cmd_bokslut.c @@ -0,0 +1,241 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "db.h" +#include "ledger.h" +#include "util.h" + +/* 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 const struct cmd_arg args_bokslut_post[] = { + { "fiscal_year", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, + { "entries", ARG_JSON, 0, NULL, NULL, + "Array of {debit_account,credit_account,amount_ore,description?}" }, + { "periodiseringsfond_ore", ARG_INT, 0, NULL, NULL, + "Periodiseringsfond in öre" }, + { "tax_rate", ARG_JSON, 0, "20.6", NULL, "Tax rate percent, 0-100" }, + { "dispose", ARG_BOOL, 0, "true", NULL, + "Post the result transfer to 8999/2099" }, + { "date", ARG_DATE, 0, NULL, NULL, + "Posting date; defaults to the fiscal year end" }, +}; + +const struct command g_cmd_bokslut[] = { + { "bokslut.post", "Year-end: dispositions, tax and result transfer", + PERM_WRITE, 1, 1, 1, h_bokslut_post, CMD_ARGS(args_bokslut_post) }, +}; + +const struct cmd_table g_cmd_table_bokslut = { + g_cmd_bokslut, sizeof g_cmd_bokslut / sizeof g_cmd_bokslut[0] +}; diff --git a/src/cmd_customers.c b/src/cmd_customers.c new file mode 100644 index 0000000..a46cd32 --- /dev/null +++ b/src/cmd_customers.c @@ -0,0 +1,517 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "db.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* customer register */ +/* ------------------------------------------------------------------ */ + +#define CUSTOMER_COLUMNS \ + "id,name,address,postal_code,city,country,vat_nr,email,your_ref," \ + "payment_days,notes,active,created_at,COALESCE(updated_at,'')" + +static yyjson_mut_val *customer_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", + sq(sqlite3_column_text(st, 6))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", + sq(sqlite3_column_text(st, 7))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", + sq(sqlite3_column_text(st, 8))); + yyjson_mut_obj_add_int(r->rdoc, o, "payment_days", + sqlite3_column_int64(st, 9)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", + sq(sqlite3_column_text(st, 10))); + yyjson_mut_obj_add_bool(r->rdoc, o, "active", + sqlite3_column_int(st, 11) != 0); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 12))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "updated_at", + sq(sqlite3_column_text(st, 13))); + return o; +} + +struct customer_input { + const char *name; + const char *address; + const char *postal_code; + const char *city; + const char *country; + const char *vat_nr; + const char *email; + const char *your_ref; + const char *notes; + int64_t payment_days; + int have_payment; + int active; + int have_active; +}; + +static void customer_input_read(struct req *r, struct customer_input *in) +{ + memset(in, 0, sizeof *in); + in->name = arg_str(r->args, "name"); + in->address = arg_str(r->args, "address"); + in->postal_code = arg_str(r->args, "postal_code"); + in->city = arg_str(r->args, "city"); + in->country = arg_str(r->args, "country"); + in->vat_nr = arg_str(r->args, "vat_nr"); + in->email = arg_str(r->args, "email"); + in->your_ref = arg_str(r->args, "your_ref"); + in->notes = arg_str(r->args, "notes"); + in->have_payment = arg_int(r->args, "payment_days", &in->payment_days); + in->have_active = arg_bool(r->args, "active", &in->active); +} + +static int customer_input_validate(struct req *r, + const struct customer_input *in, + int is_create) +{ + if (is_create && (!in->name || !*in->name)) { + fail(r, "INVALID_ARGS", "name is required"); + return -1; + } + if (in->name && !*in->name) { + fail(r, "INVALID_ARGS", "name cannot be empty"); + return -1; + } + if (in->name && strlen(in->name) > 200) { + fail(r, "INVALID_ARGS", "name is too long"); + return -1; + } + static const char *const names[] = { + "address", "postal_code", "city", "country", "vat_nr", + "email", "your_ref", "notes", + }; + static const size_t maxlen[] = { 500, 32, 120, 64, 64, 254, 120, 2000 }; + const char *values[] = { in->address, in->postal_code, in->city, + in->country, in->vat_nr, in->email, + in->your_ref, in->notes }; + for (size_t i = 0; i < sizeof maxlen / sizeof maxlen[0]; i++) { + if (values[i] && strlen(values[i]) > maxlen[i]) { + failf(r, "INVALID_ARGS", "%s is too long", names[i]); + return -1; + } + } + if (in->have_payment && in->payment_days < 0) { + fail(r, "INVALID_ARGS", "payment_days must be >= 0"); + return -1; + } + return 0; +} + +static yyjson_mut_val *customer_lookup(struct req *r, int64_t id, int *found) +{ + sqlite3_stmt *st = NULL; + *found = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT " CUSTOMER_COLUMNS " FROM customers" + " WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) { + *found = -1; + fail(r, "INTERNAL", "database error"); + return NULL; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return NULL; + } + yyjson_mut_val *o = customer_json(r, st); + sqlite3_finalize(st); + *found = 1; + return o; +} + +static int customer_name_taken(struct req *r, const char *name, int64_t except_id) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id FROM customers WHERE org_id=?1 AND name=?2" + " AND id<>?3", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 3, except_id); + int taken = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + return taken; +} + +static yyjson_mut_val *h_customer_list(struct req *r) +{ + int active_only = 0; + arg_bool(r->args, "active_only", &active_only); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT " CUSTOMER_COLUMNS " FROM customers WHERE org_id=?1" + " AND (?2=0 OR active=1) ORDER BY name COLLATE NOCASE, id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int(st, 2, active_only); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(items, customer_json(r, st)); + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static yyjson_mut_val *h_customer_get(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + int found = 0; + yyjson_mut_val *o = customer_lookup(r, id, &found); + if (found < 0) + return NULL; + if (!found) + return fail(r, "NOT_FOUND", "customer not found"); + return o; +} + +static yyjson_mut_val *h_customer_create(struct req *r) +{ + struct customer_input in; + customer_input_read(r, &in); + if (customer_input_validate(r, &in, 1) != 0) + return NULL; + if (!in.have_payment) + in.payment_days = 30; + const char *address = in.address ? in.address : ""; + const char *postal = in.postal_code ? in.postal_code : ""; + const char *city = in.city ? in.city : ""; + const char *country = in.country ? in.country : "SE"; + const char *vat = in.vat_nr ? in.vat_nr : ""; + const char *email = in.email ? in.email : ""; + const char *your_ref = in.your_ref ? in.your_ref : ""; + const char *notes = in.notes ? in.notes : ""; + int taken = customer_name_taken(r, in.name, 0); + if (taken < 0) + return NULL; + if (taken) + return fail(r, "CONFLICT", "a customer with this name already exists"); + + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", 0); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", in.name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", address); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", postal); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", city); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", country); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", vat); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", email); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", your_ref); + yyjson_mut_obj_add_int(r->rdoc, o, "payment_days", in.payment_days); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes); + yyjson_mut_obj_add_bool(r->rdoc, o, "active", true); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO customers(org_id,name,address,postal_code,city,country," + "vat_nr,email,your_ref,payment_days,notes,created_at)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, in.name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, address, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, postal, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, city, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, country, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, vat, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 8, email, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 9, your_ref, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 10, in.payment_days); + sqlite3_bind_text(st, 11, notes, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 12, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + if ((rc & 0xff) == SQLITE_CONSTRAINT) + return fail(r, "CONFLICT", "a customer with this name already exists"); + 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, + "customer.create", reqjson, "OK", NULL); + free(reqjson); + int found = 0; + yyjson_mut_val *o = customer_lookup(r, id, &found); + if (found < 0) + return NULL; + if (!found) + return fail(r, "INTERNAL", "could not read the new customer"); + return o; +} + +static yyjson_mut_val *h_customer_update(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + struct customer_input in; + customer_input_read(r, &in); + if (customer_input_validate(r, &in, 0) != 0) + return NULL; + if (!in.name && !in.address && !in.postal_code && !in.city && !in.country && + !in.vat_nr && !in.email && !in.your_ref && !in.notes && + !in.have_payment && !in.have_active) + return fail(r, "INVALID_ARGS", "nothing to update"); + + int found = 0; + yyjson_mut_val *existing = customer_lookup(r, id, &found); + (void)existing; + if (found < 0) + return NULL; + if (!found) + return fail(r, "NOT_FOUND", "customer not found"); + if (in.name) { + int taken = customer_name_taken(r, in.name, id); + if (taken < 0) + return NULL; + if (taken) + return fail(r, "CONFLICT", "a customer with this name already exists"); + } + + if (r->dry_run) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id,COALESCE(?3,name),COALESCE(?4,address)," + "COALESCE(?5,postal_code),COALESCE(?6,city)," + "COALESCE(?7,country),COALESCE(?8,vat_nr)," + "COALESCE(?9,email),COALESCE(?10,your_ref)," + "CASE WHEN ?11<0 THEN payment_days ELSE ?11 END," + "COALESCE(?12,notes)," + "CASE WHEN ?13<0 THEN active ELSE ?13 END," + "created_at,COALESCE(updated_at,'')" + " FROM customers 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); + const char *texts[] = { in.name, in.address, in.postal_code, in.city, + in.country, in.vat_nr, in.email, in.your_ref, + in.notes }; + static const int bind_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 }; + for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) { + if (texts[i]) + sqlite3_bind_text(st, bind_index[i], texts[i], -1, + SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, bind_index[i]); + } + sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1); + sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = customer_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "customer not found"); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE customers SET" + " name=COALESCE(?3,name), address=COALESCE(?4,address)," + " postal_code=COALESCE(?5,postal_code), city=COALESCE(?6,city)," + " country=COALESCE(?7,country), vat_nr=COALESCE(?8,vat_nr)," + " email=COALESCE(?9,email), your_ref=COALESCE(?10,your_ref)," + " payment_days=CASE WHEN ?11<0 THEN payment_days ELSE ?11 END," + " notes=COALESCE(?12,notes)," + " active=CASE WHEN ?13<0 THEN active ELSE ?13 END," + " updated_at=?14 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); + const char *texts[] = { in.name, in.address, in.postal_code, in.city, + in.country, in.vat_nr, in.email, in.your_ref, + in.notes }; + static const int text_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 }; + for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) { + if (texts[i]) + sqlite3_bind_text(st, text_index[i], texts[i], -1, + SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, text_index[i]); + } + sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1); + sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1); + sqlite3_bind_text(st, 14, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + if ((rc & 0xff) == SQLITE_CONSTRAINT) + return fail(r, "CONFLICT", "a customer with this name already exists"); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "customer not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "customer.update", reqjson, "OK", NULL); + free(reqjson); + found = 0; + yyjson_mut_val *o = customer_lookup(r, id, &found); + if (found < 0) + return NULL; + if (!found) + return fail(r, "INTERNAL", "could not read the customer"); + return o; +} + +static yyjson_mut_val *h_customer_archive(struct req *r) +{ + int64_t id = 0; + int active = 0; + if (!arg_int(r->args, "id", &id) || id <= 0 || + !arg_bool(r->args, "active", &active)) + return fail(r, "INVALID_ARGS", "id and active are required"); + int found = 0; + yyjson_mut_val *existing = customer_lookup(r, id, &found); + (void)existing; + if (found < 0) + return NULL; + if (!found) + return fail(r, "NOT_FOUND", "customer 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, "active", active != 0); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE customers SET active=?3, updated_at=?4" + " 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); + sqlite3_bind_int(st, 3, active); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + 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", "customer not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "customer.archive", 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, "active", active != 0); + return o; +} + + +static const struct cmd_arg args_customer_list[] = { + { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active customers" }, +}; + +static const struct cmd_arg args_customer_get[] = { + { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, +}; + +static const struct cmd_arg args_customer_create[] = { + { "name", ARG_STR, 1, NULL, NULL, "Customer name, unique per org" }, + { "address", ARG_STR, 0, NULL, NULL, "Street address; may contain newlines" }, + { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, + { "city", ARG_STR, 0, NULL, NULL, "City" }, + { "country", ARG_STR, 0, "SE", NULL, "Country code" }, + { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, + { "email", ARG_STR, 0, NULL, NULL, "E-mail address" }, + { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, + { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, + { "payment_days", ARG_INT, 0, "30", NULL, "Payment terms in days" }, +}; + +static const struct cmd_arg args_customer_update[] = { + { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, + { "name", ARG_STR, 0, NULL, NULL, "Customer name, unique per org" }, + { "address", ARG_STR, 0, NULL, NULL, "Street address" }, + { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, + { "city", ARG_STR, 0, NULL, NULL, "City" }, + { "country", ARG_STR, 0, NULL, NULL, "Country code" }, + { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, + { "email", ARG_STR, 0, NULL, NULL, "E-mail address" }, + { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, + { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, + { "payment_days", ARG_INT, 0, NULL, NULL, "Payment terms in days" }, + { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, +}; + +static const struct cmd_arg args_customer_archive[] = { + { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, + { "active", ARG_BOOL, 1, NULL, NULL, "false archives, true reactivates" }, +}; + +const struct command g_cmd_customers[] = { + { "customer.list", "List customers ordered by name", PERM_READ, 1, 0, 0, + h_customer_list, CMD_ARGS(args_customer_list) }, + { "customer.get", "Get one customer", PERM_READ, 1, 0, 0, h_customer_get, + CMD_ARGS(args_customer_get) }, + { "customer.create", "Create a customer", PERM_WRITE, 1, 1, 1, + h_customer_create, CMD_ARGS(args_customer_create) }, + { "customer.update", "Update a customer (merged)", PERM_WRITE, 1, 1, 1, + h_customer_update, CMD_ARGS(args_customer_update) }, + { "customer.archive", "Archive or reactivate a customer", PERM_WRITE, 1, 1, + 1, h_customer_archive, CMD_ARGS(args_customer_archive) }, +}; + +const struct cmd_table g_cmd_table_customers = { + g_cmd_customers, sizeof g_cmd_customers / sizeof g_cmd_customers[0] +}; diff --git a/src/cmd_fiscal.c b/src/cmd_fiscal.c new file mode 100644 index 0000000..94d2429 --- /dev/null +++ b/src/cmd_fiscal.c @@ -0,0 +1,484 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "db.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* fiscal years and period locks */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *fy_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", + sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "locked_until"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", + sqlite3_column_int64(st, 6)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "events", + sq(sqlite3_column_text(st, 7))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "agm_date", + sq(sqlite3_column_text(st, 8))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "dividend_date", + sq(sqlite3_column_text(st, 9))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "employees", + sq(sqlite3_column_text(st, 10))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", + sq(sqlite3_column_text(st, 11))); + return o; +} + +#define FY_COLUMNS \ + "id,label,start_date,end_date,status,locked_until,dividend_ore," \ + "events,agm_date,dividend_date,employees,notes" + +static yyjson_mut_val *h_fiscal_year_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT " FY_COLUMNS " FROM fiscal_years WHERE org_id=?1" + " ORDER BY start_date", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(items, fy_json(r, st)); + sqlite3_finalize(st); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, o, "items", items); + return o; +} + +static yyjson_mut_val *h_fiscal_year_get(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + sqlite3_stmt *st = NULL; + const char *sql = id + ? "SELECT " FY_COLUMNS " FROM fiscal_years" + " WHERE org_id=?1 AND id=?2" + : "SELECT " FY_COLUMNS " FROM fiscal_years" + " WHERE org_id=?1 ORDER BY start_date DESC LIMIT 1"; + 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 (id) + sqlite3_bind_int64(st, 2, id); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = fy_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "fiscal year not found"); + return o; +} + +static int fy_exists_in_range(sqlite3 *db, int64_t org_id, const char *start, + const char *end) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT count(*) FROM fiscal_years WHERE org_id=?1" + " AND NOT (end_date < ?2 OR start_date > ?3)", + -1, &st, NULL) != SQLITE_OK) + return 1; + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, end, -1, SQLITE_TRANSIENT); + int64_t n = 0; + if (sqlite3_step(st) == SQLITE_ROW) + n = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return n > 0; +} + +static yyjson_mut_val *h_fiscal_year_open(struct req *r) +{ + const char *label = arg_str(r->args, "label"); + const char *start = arg_str(r->args, "start_date"); + const char *end = arg_str(r->args, "end_date"); + if (!label || !*label) + return fail(r, "INVALID_ARGS", "label is required"); + if (!util_parse_iso_date(start) || !util_parse_iso_date(end)) + return fail(r, "INVALID_ARGS", "start_date and end_date must be YYYY-MM-DD"); + if (strcmp(start, end) >= 0) + return fail(r, "INVALID_ARGS", "start_date must be before end_date"); + if (fy_exists_in_range(r->db, r->org_id, start, end)) + return fail(r, "CONFLICT", "fiscal year overlaps an existing one"); + /* "Information om året" carries over from the latest earlier year, so + only what changes needs to be edited. Dates and the dividend are + year-specific and start empty. */ + char prev_events[600] = "", prev_emp[64] = "", prev_notes[600] = ""; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT events,employees,notes FROM fiscal_years" + " WHERE org_id=?1 AND end_date < ?2" + " ORDER BY end_date DESC LIMIT 1", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) { + snprintf(prev_events, sizeof prev_events, "%s", + (const char *)sqlite3_column_text(st, 0)); + snprintf(prev_emp, sizeof prev_emp, "%s", + (const char *)sqlite3_column_text(st, 1)); + snprintf(prev_notes, sizeof prev_notes, "%s", + (const char *)sqlite3_column_text(st, 2)); + } + sqlite3_finalize(st); + } + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO fiscal_years(org_id,label,start_date,end_date," + "events,employees,notes,created_at)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, start, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, end, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, prev_events, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, prev_emp, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, prev_notes, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 8, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "CONFLICT", "could not create fiscal year"); + 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, + "fiscal_year.open", 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_strcpy(r->rdoc, o, "label", label); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", start); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", end); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "open"); + return o; +} + +static yyjson_mut_val *h_fiscal_year_close(struct req *r) +{ + int64_t id = 0; + int confirm = 0; + arg_int(r->args, "id", &id); + arg_bool(r->args, "confirm", &confirm); + if (id <= 0 || !confirm) + return fail(r, "INVALID_ARGS", "id and confirm:true are required"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE fiscal_years SET status='closed', closed_at=?3," + " closed_by=?4 WHERE org_id=?1 AND id=?2 AND status='open'", + -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); + sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 4, r->sess->user_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", "open fiscal year not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "fiscal_year.close", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_fiscal_year_reopen(struct req *r) +{ + int64_t id = 0; + int confirm = 0; + arg_int(r->args, "id", &id); + arg_bool(r->args, "confirm", &confirm); + if (id <= 0 || !confirm) + return fail(r, "INVALID_ARGS", "id and confirm:true are required"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE fiscal_years SET status='open', closed_at=NULL," + " closed_by=NULL WHERE org_id=?1 AND id=?2 AND status='closed'", + -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", "closed fiscal year not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "fiscal_year.reopen", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +/* Fiscal-year metadata that is not a posting: the board's proposed + dividend and the year's material events, kept for the årsredovisning + draft. Both fields are optional; at least one must be given. */ +static yyjson_mut_val *h_fiscal_year_update(struct req *r) +{ + int64_t id = 0, dividend = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + int have_div = arg_int(r->args, "dividend_ore", ÷nd); + const char *events = arg_str(r->args, "events"); + const char *agm = arg_str(r->args, "agm_date"); + const char *pay = arg_str(r->args, "dividend_date"); + const char *employees = arg_str(r->args, "employees"); + const char *notes = arg_str(r->args, "notes"); + if (!have_div && !events && !agm && !pay && !employees && !notes) + return fail(r, "INVALID_ARGS", "nothing to update"); + if (have_div && dividend < 0) + return fail(r, "INVALID_ARGS", "dividend_ore must be >= 0"); + if (agm && *agm && !util_parse_iso_date(agm)) + return fail(r, "INVALID_ARGS", "agm_date must be YYYY-MM-DD"); + if (pay && *pay && !util_parse_iso_date(pay)) + return fail(r, "INVALID_ARGS", "dividend_date must be YYYY-MM-DD"); + static const char *const skeys[4] = { "events", "agm_date", + "dividend_date", "employees" }; + const char *svals[4] = { events, agm, pay, employees }; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT count(*) 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, id); + int64_t exists = 0; + if (sqlite3_step(st) == SQLITE_ROW) + exists = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + if (!exists) + return fail(r, "NOT_FOUND", "fiscal year not found"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + if (have_div) + yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", dividend); + for (int i = 0; i < 4; i++) + if (svals[i]) + yyjson_mut_obj_add_strcpy(r->rdoc, o, skeys[i], svals[i]); + if (notes) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes); + return o; + } + if (sqlite3_prepare_v2(r->db, + "UPDATE fiscal_years SET" + " dividend_ore=CASE WHEN ?3<0 THEN dividend_ore" + " ELSE ?3 END," + " events=COALESCE(?4,events)," + " agm_date=COALESCE(?5,agm_date)," + " dividend_date=COALESCE(?6,dividend_date)," + " employees=COALESCE(?7,employees)," + " notes=COALESCE(?8,notes)" + " 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); + sqlite3_bind_int64(st, 3, have_div ? dividend : -1); + for (int i = 0; i < 4; i++) { + if (svals[i]) + sqlite3_bind_text(st, i + 4, svals[i], -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, i + 4); + } + if (notes) + sqlite3_bind_text(st, 8, notes, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 8); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "fiscal_year.update", reqjson, "OK", NULL); + free(reqjson); + if (sqlite3_prepare_v2(r->db, + "SELECT " FY_COLUMNS " 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, id); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = fy_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "fiscal year not found"); + return o; +} + +static yyjson_mut_val *h_period_lock(struct req *r) +{ + int64_t fy = 0; + arg_int(r->args, "fiscal_year", &fy); + const char *until = arg_str(r->args, "until"); + if (fy <= 0 || !util_parse_iso_date(until)) + return fail(r, "INVALID_ARGS", + "fiscal_year and until (YYYY-MM-DD) are required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE fiscal_years SET locked_until=?3 WHERE org_id=?1 AND id=?2" + " AND ?3 BETWEEN start_date AND end_date", + -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); + sqlite3_bind_text(st, 3, until, -1, SQLITE_TRANSIENT); + 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", + "fiscal year not found or until is outside it"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "period.lock", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year", fy); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", until); + return o; +} + +static yyjson_mut_val *h_period_unlock(struct req *r) +{ + int64_t fy = 0; + arg_int(r->args, "fiscal_year", &fy); + if (fy <= 0) + return fail(r, "INVALID_ARGS", "fiscal_year is required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE fiscal_years SET locked_until=NULL 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); + 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", "fiscal year not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "period.unlock", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + + +static const struct cmd_arg args_fiscal_year_get[] = { + { "id", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, +}; + +static const struct cmd_arg args_fiscal_year_open[] = { + { "label", ARG_STR, 1, NULL, NULL, "Fiscal year label" }, + { "start_date", ARG_DATE, 1, NULL, NULL, "Start date (YYYY-MM-DD)" }, + { "end_date", ARG_DATE, 1, NULL, NULL, "End date (YYYY-MM-DD)" }, +}; + +static const struct cmd_arg args_fiscal_year_close[] = { + { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, + { "confirm", ARG_BOOL, 1, NULL, NULL, "Must be true" }, +}; + +static const struct cmd_arg args_fiscal_year_reopen[] = { + { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, + { "confirm", ARG_BOOL, 1, NULL, NULL, "Must be true" }, +}; + +static const struct cmd_arg args_fiscal_year_update[] = { + { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, + { "dividend_ore", ARG_INT, 0, NULL, NULL, "Proposed dividend in öre" }, + { "events", ARG_STR, 0, NULL, NULL, "Material events during the year" }, + { "agm_date", ARG_STR, 0, NULL, NULL, "AGM date; empty clears" }, + { "dividend_date", ARG_STR, 0, NULL, NULL, + "Dividend payment date; empty clears" }, + { "employees", ARG_STR, 0, NULL, NULL, "Average number of employees" }, + { "notes", ARG_STR, 0, NULL, NULL, "Other notes" }, +}; + +static const struct cmd_arg args_period_lock[] = { + { "fiscal_year", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, + { "until", ARG_DATE, 1, NULL, NULL, "Lock through this date, inclusive" }, + { "reason", ARG_STR, 0, NULL, NULL, "Reason, recorded in the audit log" }, +}; + +static const struct cmd_arg args_period_unlock[] = { + { "fiscal_year", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, + { "reason", ARG_STR, 0, NULL, NULL, "Reason, recorded in the audit log" }, +}; + +const struct command g_cmd_fiscal[] = { + { "fiscal_year.list", "List fiscal years", PERM_READ, 1, 0, 0, + h_fiscal_year_list, NULL, 0 }, + { "fiscal_year.get", "Get a fiscal year", PERM_READ, 1, 0, 0, + h_fiscal_year_get, CMD_ARGS(args_fiscal_year_get) }, + { "fiscal_year.open", "Open a new fiscal year", PERM_OWNER, 1, 1, 1, + h_fiscal_year_open, CMD_ARGS(args_fiscal_year_open) }, + { "fiscal_year.close", "Close a fiscal year", PERM_OWNER, 1, 1, 0, + h_fiscal_year_close, CMD_ARGS(args_fiscal_year_close) }, + { "fiscal_year.reopen", "Reopen a closed fiscal year", PERM_OWNER, 1, 1, 1, + h_fiscal_year_reopen, CMD_ARGS(args_fiscal_year_reopen) }, + { "fiscal_year.update", "Update fiscal-year metadata (dividend)", + PERM_WRITE, 1, 1, 1, h_fiscal_year_update, + CMD_ARGS(args_fiscal_year_update) }, + { "period.lock", "Lock a period through a date", PERM_OWNER, 1, 1, 1, + h_period_lock, CMD_ARGS(args_period_lock) }, + { "period.unlock", "Remove a period lock", PERM_OWNER, 1, 1, 1, + h_period_unlock, CMD_ARGS(args_period_unlock) }, +}; + +const struct cmd_table g_cmd_table_fiscal = { + g_cmd_fiscal, sizeof g_cmd_fiscal / sizeof g_cmd_fiscal[0] +}; diff --git a/src/cmd_invoices.c b/src/cmd_invoices.c new file mode 100644 index 0000000..b8d44e3 --- /dev/null +++ b/src/cmd_invoices.c @@ -0,0 +1,1422 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +#include "audit.h" +#include "config.h" +#include "db.h" +#include "invoice.h" +#include "ledger.h" +#include "pdf.h" +#include "secret.h" +#include "smtp.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* invoice sequence */ +/* ------------------------------------------------------------------ */ + +static int64_t invoice_next_number(struct req *r) +{ + sqlite3_stmt *st = NULL; + int64_t next = 1; + if (sqlite3_prepare_v2( + r->db, "SELECT next_number FROM invoice_sequence WHERE org_id=?1", + -1, &st, NULL) != SQLITE_OK) + return next; + sqlite3_bind_int64(st, 1, r->org_id); + if (sqlite3_step(st) == SQLITE_ROW) + next = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return next > 0 ? next : 1; +} + +static int invoice_take_number(struct req *r, int64_t *out) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, "SELECT next_number FROM invoice_sequence WHERE org_id=?1", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + int have = sqlite3_step(st) == SQLITE_ROW; + int64_t number = have ? sqlite3_column_int64(st, 0) : 1; + sqlite3_finalize(st); + if (have) { + if (sqlite3_prepare_v2( + r->db, + "UPDATE invoice_sequence SET next_number=next_number+1" + " WHERE org_id=?1", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); + return -1; + } + } else { + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO invoice_sequence(org_id,next_number) VALUES(?1,2)", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); + return -1; + } + } + *out = number; + return 0; +} + +static yyjson_mut_val *h_invoice_sequence_get(struct req *r) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "next_number", invoice_next_number(r)); + return o; +} + +static yyjson_mut_val *h_invoice_sequence_set(struct req *r) +{ + int64_t next = 0; + if (!arg_int(r->args, "next_number", &next) || next <= 0) + return fail(r, "INVALID_ARGS", + "next_number must be a positive integer"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "next_number", next); + 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 invoice_sequence(org_id,next_number) VALUES(?1,?2)" + " ON CONFLICT(org_id) DO UPDATE SET next_number=excluded.next_number", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, next); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "invoice.sequence_set", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "next_number", next); + return o; +} + +/* ------------------------------------------------------------------ */ +/* invoice drafts, rendering and issue */ +/* ------------------------------------------------------------------ */ + +struct draft_line { + const char *article_no; + const char *description; + int64_t quantity_milli; + const char *unit; + int64_t unit_price_ore; + int64_t amount_ore; + const char *note; + const char *vat_code; + char account[16]; +}; + +struct invoice_draft { + int64_t customer_id; + const char *invoice_date; + const char *due_date; + const char *delivery_date; + const char *your_ref; + const char *our_ref; + const char *notes; + struct draft_line *lines; + size_t nlines; +}; + +static void invoice_draft_free(struct invoice_draft *d) +{ + free(d->lines); + d->lines = NULL; + d->nlines = 0; +} + +static int parse_quantity(const char *s, int64_t *out) +{ + if (!s || !*s || strlen(s) > 24) + return -1; + int64_t whole = 0, frac = 0; + size_t int_digits = 0, frac_digits = 0; + int seen_sep = 0; + for (const char *p = s; *p; p++) { + char c = *p; + if (c == ',' || c == '.') { + if (seen_sep) + return -1; + seen_sep = 1; + continue; + } + if (c < '0' || c > '9') + return -1; + if (!seen_sep) { + if (int_digits >= 18) + return -1; + whole = whole * 10 + (c - '0'); + int_digits++; + } else { + if (frac_digits >= 3) + return -1; + frac = frac * 10 + (c - '0'); + frac_digits++; + } + } + if (!int_digits || (seen_sep && !frac_digits)) + return -1; + for (size_t i = frac_digits; i < 3; i++) + frac *= 10; + if (whole > (INT64_MAX - frac) / 1000) + return -1; + int64_t v = whole * 1000 + frac; + if (v <= 0) + return -1; + *out = v; + return 0; +} + +static int draft_line_parse(struct req *r, yyjson_val *item, size_t no, + const char *default_account, struct draft_line *l, + int64_t *net_total) +{ + const char *description = arg_str(item, "description"); + if (!description || !*description) { + failf(r, "INVALID_ARGS", "row %zu: description is required", no); + return -1; + } + const char *qty = arg_str(item, "quantity"); + int64_t quantity_milli = 0; + if (parse_quantity(qty, &quantity_milli) != 0) { + failf(r, "INVALID_ARGS", + "row %zu: quantity must be a positive decimal with at most 3" + " decimals", + no); + return -1; + } + int64_t price = 0; + if (!arg_int(item, "unit_price_ore", &price) || price < 0) { + failf(r, "INVALID_ARGS", + "row %zu: unit_price_ore must be a non-negative integer", no); + return -1; + } + const char *vat = arg_str(item, "vat_code"); + if (!vat || !*vat) + vat = "25"; + if (strcmp(vat, "25") != 0 && strcmp(vat, "12") != 0 && + strcmp(vat, "6") != 0 && strcmp(vat, "0") != 0 && + strcmp(vat, "rc") != 0 && strcmp(vat, "eu") != 0) { + failf(r, "INVALID_ARGS", + "row %zu: vat_code must be one of 25, 12, 6, 0, rc, eu", no); + return -1; + } + const char *account = arg_str(item, "account"); + if (account && !*account) + account = NULL; + if (account) { + if (!is_digits(account) || strlen(account) > 10) { + failf(r, "INVALID_ARGS", "row %zu: account must be 1-10 digits", + no); + return -1; + } + snprintf(l->account, sizeof l->account, "%s", account); + } else { + snprintf(l->account, sizeof l->account, "%s", default_account); + } + l->article_no = arg_str(item, "article_no"); + l->description = description; + l->quantity_milli = quantity_milli; + const char *unit = arg_str(item, "unit"); + l->unit = unit && *unit ? unit : "st"; + l->unit_price_ore = price; + l->note = arg_str(item, "note"); + l->vat_code = vat; + int64_t product = 0; + if (__builtin_mul_overflow(quantity_milli, price, &product) || + __builtin_add_overflow(product, (int64_t)500, &product)) { + failf(r, "INVALID_ARGS", "row %zu: amount overflows", no); + return -1; + } + l->amount_ore = product / 1000; + if (__builtin_add_overflow(*net_total, l->amount_ore, net_total) || + *net_total > INT64_MAX / 100) { + failf(r, "INVALID_ARGS", "row %zu: invoice total overflows", no); + return -1; + } + return 0; +} + +static int parse_invoice_draft(struct req *r, struct invoice_draft *d) +{ + memset(d, 0, sizeof *d); + if (!arg_int(r->args, "customer_id", &d->customer_id) || + d->customer_id <= 0) { + fail(r, "INVALID_ARGS", "customer_id is required"); + return -1; + } + d->invoice_date = arg_str(r->args, "invoice_date"); + d->due_date = arg_str(r->args, "due_date"); + d->delivery_date = arg_str(r->args, "delivery_date"); + if (!d->invoice_date || !util_parse_iso_date(d->invoice_date)) { + fail(r, "INVALID_ARGS", "invoice_date must be YYYY-MM-DD"); + return -1; + } + if (!d->due_date || !util_parse_iso_date(d->due_date)) { + fail(r, "INVALID_ARGS", "due_date must be YYYY-MM-DD"); + return -1; + } + if (!d->delivery_date) + d->delivery_date = ""; + if (*d->delivery_date && !util_parse_iso_date(d->delivery_date)) { + fail(r, "INVALID_ARGS", "delivery_date must be YYYY-MM-DD"); + return -1; + } + d->your_ref = arg_str(r->args, "your_ref"); + d->our_ref = arg_str(r->args, "our_ref"); + d->notes = arg_str(r->args, "notes"); + if (!d->your_ref) + d->your_ref = ""; + if (!d->our_ref) + d->our_ref = ""; + if (!d->notes) + d->notes = ""; + + yyjson_val *rows = r->args ? yyjson_obj_get(r->args, "rows") : NULL; + if (!rows || !yyjson_is_arr(rows) || yyjson_arr_size(rows) == 0) { + fail(r, "INVALID_ARGS", "rows must be a non-empty array"); + return -1; + } + char *revenue = db_setting(r->db, r->org_id, "invoice_revenue_account"); + const char *default_account = + revenue && *revenue ? revenue : "3001"; + size_t n = yyjson_arr_size(rows); + struct draft_line *lines = xcalloc(n, sizeof *lines); + size_t k = 0; + int64_t net_total = 0; + yyjson_arr_iter it = yyjson_arr_iter_with(rows); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it))) { + if (!yyjson_is_obj(item)) { + failf(r, "INVALID_ARGS", "row %zu: must be an object", k + 1); + free(lines); + free(revenue); + return -1; + } + if (draft_line_parse(r, item, k + 1, default_account, &lines[k], + &net_total) != 0) { + free(lines); + free(revenue); + return -1; + } + k++; + } + free(revenue); + if (net_total <= 0) { + fail(r, "INVALID_ARGS", "invoice total must be greater than zero"); + free(lines); + return -1; + } + d->lines = lines; + d->nlines = k; + return 0; +} + +struct invoice_view { + struct invoice_doc doc; + struct invoice_line *lines; + char number_str[32]; + char ocr[40]; + char filename[600]; + char description[600]; + char seller_name[256]; + char seller_address[1024]; + char seller_postal[64]; + char seller_city[128]; + char seller_phone[64]; + char seller_email[256]; + char seller_org_nr[64]; + char seller_vat_nr[64]; + char bankgiro[64]; + char customer_name[256]; + char customer_address[1024]; + char customer_postal[64]; + char customer_city[128]; + char customer_vat_nr[64]; +}; + +static void invoice_view_free(struct invoice_view *v) +{ + free(v->lines); + v->lines = NULL; +} + +static int invoice_view_fill(struct req *r, const struct invoice_draft *d, + int64_t number, struct invoice_view *v) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT COALESCE(name,''),COALESCE(address,'')," + "COALESCE(postal_code,''),COALESCE(city,'')," + "COALESCE(phone,''),COALESCE(email,''),COALESCE(org_nr,'')," + "COALESCE(vat_nr,'') FROM orgs WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + int have_org = sqlite3_step(st) == SQLITE_ROW; + if (have_org) { + snprintf(v->seller_name, sizeof v->seller_name, "%s", + sq(sqlite3_column_text(st, 0))); + snprintf(v->seller_address, sizeof v->seller_address, "%s", + sq(sqlite3_column_text(st, 1))); + snprintf(v->seller_postal, sizeof v->seller_postal, "%s", + sq(sqlite3_column_text(st, 2))); + snprintf(v->seller_city, sizeof v->seller_city, "%s", + sq(sqlite3_column_text(st, 3))); + snprintf(v->seller_phone, sizeof v->seller_phone, "%s", + sq(sqlite3_column_text(st, 4))); + snprintf(v->seller_email, sizeof v->seller_email, "%s", + sq(sqlite3_column_text(st, 5))); + snprintf(v->seller_org_nr, sizeof v->seller_org_nr, "%s", + sq(sqlite3_column_text(st, 6))); + snprintf(v->seller_vat_nr, sizeof v->seller_vat_nr, "%s", + sq(sqlite3_column_text(st, 7))); + } + sqlite3_finalize(st); + if (!have_org) { + fail(r, "NOT_FOUND", "org not found"); + return -1; + } + + int64_t payment_days = 30; + if (sqlite3_prepare_v2( + r->db, + "SELECT name,address,postal_code,city,vat_nr,payment_days,active" + " FROM customers WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, d->customer_id); + if (sqlite3_step(st) != SQLITE_ROW || !sqlite3_column_int(st, 6)) { + sqlite3_finalize(st); + fail(r, "NOT_FOUND", "customer not found"); + return -1; + } + snprintf(v->customer_name, sizeof v->customer_name, "%s", + sq(sqlite3_column_text(st, 0))); + snprintf(v->customer_address, sizeof v->customer_address, "%s", + sq(sqlite3_column_text(st, 1))); + snprintf(v->customer_postal, sizeof v->customer_postal, "%s", + sq(sqlite3_column_text(st, 2))); + snprintf(v->customer_city, sizeof v->customer_city, "%s", + sq(sqlite3_column_text(st, 3))); + snprintf(v->customer_vat_nr, sizeof v->customer_vat_nr, "%s", + sq(sqlite3_column_text(st, 4))); + payment_days = sqlite3_column_int64(st, 5); + sqlite3_finalize(st); + + char *bankgiro = db_setting(r->db, r->org_id, "invoice_bankgiro"); + snprintf(v->bankgiro, sizeof v->bankgiro, "%s", + bankgiro && *bankgiro ? bankgiro : ""); + free(bankgiro); + + v->lines = xcalloc(d->nlines, sizeof *v->lines); + for (size_t i = 0; i < d->nlines; i++) { + v->lines[i].article_no = d->lines[i].article_no; + v->lines[i].description = d->lines[i].description; + v->lines[i].quantity_milli = d->lines[i].quantity_milli; + v->lines[i].unit = d->lines[i].unit; + v->lines[i].unit_price_ore = d->lines[i].unit_price_ore; + v->lines[i].amount_ore = d->lines[i].amount_ore; + v->lines[i].note = d->lines[i].note; + v->lines[i].vat_code = d->lines[i].vat_code; + } + + v->doc.seller.name = v->seller_name; + v->doc.seller.address = v->seller_address; + v->doc.seller.postal_code = v->seller_postal; + v->doc.seller.city = v->seller_city; + v->doc.seller.phone = v->seller_phone; + v->doc.seller.email = v->seller_email; + v->doc.seller.org_nr = v->seller_org_nr; + v->doc.seller.vat_nr = v->seller_vat_nr; + v->doc.seller.bankgiro = v->bankgiro; + v->doc.customer.name = v->customer_name; + v->doc.customer.address = v->customer_address; + v->doc.customer.postal_code = v->customer_postal; + v->doc.customer.city = v->customer_city; + v->doc.customer.vat_nr = v->customer_vat_nr; + v->doc.number = number; + v->doc.ocr = v->ocr; + v->doc.invoice_date = d->invoice_date; + v->doc.due_date = d->due_date; + v->doc.delivery_date = d->delivery_date; + v->doc.our_ref = d->our_ref; + v->doc.your_ref = d->your_ref; + v->doc.notes = d->notes; + v->doc.payment_days = (int)payment_days; + v->doc.lines = v->lines; + v->doc.nlines = d->nlines; + + snprintf(v->number_str, sizeof v->number_str, "%lld", (long long)number); + int check = invoice_ocr_check(v->number_str); + snprintf(v->ocr, sizeof v->ocr, "%s%c", v->number_str, + (char)('0' + (check > 0 ? check : 0))); + char safe_name[256]; + snprintf(safe_name, sizeof safe_name, "%s", v->customer_name); + for (char *p = safe_name; *p; p++) + if (*p == '/') + *p = '-'; + snprintf(v->filename, sizeof v->filename, "Faktura %lld %s.pdf", + (long long)number, safe_name); + snprintf(v->description, sizeof v->description, "Faktura %lld %s", + (long long)number, safe_name); + return 0; +} + +/* Mirrors invoice.c vat_part(): round half up per rate base. */ +static int64_t invoice_vat_part(int64_t net, int64_t rate) +{ + int64_t v = net * rate; + if (v >= 0) + return (v + 50) / 100; + return -((-v + 50) / 100); +} + +static int invoice_build_pdf(struct req *r, struct invoice_view *v, + struct invoice_totals *t, unsigned char **out, + size_t *out_len) +{ + invoice_totals(&v->doc, t); + *out = NULL; + *out_len = 0; + if (invoice_render_pdf(&v->doc, out, out_len) != 0 || !*out) { + free(*out); + *out = NULL; + fail(r, "TOO_LARGE", "invoice does not fit on one page"); + return -1; + } + return 0; +} + +static int invoice_prepare(struct req *r, struct invoice_draft *d, + int64_t number, struct invoice_view *v, + struct invoice_totals *t, unsigned char **pdf, + size_t *pdf_len) +{ + memset(v, 0, sizeof *v); + if (invoice_view_fill(r, d, number, v) != 0) + return -1; + if (invoice_build_pdf(r, v, t, pdf, pdf_len) != 0) { + invoice_view_free(v); + return -1; + } + return 0; +} + +static yyjson_mut_val *h_invoice_preview(struct req *r) +{ + struct invoice_draft d; + if (parse_invoice_draft(r, &d) != 0) + return NULL; + int64_t number = invoice_next_number(r); + struct invoice_view v; + struct invoice_totals t; + unsigned char *pdf = NULL; + size_t pdf_len = 0; + int rc = invoice_prepare(r, &d, number, &v, &t, &pdf, &pdf_len); + invoice_draft_free(&d); + if (rc != 0) + return NULL; + char *b64 = util_b64(pdf, pdf_len); + free(pdf); + if (!b64) { + invoice_view_free(&v); + return fail(r, "INTERNAL", "could not encode the PDF"); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); + yyjson_mut_obj_add_int(r->rdoc, o, "number", number); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", v.ocr); + yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", t.net_ore); + yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", t.vat_ore); + yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", t.total_ore); + free(b64); + invoice_view_free(&v); + return o; +} + +static int invoice_store_attachment(struct req *r, const struct invoice_view *v, + const unsigned char *pdf, size_t pdf_len, + int64_t *out_id) +{ + unsigned char hash[32]; + util_sha256(pdf, pdf_len, hash); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes," + "content,created_at,created_by)" + " VALUES(?1,?2,?3,'application/pdf',?4,?5,?6,?7)", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, v->filename, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 4, (int64_t)pdf_len); + sqlite3_bind_blob(st, 5, pdf, (int)pdf_len, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, r->sess->user_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); + return -1; + } + *out_id = db_last_id(r->db); + return 0; +} + +static int invoice_store_invoice(struct req *r, const struct invoice_draft *d, + const struct invoice_totals *t, int64_t number, + int64_t document_id, int64_t voucher_id, + int64_t *out_id) +{ + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO invoices(org_id,customer_id,number,ocr,invoice_date," + "due_date,delivery_date,your_ref,our_ref,notes,net_ore,vat_ore," + "total_ore,document_id,voucher_id,created_at,created_by)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16," + "?17)", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, d->customer_id); + sqlite3_bind_int64(st, 3, number); + char number_str[32]; + snprintf(number_str, sizeof number_str, "%lld", (long long)number); + int check = invoice_ocr_check(number_str); + char ocr[40]; + snprintf(ocr, sizeof ocr, "%s%c", number_str, + (char)('0' + (check > 0 ? check : 0))); + sqlite3_bind_text(st, 4, ocr, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, d->invoice_date, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, d->due_date, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, d->delivery_date, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 8, d->your_ref, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 9, d->our_ref, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 10, d->notes, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 11, t->net_ore); + sqlite3_bind_int64(st, 12, t->vat_ore); + sqlite3_bind_int64(st, 13, t->total_ore); + sqlite3_bind_int64(st, 14, document_id); + sqlite3_bind_int64(st, 15, voucher_id); + sqlite3_bind_text(st, 16, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 17, r->sess->user_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + if ((rc & 0xff) == SQLITE_CONSTRAINT) { + fail(r, "CONFLICT", "invoice number already exists"); + return -1; + } + fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); + return -1; + } + int64_t id = db_last_id(r->db); + for (size_t i = 0; i < d->nlines; i++) { + const struct draft_line *l = &d->lines[i]; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO invoice_rows(org_id,invoice_id,line_no,article_no," + "description,quantity_milli,unit,unit_price_ore,amount_ore,note," + "vat_code,account)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + sqlite3_bind_int64(st, 3, (int64_t)i + 1); + sqlite3_bind_text(st, 4, l->article_no ? l->article_no : "", -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, l->description, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 6, l->quantity_milli); + sqlite3_bind_text(st, 7, l->unit, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 8, l->unit_price_ore); + sqlite3_bind_int64(st, 9, l->amount_ore); + sqlite3_bind_text(st, 10, l->note ? l->note : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 11, l->vat_code, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 12, l->account, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); + return -1; + } + } + *out_id = id; + return 0; +} + +static yyjson_mut_val *invoice_issue_result(struct req *r, int dry_run, + int64_t id, int64_t number, + const char *ocr, int64_t document_id, + int64_t voucher_id, + const struct invoice_totals *t) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + if (dry_run) { + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + yyjson_mut_obj_add_int(r->rdoc, o, "id", 0); + } else { + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + } + yyjson_mut_obj_add_int(r->rdoc, o, "number", number); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", ocr); + if (dry_run) { + yyjson_mut_obj_add_null(r->rdoc, o, "document_id"); + yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); + } else { + yyjson_mut_obj_add_int(r->rdoc, o, "document_id", document_id); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); + } + yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", t->net_ore); + yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", t->vat_ore); + yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", t->total_ore); + return o; +} + +static yyjson_mut_val *h_invoice_issue(struct req *r) +{ + struct invoice_draft d; + if (parse_invoice_draft(r, &d) != 0) + return NULL; + char *rec_setting = + db_setting(r->db, r->org_id, "invoice_receivable_account"); + char receivable[16]; + snprintf(receivable, sizeof receivable, "%s", + rec_setting && *rec_setting ? rec_setting : "1510"); + free(rec_setting); + + yyjson_mut_val *res = NULL; + struct invoice_view v; + struct invoice_totals t; + memset(&v, 0, sizeof v); + memset(&t, 0, sizeof t); + unsigned char *pdf = NULL; + size_t pdf_len = 0; + char *voucher_json = NULL; + struct ledger_row *vrows = NULL; + int64_t number = 0, attachment_id = 0, voucher_id = 0, invoice_id = 0; + int in_tx = 0; + + if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { + invoice_draft_free(&d); + return fail(r, "DB_BUSY", "could not start transaction"); + } + in_tx = 1; + + if (r->dry_run) + number = invoice_next_number(r); + else if (invoice_take_number(r, &number) != 0) + goto done; + + if (invoice_prepare(r, &d, number, &v, &t, &pdf, &pdf_len) != 0) + goto done; + + size_t cap = 1 + 3 + d.nlines; + vrows = xcalloc(cap, sizeof *vrows); + size_t vn = 0; + vrows[vn].account = receivable; + vrows[vn].debit_ore = t.total_ore; + vrows[vn].description = NULL; + vn++; + struct { + int rate; + const char *account; + int64_t net; + } legs[3] = { + { 25, "2610", t.net_25 }, + { 12, "2620", t.net_12 }, + { 6, "2630", t.net_6 }, + }; + int64_t vat_sum = 0; + for (size_t i = 0; i < 3; i++) { + int64_t vat = invoice_vat_part(legs[i].net, legs[i].rate); + if (vat <= 0) + continue; + vrows[vn].account = legs[i].account; + vrows[vn].credit_ore = vat; + if (i == 0) + vrows[vn].description = "Moms 25%"; + else if (i == 1) + vrows[vn].description = "Moms 12%"; + else + vrows[vn].description = "Moms 6%"; + vat_sum += vat; + vn++; + } + for (size_t i = 0; i < d.nlines; i++) { + if (d.lines[i].amount_ore <= 0) + continue; + vrows[vn].account = d.lines[i].account; + vrows[vn].credit_ore = d.lines[i].amount_ore; + vrows[vn].description = d.lines[i].description; + vn++; + } + int64_t sum_debit = 0, sum_credit = 0; + for (size_t i = 0; i < vn; i++) { + sum_debit += vrows[i].debit_ore; + sum_credit += vrows[i].credit_ore; + } + if (vat_sum != t.vat_ore || sum_debit != t.total_ore || + sum_debit != sum_credit) { + fail(r, "INTERNAL", "invoice totals do not match the voucher"); + goto done; + } + + if (!r->dry_run && + invoice_store_attachment(r, &v, pdf, pdf_len, &attachment_id) != 0) + goto done; + + 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 = d.invoice_date; + o.description = v.description; + o.rows = vrows; + o.nrows = vn; + o.source = "invoice"; + o.dry_run = r->dry_run; + o.already_in_tx = 1; + struct ledger_error e; + if (ledger_post(r->db, &o, &e, &voucher_json) != 0) { + fail(r, e.code ? e.code : "INTERNAL", e.msg); + goto done; + } + + if (!r->dry_run) { + if (!voucher_json) { + fail(r, "INTERNAL", "empty voucher result"); + goto done; + } + yyjson_doc *vd = yyjson_read(voucher_json, strlen(voucher_json), 0); + if (vd) { + yyjson_val *idv = yyjson_obj_get(yyjson_doc_get_root(vd), "id"); + if (idv && yyjson_is_int(idv)) + voucher_id = yyjson_get_int(idv); + yyjson_doc_free(vd); + } + if (voucher_id <= 0) { + fail(r, "INTERNAL", "voucher id missing from the posting"); + goto done; + } + if (invoice_store_invoice(r, &d, &t, number, attachment_id, voucher_id, + &invoice_id) != 0) + goto done; + if (sqlite3_exec(r->db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { + fail(r, "DB_BUSY", "commit failed"); + goto done; + } + in_tx = 0; + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "invoice.issue", reqjson, "OK", NULL); + free(reqjson); + } + + res = invoice_issue_result(r, r->dry_run, invoice_id, number, v.ocr, + attachment_id, voucher_id, &t); + +done: + if (in_tx) + sqlite3_exec(r->db, "ROLLBACK", NULL, NULL, NULL); + free(pdf); + free(voucher_json); + free(vrows); + invoice_draft_free(&d); + invoice_view_free(&v); + return res; +} + +static yyjson_mut_val *invoice_row_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "line_no", + sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "article_no", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_int(r->rdoc, o, "quantity_milli", + sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "unit", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_int(r->rdoc, o, "unit_price_ore", + sqlite3_column_int64(st, 5)); + yyjson_mut_obj_add_int(r->rdoc, o, "amount_ore", + sqlite3_column_int64(st, 6)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "note", + sq(sqlite3_column_text(st, 7))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_code", + sq(sqlite3_column_text(st, 8))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", + sq(sqlite3_column_text(st, 9))); + return o; +} + +#define INVOICE_ROW_COLUMNS \ + "line_no,article_no,description,quantity_milli,unit,unit_price_ore," \ + "amount_ore,note,vat_code,account" + +static yyjson_mut_val *h_invoice_get(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT i.id,i.customer_id,c.name,i.number,i.ocr,i.invoice_date," + "i.due_date,i.delivery_date,i.your_ref,i.our_ref,i.notes,i.net_ore," + "i.vat_ore,i.total_ore,i.status,i.document_id,i.voucher_id," + "i.last_sent_at,i.last_sent_to,i.created_at,i.created_by" + " FROM invoices i JOIN customers c" + " ON c.org_id=i.org_id AND c.id=i.customer_id" + " WHERE i.org_id=?1 AND i.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); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "invoice not found"); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_int(r->rdoc, o, "customer_id", + sqlite3_column_int64(st, 1)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "customer_name", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_date", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "due_date", + sq(sqlite3_column_text(st, 6))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "delivery_date", + sq(sqlite3_column_text(st, 7))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", + sq(sqlite3_column_text(st, 8))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "our_ref", + sq(sqlite3_column_text(st, 9))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", + sq(sqlite3_column_text(st, 10))); + yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", + sqlite3_column_int64(st, 11)); + yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", + sqlite3_column_int64(st, 12)); + yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", + sqlite3_column_int64(st, 13)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", + sq(sqlite3_column_text(st, 14))); + if (sqlite3_column_type(st, 15) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "document_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "document_id", + sqlite3_column_int64(st, 15)); + if (sqlite3_column_type(st, 16) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", + sqlite3_column_int64(st, 16)); + if (sqlite3_column_type(st, 17) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_at", + sq(sqlite3_column_text(st, 17))); + if (sqlite3_column_type(st, 18) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_to"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to", + sq(sqlite3_column_text(st, 18))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 19))); + yyjson_mut_obj_add_int(r->rdoc, o, "created_by", + sqlite3_column_int64(st, 20)); + sqlite3_finalize(st); + + yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); + if (sqlite3_prepare_v2( + r->db, + "SELECT " INVOICE_ROW_COLUMNS " FROM invoice_rows" + " WHERE org_id=?1 AND invoice_id=?2 ORDER BY line_no", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(rows, invoice_row_json(r, st)); + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); + return o; +} + +static yyjson_mut_val *h_invoice_list(struct req *r) +{ + int64_t customer_id = 0, limit = 200; + arg_int(r->args, "customer_id", &customer_id); + arg_int(r->args, "limit", &limit); + const char *status = arg_str(r->args, "status"); + if (!status) + status = ""; + if (limit < 1) + limit = 200; + if (limit > 1000) + limit = 1000; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT i.id,i.number,i.ocr,i.customer_id,c.name,i.invoice_date," + "i.due_date,i.total_ore,i.status,i.document_id,i.voucher_id," + "i.last_sent_at,i.last_sent_to" + " FROM invoices i JOIN customers c" + " ON c.org_id=i.org_id AND c.id=i.customer_id" + " WHERE i.org_id=?1" + " AND (?2=0 OR i.customer_id=?2)" + " AND (?3='' OR i.status=?3)" + " ORDER BY i.number DESC, i.id DESC LIMIT ?4", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, customer_id); + sqlite3_bind_text(st, 3, status, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 4, limit); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_int(r->rdoc, o, "number", + sqlite3_column_int64(st, 1)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_int(r->rdoc, o, "customer_id", + sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "customer_name", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_date", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "due_date", + sq(sqlite3_column_text(st, 6))); + yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", + sqlite3_column_int64(st, 7)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", + sq(sqlite3_column_text(st, 8))); + if (sqlite3_column_type(st, 9) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "document_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "document_id", + sqlite3_column_int64(st, 9)); + if (sqlite3_column_type(st, 10) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", + sqlite3_column_int64(st, 10)); + if (sqlite3_column_type(st, 11) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_at", + sq(sqlite3_column_text(st, 11))); + if (sqlite3_column_type(st, 12) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_to"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to", + sq(sqlite3_column_text(st, 12))); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static yyjson_mut_val *h_invoice_pdf(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT document_id FROM invoices 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); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "invoice not found"); + } + int64_t document_id = sqlite3_column_type(st, 0) == SQLITE_NULL + ? 0 + : sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + if (document_id <= 0) + return fail(r, "NOT_FOUND", "invoice has no stored PDF"); + const void *content = NULL; + size_t len = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT content FROM attachments 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, document_id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "invoice PDF not found"); + } + content = sqlite3_column_blob(st, 0); + len = (size_t)sqlite3_column_bytes(st, 0); + char *b64 = util_b64(content ? content : (const unsigned char *)"", len); + sqlite3_finalize(st); + if (!b64) + return fail(r, "INTERNAL", "could not encode the PDF"); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); + free(b64); + return o; +} + +/* Whole kronor with a space as thousands separator, e.g. 91500 -> "91 500". */ +static void invoice_send_amount(int64_t ore, char *out, size_t n) +{ + char digits[24]; + snprintf(digits, sizeof digits, "%lld", (long long)((ore + 50) / 100)); + size_t len = strlen(digits); + size_t o = 0; + for (size_t i = 0; i < len && o + 1 < n; i++) { + if (i > 0 && (len - i) % 3 == 0) + out[o++] = ' '; + out[o++] = digits[i]; + } + out[o] = '\0'; +} + +static yyjson_mut_val *h_invoice_send(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT i.number,i.ocr,i.due_date,i.document_id,i.total_ore," + "c.name,COALESCE(c.email,''),COALESCE(o.name,'')" + " FROM invoices i" + " JOIN customers c ON c.org_id=i.org_id AND c.id=i.customer_id" + " JOIN orgs o ON o.id=i.org_id" + " WHERE i.org_id=?1 AND i.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); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "invoice not found"); + } + int64_t number = sqlite3_column_int64(st, 0); + int64_t document_id = sqlite3_column_type(st, 3) == SQLITE_NULL + ? 0 + : sqlite3_column_int64(st, 3); + int64_t total_ore = sqlite3_column_int64(st, 4); + char ocr[40], due_date[16], customer_name[256], customer_email[256]; + char org_name[256]; + snprintf(ocr, sizeof ocr, "%s", sq(sqlite3_column_text(st, 1))); + snprintf(due_date, sizeof due_date, "%s", sq(sqlite3_column_text(st, 2))); + snprintf(customer_name, sizeof customer_name, "%s", + sq(sqlite3_column_text(st, 5))); + snprintf(customer_email, sizeof customer_email, "%s", + sq(sqlite3_column_text(st, 6))); + snprintf(org_name, sizeof org_name, "%s", sq(sqlite3_column_text(st, 7))); + sqlite3_finalize(st); + st = NULL; + + if (document_id <= 0) + return fail(r, "NOT_FOUND", "invoice has no stored PDF"); + if (sqlite3_prepare_v2( + r->db, "SELECT content FROM attachments 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, document_id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "invoice PDF not found"); + } + const void *blob = sqlite3_column_blob(st, 0); + size_t pdf_len = (size_t)sqlite3_column_bytes(st, 0); + unsigned char *pdf = xmalloc(pdf_len ? pdf_len : 1); + if (blob && pdf_len) + memcpy(pdf, blob, pdf_len); + sqlite3_finalize(st); + st = NULL; + + const char *to = arg_str(r->args, "to"); + if (!to || !*to) + to = customer_email; + if (!*to) { + free(pdf); + return fail(r, "INVALID_ARGS", "customer has no e-mail address"); + } + + char *smtp_host = db_setting(r->db, r->org_id, "smtp_host"); + char *smtp_port = db_setting(r->db, r->org_id, "smtp_port"); + char *smtp_user = db_setting(r->db, r->org_id, "smtp_user"); + char *smtp_from = db_setting(r->db, r->org_id, "smtp_from"); + char *smtp_security = db_setting(r->db, r->org_id, "smtp_security"); + char *smtp_password = db_setting(r->db, r->org_id, "smtp_password"); + char *password = NULL; + char *body = NULL; + yyjson_mut_val *res = NULL; + + if (!smtp_host || !*smtp_host || !smtp_from || !*smtp_from) { + fail(r, "SMTP_NOT_CONFIGURED", "smtp_host and smtp_from must be set"); + goto done; + } + const char *user = smtp_user && *smtp_user ? smtp_user : ""; + if (*user) { + if (!smtp_password || !*smtp_password) { + fail(r, "SMTP_NOT_CONFIGURED", + "smtp_user is set but smtp_password is missing"); + goto done; + } + if (!secret_available()) { + fail(r, "SMTP_NOT_CONFIGURED", + "smtp_password is set but BOKFD_SECRET_KEY is missing or" + " invalid"); + goto done; + } + if (secret_decrypt(smtp_password, &password) != 0 || !password) { + fail(r, "SMTP_NOT_CONFIGURED", + "cannot decrypt smtp_password, check BOKFD_SECRET_KEY"); + goto done; + } + } + int port = 587; + if (smtp_port && *smtp_port) { + long v = strtol(smtp_port, NULL, 10); + if (v >= 1 && v <= 65535) + port = (int)v; + } + const char *security = + smtp_security && *smtp_security ? smtp_security : "starttls"; + + char subject[64]; + snprintf(subject, sizeof subject, "Faktura %lld", (long long)number); + char amount[32]; + invoice_send_amount(total_ore, amount, sizeof amount); + size_t body_len = strlen(org_name) + 320; + body = xmalloc(body_len); + snprintf(body, body_len, + "Hej,\n\nBifogat finner du faktura %lld på %s kr med" + " förfallodatum %s.\nAnge OCR %s vid betalning.\n\n" + "Med vänlig hälsning\n%s\n", + (long long)number, amount, due_date, ocr, org_name); + + char safe_name[256]; + snprintf(safe_name, sizeof safe_name, "%s", customer_name); + for (char *p = safe_name; *p; p++) + if (*p == '/') + *p = '-'; + char attach_name[320]; + snprintf(attach_name, sizeof attach_name, "Faktura %lld %s.pdf", + (long long)number, safe_name); + + 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_strcpy(r->rdoc, o, "to", to); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "subject", subject); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + res = o; + goto done; + } + + struct smtp_message m; + memset(&m, 0, sizeof m); + m.host = smtp_host; + m.port = port; + m.security = security; + m.user = user; + m.password = password ? password : ""; + m.from = smtp_from; + m.from_name = org_name; + m.to = to; + m.subject = subject; + m.body = body; + m.attach_name = attach_name; + m.attach = pdf; + m.attach_len = pdf_len; + + char errbuf[512]; + if (smtp_send(&m, errbuf, sizeof errbuf) != 0) { + fail(r, "SMTP_FAILED", errbuf[0] ? errbuf : "smtp send failed"); + goto done; + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (sqlite3_prepare_v2( + r->db, + "UPDATE invoices SET last_sent_at=?1,last_sent_to=?2" + " WHERE org_id=?3 AND id=?4", + -1, &st, NULL) != SQLITE_OK) { + fail(r, "INTERNAL", "database error"); + goto done; + } + sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, to, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 3, r->org_id); + sqlite3_bind_int64(st, 4, id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + st = NULL; + if (rc != SQLITE_DONE) { + fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); + goto done; + } + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *a = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, a); + yyjson_mut_obj_add_int(doc, a, "id", id); + yyjson_mut_obj_add_strcpy(doc, a, "to", to); + yyjson_mut_obj_add_strcpy(doc, a, "subject", subject); + char *reqjson = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "invoice.send", reqjson ? 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_strcpy(r->rdoc, o, "sent_to", to); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts); + res = o; + +done: + free(smtp_host); + free(smtp_port); + free(smtp_user); + free(smtp_from); + free(smtp_security); + free(smtp_password); + free(password); + free(body); + free(pdf); + return res; +} + + +static const struct cmd_arg args_invoice_sequence_set[] = { + { "next_number", ARG_INT, 1, NULL, NULL, "Next invoice number" }, +}; + +static const struct cmd_arg args_invoice_draft[] = { + { "customer_id", ARG_INT, 1, NULL, NULL, "Customer id" }, + { "invoice_date", ARG_DATE, 1, NULL, NULL, "Invoice date (YYYY-MM-DD)" }, + { "due_date", ARG_DATE, 1, NULL, NULL, "Due date (YYYY-MM-DD)" }, + { "delivery_date", ARG_STR, 0, NULL, NULL, "Delivery date or empty" }, + { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, + { "our_ref", ARG_STR, 0, NULL, NULL, "Our reference" }, + { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, + { "rows", ARG_JSON, 1, NULL, NULL, + "Array of {article_no,description,quantity,unit,unit_price_ore,note," + "vat_code,account}" }, +}; + +static const struct cmd_arg args_invoice_get[] = { + { "id", ARG_INT, 1, NULL, NULL, "Invoice id" }, +}; + +static const struct cmd_arg args_invoice_list[] = { + { "customer_id", ARG_INT, 0, NULL, NULL, "Customer filter" }, + { "status", ARG_ENUM, 0, NULL, "issued,credited", "Status filter" }, + { "limit", ARG_INT, 0, "200", NULL, "Page size, 1-1000" }, +}; + +static const struct cmd_arg args_invoice_send[] = { + { "id", ARG_INT, 1, NULL, NULL, "Invoice id" }, + { "to", ARG_STR, 0, NULL, NULL, + "Recipient e-mail; defaults to the customer's address" }, +}; + +const struct command g_cmd_invoices[] = { + { "invoice.sequence_get", "Read the next invoice number", PERM_READ, 1, 0, + 0, h_invoice_sequence_get, NULL, 0 }, + { "invoice.sequence_set", "Set the next invoice number (owner)", + PERM_OWNER, 1, 1, 1, h_invoice_sequence_set, + CMD_ARGS(args_invoice_sequence_set) }, + { "invoice.preview", "Render an invoice draft without storing it", + PERM_READ, 1, 0, 0, h_invoice_preview, CMD_ARGS(args_invoice_draft) }, + { "invoice.issue", "Issue an invoice: number, PDF and voucher", + PERM_WRITE, 1, 1, 1, h_invoice_issue, CMD_ARGS(args_invoice_draft) }, + { "invoice.get", "Get an invoice with rows", PERM_READ, 1, 0, 0, + h_invoice_get, CMD_ARGS(args_invoice_get) }, + { "invoice.list", "List invoices, newest first", PERM_READ, 1, 0, 0, + h_invoice_list, CMD_ARGS(args_invoice_list) }, + { "invoice.pdf", "Fetch the stored invoice PDF", PERM_READ, 1, 0, 0, + h_invoice_pdf, CMD_ARGS(args_invoice_get) }, + { "invoice.send", "E-mail the stored invoice PDF to the customer", + PERM_WRITE, 1, 1, 1, h_invoice_send, CMD_ARGS(args_invoice_send) }, +}; + +const struct cmd_table g_cmd_table_invoices = { + g_cmd_invoices, sizeof g_cmd_invoices / sizeof g_cmd_invoices[0] +}; diff --git a/src/cmd_org.c b/src/cmd_org.c new file mode 100644 index 0000000..4776491 --- /dev/null +++ b/src/cmd_org.c @@ -0,0 +1,753 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "config.h" +#include "db.h" +#include "seed.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* orgs */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *org_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", sq(sqlite3_column_text(st, 6))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", + sq(sqlite3_column_text(st, 7))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", sq(sqlite3_column_text(st, 8))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "phone", sq(sqlite3_column_text(st, 9))); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month", + sqlite3_column_int(st, 10)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period", + sq(sqlite3_column_text(st, 11))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework", + sq(sqlite3_column_text(st, 12))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 13))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 14))); + yyjson_mut_obj_add_int(r->rdoc, o, "shares", + sqlite3_column_int64(st, 15)); + return o; +} + +#define ORG_COLUMNS \ + "id,name,org_nr,vat_nr,address,postal_code,city,country,email," \ + "phone,fiscal_year_start_month,moms_period,framework,created_at," \ + "description,shares" + +static yyjson_mut_val *h_org_create(struct req *r) +{ + if (!g_cfg.allow_org_create && !r->is_admin) + return fail(r, "FORBIDDEN", "org creation is disabled"); + const char *name = arg_str(r->args, "name"); + if (!name || !*name) + return fail(r, "INVALID_ARGS", "name is required"); + const char *org_nr = arg_str(r->args, "org_nr"); + int64_t fy_month = 1; + arg_int(r->args, "fiscal_year_start_month", &fy_month); + if (fy_month < 1 || fy_month > 12) + return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12"); + const char *moms = arg_str(r->args, "moms_period"); + if (!moms) + moms = "month"; + if (strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 && + strcmp(moms, "year") != 0) + return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year"); + const char *framework = arg_str(r->args, "framework"); + if (!framework) + framework = "K2"; + if (strcmp(framework, "K2") != 0 && strcmp(framework, "K3") != 0) + return fail(r, "INVALID_ARGS", "framework must be K2 or K3"); + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) + return fail(r, "DB_BUSY", "could not start transaction"); + + sqlite3_stmt *st = NULL; + int rc = sqlite3_prepare_v2( + r->db, + "INSERT INTO orgs(name,org_nr,fiscal_year_start_month,moms_period," + "framework,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7)", + -1, &st, NULL); + if (rc != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT); + if (org_nr) + sqlite3_bind_text(st, 2, org_nr, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 2); + sqlite3_bind_int64(st, 3, fy_month); + sqlite3_bind_text(st, 4, moms, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, framework, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, r->sess->user_id); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t org_id = db_last_id(r->db); + + rc = sqlite3_prepare_v2( + r->db, + "INSERT INTO memberships(org_id,user_id,role,created_at)" + " VALUES(?1,?2,'owner',?3)", + -1, &st, NULL); + if (rc != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_int64(st, 2, r->sess->user_id); + sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t fy_id = 0; + char *seed_err = NULL; + if (seed_org(r->db, org_id, framework, (int)fy_month, &fy_id, + &seed_err) != 0) { + db_exec(r->db, "ROLLBACK", NULL); + yyjson_mut_val *res = + fail(r, "INTERNAL", seed_err ? seed_err : "seeding failed"); + free(seed_err); + return res; + } + if (db_exec(r->db, "COMMIT", NULL) != 0) + return fail(r, "INTERNAL", "commit failed"); + + char *reqjson = audit_args_json(r->args); + audit_append(r->db, org_id, r->sess->user_id, r->sess->token_id, + "org.create", reqjson, "OK", NULL); + free(reqjson); + r->sess->active_org = org_id; + + st = NULL; + if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, org_id); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = org_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "INTERNAL", "created org not found"); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy_id); + return o; +} + +static yyjson_mut_val *h_org_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT o.id,o.name,o.org_nr,m.role FROM memberships m" + " JOIN orgs o ON o.id=m.org_id WHERE m.user_id=?1 ORDER BY o.id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->sess->user_id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", + sq(sqlite3_column_text(st, 3))); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static yyjson_mut_val *h_org_get(struct req *r) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = org_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "org not found"); + return o; +} + +static yyjson_mut_val *h_org_update(struct req *r) +{ + const char *name = arg_str(r->args, "name"); + const char *org_nr = arg_str(r->args, "org_nr"); + const char *vat_nr = arg_str(r->args, "vat_nr"); + const char *address = arg_str(r->args, "address"); + const char *postal = arg_str(r->args, "postal_code"); + const char *city = arg_str(r->args, "city"); + const char *country = arg_str(r->args, "country"); + const char *email = arg_str(r->args, "email"); + const char *phone = arg_str(r->args, "phone"); + const char *moms = arg_str(r->args, "moms_period"); + const char *framework = arg_str(r->args, "framework"); + const char *description = arg_str(r->args, "description"); + int64_t fy_month = 0; + arg_int(r->args, "fiscal_year_start_month", &fy_month); + int64_t shares = -1; + arg_int(r->args, "shares", &shares); + if (!name && !org_nr && !vat_nr && !address && !postal && !city && + !country && !email && !phone && !moms && !framework && fy_month == 0 && + !description && shares < 0) + return fail(r, "INVALID_ARGS", "nothing to update"); + if (name && !*name) + return fail(r, "INVALID_ARGS", "name cannot be empty"); + if (fy_month && (fy_month < 1 || fy_month > 12)) + return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12"); + if (shares < -1) + return fail(r, "INVALID_ARGS", "shares must be >= 0"); + if (moms && strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 && + strcmp(moms, "year") != 0) + return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year"); + if (framework && strcmp(framework, "K2") != 0 && + strcmp(framework, "K3") != 0) + return fail(r, "INVALID_ARGS", "framework must be K2 or K3"); + + static const char *const keys[10] = { "name", "org_nr", "vat_nr", + "address", "postal_code", "city", + "country", "email", "phone", + "description" }; + const char *texts[10] = { name, org_nr, vat_nr, address, postal, + city, country, email, phone, description }; + + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + for (int i = 0; i < 10; i++) + if (texts[i]) + yyjson_mut_obj_add_strcpy(r->rdoc, o, keys[i], texts[i]); + if (fy_month) + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month", + fy_month); + if (moms) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period", moms); + if (framework) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework", framework); + if (shares >= 0) + yyjson_mut_obj_add_int(r->rdoc, o, "shares", shares); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE orgs SET" + " name=COALESCE(?2,name), org_nr=COALESCE(?3,org_nr)," + " vat_nr=COALESCE(?4,vat_nr), address=COALESCE(?5,address)," + " postal_code=COALESCE(?6,postal_code), city=COALESCE(?7,city)," + " country=COALESCE(?8,country), email=COALESCE(?9,email)," + " phone=COALESCE(?10,phone), description=COALESCE(?11," + " description)," + " fiscal_year_start_month=CASE WHEN ?12=0" + " THEN fiscal_year_start_month ELSE ?12 END," + " moms_period=COALESCE(?13,moms_period)," + " framework=COALESCE(?14,framework)," + " shares=CASE WHEN ?15<0 THEN shares ELSE ?15 END" + " WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + for (int i = 0; i < 10; i++) { + if (texts[i]) + sqlite3_bind_text(st, i + 2, texts[i], -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, i + 2); + } + sqlite3_bind_int64(st, 12, fy_month); + if (moms) + sqlite3_bind_text(st, 13, moms, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 13); + if (framework) + sqlite3_bind_text(st, 14, framework, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 14); + sqlite3_bind_int64(st, 15, shares); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "org.update", reqjson, "OK", NULL); + free(reqjson); + + st = NULL; + if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS + " FROM orgs WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = org_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "org not found"); + return o; +} + +static yyjson_mut_val *h_org_member_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT u.id,u.username,u.display_name,m.role FROM memberships m" + " JOIN users u ON u.id=m.user_id WHERE m.org_id=?1 ORDER BY u.id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "user_id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", + sq(sqlite3_column_text(st, 3))); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static int64_t username_to_id(sqlite3 *db, const char *username) +{ + sqlite3_stmt *st = NULL; + int64_t id = -1; + if (sqlite3_prepare_v2(db, "SELECT id FROM users WHERE username=?1", -1, + &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_text(st, 1, username, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) + id = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return id; +} + +static int64_t owner_count(sqlite3 *db, int64_t org_id) +{ + sqlite3_stmt *st = NULL; + int64_t n = -1; + if (sqlite3_prepare_v2( + db, "SELECT count(*) FROM memberships WHERE org_id=?1 AND role='owner'", + -1, &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_int64(st, 1, org_id); + if (sqlite3_step(st) == SQLITE_ROW) + n = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return n; +} + +static int valid_role(const char *role) +{ + return role && (strcmp(role, "owner") == 0 || + strcmp(role, "bookkeeper") == 0 || + strcmp(role, "viewer") == 0); +} + +static yyjson_mut_val *h_org_member_add(struct req *r) +{ + const char *username = arg_str(r->args, "username"); + const char *role = arg_str(r->args, "role"); + if (!username || !valid_role(role)) + return fail(r, "INVALID_ARGS", "username and a valid role are required"); + int64_t uid = username_to_id(r->db, username); + if (uid < 0) + return fail(r, "NOT_FOUND", "user not found"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO memberships(org_id,user_id,role,created_at)" + " VALUES(?1,?2,?3,?4)", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, uid); + sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "CONFLICT", "user is already a member"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.add", reqjson, + "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_org_member_set_role(struct req *r) +{ + const char *username = arg_str(r->args, "username"); + const char *role = arg_str(r->args, "role"); + if (!username || !valid_role(role)) + return fail(r, "INVALID_ARGS", "username and a valid role are required"); + int64_t uid = username_to_id(r->db, username); + if (uid < 0) + return fail(r, "NOT_FOUND", "user not found"); + char *current = db_membership_role(r->db, r->org_id, uid); + if (!current) + return fail(r, "NOT_FOUND", "user is not a member"); + if (strcmp(current, "owner") == 0 && strcmp(role, "owner") != 0 && + owner_count(r->db, r->org_id) <= 1) { + free(current); + return fail(r, "CONFLICT", "cannot demote the last owner"); + } + free(current); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE memberships SET role=?1 WHERE org_id=?2 AND user_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_text(st, 1, role, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 2, r->org_id); + sqlite3_bind_int64(st, 3, uid); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.set_role", + reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_org_member_remove(struct req *r) +{ + const char *username = arg_str(r->args, "username"); + if (!username) + return fail(r, "INVALID_ARGS", "username is required"); + int64_t uid = username_to_id(r->db, username); + if (uid < 0) + return fail(r, "NOT_FOUND", "user not found"); + char *current = db_membership_role(r->db, r->org_id, uid); + if (!current) + return fail(r, "NOT_FOUND", "user is not a member"); + if (strcmp(current, "owner") == 0 && owner_count(r->db, r->org_id) <= 1) { + free(current); + return fail(r, "CONFLICT", "cannot remove the last owner"); + } + free(current); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, "DELETE FROM memberships WHERE org_id=?1 AND user_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, uid); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.remove", + reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +/* ------------------------------------------------------------------ */ +/* board members (årsredovisning signatures) */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *board_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", + sq(sqlite3_column_text(st, 2))); + return o; +} + +static yyjson_mut_val *h_board_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT id,name,title FROM board_members" + " WHERE org_id=?1 ORDER BY id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(items, board_json(r, st)); + sqlite3_finalize(st); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, o, "items", items); + return o; +} + +static yyjson_mut_val *board_get(struct req *r, int64_t id) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT id,name,title FROM board_members" + " 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); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = board_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "board member not found"); + return o; +} + +static yyjson_mut_val *h_board_add(struct req *r) +{ + const char *name = arg_str(r->args, "name"); + const char *title = arg_str(r->args, "title"); + if (!name || !*name) + return fail(r, "INVALID_ARGS", "name is required"); + if (!title || !*title) + title = "Styrelseledamot"; + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title); + return o; + } + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "INSERT INTO board_members(org_id,name,title," + "created_at) VALUES(?1,?2,?3,?4)", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + 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, + "board.add", reqjson, "OK", NULL); + free(reqjson); + return board_get(r, id); +} + +static yyjson_mut_val *h_board_update(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + const char *name = arg_str(r->args, "name"); + const char *title = arg_str(r->args, "title"); + if (!name && !title) + return fail(r, "INVALID_ARGS", "nothing to update"); + if (name && !*name) + return fail(r, "INVALID_ARGS", "name cannot be empty"); + if (title && !*title) + return fail(r, "INVALID_ARGS", "title cannot be empty"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + if (name) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + if (title) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "UPDATE board_members SET name=COALESCE(?3,name)," + " title=COALESCE(?4,title)" + " 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); + if (name) + sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 3); + if (title) + sqlite3_bind_text(st, 4, title, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 4); + 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", "board member not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "board.update", reqjson, "OK", NULL); + free(reqjson); + return board_get(r, id); +} + +static yyjson_mut_val *h_board_remove(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "DELETE FROM board_members" + " 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", "board member not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "board.remove", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + + +static const struct cmd_arg args_org_create[] = { + { "name", ARG_STR, 1, NULL, NULL, "Org name" }, + { "org_nr", ARG_STR, 0, NULL, NULL, "Swedish org number" }, + { "fiscal_year_start_month", ARG_INT, 0, "1", NULL, + "Fiscal year start month 1-12" }, + { "moms_period", ARG_ENUM, 0, "month", "month,quarter,year", + "VAT reporting period" }, + { "framework", ARG_ENUM, 0, "K2", "K2,K3", "Accounting framework" }, +}; + +static const struct cmd_arg args_org_update[] = { + { "name", ARG_STR, 0, NULL, NULL, "Org name" }, + { "org_nr", ARG_STR, 0, NULL, NULL, "Swedish org number" }, + { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, + { "address", ARG_STR, 0, NULL, NULL, "Street address" }, + { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, + { "city", ARG_STR, 0, NULL, NULL, "City" }, + { "country", ARG_STR, 0, NULL, NULL, "Country" }, + { "email", ARG_STR, 0, NULL, NULL, "E-mail" }, + { "phone", ARG_STR, 0, NULL, NULL, "Phone" }, + { "moms_period", ARG_ENUM, 0, NULL, "month,quarter,year", + "VAT reporting period" }, + { "framework", ARG_ENUM, 0, NULL, "K2,K3", "Accounting framework" }, + { "description", ARG_STR, 0, NULL, NULL, "Free-text description" }, + { "fiscal_year_start_month", ARG_INT, 0, NULL, NULL, + "Fiscal year start month 1-12" }, + { "shares", ARG_INT, 0, NULL, NULL, "Number of shares" }, +}; + +static const struct cmd_arg args_member_role[] = { + { "username", ARG_STR, 1, NULL, NULL, "Username" }, + { "role", ARG_ENUM, 1, NULL, "owner,bookkeeper,viewer", + "Membership role" }, +}; + +static const struct cmd_arg args_member_username[] = { + { "username", ARG_STR, 1, NULL, NULL, "Username" }, +}; + +static const struct cmd_arg args_board_add[] = { + { "name", ARG_STR, 1, NULL, NULL, "Board member name" }, + { "title", ARG_STR, 0, "Styrelseledamot", NULL, "Role on the board" }, +}; + +static const struct cmd_arg args_board_update[] = { + { "id", ARG_INT, 1, NULL, NULL, "Board member id" }, + { "name", ARG_STR, 0, NULL, NULL, "Board member name" }, + { "title", ARG_STR, 0, NULL, NULL, "Role on the board" }, +}; + +static const struct cmd_arg args_board_remove[] = { + { "id", ARG_INT, 1, NULL, NULL, "Board member id" }, +}; + +const struct command g_cmd_org[] = { + { "org.create", "Create an org and become its owner", PERM_READ, 0, 1, 1, + h_org_create, CMD_ARGS(args_org_create) }, + { "org.list", "List orgs for the current user", PERM_READ, 0, 0, 0, + h_org_list, NULL, 0 }, + { "org.get", "Get org details", PERM_READ, 1, 0, 0, h_org_get, NULL, 0 }, + { "org.update", "Update org details (owner)", PERM_OWNER, 1, 1, 1, + h_org_update, CMD_ARGS(args_org_update) }, + { "org.member_list", "List org members", PERM_READ, 1, 0, 0, + h_org_member_list, NULL, 0 }, + { "org.member_add", "Add a member to the org", PERM_OWNER, 1, 1, 1, + h_org_member_add, CMD_ARGS(args_member_role) }, + { "org.member_set_role", "Change a member's role", PERM_OWNER, 1, 1, 1, + h_org_member_set_role, CMD_ARGS(args_member_role) }, + { "org.member_remove", "Remove a member from the org", PERM_OWNER, 1, 1, 1, + h_org_member_remove, CMD_ARGS(args_member_username) }, + { "board.list", "List board members", PERM_READ, 1, 0, 0, h_board_list, + NULL, 0 }, + { "board.add", "Add a board member", PERM_OWNER, 1, 1, 1, h_board_add, + CMD_ARGS(args_board_add) }, + { "board.update", "Update a board member", PERM_OWNER, 1, 1, 1, + h_board_update, CMD_ARGS(args_board_update) }, + { "board.remove", "Remove a board member", PERM_OWNER, 1, 1, 1, + h_board_remove, CMD_ARGS(args_board_remove) }, +}; + +const struct cmd_table g_cmd_table_org = { + g_cmd_org, sizeof g_cmd_org / sizeof g_cmd_org[0] +}; diff --git a/src/cmd_reports.c b/src/cmd_reports.c new file mode 100644 index 0000000..a7be995 --- /dev/null +++ b/src/cmd_reports.c @@ -0,0 +1,209 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "db.h" +#include "reports.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* reports and SIE */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *h_report_trial_balance(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + int include_zero = 0; + arg_bool(r->args, "include_zero", &include_zero); + char *err = NULL; + yyjson_mut_val *res = report_trial_balance( + r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), + arg_str(r->args, "to"), include_zero, &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static yyjson_mut_val *h_report_income_statement(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + char *err = NULL; + yyjson_mut_val *res = report_income_statement( + r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), + arg_str(r->args, "to"), &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static yyjson_mut_val *h_report_balance_sheet(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + char *err = NULL; + yyjson_mut_val *res = report_balance_sheet( + r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "to"), &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static yyjson_mut_val *h_report_vat(struct req *r) +{ + const char *from = arg_str(r->args, "from"); + const char *to = arg_str(r->args, "to"); + if (!from || !to) + return fail(r, "INVALID_ARGS", "from and to are required"); + char *err = NULL; + yyjson_mut_val *res = + report_vat(r->rdoc, r->db, r->org_id, from, to, &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static yyjson_mut_val *h_report_general_ledger(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + yyjson_val *accounts = yyjson_obj_get(r->args, "accounts"); + if (accounts && !yyjson_is_arr(accounts)) + return fail(r, "INVALID_ARGS", "accounts must be an array of strings"); + char *err = NULL; + yyjson_mut_val *res = report_general_ledger( + r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), + arg_str(r->args, "to"), accounts, &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static yyjson_mut_val *h_report_voucher_list(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + char *err = NULL; + yyjson_mut_val *res = report_voucher_list( + r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "series"), &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static yyjson_mut_val *h_report_vat_eskd(struct req *r) +{ + const char *from = arg_str(r->args, "from"); + const char *to = arg_str(r->args, "to"); + if (!from || !to) + return fail(r, "INVALID_ARGS", "from and to are required"); + char *err = NULL; + yyjson_mut_val *res = + report_vat_eskd(r->rdoc, r->db, r->org_id, from, to, + arg_str(r->args, "upplysning"), &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INVALID_ARGS", + err ? err : "could not build the eSKD file"); + free(err); + return e; + } + return res; +} + + +static const struct cmd_arg args_report_trial_balance[] = { + { "fiscal_year", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, + { "from", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, + { "to", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, + { "include_zero", ARG_BOOL, 0, "false", NULL, + "Include accounts with no activity" }, +}; + +static const struct cmd_arg args_report_income_statement[] = { + { "fiscal_year", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, + { "from", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, + { "to", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, +}; + +static const struct cmd_arg args_report_balance_sheet[] = { + { "fiscal_year", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, + { "to", ARG_DATE, 0, NULL, NULL, "Report through this date" }, +}; + +static const struct cmd_arg args_report_vat[] = { + { "from", ARG_DATE, 1, NULL, NULL, "Period start" }, + { "to", ARG_DATE, 1, NULL, NULL, "Period end" }, +}; + +static const struct cmd_arg args_report_general_ledger[] = { + { "fiscal_year", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, + { "accounts", ARG_JSON, 0, NULL, NULL, + "Array of account numbers to include" }, + { "from", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, + { "to", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, +}; + +static const struct cmd_arg args_report_voucher_list[] = { + { "fiscal_year", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, + { "series", ARG_STR, 0, NULL, NULL, "Number series filter" }, +}; + +static const struct cmd_arg args_report_vat_eskd[] = { + { "from", ARG_DATE, 1, NULL, NULL, "Period start" }, + { "to", ARG_DATE, 1, NULL, NULL, "Period end" }, + { "upplysning", ARG_STR, 0, NULL, NULL, + "Free-text message to Skatteverket" }, +}; + +const struct command g_cmd_reports[] = { + { "report.trial_balance", "Saldobalans (IB, period, UB)", PERM_READ, 1, 0, + 0, h_report_trial_balance, CMD_ARGS(args_report_trial_balance) }, + { "report.income_statement", "Resultaträkning", PERM_READ, 1, 0, 0, + h_report_income_statement, CMD_ARGS(args_report_income_statement) }, + { "report.balance_sheet", "Balansräkning", PERM_READ, 1, 0, 0, + h_report_balance_sheet, CMD_ARGS(args_report_balance_sheet) }, + { "report.vat", "Momsdeklaration ruta för ruta", PERM_READ, 1, 0, 0, + h_report_vat, CMD_ARGS(args_report_vat) }, + { "report.general_ledger", "Huvudbok", PERM_READ, 1, 0, 0, + h_report_general_ledger, CMD_ARGS(args_report_general_ledger) }, + { "report.voucher_list", "Verifikationslista", PERM_READ, 1, 0, 0, + h_report_voucher_list, CMD_ARGS(args_report_voucher_list) }, + { "report.vat_eskd", "eSKD XML for the momsdeklaration (ISO-8859-1)", + PERM_READ, 1, 0, 0, h_report_vat_eskd, CMD_ARGS(args_report_vat_eskd) }, +}; + +const struct cmd_table g_cmd_table_reports = { + g_cmd_reports, sizeof g_cmd_reports / sizeof g_cmd_reports[0] +}; diff --git a/src/cmd_rules.c b/src/cmd_rules.c new file mode 100644 index 0000000..04772f3 --- /dev/null +++ b/src/cmd_rules.c @@ -0,0 +1,398 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "db.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* 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; +} + + +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" }, +}; + +const struct command g_cmd_rules[] = { + { "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) }, +}; + +const struct cmd_table g_cmd_table_rules = { + g_cmd_rules, sizeof g_cmd_rules / sizeof g_cmd_rules[0] +}; diff --git a/src/cmd_settings.c b/src/cmd_settings.c new file mode 100644 index 0000000..8ae60ed --- /dev/null +++ b/src/cmd_settings.c @@ -0,0 +1,234 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "config.h" +#include "db.h" +#include "secret.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* settings */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *h_settings_get(struct req *r) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + int have_default = 0, have_bank = 0, have_receivable = 0, have_revenue = 0; + int have_password = 0, have_security = 0; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st, + NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *k = (const char *)sqlite3_column_text(st, 0); + const char *v = (const char *)sqlite3_column_text(st, 1); + if (k) { + if (secret_key_is_secret(k)) { + have_password = 1; + continue; + } + yyjson_mut_obj_add(o, yyjson_mut_strcpy(r->rdoc, k), + yyjson_mut_strcpy(r->rdoc, v ? v : "")); + if (strcmp(k, "default_series") == 0) + have_default = 1; + if (strcmp(k, "bank_account") == 0) + have_bank = 1; + if (strcmp(k, "invoice_receivable_account") == 0) + have_receivable = 1; + if (strcmp(k, "invoice_revenue_account") == 0) + have_revenue = 1; + if (strcmp(k, "smtp_security") == 0) + have_security = 1; + } + } + sqlite3_finalize(st); + } + if (!have_default) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A"); + if (!have_bank) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "bank_account", "1930"); + if (!have_receivable) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_receivable_account", + "1510"); + if (!have_revenue) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_revenue_account", + "3001"); + if (!have_security) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "smtp_security", "starttls"); + yyjson_mut_obj_add_bool(r->rdoc, o, "smtp_password_set", have_password); + return o; +} + +static yyjson_mut_val *settings_result(struct req *r, const char *key, + const char *value, int dry) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); + if (dry) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; +} + +static char *settings_secret_audit_json(const char *key) +{ + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, o); + yyjson_mut_obj_add_strcpy(doc, o, "key", key); + yyjson_mut_obj_add_strcpy(doc, o, "value", "[redacted]"); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json ? json : xstrdup("{}"); +} + +static yyjson_mut_val *h_settings_set(struct req *r) +{ + const char *key = arg_str(r->args, "key"); + const char *value = arg_str(r->args, "value"); + if (!key || !value) + return fail(r, "INVALID_ARGS", "key and value are required"); + if (secret_key_is_secret(key)) { + if (!secret_available()) + return fail(r, "INTERNAL", + "BOKFD_SECRET_KEY is missing or invalid"); + char *stored = NULL; + if (*value && secret_encrypt(value, &stored) != 0) + return fail(r, "INTERNAL", + "BOKFD_SECRET_KEY is missing or invalid"); + if (r->dry_run) { + free(stored); + return settings_result(r, key, "[redacted]", 1); + } + sqlite3_stmt *st = NULL; + const char *sql = *value + ? "INSERT INTO settings(org_id,key,value)" + " VALUES(?1,?2,?3)" + " ON CONFLICT(org_id,key) DO UPDATE SET" + " value=excluded.value" + : "DELETE FROM settings WHERE org_id=?1" + " AND key=?2"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) { + free(stored); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); + if (*value) + sqlite3_bind_text(st, 3, stored, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + free(stored); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = settings_secret_audit_json(key); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "settings.set", reqjson, "OK", NULL); + free(reqjson); + return settings_result(r, key, "[redacted]", 0); + } + size_t maxlen; + int digits_only = 0, bankgiro = 0, port = 0, security = 0; + if (strcmp(key, "default_series") == 0) + maxlen = 8; + else if (strcmp(key, "attachment_dir") == 0 || + strcmp(key, "smtp_host") == 0 || + strcmp(key, "smtp_user") == 0) + maxlen = 255; + else if (strcmp(key, "smtp_from") == 0 || + strcmp(key, "smtp_reply_to") == 0) + maxlen = 254; + else if (strcmp(key, "smtp_port") == 0) { + maxlen = 5; + port = 1; + } else if (strcmp(key, "smtp_security") == 0) { + maxlen = 8; + security = 1; + } else if (strcmp(key, "bank_account") == 0 || + strcmp(key, "invoice_receivable_account") == 0 || + strcmp(key, "invoice_revenue_account") == 0) { + maxlen = 10; + digits_only = 1; + } else if (strcmp(key, "invoice_bankgiro") == 0) { + maxlen = 16; + bankgiro = 1; + } else if (strcmp(key, "invoice_our_ref") == 0) { + maxlen = 64; + } else + return fail(r, "UNSUPPORTED", "unknown setting"); + size_t len = strlen(value); + if (len == 0 || len > maxlen) + return failf(r, "INVALID_ARGS", "%s must be 1-%zu characters", key, + maxlen); + for (const char *p = value; *p; p++) { + if ((digits_only || port) && (*p < '0' || *p > '9')) + return failf(r, "INVALID_ARGS", "%s must be digits only", key); + if (bankgiro && !((*p >= '0' && *p <= '9') || *p == '-')) + return failf(r, "INVALID_ARGS", + "%s must be digits and hyphens", key); + if ((unsigned char)*p < 32) + return failf(r, "INVALID_ARGS", + "%s must not contain control characters", key); + } + if (port) { + long v = strtol(value, NULL, 10); + if (v < 1 || v > 65535) + return failf(r, "INVALID_ARGS", "%s must be 1-65535", key); + } + if (security && strcmp(value, "starttls") != 0 && + strcmp(value, "tls") != 0 && strcmp(value, "plain") != 0) + return failf(r, "INVALID_ARGS", "%s must be starttls, tls or plain", + key); + if (r->dry_run) + return settings_result(r, key, value, 1); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO settings(org_id,key,value) VALUES(?1,?2,?3)" + " ON CONFLICT(org_id,key) DO UPDATE SET value=excluded.value", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, value, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "settings.set", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); + return o; +} + + +static const struct cmd_arg args_settings_set[] = { + { "key", ARG_STR, 1, NULL, NULL, + "default_series, attachment_dir, bank_account," + " invoice_receivable_account, invoice_revenue_account, smtp_host," + " smtp_port, smtp_user, smtp_from, smtp_reply_to, smtp_security or" + " smtp_password" }, + { "value", ARG_STR, 0, NULL, NULL, + "Setting value; an empty value clears smtp_password" }, +}; + +const struct command g_cmd_settings[] = { + { "settings.get", "Read org settings", PERM_READ, 1, 0, 0, + h_settings_get, NULL, 0 }, + { "settings.set", "Change an org setting", PERM_WRITE, 1, 1, 1, + h_settings_set, CMD_ARGS(args_settings_set) }, +}; + +const struct cmd_table g_cmd_table_settings = { + g_cmd_settings, sizeof g_cmd_settings / sizeof g_cmd_settings[0] +}; diff --git a/src/cmd_sie.c b/src/cmd_sie.c new file mode 100644 index 0000000..d5b2e8e --- /dev/null +++ b/src/cmd_sie.c @@ -0,0 +1,171 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "config.h" +#include "db.h" +#include "sha256.h" +#include "sie.h" +#include "util.h" + +static int hash_file(const char *path, char hex[65], int64_t *size) +{ + FILE *f = fopen(path, "rb"); + if (!f) + return -1; + SHA256_CTX ctx; + sha256_init(&ctx); + unsigned char chunk[65536]; + size_t rn; + int64_t total = 0; + while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) { + sha256_update(&ctx, (const BYTE *)chunk, rn); + total += (int64_t)rn; + } + int bad = ferror(f); + fclose(f); + if (bad) + return -1; + unsigned char digest[32]; + sha256_final(&ctx, digest); + util_hex(digest, 32, hex); + if (size) + *size = total; + return 0; +} +static yyjson_mut_val *h_sie_export(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + int inline_out = 0; + arg_bool(r->args, "inline", &inline_out); + + if (mkdir_p(g_cfg.export_dir, 0700) != 0) + return fail(r, "INTERNAL", "cannot create export directory"); + char label[64] = ""; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT label 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); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "fiscal year not found"); + } + snprintf(label, sizeof label, "%s", sqlite3_column_text(st, 0)); + sqlite3_finalize(st); + for (char *p = label; *p; p++) + if (*p == '/') + *p = '-'; + char path[4096]; + snprintf(path, sizeof path, "%s/%lld_%s.se", g_cfg.export_dir, + (long long)r->org_id, label); + char *err = NULL; + if (sie_export_file(r->db, r->org_id, fy, path, &err) != 0) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "export failed"); + free(err); + return e; + } + char hex[65]; + int64_t size = 0; + if (hash_file(path, hex, &size) != 0) + return fail(r, "INTERNAL", "could not read exported file"); + + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "sie.export", "{}", "OK", NULL); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + yyjson_mut_obj_add_int(r->rdoc, o, "size", size); + if (inline_out) { + size_t flen = 0; + unsigned char *content = read_file(path, &flen); + if (!content) + return fail(r, "INTERNAL", "could not read exported file"); + char *b64 = util_b64(content, flen); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); + free(b64); + free(content); + } + return o; +} + +static yyjson_mut_val *h_sie_import(struct req *r) +{ + const char *b64 = arg_str(r->args, "content_base64"); + const char *path = arg_str(r->args, "path"); + unsigned char *content = NULL; + size_t len = 0; + if (b64) { + if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) + return fail(r, "INVALID_ARGS", + "content_base64 is not valid base64"); + } else if (path) { + content = read_file(path, &len); + if (!content) + return fail(r, "NOT_FOUND", "could not read file"); + } else { + return fail(r, "INVALID_ARGS", "content_base64 or path is required"); + } + if (len > 64u * 1024u * 1024u) { + free(content); + return fail(r, "TOO_LARGE", "SIE file is too large"); + } + int64_t fy = 0, vouchers = 0, accounts = 0; + char *err = NULL; + int rc = sie_import_content(r->db, r->org_id, r->sess->user_id, content, + len, r->dry_run, &fy, &vouchers, &accounts, + &err); + free(content); + if (rc != 0) { + yyjson_mut_val *e = fail(r, "INVALID_ARGS", err ? err : "import failed"); + free(err); + return e; + } + 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, + "sie.import", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy); + yyjson_mut_obj_add_int(r->rdoc, o, "vouchers", vouchers); + yyjson_mut_obj_add_int(r->rdoc, o, "accounts_created", accounts); + if (r->dry_run) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; +} + + +static const struct cmd_arg args_sie_export[] = { + { "fiscal_year", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, + { "inline", ARG_BOOL, 0, "false", NULL, "Also return content_base64" }, +}; + +static const struct cmd_arg args_sie_import[] = { + { "content_base64", ARG_STR, 0, NULL, NULL, "SIE file content, base64" }, + { "path", ARG_STR, 0, NULL, NULL, + "Server-side path; alternative to content_base64" }, +}; + +const struct command g_cmd_sie[] = { + { "sie.export", "Export SIE 4 (PC8)", PERM_READ, 1, 0, 0, h_sie_export, + CMD_ARGS(args_sie_export) }, + { "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1, + 1, h_sie_import, CMD_ARGS(args_sie_import) }, +}; + +const struct cmd_table g_cmd_table_sie = { + g_cmd_sie, sizeof g_cmd_sie / sizeof g_cmd_sie[0] +}; diff --git a/src/cmd_sru.c b/src/cmd_sru.c new file mode 100644 index 0000000..337ef69 --- /dev/null +++ b/src/cmd_sru.c @@ -0,0 +1,50 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "db.h" +#include "sru.h" +#include "util.h" + +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 const struct cmd_arg args_sru_export[] = { + { "fiscal_year", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, + { "adjustments", ARG_JSON, 0, NULL, NULL, + "Array of {code,amount_ore} tax adjustments" }, + { "submitter", ARG_JSON, 0, NULL, NULL, + "Object overriding the INFO.SRU submitter" }, + { "assisted", ARG_BOOL, 0, NULL, NULL, "Assisted declaration" }, + { "audited", ARG_BOOL, 0, NULL, NULL, "Audited declaration" }, + { "ignore_unmapped", ARG_BOOL, 0, "false", NULL, + "Proceed despite unmapped accounts" }, +}; + +const struct command g_cmd_sru[] = { + { "sru.export", "INK2 SRU files (INFO.SRU + BLANKETTER.SRU)", + PERM_READ, 1, 0, 0, h_sru_export, CMD_ARGS(args_sru_export) }, +}; + +const struct cmd_table g_cmd_table_sru = { + g_cmd_sru, sizeof g_cmd_sru / sizeof g_cmd_sru[0] +}; diff --git a/src/cmd_templates.c b/src/cmd_templates.c new file mode 100644 index 0000000..a134d96 --- /dev/null +++ b/src/cmd_templates.c @@ -0,0 +1,539 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "db.h" +#include "formula.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* voucher templates (konteringsmallar) */ +/* ------------------------------------------------------------------ */ + +struct tpl_row_in { + int64_t account_id; + char account[16]; + char formula[256]; + char desc[128]; +}; +static int parse_template_rows(struct req *r, yyjson_val *rowsv, + struct tpl_row_in **out, size_t *out_n) +{ + *out = NULL; + *out_n = 0; + if (!rowsv || !yyjson_is_arr(rowsv)) + return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1; + size_t n = yyjson_arr_size(rowsv); + if (n == 0 || n > 100) + return fail(r, "INVALID_ARGS", + "a template needs between 1 and 100 rows") + ? -1 + : -1; + struct tpl_row_in *rows = xcalloc(n, sizeof *rows); + size_t k = 0; + yyjson_arr_iter it = yyjson_arr_iter_with(rowsv); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it))) { + if (!yyjson_is_obj(item)) { + free(rows); + fail(r, "INVALID_ARGS", "each row must be an object"); + return -1; + } + yyjson_val *av = yyjson_obj_get(item, "account"); + yyjson_val *fv = yyjson_obj_get(item, "formula"); + if (!av || !yyjson_is_str(av) || !fv || !yyjson_is_str(fv)) { + free(rows); + failf(r, "INVALID_ARGS", + "row %zu: account and formula are required", k + 1); + return -1; + } + const char *account = yyjson_get_str(av); + const char *formula = yyjson_get_str(fv); + if (!formula_valid(formula)) { + free(rows); + failf(r, "INVALID_ARGS", "row %zu: ogiltig formel '%s'", k + 1, + formula); + return -1; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT id FROM accounts WHERE org_id=?1" + " AND number=?2", + -1, &st, NULL) != SQLITE_OK) { + free(rows); + return fail(r, "INTERNAL", "database error") ? -1 : -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + free(rows); + failf(r, "ACCOUNT_NOT_FOUND", "row %zu: account %s does not exist", + k + 1, account); + return -1; + } + rows[k].account_id = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + snprintf(rows[k].account, sizeof rows[k].account, "%s", account); + snprintf(rows[k].formula, sizeof rows[k].formula, "%s", formula); + yyjson_val *dv = yyjson_obj_get(item, "description"); + if (dv && yyjson_is_str(dv)) + snprintf(rows[k].desc, sizeof rows[k].desc, "%s", + yyjson_get_str(dv)); + k++; + } + *out = rows; + *out_n = k; + return 0; +} + +static yyjson_mut_val *tpl_rows_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "line_no", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "formula", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 4))); + return o; +} + +static yyjson_mut_val *h_template_list(struct req *r) +{ + int active_only = 0; + arg_bool(r->args, "active_only", &active_only); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + const char *sql = + active_only + ? "SELECT t.id,t.name,t.series,t.description,t.active," + "(SELECT count(*) FROM voucher_template_rows tr" + " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" + " FROM voucher_templates t WHERE t.org_id=?1 AND t.active=1" + " ORDER BY t.name" + : "SELECT t.id,t.name,t.series,t.description,t.active," + "(SELECT count(*) FROM voucher_template_rows tr" + " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" + " FROM voucher_templates t WHERE t.org_id=?1 ORDER BY t.name"; + 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); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_bool(r->rdoc, o, "active", + sqlite3_column_int(st, 4) != 0); + yyjson_mut_obj_add_int(r->rdoc, o, "row_count", + sqlite3_column_int64(st, 5)); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static yyjson_mut_val *h_template_get(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + const char *name = arg_str(r->args, "name"); + if (id <= 0 && !name) + return fail(r, "INVALID_ARGS", "id or name is required"); + sqlite3_stmt *st = NULL; + const char *sql = + id > 0 ? "SELECT id,name,series,description,active" + " FROM voucher_templates WHERE org_id=?1 AND id=?2" + : "SELECT id,name,series,description,active" + " FROM voucher_templates WHERE org_id=?1 AND name=?2"; + 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 (id > 0) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "template not found"); + } + int64_t tpl = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_bool(r->rdoc, o, "active", + sqlite3_column_int(st, 4) != 0); + sqlite3_finalize(st); + + yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); + if (sqlite3_prepare_v2( + r->db, + "SELECT tr.line_no,a.number,a.name,tr.formula," + "COALESCE(tr.description,'') FROM voucher_template_rows tr" + " JOIN accounts a ON a.org_id=tr.org_id AND a.id=tr.account_id" + " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tpl); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(rows, tpl_rows_json(r, st)); + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); + return o; +} + +static yyjson_mut_val *h_template_create(struct req *r) +{ + const char *name = arg_str(r->args, "name"); + const char *series = arg_str(r->args, "series"); + const char *description = arg_str(r->args, "description"); + if (!name || !*name) + return fail(r, "INVALID_ARGS", "name is required"); + char *series_owned = NULL; + if (!series || !*series) { + series_owned = db_setting(r->db, r->org_id, "default_series"); + series = series_owned && *series_owned ? series_owned : "A"; + } + if (!description) + description = ""; + struct tpl_row_in *rows = NULL; + size_t nrows = 0; + if (parse_template_rows( + r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, &rows, + &nrows) != 0) + return NULL; + + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); + yyjson_mut_val *arr = yyjson_mut_arr(r->rdoc); + for (size_t i = 0; i < nrows; i++) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, arr); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", rows[i].account); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "formula", rows[i].formula); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", rows[i].desc); + } + yyjson_mut_obj_add_val(r->rdoc, o, "rows", arr); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + free(rows); + free(series_owned); + return o; + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { + free(rows); + free(series_owned); + return fail(r, "DB_BUSY", "could not start transaction"); + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "INSERT INTO voucher_templates(org_id,name,series," + "description,created_at) VALUES(?1,?2,?3,?4,?5)", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + if ((rc & 0xff) == SQLITE_CONSTRAINT) + return fail(r, "CONFLICT", "a template with that name exists"); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t tpl = db_last_id(r->db); + for (size_t i = 0; i < nrows; i++) { + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO voucher_template_rows(org_id,template_id,line_no," + "account_id,formula,description) VALUES(?1,?2,?3,?4,?5,?6)", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tpl); + sqlite3_bind_int64(st, 3, (int64_t)i + 1); + sqlite3_bind_int64(st, 4, rows[i].account_id); + sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); + if (rows[i].desc[0]) + sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 6); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + } + free(rows); + if (db_exec(r->db, "COMMIT", NULL) != 0) + return fail(r, "INTERNAL", "commit failed"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "template.create", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); + free(series_owned); + return o; +} + +static yyjson_mut_val *h_template_update(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + const char *name_arg = arg_str(r->args, "name"); + if (id <= 0 && !name_arg) + return fail(r, "INVALID_ARGS", "id or name is required"); + struct tpl_head head; + char *err = NULL; + if (load_template(r->db, r->org_id, id, name_arg, &head, &err) != 0) { + yyjson_mut_val *res = + fail(r, "NOT_FOUND", err ? err : "template not found"); + free(err); + return res; + } + const char *new_name = arg_str(r->args, "name"); + const char *series = arg_str(r->args, "series"); + const char *description = arg_str(r->args, "description"); + int active = -1; + arg_bool(r->args, "active", &active); + yyjson_val *rowsv = r->args ? yyjson_obj_get(r->args, "rows") : NULL; + struct tpl_row_in *rows = NULL; + size_t nrows = 0; + if (rowsv) { + if (parse_template_rows(r, rowsv, &rows, &nrows) != 0) + return NULL; + } + if (!new_name && !series && !description && active < 0 && !rowsv) { + free(rows); + return fail(r, "INVALID_ARGS", "nothing to update"); + } + + if (r->dry_run) { + free(rows); + return h_template_get(r); + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { + free(rows); + return fail(r, "DB_BUSY", "could not start transaction"); + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE voucher_templates SET" + " name=COALESCE(?2,name), series=COALESCE(?3,series)," + " description=COALESCE(?4,description)," + " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6" + " WHERE org_id=?1 AND id=?7", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + if (new_name) + sqlite3_bind_text(st, 2, new_name, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 2); + if (series) + sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 3); + if (description) + sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 4); + sqlite3_bind_int(st, 5, active); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, head.id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + if (rowsv) { + if (sqlite3_prepare_v2( + r->db, + "DELETE FROM voucher_template_rows WHERE org_id=?1" + " AND template_id=?2", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, head.id); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + for (size_t i = 0; i < nrows; i++) { + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO voucher_template_rows(org_id,template_id," + "line_no,account_id,formula,description)" + " VALUES(?1,?2,?3,?4,?5,?6)", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, head.id); + sqlite3_bind_int64(st, 3, (int64_t)i + 1); + sqlite3_bind_int64(st, 4, rows[i].account_id); + sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); + if (rows[i].desc[0]) + sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 6); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + } + } + free(rows); + if (db_exec(r->db, "COMMIT", NULL) != 0) + return fail(r, "INTERNAL", "commit failed"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "template.update", reqjson, "OK", NULL); + free(reqjson); + return h_template_get(r); +} + +static yyjson_mut_val *h_template_archive(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + const char *name = arg_str(r->args, "name"); + if (id <= 0 && !name) + return fail(r, "INVALID_ARGS", "id or name is required"); + sqlite3_stmt *st = NULL; + const char *sql = + id > 0 ? "UPDATE voucher_templates SET active=0 WHERE org_id=?1" + " AND id=?2" + : "UPDATE voucher_templates SET active=0 WHERE org_id=?1" + " AND name=?2"; + 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 (id > 0) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + 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", "template not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "template.archive", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + + +static const struct cmd_arg args_template_list[] = { + { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active templates" }, +}; + +static const struct cmd_arg args_template_get[] = { + { "id", ARG_INT, 0, NULL, NULL, "Template id" }, + { "name", ARG_STR, 0, NULL, NULL, "Template name" }, +}; + +static const struct cmd_arg args_template_create[] = { + { "name", ARG_STR, 1, NULL, NULL, "Template name" }, + { "series", ARG_STR, 0, NULL, NULL, + "Number series; defaults to settings.default_series" }, + { "description", ARG_STR, 0, NULL, NULL, + "Voucher text; {x} is replaced with the amount" }, + { "rows", ARG_JSON, 1, NULL, NULL, + "Array of {account,formula,description?}" }, +}; + +static const struct cmd_arg args_template_update[] = { + { "id", ARG_INT, 0, NULL, NULL, "Template id" }, + { "name", ARG_STR, 0, NULL, NULL, + "Template name, or the new name when id is given" }, + { "series", ARG_STR, 0, NULL, NULL, "Number series" }, + { "description", ARG_STR, 0, NULL, NULL, "Voucher text" }, + { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, + { "rows", ARG_JSON, 0, NULL, NULL, + "Replacement rows, {account,formula,description?}" }, +}; + +static const struct cmd_arg args_template_archive[] = { + { "id", ARG_INT, 0, NULL, NULL, "Template id" }, + { "name", ARG_STR, 0, NULL, NULL, "Template name" }, +}; + +const struct command g_cmd_templates[] = { + { "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, + 0, h_template_get, CMD_ARGS(args_template_get) }, + { "template.create", "Create a voucher template", PERM_WRITE, 1, 1, 1, + h_template_create, CMD_ARGS(args_template_create) }, + { "template.update", "Update a voucher template", PERM_WRITE, 1, 1, 1, + h_template_update, CMD_ARGS(args_template_update) }, + { "template.archive", "Archive a voucher template", PERM_WRITE, 1, 1, 1, + h_template_archive, CMD_ARGS(args_template_archive) }, +}; + +const struct cmd_table g_cmd_table_templates = { + g_cmd_templates, sizeof g_cmd_templates / sizeof g_cmd_templates[0] +}; diff --git a/src/cmd_users.c b/src/cmd_users.c new file mode 100644 index 0000000..f327c13 --- /dev/null +++ b/src/cmd_users.c @@ -0,0 +1,287 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +#include "audit.h" +#include "auth.h" +#include "db.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* users and tokens */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *h_user_create(struct req *r) +{ + const char *username = arg_str(r->args, "username"); + const char *password = arg_str(r->args, "password"); + const char *display = arg_str(r->args, "display_name"); + if (!username || !password) + return fail(r, "INVALID_ARGS", "username and password are required"); + int is_admin = 0; + arg_bool(r->args, "is_admin", &is_admin); + char *err = NULL; + int64_t uid = 0; + if (db_create_user(r->db, username, display, password, is_admin, &uid, + &err) != 0) { + const char *code = err && strstr(err, "already exists") ? "CONFLICT" + : "INTERNAL"; + yyjson_mut_val *res = fail(r, code, err ? err : "could not create user"); + free(err); + return res; + } + free(err); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, 0, r->sess->user_id, 0, "user.create", reqjson, "OK", + NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", uid); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", username); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", + display ? display : username); + yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", is_admin != 0); + return o; +} + +static yyjson_mut_val *h_user_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id,username,display_name,is_admin,created_at,disabled_at" + " FROM users ORDER BY id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", + sqlite3_column_int(st, 3) != 0); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "disabled_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "disabled_at", + sq(sqlite3_column_text(st, 5))); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static int scope_allowed(const char *role, const char *scope) +{ + int is_owner = strcmp(role, "owner") == 0; + int is_bookkeeper = is_owner || strcmp(role, "bookkeeper") == 0; + if (strcmp(scope, "read") == 0) + return 1; + if (strcmp(scope, "write") == 0) + return is_bookkeeper; + if (strcmp(scope, "admin") == 0) + return is_owner; + return 0; +} + +static yyjson_mut_val *h_token_create(struct req *r) +{ + const char *label = arg_str(r->args, "label"); + if (!label || !*label) + return fail(r, "INVALID_ARGS", "label is required"); + char scopes[64] = ""; + yyjson_val *sv = r->args && yyjson_is_obj(r->args) + ? yyjson_obj_get(r->args, "scopes") + : NULL; + if (sv) { + if (!yyjson_is_arr(sv)) + return fail(r, "INVALID_ARGS", "scopes must be an array"); + yyjson_val *item; + yyjson_arr_iter it = yyjson_arr_iter_with(sv); + while ((item = yyjson_arr_iter_next(&it))) { + const char *s = yyjson_get_str(item); + if (!s || !scope_allowed(r->role, s)) + return fail(r, "FORBIDDEN", "scope not allowed for your role"); + if (scopes[0]) + strncat(scopes, ",", sizeof scopes - strlen(scopes) - 1); + strncat(scopes, s, sizeof scopes - strlen(scopes) - 1); + } + } + if (!scopes[0]) + snprintf(scopes, sizeof scopes, "%s", + strcmp(r->role, "owner") == 0 + ? "read,write,admin" + : (strcmp(r->role, "bookkeeper") == 0 ? "read,write" + : "read")); + const char *expires_at = arg_str(r->args, "expires_at"); + if (expires_at && !util_parse_iso_date(expires_at)) + return fail(r, "INVALID_ARGS", "expires_at must be YYYY-MM-DD"); + char *token = auth_generate_token(); + unsigned char th[32]; + auth_hash_token(token, th); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO api_tokens(org_id,user_id,label,token_hash,scopes," + "created_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7)", + -1, &st, NULL) != SQLITE_OK) { + free(token); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, r->sess->user_id); + sqlite3_bind_text(st, 3, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_blob(st, 4, th, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, scopes, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + if (expires_at) + sqlite3_bind_text(st, 7, expires_at, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 7); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + free(token); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t token_id = db_last_id(r->db); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, token_id, "token.create", + reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", token_id); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", scopes); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "token", token); + if (expires_at) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", expires_at); + else + yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); + free(token); + return o; +} + +static yyjson_mut_val *h_token_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id,label,scopes,created_at,expires_at,last_used_at," + "revoked_at FROM api_tokens WHERE org_id=?1 AND user_id=?2" + " ORDER BY id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, r->sess->user_id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 3))); + if (sqlite3_column_type(st, 4) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", + sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "last_used_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_used_at", + sq(sqlite3_column_text(st, 5))); + if (sqlite3_column_type(st, 6) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "revoked_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "revoked_at", + sq(sqlite3_column_text(st, 6))); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static yyjson_mut_val *h_token_revoke(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + int is_owner = strcmp(r->role, "owner") == 0; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + is_owner + ? "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" + : "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" + " AND user_id=?4", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 2, id); + sqlite3_bind_int64(st, 3, r->org_id); + if (!is_owner) + sqlite3_bind_int64(st, 4, r->sess->user_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", "token not found"); + audit_append(r->db, r->org_id, r->sess->user_id, id, "token.revoke", "{}", + "OK", NULL); + return yyjson_mut_obj(r->rdoc); +} + + +static const struct cmd_arg args_user_create[] = { + { "username", ARG_STR, 1, NULL, NULL, "Username" }, + { "password", ARG_STR, 1, NULL, NULL, "Password" }, + { "display_name", ARG_STR, 0, NULL, NULL, "Display name" }, + { "is_admin", ARG_BOOL, 0, "false", NULL, "System admin" }, +}; + +static const struct cmd_arg args_token_create[] = { + { "label", ARG_STR, 1, NULL, NULL, "Label shown in token.list" }, + { "scopes", ARG_JSON, 0, NULL, NULL, "Array of read, write, admin" }, + { "expires_at", ARG_DATE, 0, NULL, NULL, "Expiry date" }, +}; + +static const struct cmd_arg args_token_revoke[] = { + { "id", ARG_INT, 1, NULL, NULL, "Token id" }, +}; + +const struct command g_cmd_users[] = { + { "user.create", "Create a user (system admin)", PERM_ADMIN, 0, 1, 1, + h_user_create, CMD_ARGS(args_user_create) }, + { "user.list", "List users (system admin)", PERM_ADMIN, 0, 0, 0, + h_user_list, NULL, 0 }, + { "token.create", "Create an API token for the active org", PERM_READ, 1, + 1, 1, h_token_create, CMD_ARGS(args_token_create) }, + { "token.list", "List your API tokens in the active org", PERM_READ, 1, 0, + 0, h_token_list, NULL, 0 }, + { "token.revoke", "Revoke an API token", PERM_READ, 1, 1, 1, + h_token_revoke, CMD_ARGS(args_token_revoke) }, +}; + +const struct cmd_table g_cmd_table_users = { + g_cmd_users, sizeof g_cmd_users / sizeof g_cmd_users[0] +}; diff --git a/src/cmd_util.c b/src/cmd_util.c new file mode 100644 index 0000000..747f836 --- /dev/null +++ b/src/cmd_util.c @@ -0,0 +1,412 @@ +#include "cmd_util.h" + +#include <errno.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +#include "db.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* small helpers */ +/* ------------------------------------------------------------------ */ + +const char *sq(const unsigned char *p) +{ + return p ? (const char *)p : ""; +} + +const char *arg_str(yyjson_val *args, const char *key) +{ + if (!args || !yyjson_is_obj(args)) + return NULL; + yyjson_val *v = yyjson_obj_get(args, key); + return v && yyjson_is_str(v) ? yyjson_get_str(v) : NULL; +} + +int arg_int(yyjson_val *args, const char *key, int64_t *out) +{ + if (!args || !yyjson_is_obj(args)) + return 0; + yyjson_val *v = yyjson_obj_get(args, key); + if (!v || !yyjson_is_int(v)) + return 0; + *out = yyjson_get_int(v); + return 1; +} + +int arg_bool(yyjson_val *args, const char *key, int *out) +{ + if (!args || !yyjson_is_obj(args)) + return 0; + yyjson_val *v = yyjson_obj_get(args, key); + if (!v || !yyjson_is_bool(v)) + return 0; + *out = yyjson_get_bool(v) ? 1 : 0; + return 1; +} + +const char *arg_type_name(enum arg_type type) +{ + switch (type) { + case ARG_STR: + return "string"; + case ARG_INT: + return "int"; + case ARG_BOOL: + return "bool"; + case ARG_ENUM: + return "enum"; + case ARG_DATE: + return "date"; + case ARG_JSON: + return "json"; + } + return "json"; +} + +int enum_allowed(const char *values, const char *v) +{ + size_t n = strlen(v); + const char *p = values; + while (p && *p) { + const char *comma = strchr(p, ','); + size_t len = comma ? (size_t)(comma - p) : strlen(p); + if (len == n && strncmp(p, v, n) == 0) + return 1; + if (!comma) + break; + p = comma + 1; + } + return 0; +} + +void arg_error(char *err, size_t errlen, const char *fmt, ...) + __attribute__((format(printf, 3, 4))); + +void arg_error(char *err, size_t errlen, const char *fmt, ...) +{ + if (!err || errlen == 0) + return; + va_list ap; + va_start(ap, fmt); + vsnprintf(err, errlen, fmt, ap); + va_end(ap); +} + +int command_validate_args(const struct command *cmd, yyjson_val *args, + char *err, size_t errlen) +{ + if (!cmd || !cmd->args) + return 0; + if (err && errlen) + err[0] = '\0'; + for (size_t i = 0; i < cmd->nargs; i++) { + const struct cmd_arg *a = &cmd->args[i]; + yyjson_val *v = args && yyjson_is_obj(args) + ? yyjson_obj_get(args, a->name) + : NULL; + if (!v || yyjson_is_null(v)) { + if (a->required) { + arg_error(err, errlen, "missing required argument \"%s\"", + a->name); + return -1; + } + continue; + } + int bad = 0, empty = 0; + switch (a->type) { + case ARG_STR: + bad = !yyjson_is_str(v); + empty = !bad && a->required && !*yyjson_get_str(v); + break; + case ARG_INT: + bad = !yyjson_is_int(v); + break; + case ARG_BOOL: + bad = !yyjson_is_bool(v); + break; + case ARG_ENUM: + bad = !yyjson_is_str(v) || + !enum_allowed(a->values, yyjson_get_str(v)); + empty = !bad && a->required && !*yyjson_get_str(v); + break; + case ARG_DATE: + bad = !yyjson_is_str(v) || + !util_parse_iso_date(yyjson_get_str(v)); + break; + case ARG_JSON: + break; + } + if (empty) { + arg_error(err, errlen, "missing required argument \"%s\"", + a->name); + return -1; + } + if (bad) { + switch (a->type) { + case ARG_STR: + arg_error(err, errlen, "argument \"%s\" must be a string", + a->name); + break; + case ARG_INT: + arg_error(err, errlen, "argument \"%s\" must be an integer", + a->name); + break; + case ARG_BOOL: + arg_error(err, errlen, "argument \"%s\" must be a boolean", + a->name); + break; + case ARG_ENUM: + arg_error(err, errlen, "argument \"%s\" must be one of: %s", + a->name, a->values ? a->values : ""); + break; + case ARG_DATE: + arg_error(err, errlen, "argument \"%s\" must be YYYY-MM-DD", + a->name); + break; + case ARG_JSON: + break; + } + return -1; + } + } + return 0; +} + +yyjson_mut_val *fail(struct req *r, const char *code, const char *msg) +{ + r->err_code = code; + snprintf(r->err_msg, sizeof r->err_msg, "%s", msg); + return NULL; +} + +yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...) + __attribute__((format(printf, 3, 4))); + +yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...) +{ + r->err_code = code; + va_list ap; + va_start(ap, fmt); + vsnprintf(r->err_msg, sizeof r->err_msg, fmt, ap); + va_end(ap); + return NULL; +} + +int mkdir_p(const char *path, mode_t mode) +{ + char tmp[4096]; + if (!path || strlen(path) >= sizeof tmp) + return -1; + strcpy(tmp, path); + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (mkdir(tmp, mode) != 0 && errno != EEXIST) + return -1; + *p = '/'; + } + } + if (mkdir(tmp, mode) != 0 && errno != EEXIST) + return -1; + return 0; +} + +/* ------------------------------------------------------------------ */ +/* ------------------------------------------------------------------ */ +/* shared helpers for M2 commands */ +/* ------------------------------------------------------------------ */ + +yyjson_mut_val *json_to_mut(yyjson_mut_doc *doc, const char *json) +{ + if (!json) + return NULL; + yyjson_doc *d = yyjson_read(json, strlen(json), 0); + if (!d) + return NULL; + yyjson_mut_val *v = yyjson_val_mut_copy(doc, yyjson_doc_get_root(d)); + yyjson_doc_free(d); + return v; +} +int is_digits(const char *s) +{ + if (!s || !*s) + return 0; + for (; *s; s++) + if (*s < '0' || *s > '9') + return 0; + return 1; +} +double parse_kr_double(const char *s, int *ok) +{ + *ok = 0; + if (!s) + return 0; + char buf[64]; + size_t j = 0; + for (const char *p = s; *p && j < sizeof buf - 1; p++) { + if (*p == ' ' || *p == '\t') + continue; + buf[j++] = (*p == ',') ? '.' : *p; + } + buf[j] = '\0'; + if (!j || (j == 1 && buf[0] == '-')) + return 0; + char *end = NULL; + double v = strtod(buf, &end); + if (!end || *end) + return 0; + *ok = 1; + return v; +} + +char *replace_x(const char *text, double x) +{ + struct buf b; + buf_init(&b); + for (const char *p = text; *p;) { + if (p[0] == '{' && p[1] == 'x' && p[2] == '}') { + char tmp[64]; + snprintf(tmp, sizeof tmp, "%.2f", x); + buf_append(&b, tmp, strlen(tmp)); + p += 3; + } else { + buf_append(&b, p, 1); + p++; + } + } + buf_append(&b, "", 1); + return (char *)b.p; +} + +int load_template(sqlite3 *db, int64_t org_id, int64_t id, + const char *name, struct tpl_head *head, char **err) +{ + sqlite3_stmt *st = NULL; + const char *sql = + id ? "SELECT id,name,series,description FROM voucher_templates" + " WHERE org_id=?1 AND id=?2 AND active=1" + : "SELECT id,name,series,description FROM voucher_templates" + " WHERE org_id=?1 AND name=?2 AND active=1"; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + if (err) + *err = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, org_id); + if (id) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, name ? name : "", -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + if (err) + *err = xstrdup("template not found"); + return -1; + } + head->id = sqlite3_column_int64(st, 0); + snprintf(head->name, sizeof head->name, "%s", + sqlite3_column_text(st, 1)); + snprintf(head->series, sizeof head->series, "%s", + sqlite3_column_text(st, 2)); + snprintf(head->description, sizeof head->description, "%s", + sqlite3_column_text(st, 3)); + sqlite3_finalize(st); + return 0; +} + +int load_template_rows(sqlite3 *db, int64_t org_id, int64_t tpl, + struct tpl_loaded **out, size_t *out_n, + char **err) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT a.number,tr.formula,COALESCE(tr.description,'')" + " FROM voucher_template_rows tr JOIN accounts a" + " ON a.org_id=tr.org_id AND a.id=tr.account_id" + " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no", + -1, &st, NULL) != SQLITE_OK) { + if (err) + *err = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_int64(st, 2, tpl); + size_t cap = 0, n = 0; + struct tpl_loaded *rows = NULL; + while (sqlite3_step(st) == SQLITE_ROW) { + if (n == cap) { + cap = cap ? cap * 2 : 8; + rows = xrealloc(rows, cap * sizeof *rows); + } + memset(&rows[n], 0, sizeof rows[n]); + snprintf(rows[n].account, sizeof rows[n].account, "%s", + sqlite3_column_text(st, 0)); + snprintf(rows[n].formula, sizeof rows[n].formula, "%s", + sqlite3_column_text(st, 1)); + snprintf(rows[n].desc, sizeof rows[n].desc, "%s", + sqlite3_column_text(st, 2)); + n++; + } + sqlite3_finalize(st); + *out = rows; + *out_n = n; + return 0; +} +int64_t default_fy_id(sqlite3 *db, int64_t org_id) +{ + sqlite3_stmt *st = NULL; + int64_t id = 0; + if (sqlite3_prepare_v2( + db, + "SELECT id FROM fiscal_years WHERE org_id=?1" + " ORDER BY start_date DESC LIMIT 1", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, org_id); + if (sqlite3_step(st) == SQLITE_ROW) + id = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + return id; +} + +int req_fy(struct req *r, int64_t *out) +{ + int64_t fy = 0; + arg_int(r->args, "fiscal_year", &fy); + if (!fy) + fy = default_fy_id(r->db, r->org_id); + if (!fy) { + fail(r, "NOT_FOUND", "no fiscal year; open one first"); + return -1; + } + *out = fy; + return 0; +} + +unsigned char *read_file(const char *path, size_t *out_len) +{ + FILE *f = fopen(path, "rb"); + if (!f) + return NULL; + struct buf b; + buf_init(&b); + unsigned char chunk[65536]; + size_t rn; + while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) + buf_append(&b, chunk, rn); + int bad = ferror(f); + fclose(f); + if (bad) { + buf_free(&b); + return NULL; + } + *out_len = b.len; + return b.p; +} diff --git a/src/cmd_util.h b/src/cmd_util.h new file mode 100644 index 0000000..4113eb2 --- /dev/null +++ b/src/cmd_util.h @@ -0,0 +1,52 @@ +#ifndef BOKF_CMD_UTIL_H +#define BOKF_CMD_UTIL_H + +#include <stddef.h> +#include <stdint.h> +#include <sys/types.h> + +#include "commands.h" +#include "protocol.h" +#include "yyjson.h" + +const char *sq(const unsigned char *p); +const char *arg_str(yyjson_val *args, const char *key); +int arg_int(yyjson_val *args, const char *key, int64_t *out); +int arg_bool(yyjson_val *args, const char *key, int *out); +const char *arg_type_name(enum arg_type type); +int enum_allowed(const char *values, const char *v); +void arg_error(char *err, size_t errlen, const char *fmt, ...) + __attribute__((format(printf, 3, 4))); +int command_validate_args(const struct command *cmd, yyjson_val *args, + char *err, size_t errlen); +yyjson_mut_val *fail(struct req *r, const char *code, const char *msg); +yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...) + __attribute__((format(printf, 3, 4))); +int mkdir_p(const char *path, mode_t mode); +yyjson_mut_val *json_to_mut(yyjson_mut_doc *doc, const char *json); +int is_digits(const char *s); +int64_t default_fy_id(sqlite3 *db, int64_t org_id); +int req_fy(struct req *r, int64_t *out); +unsigned char *read_file(const char *path, size_t *out_len); + +struct tpl_head { + int64_t id; + char name[128]; + char series[16]; + char description[256]; +}; + +struct tpl_loaded { + char account[16]; + char formula[256]; + char desc[128]; +}; + +double parse_kr_double(const char *s, int *ok); +char *replace_x(const char *text, double x); +int load_template(sqlite3 *db, int64_t org_id, int64_t id, const char *name, + struct tpl_head *head, char **err); +int load_template_rows(sqlite3 *db, int64_t org_id, int64_t tpl, + struct tpl_loaded **out, size_t *out_n, char **err); + +#endif diff --git a/src/cmd_vouchers.c b/src/cmd_vouchers.c new file mode 100644 index 0000000..3e30544 --- /dev/null +++ b/src/cmd_vouchers.c @@ -0,0 +1,625 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "audit.h" +#include "db.h" +#include "formula.h" +#include "ledger.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* vouchers */ +/* ------------------------------------------------------------------ */ + +static int parse_rows(struct req *r, yyjson_val *rowsv, struct ledger_row **out, + size_t *out_n) +{ + if (!rowsv || !yyjson_is_arr(rowsv)) + return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1; + size_t n = yyjson_arr_size(rowsv); + struct ledger_row *rows = xcalloc(n ? n : 1, sizeof *rows); + size_t k = 0; + yyjson_arr_iter it = yyjson_arr_iter_with(rowsv); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it))) { + if (!yyjson_is_obj(item)) { + free(rows); + fail(r, "INVALID_ARGS", "each row must be an object"); + return -1; + } + yyjson_val *av = yyjson_obj_get(item, "account"); + if (!av || !yyjson_is_str(av)) { + free(rows); + failf(r, "INVALID_ARGS", "row %zu: account is required", k + 1); + return -1; + } + yyjson_val *dv = yyjson_obj_get(item, "debit_ore"); + yyjson_val *cv = yyjson_obj_get(item, "credit_ore"); + if ((dv && !yyjson_is_int(dv)) || (cv && !yyjson_is_int(cv))) { + free(rows); + failf(r, "INVALID_ARGS", "row %zu: amounts must be integer öre", + k + 1); + return -1; + } + rows[k].account = yyjson_get_str(av); + rows[k].debit_ore = dv ? yyjson_get_int(dv) : 0; + rows[k].credit_ore = cv ? yyjson_get_int(cv) : 0; + yyjson_val *d = yyjson_obj_get(item, "description"); + rows[k].description = d && yyjson_is_str(d) ? yyjson_get_str(d) : NULL; + k++; + } + *out = rows; + *out_n = k; + return 0; +} + +static int parse_attachment_ids(struct req *r, int64_t **out, size_t *out_n) +{ + yyjson_val *av = r->args ? yyjson_obj_get(r->args, "attachment_ids") : NULL; + *out = NULL; + *out_n = 0; + if (!av) + return 0; + if (!yyjson_is_arr(av)) + return fail(r, "INVALID_ARGS", "attachment_ids must be an array") ? -1 + : -1; + size_t n = yyjson_arr_size(av); + int64_t *ids = xcalloc(n ? n : 1, sizeof *ids); + size_t k = 0; + yyjson_arr_iter it = yyjson_arr_iter_with(av); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it))) { + if (!yyjson_is_int(item)) { + free(ids); + fail(r, "INVALID_ARGS", "attachment_ids must be integers"); + return -1; + } + ids[k++] = yyjson_get_int(item); + } + *out = ids; + *out_n = k; + return 0; +} + +static yyjson_mut_val *h_voucher_post(struct req *r) +{ + const char *date = arg_str(r->args, "date"); + const char *description = arg_str(r->args, "description"); + const char *series = arg_str(r->args, "series"); + const char *client_ref = arg_str(r->args, "client_ref"); + int64_t corrects = 0; + arg_int(r->args, "corrects_voucher", &corrects); + if (!date) + return fail(r, "INVALID_ARGS", "date is required"); + + /* template application: {template, x} instead of {rows} */ + int64_t tpl_id = 0; + const char *tpl_name = NULL; + yyjson_val *tv = r->args ? yyjson_obj_get(r->args, "template") : NULL; + if (tv) { + if (yyjson_is_int(tv)) + tpl_id = yyjson_get_int(tv); + else if (yyjson_is_str(tv)) + tpl_name = yyjson_get_str(tv); + else + return fail(r, "INVALID_ARGS", "template must be an id or a name"); + if (r->args && yyjson_obj_get(r->args, "rows")) + return fail(r, "INVALID_ARGS", + "use either rows or template, not both"); + } + double x = 0; + yyjson_val *xv = r->args ? yyjson_obj_get(r->args, "x") : NULL; + if (xv) { + if (yyjson_is_str(xv)) { + int ok = 0; + x = parse_kr_double(yyjson_get_str(xv), &ok); + if (!ok) + return fail(r, "INVALID_ARGS", "x must be a number in kronor"); + } else if (yyjson_is_real(xv)) { + x = yyjson_get_real(xv); + } else if (yyjson_is_int(xv)) { + x = (double)yyjson_get_int(xv); + } else { + return fail(r, "INVALID_ARGS", "x must be a number in kronor"); + } + } + + struct ledger_row *rows = NULL; + size_t nrows = 0; + char *owned_desc = NULL; + char owned_series[16] = ""; + int rows_owned = 0; + + if (tv) { + struct tpl_head head; + char *err = NULL; + if (load_template(r->db, r->org_id, tpl_id, tpl_name, &head, &err) != + 0) { + yyjson_mut_val *res = + fail(r, "NOT_FOUND", err ? err : "template not found"); + free(err); + return res; + } + struct tpl_loaded *trows = NULL; + size_t ntrows = 0; + if (load_template_rows(r->db, r->org_id, head.id, &trows, &ntrows, + &err) != 0) { + yyjson_mut_val *res = + fail(r, "INTERNAL", err ? err : "could not load template"); + free(err); + return res; + } + struct template_row *in = + xcalloc(ntrows ? ntrows : 1, sizeof *in); + for (size_t i = 0; i < ntrows; i++) { + in[i].account = trows[i].account; + in[i].formula = trows[i].formula; + in[i].description = trows[i].desc[0] ? trows[i].desc : NULL; + } + struct resolved_row *resolved = + xcalloc(ntrows ? ntrows : 1, sizeof *resolved); + char ferr[256] = ""; + if (formula_resolve_rows(in, ntrows, x, resolved, &nrows, ferr, + sizeof ferr) != 0) { + free(in); + free(resolved); + free(trows); + return fail(r, "INVALID_ARGS", + ferr[0] ? ferr : "template could not be resolved"); + } + free(in); + free(trows); + rows = xcalloc(nrows, sizeof *rows); + for (size_t i = 0; i < nrows; i++) { + rows[i].account = xstrdup(resolved[i].account); + rows[i].debit_ore = resolved[i].debit_ore; + rows[i].credit_ore = resolved[i].credit_ore; + rows[i].description = + resolved[i].description[0] ? xstrdup(resolved[i].description) + : NULL; + } + free(resolved); + rows_owned = 1; + if (!series && head.series[0]) + snprintf(owned_series, sizeof owned_series, "%s", head.series); + if (!description && head.description[0]) + owned_desc = replace_x(head.description, x); + if (!description && !owned_desc) + owned_desc = xstrdup(head.name); + } else { + if (!description) + return fail(r, "INVALID_ARGS", + "date and description are required"); + if (parse_rows(r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, + &rows, &nrows) != 0) + return NULL; + } + + int64_t *att = NULL; + size_t natt = 0; + if (parse_attachment_ids(r, &att, &natt) != 0) { + if (rows_owned) + for (size_t i = 0; i < nrows; i++) { + free((char *)rows[i].account); + free((char *)rows[i].description); + } + free(rows); + free(owned_desc); + return NULL; + } + + 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 = description ? description : owned_desc; + o.series = series ? series + : (owned_series[0] ? owned_series : NULL); + o.rows = rows; + o.nrows = nrows; + o.corrects_voucher_id = corrects; + o.attachment_ids = att; + o.n_attachments = natt; + o.client_ref = client_ref; + o.dry_run = r->dry_run; + + struct ledger_error e; + char *json = NULL; + int rc = ledger_post(r->db, &o, &e, &json); + if (rows_owned) + for (size_t i = 0; i < nrows; i++) { + free((char *)rows[i].account); + free((char *)rows[i].description); + } + free(rows); + free(att); + free(owned_desc); + if (rc != 0) { + if (e.has_details) { + yyjson_mut_val *details = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, details, "difference_ore", + e.difference_ore); + r->err_details = details; + } + return fail(r, e.code ? e.code : "INTERNAL", e.msg); + } + + 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, + "voucher.post", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *res = json_to_mut(r->rdoc, json); + free(json); + if (!res) + return fail(r, "INTERNAL", "could not serialize result"); + return res; +} + +static yyjson_mut_val *voucher_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", + sqlite3_column_int64(st, 1)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "source", + sq(sqlite3_column_text(st, 6))); + if (sqlite3_column_type(st, 7) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id", + sqlite3_column_int64(st, 7)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 8))); + yyjson_mut_obj_add_int(r->rdoc, o, "created_by_user", + sqlite3_column_int64(st, 9)); + char hex[65]; + const void *hb = sqlite3_column_blob(st, 10); + if (hb && sqlite3_column_bytes(st, 10) == 32) { + util_hex((const unsigned char *)hb, 32, hex); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash_prev", hex); + } + const void *vb = sqlite3_column_blob(st, 11); + if (vb && sqlite3_column_bytes(st, 11) == 32) { + util_hex((const unsigned char *)vb, 32, hex); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash", hex); + } + return o; +} + +#define VOUCHER_COLUMNS \ + "id,fiscal_year_id,series,number,date,description,source," \ + "corrects_voucher_id,created_at,created_by_user,hash_prev,hash" + +static yyjson_mut_val *h_voucher_get(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT " VOUCHER_COLUMNS " FROM vouchers 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); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "voucher not found"); + } + yyjson_mut_val *o = voucher_json(r, st); + sqlite3_finalize(st); + + yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); + if (sqlite3_prepare_v2( + r->db, + "SELECT a.number,a.name,r.debit_ore,r.credit_ore," + "COALESCE(r.description,'') FROM voucher_rows r" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, rows); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", + sq(sqlite3_column_text(st, 0))); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_int(r->rdoc, ro, "debit_ore", + sqlite3_column_int64(st, 2)); + yyjson_mut_obj_add_int(r->rdoc, ro, "credit_ore", + sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", + sq(sqlite3_column_text(st, 4))); + } + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); + + yyjson_mut_val *atts = yyjson_mut_arr(r->rdoc); + if (sqlite3_prepare_v2( + r->db, + "SELECT a.id,a.filename FROM voucher_attachments va" + " JOIN attachments a ON a.org_id=va.org_id AND a.id=va.attachment_id" + " WHERE va.org_id=?1 AND va.voucher_id=?2 ORDER BY a.id", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *ao = yyjson_mut_arr_add_obj(r->rdoc, atts); + yyjson_mut_obj_add_int(r->rdoc, ao, "id", + sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, ao, "filename", + sq(sqlite3_column_text(st, 1))); + } + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(r->rdoc, o, "attachments", atts); + return o; +} + +static yyjson_mut_val *h_voucher_list(struct req *r) +{ + int64_t fy = 0, cursor = 0, limit = 100, account_id = 0; + arg_int(r->args, "fiscal_year", &fy); + arg_int(r->args, "cursor", &cursor); + arg_int(r->args, "limit", &limit); + if (limit < 1) + limit = 100; + if (limit > 1000) + limit = 1000; + const char *series = arg_str(r->args, "series"); + const char *from = arg_str(r->args, "from"); + const char *to = arg_str(r->args, "to"); + const char *text = arg_str(r->args, "text"); + const char *account = arg_str(r->args, "account"); + if (account) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id FROM accounts WHERE org_id=?1 AND number=?2", -1, + &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "account not found"); + } + account_id = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "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)," + "(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)" + " AND (?4='' OR v.date>=?4)" + " AND (?5='' OR v.date<=?5)" + " AND (?6=0 OR EXISTS(SELECT 1 FROM voucher_rows r2" + " WHERE r2.org_id=v.org_id AND r2.voucher_id=v.id" + " AND r2.account_id=?6))" + " AND (?7='' OR v.description LIKE '%'||?7||'%')" + " AND v.id>?8 ORDER BY v.id LIMIT ?9", + -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); + sqlite3_bind_text(st, 3, series ? series : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, from ? from : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, to ? to : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 6, account_id); + sqlite3_bind_text(st, 7, text ? text : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 8, cursor); + sqlite3_bind_int64(st, 9, limit); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + int64_t last = cursor, n = 0; + while (sqlite3_step(st) == SQLITE_ROW) { + n++; + last = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", last); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_int(r->rdoc, o, "number", + sqlite3_column_int64(st, 2)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "source", + sq(sqlite3_column_text(st, 5))); + if (sqlite3_column_type(st, 6) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id", + 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); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + if (n == limit) + yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last); + else + yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); + return out; +} + +static yyjson_mut_val *h_voucher_correct(struct req *r) +{ + int64_t id = 0; + const char *description = arg_str(r->args, "description"); + const char *date = arg_str(r->args, "date"); + const char *client_ref = arg_str(r->args, "client_ref"); + if (!arg_int(r->args, "voucher", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "voucher is required"); + if (!description || !*description) + return fail(r, "INVALID_ARGS", "description is required"); + + sqlite3_stmt *st = NULL; + char series[16] = "", orig_date[16] = ""; + if (sqlite3_prepare_v2( + r->db, + "SELECT series,date FROM vouchers 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); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "voucher not found"); + } + snprintf(series, sizeof series, "%s", sqlite3_column_text(st, 0)); + snprintf(orig_date, sizeof orig_date, "%s", sqlite3_column_text(st, 1)); + sqlite3_finalize(st); + + struct ledger_row *rows = NULL; + size_t nrows = 0, cap = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT a.number,r.debit_ore,r.credit_ore," + "COALESCE(r.description,'') FROM voucher_rows r" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", + -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); + while (sqlite3_step(st) == SQLITE_ROW) { + if (nrows == cap) { + cap = cap ? cap * 2 : 8; + rows = xrealloc(rows, cap * sizeof *rows); + } + memset(&rows[nrows], 0, sizeof rows[nrows]); + rows[nrows].account = xstrdup(sq(sqlite3_column_text(st, 0))); + rows[nrows].debit_ore = sqlite3_column_int64(st, 2); + rows[nrows].credit_ore = sqlite3_column_int64(st, 1); + const unsigned char *d = sqlite3_column_text(st, 3); + rows[nrows].description = d && *d ? xstrdup((const char *)d) : NULL; + nrows++; + } + sqlite3_finalize(st); + if (nrows == 0) + return fail(r, "INTERNAL", "voucher has no rows"); + + 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 ? date : orig_date; + o.description = description; + o.series = series; + o.rows = rows; + o.nrows = nrows; + o.corrects_voucher_id = id; + o.client_ref = client_ref; + o.dry_run = r->dry_run; + + struct ledger_error e; + char *json = NULL; + int rc = ledger_post(r->db, &o, &e, &json); + for (size_t i = 0; i < nrows; i++) { + free((char *)rows[i].account); + free((char *)rows[i].description); + } + free(rows); + if (rc != 0) + return fail(r, e.code ? e.code : "INTERNAL", e.msg); + 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, + "voucher.correct", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *res = json_to_mut(r->rdoc, json); + free(json); + if (!res) + return fail(r, "INTERNAL", "could not serialize result"); + return res; +} + + +static const struct cmd_arg args_voucher_post[] = { + { "date", ARG_DATE, 1, NULL, NULL, "Voucher date (YYYY-MM-DD)" }, + { "description", ARG_STR, 0, NULL, NULL, "Voucher text" }, + { "series", ARG_STR, 0, NULL, NULL, + "Number series; defaults to settings.default_series" }, + { "client_ref", ARG_STR, 0, NULL, NULL, + "Idempotency key, unique per org" }, + { "corrects_voucher", ARG_INT, 0, NULL, NULL, + "Voucher id this one corrects" }, + { "rows", ARG_JSON, 0, NULL, NULL, + "Array of {account,debit_ore,credit_ore,description?}" }, + { "template", ARG_JSON, 0, NULL, NULL, + "Template id or name; alternative to rows" }, + { "x", ARG_JSON, 0, NULL, NULL, "Template variable in kronor" }, + { "attachment_ids", ARG_JSON, 0, NULL, NULL, + "Array of attachment ids to link" }, +}; + +static const struct cmd_arg args_voucher_get[] = { + { "id", ARG_INT, 1, NULL, NULL, "Voucher id" }, +}; + +static const struct cmd_arg args_voucher_list[] = { + { "fiscal_year", ARG_INT, 0, NULL, NULL, + "Fiscal year id; defaults to the latest" }, + { "from", ARG_DATE, 0, NULL, NULL, "Earliest date" }, + { "to", ARG_DATE, 0, NULL, NULL, "Latest date" }, + { "series", ARG_STR, 0, NULL, NULL, "Number series filter" }, + { "account", ARG_STR, 0, NULL, NULL, "Account number filter" }, + { "text", ARG_STR, 0, NULL, NULL, "Description substring filter" }, + { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, + { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, +}; + +static const struct cmd_arg args_voucher_correct[] = { + { "voucher", ARG_INT, 1, NULL, NULL, "Voucher id to correct" }, + { "description", ARG_STR, 1, NULL, NULL, "Correction text" }, + { "date", ARG_DATE, 0, NULL, NULL, "Defaults to the original date" }, + { "client_ref", ARG_STR, 0, NULL, NULL, "Idempotency key" }, +}; + +const struct command g_cmd_vouchers[] = { + { "voucher.post", "Post an immutable voucher (rows or template+x)", + PERM_WRITE, 1, 1, 1, h_voucher_post, CMD_ARGS(args_voucher_post) }, + { "voucher.get", "Get a voucher with rows", PERM_READ, 1, 0, 0, + h_voucher_get, CMD_ARGS(args_voucher_get) }, + { "voucher.list", "List vouchers", PERM_READ, 1, 0, 0, h_voucher_list, + CMD_ARGS(args_voucher_list) }, + { "voucher.correct", "Post an ändringsverifikat", PERM_WRITE, 1, 1, 1, + h_voucher_correct, CMD_ARGS(args_voucher_correct) }, +}; + +const struct cmd_table g_cmd_table_vouchers = { + g_cmd_vouchers, sizeof g_cmd_vouchers / sizeof g_cmd_vouchers[0] +}; diff --git a/src/commands.c b/src/commands.c index fe6eeb8..fe97afa 100644 --- a/src/commands.c +++ b/src/commands.c @@ -1,1522 +1,12 @@ #include "commands.h" -#include <errno.h> -#include <stdarg.h> -#include <stdio.h> #include <stdlib.h> #include <string.h> -#include <sys/stat.h> -#include <time.h> -#include <unistd.h> -#include "audit.h" -#include "sha256.h" -#include "auth.h" -#include "config.h" -#include "db.h" -#include "formula.h" -#include "invoice.h" -#include "ledger.h" -#include "log.h" -#include "reports.h" -#include "secret.h" -#include "seed.h" -#include "sie.h" -#include "smtp.h" -#include "sru.h" -#include "util.h" +#include "cmd_util.h" #include "version.h" /* ------------------------------------------------------------------ */ -/* small helpers */ -/* ------------------------------------------------------------------ */ - -static const char *sq(const unsigned char *p) -{ - return p ? (const char *)p : ""; -} - -static const char *arg_str(yyjson_val *args, const char *key) -{ - if (!args || !yyjson_is_obj(args)) - return NULL; - yyjson_val *v = yyjson_obj_get(args, key); - return v && yyjson_is_str(v) ? yyjson_get_str(v) : NULL; -} - -static int arg_int(yyjson_val *args, const char *key, int64_t *out) -{ - if (!args || !yyjson_is_obj(args)) - return 0; - yyjson_val *v = yyjson_obj_get(args, key); - if (!v || !yyjson_is_int(v)) - return 0; - *out = yyjson_get_int(v); - return 1; -} - -static int arg_bool(yyjson_val *args, const char *key, int *out) -{ - if (!args || !yyjson_is_obj(args)) - return 0; - yyjson_val *v = yyjson_obj_get(args, key); - if (!v || !yyjson_is_bool(v)) - return 0; - *out = yyjson_get_bool(v) ? 1 : 0; - return 1; -} - -static const char *arg_type_name(enum arg_type type) -{ - switch (type) { - case ARG_STR: - return "string"; - case ARG_INT: - return "int"; - case ARG_BOOL: - return "bool"; - case ARG_ENUM: - return "enum"; - case ARG_DATE: - return "date"; - case ARG_JSON: - return "json"; - } - return "json"; -} - -static int enum_allowed(const char *values, const char *v) -{ - size_t n = strlen(v); - const char *p = values; - while (p && *p) { - const char *comma = strchr(p, ','); - size_t len = comma ? (size_t)(comma - p) : strlen(p); - if (len == n && strncmp(p, v, n) == 0) - return 1; - if (!comma) - break; - p = comma + 1; - } - return 0; -} - -static void arg_error(char *err, size_t errlen, const char *fmt, ...) - __attribute__((format(printf, 3, 4))); - -static void arg_error(char *err, size_t errlen, const char *fmt, ...) -{ - if (!err || errlen == 0) - return; - va_list ap; - va_start(ap, fmt); - vsnprintf(err, errlen, fmt, ap); - va_end(ap); -} - -int command_validate_args(const struct command *cmd, yyjson_val *args, - char *err, size_t errlen) -{ - if (!cmd || !cmd->args) - return 0; - if (err && errlen) - err[0] = '\0'; - for (size_t i = 0; i < cmd->nargs; i++) { - const struct cmd_arg *a = &cmd->args[i]; - yyjson_val *v = args && yyjson_is_obj(args) - ? yyjson_obj_get(args, a->name) - : NULL; - if (!v || yyjson_is_null(v)) { - if (a->required) { - arg_error(err, errlen, "missing required argument \"%s\"", - a->name); - return -1; - } - continue; - } - int bad = 0, empty = 0; - switch (a->type) { - case ARG_STR: - bad = !yyjson_is_str(v); - empty = !bad && a->required && !*yyjson_get_str(v); - break; - case ARG_INT: - bad = !yyjson_is_int(v); - break; - case ARG_BOOL: - bad = !yyjson_is_bool(v); - break; - case ARG_ENUM: - bad = !yyjson_is_str(v) || - !enum_allowed(a->values, yyjson_get_str(v)); - empty = !bad && a->required && !*yyjson_get_str(v); - break; - case ARG_DATE: - bad = !yyjson_is_str(v) || - !util_parse_iso_date(yyjson_get_str(v)); - break; - case ARG_JSON: - break; - } - if (empty) { - arg_error(err, errlen, "missing required argument \"%s\"", - a->name); - return -1; - } - if (bad) { - switch (a->type) { - case ARG_STR: - arg_error(err, errlen, "argument \"%s\" must be a string", - a->name); - break; - case ARG_INT: - arg_error(err, errlen, "argument \"%s\" must be an integer", - a->name); - break; - case ARG_BOOL: - arg_error(err, errlen, "argument \"%s\" must be a boolean", - a->name); - break; - case ARG_ENUM: - arg_error(err, errlen, "argument \"%s\" must be one of: %s", - a->name, a->values ? a->values : ""); - break; - case ARG_DATE: - arg_error(err, errlen, "argument \"%s\" must be YYYY-MM-DD", - a->name); - break; - case ARG_JSON: - break; - } - return -1; - } - } - return 0; -} - -static yyjson_mut_val *fail(struct req *r, const char *code, const char *msg) -{ - r->err_code = code; - snprintf(r->err_msg, sizeof r->err_msg, "%s", msg); - return NULL; -} - -static yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...) - __attribute__((format(printf, 3, 4))); - -static yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...) -{ - r->err_code = code; - va_list ap; - va_start(ap, fmt); - vsnprintf(r->err_msg, sizeof r->err_msg, fmt, ap); - va_end(ap); - return NULL; -} - -static int mkdir_p(const char *path, mode_t mode) -{ - char tmp[4096]; - if (!path || strlen(path) >= sizeof tmp) - return -1; - strcpy(tmp, path); - for (char *p = tmp + 1; *p; p++) { - if (*p == '/') { - *p = '\0'; - if (mkdir(tmp, mode) != 0 && errno != EEXIST) - return -1; - *p = '/'; - } - } - if (mkdir(tmp, mode) != 0 && errno != EEXIST) - return -1; - return 0; -} - -/* ------------------------------------------------------------------ */ -/* login rate limiting (in-memory, per key) */ -/* ------------------------------------------------------------------ */ - -#define RL_MAX_KEYS 16 -#define RL_MAX_FAILS 5 -#define RL_WINDOW 900 - -struct rl_entry { - char key[64]; - int fails; - int64_t window_end; -}; - -static struct rl_entry g_rl[RL_MAX_KEYS]; - -static struct rl_entry *rl_get(const char *key, int create) -{ - struct rl_entry *slot = NULL; - for (size_t i = 0; i < RL_MAX_KEYS; i++) { - if (g_rl[i].key[0] && strcmp(g_rl[i].key, key) == 0) - return &g_rl[i]; - if (create && !g_rl[i].key[0] && !slot) - slot = &g_rl[i]; - } - if (create && slot) { - snprintf(slot->key, sizeof slot->key, "%s", key); - slot->fails = 0; - slot->window_end = 0; - } - return slot; -} - -static int rl_blocked(const char *key, int64_t *retry_after) -{ - struct rl_entry *e = rl_get(key, 0); - if (!e || e->fails < RL_MAX_FAILS) - return 0; - int64_t now = util_now(); - if (e->window_end <= now) - return 0; - if (retry_after) - *retry_after = e->window_end - now; - return 1; -} - -static void rl_fail(const char *key) -{ - struct rl_entry *e = rl_get(key, 1); - if (!e) - return; - int64_t now = util_now(); - if (e->fails == 0 || e->window_end <= now) - e->window_end = now + RL_WINDOW; - e->fails++; -} - -static void rl_ok(const char *key) -{ - struct rl_entry *e = rl_get(key, 0); - if (e) { - e->fails = 0; - e->window_end = 0; - } -} - -/* ------------------------------------------------------------------ */ -/* public commands */ -/* ------------------------------------------------------------------ */ - -static yyjson_mut_val *h_health(struct req *r) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "ok"); - return o; -} - -static yyjson_mut_val *h_meta(struct req *r) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "server", "bokfd"); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "version", BOKF_VERSION); - yyjson_mut_obj_add_int(r->rdoc, o, "protocol", BOKF_PROTOCOL_VERSION); - yyjson_mut_val *features = yyjson_mut_arr(r->rdoc); - yyjson_mut_arr_add_strcpy(r->rdoc, features, "describe"); - yyjson_mut_arr_add_strcpy(r->rdoc, features, "agent.instructions"); - yyjson_mut_arr_add_strcpy(r->rdoc, features, "orgs"); - yyjson_mut_arr_add_strcpy(r->rdoc, features, "tokens"); - yyjson_mut_arr_add_strcpy(r->rdoc, features, "backup"); - yyjson_mut_obj_add_val(r->rdoc, o, "features", features); - yyjson_mut_val *limits = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, limits, "max_line_bytes", - g_cfg.max_line_bytes); - yyjson_mut_obj_add_int(r->rdoc, limits, "max_attachment_bytes", - g_cfg.max_attachment_bytes); - yyjson_mut_obj_add_int(r->rdoc, limits, "session_ttl_seconds", - g_cfg.session_ttl); - yyjson_mut_obj_add_val(r->rdoc, o, "limits", limits); - yyjson_mut_obj_add_bool(r->rdoc, o, "tcp_enabled", g_cfg.tcp_enabled != 0); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "time", ts); - return o; -} - -static yyjson_mut_val *orgs_for_user(sqlite3 *db, yyjson_mut_doc *doc, - int64_t user_id, int64_t only_org, - int64_t *first_org) -{ - yyjson_mut_val *arr = yyjson_mut_arr(doc); - const char *sql = only_org - ? "SELECT o.id,o.name,m.role FROM memberships m" - " JOIN orgs o ON o.id=m.org_id" - " WHERE m.user_id=?1 AND o.id=?2 ORDER BY o.id" - : "SELECT o.id,o.name,m.role FROM memberships m" - " JOIN orgs o ON o.id=m.org_id" - " WHERE m.user_id=?1 ORDER BY o.id"; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) - return arr; - sqlite3_bind_int64(st, 1, user_id); - if (only_org) - sqlite3_bind_int64(st, 2, only_org); - while (sqlite3_step(st) == SQLITE_ROW) { - int64_t id = sqlite3_column_int64(st, 0); - yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr); - yyjson_mut_obj_add_int(doc, o, "id", id); - yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(doc, o, "role", sq(sqlite3_column_text(st, 2))); - if (first_org && *first_org == 0) - *first_org = id; - } - sqlite3_finalize(st); - return arr; -} - -static yyjson_mut_val *session_payload(struct req *r, struct session *s, - int64_t user_id, const char *username, - const char *display, - yyjson_mut_val *orgs, - int64_t active_org) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "session", s->id); - yyjson_mut_val *u = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, u, "id", user_id); - yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : ""); - yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name", - display ? display : ""); - yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", s->is_admin != 0); - yyjson_mut_obj_add_val(r->rdoc, o, "user", u); - yyjson_mut_obj_add_val(r->rdoc, o, "orgs", orgs); - if (active_org) - yyjson_mut_obj_add_int(r->rdoc, o, "active_org", active_org); - else - yyjson_mut_obj_add_null(r->rdoc, o, "active_org"); - return o; -} - -static yyjson_mut_val *h_session_open(struct req *r) -{ - const char *method = arg_str(r->args, "method"); - if (!method) - return fail(r, "INVALID_ARGS", "method is required"); - char *reqjson = audit_args_json(r->args); - - if (strcmp(method, "password") == 0) { - const char *username = arg_str(r->args, "username"); - const char *password = arg_str(r->args, "password"); - if (!username || !password) { - free(reqjson); - return fail(r, "INVALID_ARGS", "username and password are required"); - } - int64_t retry = 0; - if (rl_blocked("local", &retry)) { - free(reqjson); - return failf(r, "RATE_LIMITED", - "too many failed logins, retry in %lld seconds", - (long long)retry); - } - char *pwhash = NULL, *display = NULL; - int is_admin = 0, disabled = 0; - int64_t uid = auth_user_lookup(r->db, username, &pwhash, &display, - &is_admin, &disabled); - int ok = uid > 0 && !disabled && pwhash && - auth_verify_password(pwhash, password) == 0; - free(pwhash); - if (!ok) { - rl_fail("local"); - audit_append(r->db, 0, uid > 0 ? uid : 0, 0, "auth.fail", reqjson, - "AUTH_FAILED", NULL); - free(display); - free(reqjson); - return fail(r, "AUTH_FAILED", "invalid credentials"); - } - rl_ok("local"); - int64_t active = 0; - yyjson_mut_val *orgs = - orgs_for_user(r->db, r->rdoc, uid, 0, &active); - struct session *s = - sessions_create(uid, is_admin, active, 0, "read,write,admin"); - audit_append(r->db, active, uid, 0, "auth.open", reqjson, "OK", NULL); - yyjson_mut_val *out = session_payload(r, s, uid, username, display, - orgs, active); - free(display); - free(reqjson); - return out; - } - - if (strcmp(method, "token") == 0) { - const char *token = arg_str(r->args, "token"); - if (!token) { - free(reqjson); - return fail(r, "INVALID_ARGS", "token is required"); - } - unsigned char th[32]; - auth_hash_token(token, th); - sqlite3_stmt *st = NULL; - int rc = sqlite3_prepare_v2( - r->db, - "SELECT t.id,t.user_id,t.org_id,t.scopes,t.expires_at,t.revoked_at," - " t.label,u.username,u.display_name,u.is_admin,u.disabled_at" - " FROM api_tokens t JOIN users u ON u.id=t.user_id" - " WHERE t.token_hash=?1", - -1, &st, NULL); - if (rc != SQLITE_OK) { - free(reqjson); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_blob(st, 1, th, 32, SQLITE_TRANSIENT); - int found = sqlite3_step(st) == SQLITE_ROW; - int64_t token_id = 0, uid = 0, org_id = 0; - char *scopes = NULL, *expires = NULL, *revoked = NULL; - char *username = NULL, *display = NULL; - int is_admin = 0, disabled = 0; - if (found) { - token_id = sqlite3_column_int64(st, 0); - uid = sqlite3_column_int64(st, 1); - org_id = sqlite3_column_int64(st, 2); - scopes = xstrdup(sq(sqlite3_column_text(st, 3))); - if (sqlite3_column_type(st, 4) != SQLITE_NULL) - expires = xstrdup(sq(sqlite3_column_text(st, 4))); - if (sqlite3_column_type(st, 5) != SQLITE_NULL) - revoked = xstrdup(sq(sqlite3_column_text(st, 5))); - username = xstrdup(sq(sqlite3_column_text(st, 7))); - display = xstrdup(sq(sqlite3_column_text(st, 8))); - is_admin = sqlite3_column_int(st, 9); - disabled = sqlite3_column_type(st, 10) != SQLITE_NULL; - } - sqlite3_finalize(st); - - int expired = 0; - if (expires) { - char today[16]; - time_t t = (time_t)util_now(); - struct tm tm; - gmtime_r(&t, &tm); - strftime(today, sizeof today, "%Y-%m-%d", &tm); - expired = strcmp(expires, today) < 0; - } - if (!found || revoked || disabled || expired) { - audit_append(r->db, 0, uid, 0, "auth.fail", reqjson, "AUTH_FAILED", - NULL); - free(scopes); - free(expires); - free(revoked); - free(username); - free(display); - free(reqjson); - return fail(r, "AUTH_FAILED", "invalid token"); - } - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *up = NULL; - if (sqlite3_prepare_v2( - r->db, "UPDATE api_tokens SET last_used_at=?1 WHERE id=?2", -1, - &up, NULL) == SQLITE_OK) { - sqlite3_bind_text(up, 1, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(up, 2, token_id); - sqlite3_step(up); - sqlite3_finalize(up); - } - int64_t active = org_id; - yyjson_mut_val *orgs = - orgs_for_user(r->db, r->rdoc, uid, org_id, &active); - struct session *s = sessions_create(uid, is_admin, org_id, org_id, - scopes ? scopes : "read"); - s->active_org = org_id; - s->token_id = token_id; - audit_append(r->db, org_id, uid, token_id, "auth.open", reqjson, "OK", - NULL); - yyjson_mut_val *out = session_payload(r, s, uid, username, display, - orgs, org_id); - free(scopes); - free(expires); - free(revoked); - free(username); - free(display); - free(reqjson); - return out; - } - - free(reqjson); - return fail(r, "UNSUPPORTED", "unsupported auth method"); -} - -static yyjson_mut_val *h_session_close(struct req *r) -{ - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->sess->active_org, r->sess->user_id, 0, - "session.close", reqjson, "OK", NULL); - free(reqjson); - sessions_destroy(r->sess->id); - return yyjson_mut_obj(r->rdoc); -} - -static yyjson_mut_val *h_session_whoami(struct req *r) -{ - char *display = NULL, *username = NULL; - int is_admin = 0; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, - "SELECT username,display_name,is_admin FROM users" - " WHERE id=?1", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->sess->user_id); - if (sqlite3_step(st) == SQLITE_ROW) { - username = xstrdup(sq(sqlite3_column_text(st, 0))); - display = xstrdup(sq(sqlite3_column_text(st, 1))); - is_admin = sqlite3_column_int(st, 2); - } - sqlite3_finalize(st); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_val *u = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, u, "id", r->sess->user_id); - yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : ""); - yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name", display ? display : ""); - yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", is_admin != 0); - yyjson_mut_obj_add_val(r->rdoc, o, "user", u); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", r->sess->scopes); - if (r->sess->active_org) { - yyjson_mut_obj_add_int(r->rdoc, o, "active_org", r->sess->active_org); - char *role = db_membership_role(r->db, r->sess->active_org, - r->sess->user_id); - if (role) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role); - else - yyjson_mut_obj_add_null(r->rdoc, o, "role"); - free(role); - } else { - yyjson_mut_obj_add_null(r->rdoc, o, "active_org"); - yyjson_mut_obj_add_null(r->rdoc, o, "role"); - } - free(username); - free(display); - return o; -} - -static yyjson_mut_val *h_session_list_orgs(struct req *r) -{ - int64_t first = 0; - yyjson_mut_val *orgs = orgs_for_user(r->db, r->rdoc, r->sess->user_id, 0, - &first); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, o, "items", orgs); - return o; -} - -static yyjson_mut_val *h_session_use_org(struct req *r) -{ - int64_t org = 0; - if (!arg_int(r->args, "org", &org) || org <= 0) - return fail(r, "INVALID_ARGS", "org is required"); - if (r->sess->bound_org && org != r->sess->bound_org) - return fail(r, "ORG_FORBIDDEN", "token is bound to another org"); - char *role = db_membership_role(r->db, org, r->sess->user_id); - if (!role) - return fail(r, "ORG_FORBIDDEN", "not a member of this org"); - r->sess->active_org = org; - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "active_org", org); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role); - free(role); - return o; -} - -/* ------------------------------------------------------------------ */ -/* orgs */ -/* ------------------------------------------------------------------ */ - -static yyjson_mut_val *org_json(struct req *r, sqlite3_stmt *st) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", sq(sqlite3_column_text(st, 3))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", - sq(sqlite3_column_text(st, 4))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", - sq(sqlite3_column_text(st, 5))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", sq(sqlite3_column_text(st, 6))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", - sq(sqlite3_column_text(st, 7))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", sq(sqlite3_column_text(st, 8))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "phone", sq(sqlite3_column_text(st, 9))); - yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month", - sqlite3_column_int(st, 10)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period", - sq(sqlite3_column_text(st, 11))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework", - sq(sqlite3_column_text(st, 12))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", - sq(sqlite3_column_text(st, 13))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", - sq(sqlite3_column_text(st, 14))); - yyjson_mut_obj_add_int(r->rdoc, o, "shares", - sqlite3_column_int64(st, 15)); - return o; -} - -#define ORG_COLUMNS \ - "id,name,org_nr,vat_nr,address,postal_code,city,country,email," \ - "phone,fiscal_year_start_month,moms_period,framework,created_at," \ - "description,shares" - -static yyjson_mut_val *h_org_create(struct req *r) -{ - if (!g_cfg.allow_org_create && !r->is_admin) - return fail(r, "FORBIDDEN", "org creation is disabled"); - const char *name = arg_str(r->args, "name"); - if (!name || !*name) - return fail(r, "INVALID_ARGS", "name is required"); - const char *org_nr = arg_str(r->args, "org_nr"); - int64_t fy_month = 1; - arg_int(r->args, "fiscal_year_start_month", &fy_month); - if (fy_month < 1 || fy_month > 12) - return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12"); - const char *moms = arg_str(r->args, "moms_period"); - if (!moms) - moms = "month"; - if (strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 && - strcmp(moms, "year") != 0) - return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year"); - const char *framework = arg_str(r->args, "framework"); - if (!framework) - framework = "K2"; - if (strcmp(framework, "K2") != 0 && strcmp(framework, "K3") != 0) - return fail(r, "INVALID_ARGS", "framework must be K2 or K3"); - - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) - return fail(r, "DB_BUSY", "could not start transaction"); - - sqlite3_stmt *st = NULL; - int rc = sqlite3_prepare_v2( - r->db, - "INSERT INTO orgs(name,org_nr,fiscal_year_start_month,moms_period," - "framework,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7)", - -1, &st, NULL); - if (rc != SQLITE_OK) { - db_exec(r->db, "ROLLBACK", NULL); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT); - if (org_nr) - sqlite3_bind_text(st, 2, org_nr, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 2); - sqlite3_bind_int64(st, 3, fy_month); - sqlite3_bind_text(st, 4, moms, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, framework, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 7, r->sess->user_id); - rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - db_exec(r->db, "ROLLBACK", NULL); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - int64_t org_id = db_last_id(r->db); - - rc = sqlite3_prepare_v2( - r->db, - "INSERT INTO memberships(org_id,user_id,role,created_at)" - " VALUES(?1,?2,'owner',?3)", - -1, &st, NULL); - if (rc != SQLITE_OK) { - db_exec(r->db, "ROLLBACK", NULL); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - sqlite3_bind_int64(st, 1, org_id); - sqlite3_bind_int64(st, 2, r->sess->user_id); - sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT); - rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - db_exec(r->db, "ROLLBACK", NULL); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - int64_t fy_id = 0; - char *seed_err = NULL; - if (seed_org(r->db, org_id, framework, (int)fy_month, &fy_id, - &seed_err) != 0) { - db_exec(r->db, "ROLLBACK", NULL); - yyjson_mut_val *res = - fail(r, "INTERNAL", seed_err ? seed_err : "seeding failed"); - free(seed_err); - return res; - } - if (db_exec(r->db, "COMMIT", NULL) != 0) - return fail(r, "INTERNAL", "commit failed"); - - char *reqjson = audit_args_json(r->args); - audit_append(r->db, org_id, r->sess->user_id, r->sess->token_id, - "org.create", reqjson, "OK", NULL); - free(reqjson); - r->sess->active_org = org_id; - - st = NULL; - if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, org_id); - yyjson_mut_val *o = NULL; - if (sqlite3_step(st) == SQLITE_ROW) - o = org_json(r, st); - sqlite3_finalize(st); - if (!o) - return fail(r, "INTERNAL", "created org not found"); - yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy_id); - return o; -} - -static yyjson_mut_val *h_org_list(struct req *r) -{ - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT o.id,o.name,o.org_nr,m.role FROM memberships m" - " JOIN orgs o ON o.id=m.org_id WHERE m.user_id=?1 ORDER BY o.id", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->sess->user_id); - while (sqlite3_step(st) == SQLITE_ROW) { - yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", - sq(sqlite3_column_text(st, 3))); - } - sqlite3_finalize(st); - yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - return out; -} - -static yyjson_mut_val *h_org_get(struct req *r) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - yyjson_mut_val *o = NULL; - if (sqlite3_step(st) == SQLITE_ROW) - o = org_json(r, st); - sqlite3_finalize(st); - if (!o) - return fail(r, "NOT_FOUND", "org not found"); - return o; -} - -static yyjson_mut_val *h_org_update(struct req *r) -{ - const char *name = arg_str(r->args, "name"); - const char *org_nr = arg_str(r->args, "org_nr"); - const char *vat_nr = arg_str(r->args, "vat_nr"); - const char *address = arg_str(r->args, "address"); - const char *postal = arg_str(r->args, "postal_code"); - const char *city = arg_str(r->args, "city"); - const char *country = arg_str(r->args, "country"); - const char *email = arg_str(r->args, "email"); - const char *phone = arg_str(r->args, "phone"); - const char *moms = arg_str(r->args, "moms_period"); - const char *framework = arg_str(r->args, "framework"); - const char *description = arg_str(r->args, "description"); - int64_t fy_month = 0; - arg_int(r->args, "fiscal_year_start_month", &fy_month); - int64_t shares = -1; - arg_int(r->args, "shares", &shares); - if (!name && !org_nr && !vat_nr && !address && !postal && !city && - !country && !email && !phone && !moms && !framework && fy_month == 0 && - !description && shares < 0) - return fail(r, "INVALID_ARGS", "nothing to update"); - if (name && !*name) - return fail(r, "INVALID_ARGS", "name cannot be empty"); - if (fy_month && (fy_month < 1 || fy_month > 12)) - return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12"); - if (shares < -1) - return fail(r, "INVALID_ARGS", "shares must be >= 0"); - if (moms && strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 && - strcmp(moms, "year") != 0) - return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year"); - if (framework && strcmp(framework, "K2") != 0 && - strcmp(framework, "K3") != 0) - return fail(r, "INVALID_ARGS", "framework must be K2 or K3"); - - static const char *const keys[10] = { "name", "org_nr", "vat_nr", - "address", "postal_code", "city", - "country", "email", "phone", - "description" }; - const char *texts[10] = { name, org_nr, vat_nr, address, postal, - city, country, email, phone, description }; - - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - for (int i = 0; i < 10; i++) - if (texts[i]) - yyjson_mut_obj_add_strcpy(r->rdoc, o, keys[i], texts[i]); - if (fy_month) - yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month", - fy_month); - if (moms) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period", moms); - if (framework) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework", framework); - if (shares >= 0) - yyjson_mut_obj_add_int(r->rdoc, o, "shares", shares); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; - } - - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE orgs SET" - " name=COALESCE(?2,name), org_nr=COALESCE(?3,org_nr)," - " vat_nr=COALESCE(?4,vat_nr), address=COALESCE(?5,address)," - " postal_code=COALESCE(?6,postal_code), city=COALESCE(?7,city)," - " country=COALESCE(?8,country), email=COALESCE(?9,email)," - " phone=COALESCE(?10,phone), description=COALESCE(?11," - " description)," - " fiscal_year_start_month=CASE WHEN ?12=0" - " THEN fiscal_year_start_month ELSE ?12 END," - " moms_period=COALESCE(?13,moms_period)," - " framework=COALESCE(?14,framework)," - " shares=CASE WHEN ?15<0 THEN shares ELSE ?15 END" - " WHERE id=?1", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - for (int i = 0; i < 10; i++) { - if (texts[i]) - sqlite3_bind_text(st, i + 2, texts[i], -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, i + 2); - } - sqlite3_bind_int64(st, 12, fy_month); - if (moms) - sqlite3_bind_text(st, 13, moms, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 13); - if (framework) - sqlite3_bind_text(st, 14, framework, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 14); - sqlite3_bind_int64(st, 15, shares); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "org.update", reqjson, "OK", NULL); - free(reqjson); - - st = NULL; - if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS - " FROM orgs WHERE id=?1", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - yyjson_mut_val *o = NULL; - if (sqlite3_step(st) == SQLITE_ROW) - o = org_json(r, st); - sqlite3_finalize(st); - if (!o) - return fail(r, "NOT_FOUND", "org not found"); - return o; -} - -static yyjson_mut_val *h_org_member_list(struct req *r) -{ - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT u.id,u.username,u.display_name,m.role FROM memberships m" - " JOIN users u ON u.id=m.user_id WHERE m.org_id=?1 ORDER BY u.id", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - while (sqlite3_step(st) == SQLITE_ROW) { - yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, o, "user_id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", - sq(sqlite3_column_text(st, 3))); - } - sqlite3_finalize(st); - yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - return out; -} - -static int64_t username_to_id(sqlite3 *db, const char *username) -{ - sqlite3_stmt *st = NULL; - int64_t id = -1; - if (sqlite3_prepare_v2(db, "SELECT id FROM users WHERE username=?1", -1, - &st, NULL) != SQLITE_OK) - return -1; - sqlite3_bind_text(st, 1, username, -1, SQLITE_TRANSIENT); - if (sqlite3_step(st) == SQLITE_ROW) - id = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - return id; -} - -static int64_t owner_count(sqlite3 *db, int64_t org_id) -{ - sqlite3_stmt *st = NULL; - int64_t n = -1; - if (sqlite3_prepare_v2( - db, "SELECT count(*) FROM memberships WHERE org_id=?1 AND role='owner'", - -1, &st, NULL) != SQLITE_OK) - return -1; - sqlite3_bind_int64(st, 1, org_id); - if (sqlite3_step(st) == SQLITE_ROW) - n = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - return n; -} - -static int valid_role(const char *role) -{ - return role && (strcmp(role, "owner") == 0 || - strcmp(role, "bookkeeper") == 0 || - strcmp(role, "viewer") == 0); -} - -static yyjson_mut_val *h_org_member_add(struct req *r) -{ - const char *username = arg_str(r->args, "username"); - const char *role = arg_str(r->args, "role"); - if (!username || !valid_role(role)) - return fail(r, "INVALID_ARGS", "username and a valid role are required"); - int64_t uid = username_to_id(r->db, username); - if (uid < 0) - return fail(r, "NOT_FOUND", "user not found"); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO memberships(org_id,user_id,role,created_at)" - " VALUES(?1,?2,?3,?4)", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, uid); - sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "CONFLICT", "user is already a member"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.add", reqjson, - "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -static yyjson_mut_val *h_org_member_set_role(struct req *r) -{ - const char *username = arg_str(r->args, "username"); - const char *role = arg_str(r->args, "role"); - if (!username || !valid_role(role)) - return fail(r, "INVALID_ARGS", "username and a valid role are required"); - int64_t uid = username_to_id(r->db, username); - if (uid < 0) - return fail(r, "NOT_FOUND", "user not found"); - char *current = db_membership_role(r->db, r->org_id, uid); - if (!current) - return fail(r, "NOT_FOUND", "user is not a member"); - if (strcmp(current, "owner") == 0 && strcmp(role, "owner") != 0 && - owner_count(r->db, r->org_id) <= 1) { - free(current); - return fail(r, "CONFLICT", "cannot demote the last owner"); - } - free(current); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE memberships SET role=?1 WHERE org_id=?2 AND user_id=?3", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_text(st, 1, role, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 2, r->org_id); - sqlite3_bind_int64(st, 3, uid); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.set_role", - reqjson, "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -static yyjson_mut_val *h_org_member_remove(struct req *r) -{ - const char *username = arg_str(r->args, "username"); - if (!username) - return fail(r, "INVALID_ARGS", "username is required"); - int64_t uid = username_to_id(r->db, username); - if (uid < 0) - return fail(r, "NOT_FOUND", "user not found"); - char *current = db_membership_role(r->db, r->org_id, uid); - if (!current) - return fail(r, "NOT_FOUND", "user is not a member"); - if (strcmp(current, "owner") == 0 && owner_count(r->db, r->org_id) <= 1) { - free(current); - return fail(r, "CONFLICT", "cannot remove the last owner"); - } - free(current); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, "DELETE FROM memberships WHERE org_id=?1 AND user_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, uid); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.remove", - reqjson, "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -/* ------------------------------------------------------------------ */ -/* board members (årsredovisning signatures) */ -/* ------------------------------------------------------------------ */ - -static yyjson_mut_val *board_json(struct req *r, sqlite3_stmt *st) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", - sq(sqlite3_column_text(st, 2))); - return o; -} - -static yyjson_mut_val *h_board_list(struct req *r) -{ - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, - "SELECT id,name,title FROM board_members" - " WHERE org_id=?1 ORDER BY id", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - while (sqlite3_step(st) == SQLITE_ROW) - yyjson_mut_arr_add_val(items, board_json(r, st)); - sqlite3_finalize(st); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, o, "items", items); - return o; -} - -static yyjson_mut_val *board_get(struct req *r, int64_t id) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, - "SELECT id,name,title FROM board_members" - " 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); - yyjson_mut_val *o = NULL; - if (sqlite3_step(st) == SQLITE_ROW) - o = board_json(r, st); - sqlite3_finalize(st); - if (!o) - return fail(r, "NOT_FOUND", "board member not found"); - return o; -} - -static yyjson_mut_val *h_board_add(struct req *r) -{ - const char *name = arg_str(r->args, "name"); - const char *title = arg_str(r->args, "title"); - if (!name || !*name) - return fail(r, "INVALID_ARGS", "name is required"); - if (!title || !*title) - title = "Styrelseledamot"; - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title); - return o; - } - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, - "INSERT INTO board_members(org_id,name,title," - "created_at) VALUES(?1,?2,?3,?4)", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); - 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, - "board.add", reqjson, "OK", NULL); - free(reqjson); - return board_get(r, id); -} - -static yyjson_mut_val *h_board_update(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - const char *name = arg_str(r->args, "name"); - const char *title = arg_str(r->args, "title"); - if (!name && !title) - return fail(r, "INVALID_ARGS", "nothing to update"); - if (name && !*name) - return fail(r, "INVALID_ARGS", "name cannot be empty"); - if (title && !*title) - return fail(r, "INVALID_ARGS", "title cannot be empty"); - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - yyjson_mut_obj_add_int(r->rdoc, o, "id", id); - if (name) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); - if (title) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title); - return o; - } - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, - "UPDATE board_members SET name=COALESCE(?3,name)," - " title=COALESCE(?4,title)" - " 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); - if (name) - sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 3); - if (title) - sqlite3_bind_text(st, 4, title, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 4); - 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", "board member not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "board.update", reqjson, "OK", NULL); - free(reqjson); - return board_get(r, id); -} - -static yyjson_mut_val *h_board_remove(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - yyjson_mut_obj_add_int(r->rdoc, o, "id", id); - return o; - } - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, - "DELETE FROM board_members" - " 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", "board member not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "board.remove", reqjson, "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -/* ------------------------------------------------------------------ */ -/* users and tokens */ -/* ------------------------------------------------------------------ */ - -static yyjson_mut_val *h_user_create(struct req *r) -{ - const char *username = arg_str(r->args, "username"); - const char *password = arg_str(r->args, "password"); - const char *display = arg_str(r->args, "display_name"); - if (!username || !password) - return fail(r, "INVALID_ARGS", "username and password are required"); - int is_admin = 0; - arg_bool(r->args, "is_admin", &is_admin); - char *err = NULL; - int64_t uid = 0; - if (db_create_user(r->db, username, display, password, is_admin, &uid, - &err) != 0) { - const char *code = err && strstr(err, "already exists") ? "CONFLICT" - : "INTERNAL"; - yyjson_mut_val *res = fail(r, code, err ? err : "could not create user"); - free(err); - return res; - } - free(err); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, 0, r->sess->user_id, 0, "user.create", reqjson, "OK", - NULL); - free(reqjson); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", uid); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", username); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", - display ? display : username); - yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", is_admin != 0); - return o; -} - -static yyjson_mut_val *h_user_list(struct req *r) -{ - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT id,username,display_name,is_admin,created_at,disabled_at" - " FROM users ORDER BY id", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - while (sqlite3_step(st) == SQLITE_ROW) { - yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", - sqlite3_column_int(st, 3) != 0); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", - sq(sqlite3_column_text(st, 4))); - if (sqlite3_column_type(st, 5) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "disabled_at"); - else - yyjson_mut_obj_add_strcpy(r->rdoc, o, "disabled_at", - sq(sqlite3_column_text(st, 5))); - } - sqlite3_finalize(st); - yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - return out; -} - -static int scope_allowed(const char *role, const char *scope) -{ - int is_owner = strcmp(role, "owner") == 0; - int is_bookkeeper = is_owner || strcmp(role, "bookkeeper") == 0; - if (strcmp(scope, "read") == 0) - return 1; - if (strcmp(scope, "write") == 0) - return is_bookkeeper; - if (strcmp(scope, "admin") == 0) - return is_owner; - return 0; -} - -static yyjson_mut_val *h_token_create(struct req *r) -{ - const char *label = arg_str(r->args, "label"); - if (!label || !*label) - return fail(r, "INVALID_ARGS", "label is required"); - char scopes[64] = ""; - yyjson_val *sv = r->args && yyjson_is_obj(r->args) - ? yyjson_obj_get(r->args, "scopes") - : NULL; - if (sv) { - if (!yyjson_is_arr(sv)) - return fail(r, "INVALID_ARGS", "scopes must be an array"); - yyjson_val *item; - yyjson_arr_iter it = yyjson_arr_iter_with(sv); - while ((item = yyjson_arr_iter_next(&it))) { - const char *s = yyjson_get_str(item); - if (!s || !scope_allowed(r->role, s)) - return fail(r, "FORBIDDEN", "scope not allowed for your role"); - if (scopes[0]) - strncat(scopes, ",", sizeof scopes - strlen(scopes) - 1); - strncat(scopes, s, sizeof scopes - strlen(scopes) - 1); - } - } - if (!scopes[0]) - snprintf(scopes, sizeof scopes, "%s", - strcmp(r->role, "owner") == 0 - ? "read,write,admin" - : (strcmp(r->role, "bookkeeper") == 0 ? "read,write" - : "read")); - const char *expires_at = arg_str(r->args, "expires_at"); - if (expires_at && !util_parse_iso_date(expires_at)) - return fail(r, "INVALID_ARGS", "expires_at must be YYYY-MM-DD"); - char *token = auth_generate_token(); - unsigned char th[32]; - auth_hash_token(token, th); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO api_tokens(org_id,user_id,label,token_hash,scopes," - "created_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7)", - -1, &st, NULL) != SQLITE_OK) { - free(token); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, r->sess->user_id); - sqlite3_bind_text(st, 3, label, -1, SQLITE_TRANSIENT); - sqlite3_bind_blob(st, 4, th, 32, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, scopes, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); - if (expires_at) - sqlite3_bind_text(st, 7, expires_at, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 7); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - free(token); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - int64_t token_id = db_last_id(r->db); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, token_id, "token.create", - reqjson, "OK", NULL); - free(reqjson); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", token_id); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", scopes); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "token", token); - if (expires_at) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", expires_at); - else - yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); - free(token); - return o; -} - -static yyjson_mut_val *h_token_list(struct req *r) -{ - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT id,label,scopes,created_at,expires_at,last_used_at," - "revoked_at FROM api_tokens WHERE org_id=?1 AND user_id=?2" - " ORDER BY id", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, r->sess->user_id); - while (sqlite3_step(st) == SQLITE_ROW) { - yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", - sq(sqlite3_column_text(st, 3))); - if (sqlite3_column_type(st, 4) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); - else - yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", - sq(sqlite3_column_text(st, 4))); - if (sqlite3_column_type(st, 5) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "last_used_at"); - else - yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_used_at", - sq(sqlite3_column_text(st, 5))); - if (sqlite3_column_type(st, 6) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "revoked_at"); - else - yyjson_mut_obj_add_strcpy(r->rdoc, o, "revoked_at", - sq(sqlite3_column_text(st, 6))); - } - sqlite3_finalize(st); - yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - return out; -} - -static yyjson_mut_val *h_token_revoke(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - int is_owner = strcmp(r->role, "owner") == 0; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - is_owner - ? "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" - : "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" - " AND user_id=?4", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 2, id); - sqlite3_bind_int64(st, 3, r->org_id); - if (!is_owner) - sqlite3_bind_int64(st, 4, r->sess->user_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", "token not found"); - audit_append(r->db, r->org_id, r->sess->user_id, id, "token.revoke", "{}", - "OK", NULL); - return yyjson_mut_obj(r->rdoc); -} - -/* ------------------------------------------------------------------ */ /* discovery */ /* ------------------------------------------------------------------ */ @@ -1580,8 +70,11 @@ static yyjson_mut_val *h_describe(struct req *r) return o; } yyjson_mut_val *arr = yyjson_mut_arr(r->rdoc); - for (size_t i = 0; i < g_commands_count; i++) - yyjson_mut_arr_add_val(arr, command_json(r, &g_commands[i])); + for (size_t t = 0; t < g_command_tables_count; t++) { + const struct cmd_table *tab = g_command_tables[t]; + for (size_t i = 0; i < tab->n; i++) + yyjson_mut_arr_add_val(arr, command_json(r, &tab->cmds[i])); + } yyjson_mut_obj_add_val(r->rdoc, o, "commands", arr); return o; } @@ -1637,6802 +130,32 @@ static yyjson_mut_val *h_agent_instructions(struct req *r) return o; } -/* ------------------------------------------------------------------ */ -/* audit and backup */ -/* ------------------------------------------------------------------ */ - -static yyjson_mut_val *h_audit_list(struct req *r) -{ - int64_t cursor = 0, limit = 100; - arg_int(r->args, "cursor", &cursor); - arg_int(r->args, "limit", &limit); - if (limit < 1) - limit = 100; - if (limit > 1000) - limit = 1000; - const char *action = arg_str(r->args, "action"); - - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *st = NULL; - const char *sql = action - ? "SELECT seq,at,actor_user_id,actor_token_id,action," - "request_json,result_code FROM audit_log" - " WHERE org_id=?1 AND seq>?2 AND action=?3" - " ORDER BY seq LIMIT ?4" - : "SELECT seq,at,actor_user_id,actor_token_id,action," - "request_json,result_code FROM audit_log" - " WHERE org_id=?1 AND seq>?2 ORDER BY seq LIMIT ?4"; - 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); - sqlite3_bind_int64(st, 2, cursor); - if (action) - sqlite3_bind_text(st, 3, action, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 4, limit); - int64_t last_seq = cursor; - int64_t n = 0; - while (sqlite3_step(st) == SQLITE_ROW) { - n++; - last_seq = sqlite3_column_int64(st, 0); - yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, o, "seq", last_seq); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", - sq(sqlite3_column_text(st, 1))); - if (sqlite3_column_type(st, 2) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "actor_user_id"); - else - yyjson_mut_obj_add_int(r->rdoc, o, "actor_user_id", - sqlite3_column_int64(st, 2)); - if (sqlite3_column_type(st, 3) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "actor_token_id"); - else - yyjson_mut_obj_add_int(r->rdoc, o, "actor_token_id", - sqlite3_column_int64(st, 3)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "action", - sq(sqlite3_column_text(st, 4))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "result_code", - sq(sqlite3_column_text(st, 6))); - const char *req = (const char *)sqlite3_column_text(st, 5); - yyjson_doc *parsed = req ? yyjson_read(req, strlen(req), 0) : NULL; - if (parsed && yyjson_is_obj(yyjson_doc_get_root(parsed))) - yyjson_mut_obj_add_val(r->rdoc, o, "request", - yyjson_val_mut_copy(r->rdoc, - yyjson_doc_get_root(parsed))); - if (parsed) - yyjson_doc_free(parsed); - } - sqlite3_finalize(st); - yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - if (n == limit) - yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last_seq); - else - yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); - return out; -} - -static int verify_attachments(sqlite3 *db, int64_t *checked, int64_t *bad_id, - char **err) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(db, - "SELECT id,sha256,size_bytes,content FROM attachments" - " ORDER BY id", - -1, &st, NULL) != SQLITE_OK) { - if (err && !*err) - *err = xstrdup(sqlite3_errmsg(db)); - return -1; - } - int ret = 0; - for (;;) { - int step = sqlite3_step(st); - if (step == SQLITE_DONE) - break; - if (step != SQLITE_ROW) { - if (err && !*err) - *err = xstrdup(sqlite3_errmsg(db)); - ret = -1; - break; - } - int64_t id = sqlite3_column_int64(st, 0); - const void *sha = sqlite3_column_blob(st, 1); - int sha_n = sqlite3_column_bytes(st, 1); - int64_t size = sqlite3_column_int64(st, 2); - const void *content = sqlite3_column_blob(st, 3); - int content_n = sqlite3_column_bytes(st, 3); - unsigned char digest[32]; - util_sha256(content, (size_t)content_n, digest); - (*checked)++; - if (sha_n != 32 || memcmp(sha, digest, 32) != 0 || - size != content_n) { - if (!*bad_id) - *bad_id = id; - ret = 1; - } - } - sqlite3_finalize(st); - return ret; -} - -static yyjson_mut_val *h_audit_verify(struct req *r) -{ - int full = 0; - arg_bool(r->args, "full", &full); - - int64_t audit_checked = 0, audit_bad = 0; - char *err = NULL; - int audit_rc = audit_verify(r->db, &audit_checked, &audit_bad, &err); - if (audit_rc < 0) { - yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); - free(err); - return res; - } - free(err); - err = NULL; - - struct ledger_verify_result lv; - int ledger_rc = ledger_verify(r->db, &lv, &err); - if (ledger_rc < 0) { - yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); - free(err); - return res; - } - free(err); - err = NULL; - - int64_t att_checked = 0, att_bad = 0; - int att_rc = 0; - if (full) { - att_rc = verify_attachments(r->db, &att_checked, &att_bad, &err); - if (att_rc < 0) { - yyjson_mut_val *res = - fail(r, "INTERNAL", err ? err : "verify failed"); - free(err); - return res; - } - free(err); - } - - int ok = audit_rc == 0 && ledger_rc == 0 && att_rc == 0; - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_bool(r->rdoc, o, "ok", ok); - yyjson_mut_obj_add_int(r->rdoc, o, "checked", audit_checked); - yyjson_mut_obj_add_int(r->rdoc, o, "vouchers_checked", - lv.vouchers_checked); - yyjson_mut_obj_add_int(r->rdoc, o, "unbalanced_vouchers", - lv.unbalanced_vouchers); - if (audit_bad) - yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", audit_bad); - else - yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_seq"); - if (lv.first_bad_voucher_id) - yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_voucher_id", - lv.first_bad_voucher_id); - else - yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_voucher_id"); - if (lv.first_unbalanced_voucher_id) - yyjson_mut_obj_add_int(r->rdoc, o, "first_unbalanced_voucher_id", - lv.first_unbalanced_voucher_id); - else - yyjson_mut_obj_add_null(r->rdoc, o, "first_unbalanced_voucher_id"); - if (full) { - yyjson_mut_obj_add_int(r->rdoc, o, "attachments_checked", - att_checked); - if (att_bad) - yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_attachment_id", - att_bad); - else - yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_attachment_id"); - } - return o; -} - -static yyjson_mut_val *h_backup_snapshot(struct req *r) -{ - const char *dest = arg_str(r->args, "dest"); - if (mkdir_p(g_cfg.backup_dir, 0700) != 0) - return fail(r, "INTERNAL", "cannot create backup directory"); - char path[4096]; - if (dest && *dest) { - snprintf(path, sizeof path, "%s", dest); - } else { - char stamp[32]; - time_t t = (time_t)util_now(); - struct tm tm; - gmtime_r(&t, &tm); - strftime(stamp, sizeof stamp, "%Y%m%dT%H%M%SZ", &tm); - snprintf(path, sizeof path, "%s/bokfd-%s.db", g_cfg.backup_dir, stamp); - } - if (access(path, F_OK) == 0) - return fail(r, "CONFLICT", "destination already exists"); - char *sql = sqlite3_mprintf("VACUUM INTO %Q", path); - int rc = db_exec(r->db, sql, NULL); - sqlite3_free(sql); - if (rc != 0) - return fail(r, "INTERNAL", "VACUUM INTO failed"); - - struct stat sb; - if (stat(path, &sb) != 0) - return fail(r, "INTERNAL", "backup file not found after snapshot"); - FILE *f = fopen(path, "rb"); - if (!f) - return fail(r, "INTERNAL", "cannot read backup file"); - SHA256_CTX ctx; - sha256_init(&ctx); - unsigned char chunk[65536]; - size_t rn; - while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) - sha256_update(&ctx, (const BYTE *)chunk, rn); - fclose(f); - unsigned char digest[32]; - sha256_final(&ctx, digest); - char hex[65]; - util_hex(digest, 32, hex); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - - audit_append(r->db, r->org_id, r->sess->user_id, 0, "backup.snapshot", - "{}", "OK", NULL); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); - yyjson_mut_obj_add_int(r->rdoc, o, "size", (int64_t)sb.st_size); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts); - return o; -} - -/* ------------------------------------------------------------------ */ -/* ------------------------------------------------------------------ */ -/* shared helpers for M2 commands */ -/* ------------------------------------------------------------------ */ - -static yyjson_mut_val *json_to_mut(yyjson_mut_doc *doc, const char *json) -{ - if (!json) - return NULL; - yyjson_doc *d = yyjson_read(json, strlen(json), 0); - if (!d) - return NULL; - yyjson_mut_val *v = yyjson_val_mut_copy(doc, yyjson_doc_get_root(d)); - yyjson_doc_free(d); - return v; -} - -static int hash_file(const char *path, char hex[65], int64_t *size) -{ - FILE *f = fopen(path, "rb"); - if (!f) - return -1; - SHA256_CTX ctx; - sha256_init(&ctx); - unsigned char chunk[65536]; - size_t rn; - int64_t total = 0; - while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) { - sha256_update(&ctx, (const BYTE *)chunk, rn); - total += (int64_t)rn; - } - int bad = ferror(f); - fclose(f); - if (bad) - return -1; - unsigned char digest[32]; - sha256_final(&ctx, digest); - util_hex(digest, 32, hex); - if (size) - *size = total; - return 0; -} - -static int is_digits(const char *s) -{ - if (!s || !*s) - return 0; - for (; *s; s++) - if (*s < '0' || *s > '9') - return 0; - return 1; -} - -/* ------------------------------------------------------------------ */ -/* kontoplan */ -/* ------------------------------------------------------------------ */ - -static yyjson_mut_val *account_json(yyjson_mut_doc *doc, sqlite3_stmt *st) -{ - yyjson_mut_val *o = yyjson_mut_obj(doc); - yyjson_mut_obj_add_int(doc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(doc, o, "number", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(doc, o, "type", sq(sqlite3_column_text(st, 3))); - if (sqlite3_column_type(st, 4) == SQLITE_NULL) - yyjson_mut_obj_add_null(doc, o, "sru_code"); - else - yyjson_mut_obj_add_strcpy(doc, o, "sru_code", - sq(sqlite3_column_text(st, 4))); - if (sqlite3_column_type(st, 5) == SQLITE_NULL) - yyjson_mut_obj_add_null(doc, o, "vat_code"); - else - yyjson_mut_obj_add_strcpy(doc, o, "vat_code", - sq(sqlite3_column_text(st, 5))); - yyjson_mut_obj_add_bool(doc, o, "active", sqlite3_column_int(st, 6) != 0); - return o; -} - -#define ACCOUNT_COLUMNS "id,number,name,type,sru_code,vat_code,active" - -static yyjson_mut_val *h_account_list(struct req *r) -{ - int active_only = 0; - arg_bool(r->args, "active_only", &active_only); - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *st = NULL; - const char *sql = active_only - ? "SELECT " ACCOUNT_COLUMNS " FROM accounts" - " WHERE org_id=?1 AND active=1 ORDER BY number" - : "SELECT " ACCOUNT_COLUMNS " FROM accounts" - " WHERE org_id=?1 ORDER BY number"; - 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); - while (sqlite3_step(st) == SQLITE_ROW) - yyjson_mut_arr_add_val(items, account_json(r->rdoc, st)); - sqlite3_finalize(st); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, o, "items", items); - return o; -} - -static yyjson_mut_val *h_account_get(struct req *r) -{ - int64_t id = 0; - const char *number = arg_str(r->args, "number"); - arg_int(r->args, "id", &id); - if (!id && !number) - return fail(r, "INVALID_ARGS", "id or number is required"); - sqlite3_stmt *st = NULL; - const char *sql = id ? "SELECT " ACCOUNT_COLUMNS " FROM accounts" - " WHERE org_id=?1 AND id=?2" - : "SELECT " ACCOUNT_COLUMNS " FROM accounts" - " WHERE org_id=?1 AND number=?2"; - 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 (id) - sqlite3_bind_int64(st, 2, id); - else - sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT); - yyjson_mut_val *o = NULL; - if (sqlite3_step(st) == SQLITE_ROW) - o = account_json(r->rdoc, st); - sqlite3_finalize(st); - if (!o) - return fail(r, "NOT_FOUND", "account not found"); - return o; -} - -static int valid_account_type(const char *t) -{ - return t && (!strcmp(t, "asset") || !strcmp(t, "liability") || - !strcmp(t, "equity") || !strcmp(t, "revenue") || - !strcmp(t, "expense")); -} - -static yyjson_mut_val *h_account_create(struct req *r) -{ - const char *number = arg_str(r->args, "number"); - const char *name = arg_str(r->args, "name"); - const char *type = arg_str(r->args, "type"); - const char *sru = arg_str(r->args, "sru_code"); - const char *vat = arg_str(r->args, "vat_code"); - if (!is_digits(number) || strlen(number) > 10) - return fail(r, "INVALID_ARGS", "number must be 1-10 digits"); - if (!name || !*name) - return fail(r, "INVALID_ARGS", "name is required"); - if (!valid_account_type(type)) - return fail(r, "INVALID_ARGS", - "type must be asset, liability, equity, revenue or expense"); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO accounts(org_id,number,name,type,sru_code,vat_code," - "created_at) 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, number, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, type, -1, SQLITE_TRANSIENT); - if (sru) - sqlite3_bind_text(st, 5, sru, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 5); - if (vat) - sqlite3_bind_text(st, 6, vat, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 6); - sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - if ((rc & 0xff) == SQLITE_CONSTRAINT) - return fail(r, "CONFLICT", "account number already exists"); - 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, - "account.create", 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_strcpy(r->rdoc, o, "number", number); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "type", type); - return o; -} - -static yyjson_mut_val *h_account_update(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - const char *name = arg_str(r->args, "name"); - const char *sru = arg_str(r->args, "sru_code"); - const char *vat = arg_str(r->args, "vat_code"); - int active = -1; - arg_bool(r->args, "active", &active); - if (!name && !sru && !vat && active < 0) - return fail(r, "INVALID_ARGS", "nothing to update"); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE accounts SET" - " name=COALESCE(?2,name), sru_code=COALESCE(?3,sru_code)," - " vat_code=COALESCE(?4,vat_code)," - " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6" - " WHERE org_id=?1 AND id=?7", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - if (name) - sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 2); - if (sru) - sqlite3_bind_text(st, 3, sru, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 3); - if (vat) - sqlite3_bind_text(st, 4, vat, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 4); - sqlite3_bind_int(st, 5, active); - sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 7, 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", "account not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "account.update", reqjson, "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -/* ------------------------------------------------------------------ */ -/* fiscal years and period locks */ -/* ------------------------------------------------------------------ */ - -static yyjson_mut_val *fy_json(struct req *r, sqlite3_stmt *st) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", - sq(sqlite3_column_text(st, 3))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", - sq(sqlite3_column_text(st, 4))); - if (sqlite3_column_type(st, 5) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "locked_until"); - else - yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", - sq(sqlite3_column_text(st, 5))); - yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", - sqlite3_column_int64(st, 6)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "events", - sq(sqlite3_column_text(st, 7))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "agm_date", - sq(sqlite3_column_text(st, 8))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "dividend_date", - sq(sqlite3_column_text(st, 9))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "employees", - sq(sqlite3_column_text(st, 10))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", - sq(sqlite3_column_text(st, 11))); - return o; -} - -#define FY_COLUMNS \ - "id,label,start_date,end_date,status,locked_until,dividend_ore," \ - "events,agm_date,dividend_date,employees,notes" - -static yyjson_mut_val *h_fiscal_year_list(struct req *r) -{ - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT " FY_COLUMNS " FROM fiscal_years WHERE org_id=?1" - " ORDER BY start_date", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - while (sqlite3_step(st) == SQLITE_ROW) - yyjson_mut_arr_add_val(items, fy_json(r, st)); - sqlite3_finalize(st); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, o, "items", items); - return o; -} - -static yyjson_mut_val *h_fiscal_year_get(struct req *r) -{ - int64_t id = 0; - arg_int(r->args, "id", &id); - sqlite3_stmt *st = NULL; - const char *sql = id - ? "SELECT " FY_COLUMNS " FROM fiscal_years" - " WHERE org_id=?1 AND id=?2" - : "SELECT " FY_COLUMNS " FROM fiscal_years" - " WHERE org_id=?1 ORDER BY start_date DESC LIMIT 1"; - 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 (id) - sqlite3_bind_int64(st, 2, id); - yyjson_mut_val *o = NULL; - if (sqlite3_step(st) == SQLITE_ROW) - o = fy_json(r, st); - sqlite3_finalize(st); - if (!o) - return fail(r, "NOT_FOUND", "fiscal year not found"); - return o; -} - -static int fy_exists_in_range(sqlite3 *db, int64_t org_id, const char *start, - const char *end) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - db, - "SELECT count(*) FROM fiscal_years WHERE org_id=?1" - " AND NOT (end_date < ?2 OR start_date > ?3)", - -1, &st, NULL) != SQLITE_OK) - return 1; - sqlite3_bind_int64(st, 1, org_id); - sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, end, -1, SQLITE_TRANSIENT); - int64_t n = 0; - if (sqlite3_step(st) == SQLITE_ROW) - n = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - return n > 0; -} - -static yyjson_mut_val *h_fiscal_year_open(struct req *r) -{ - const char *label = arg_str(r->args, "label"); - const char *start = arg_str(r->args, "start_date"); - const char *end = arg_str(r->args, "end_date"); - if (!label || !*label) - return fail(r, "INVALID_ARGS", "label is required"); - if (!util_parse_iso_date(start) || !util_parse_iso_date(end)) - return fail(r, "INVALID_ARGS", "start_date and end_date must be YYYY-MM-DD"); - if (strcmp(start, end) >= 0) - return fail(r, "INVALID_ARGS", "start_date must be before end_date"); - if (fy_exists_in_range(r->db, r->org_id, start, end)) - return fail(r, "CONFLICT", "fiscal year overlaps an existing one"); - /* "Information om året" carries over from the latest earlier year, so - only what changes needs to be edited. Dates and the dividend are - year-specific and start empty. */ - char prev_events[600] = "", prev_emp[64] = "", prev_notes[600] = ""; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT events,employees,notes FROM fiscal_years" - " WHERE org_id=?1 AND end_date < ?2" - " ORDER BY end_date DESC LIMIT 1", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); - if (sqlite3_step(st) == SQLITE_ROW) { - snprintf(prev_events, sizeof prev_events, "%s", - (const char *)sqlite3_column_text(st, 0)); - snprintf(prev_emp, sizeof prev_emp, "%s", - (const char *)sqlite3_column_text(st, 1)); - snprintf(prev_notes, sizeof prev_notes, "%s", - (const char *)sqlite3_column_text(st, 2)); - } - sqlite3_finalize(st); - } - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO fiscal_years(org_id,label,start_date,end_date," - "events,employees,notes,created_at)" - " VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, label, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, start, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, end, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, prev_events, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 6, prev_emp, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 7, prev_notes, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 8, ts, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "CONFLICT", "could not create fiscal year"); - 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, - "fiscal_year.open", 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_strcpy(r->rdoc, o, "label", label); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", start); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", end); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "open"); - return o; -} - -static yyjson_mut_val *h_fiscal_year_close(struct req *r) -{ - int64_t id = 0; - int confirm = 0; - arg_int(r->args, "id", &id); - arg_bool(r->args, "confirm", &confirm); - if (id <= 0 || !confirm) - return fail(r, "INVALID_ARGS", "id and confirm:true are required"); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE fiscal_years SET status='closed', closed_at=?3," - " closed_by=?4 WHERE org_id=?1 AND id=?2 AND status='open'", - -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); - sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 4, r->sess->user_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", "open fiscal year not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "fiscal_year.close", reqjson, "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -static yyjson_mut_val *h_fiscal_year_reopen(struct req *r) -{ - int64_t id = 0; - int confirm = 0; - arg_int(r->args, "id", &id); - arg_bool(r->args, "confirm", &confirm); - if (id <= 0 || !confirm) - return fail(r, "INVALID_ARGS", "id and confirm:true are required"); - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; - } - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE fiscal_years SET status='open', closed_at=NULL," - " closed_by=NULL WHERE org_id=?1 AND id=?2 AND status='closed'", - -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", "closed fiscal year not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "fiscal_year.reopen", reqjson, "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -/* Fiscal-year metadata that is not a posting: the board's proposed - dividend and the year's material events, kept for the årsredovisning - draft. Both fields are optional; at least one must be given. */ -static yyjson_mut_val *h_fiscal_year_update(struct req *r) -{ - int64_t id = 0, dividend = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - int have_div = arg_int(r->args, "dividend_ore", ÷nd); - const char *events = arg_str(r->args, "events"); - const char *agm = arg_str(r->args, "agm_date"); - const char *pay = arg_str(r->args, "dividend_date"); - const char *employees = arg_str(r->args, "employees"); - const char *notes = arg_str(r->args, "notes"); - if (!have_div && !events && !agm && !pay && !employees && !notes) - return fail(r, "INVALID_ARGS", "nothing to update"); - if (have_div && dividend < 0) - return fail(r, "INVALID_ARGS", "dividend_ore must be >= 0"); - if (agm && *agm && !util_parse_iso_date(agm)) - return fail(r, "INVALID_ARGS", "agm_date must be YYYY-MM-DD"); - if (pay && *pay && !util_parse_iso_date(pay)) - return fail(r, "INVALID_ARGS", "dividend_date must be YYYY-MM-DD"); - static const char *const skeys[4] = { "events", "agm_date", - "dividend_date", "employees" }; - const char *svals[4] = { events, agm, pay, employees }; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, - "SELECT count(*) 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, id); - int64_t exists = 0; - if (sqlite3_step(st) == SQLITE_ROW) - exists = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - if (!exists) - return fail(r, "NOT_FOUND", "fiscal year not found"); - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - yyjson_mut_obj_add_int(r->rdoc, o, "id", id); - if (have_div) - yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", dividend); - for (int i = 0; i < 4; i++) - if (svals[i]) - yyjson_mut_obj_add_strcpy(r->rdoc, o, skeys[i], svals[i]); - if (notes) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes); - return o; - } - if (sqlite3_prepare_v2(r->db, - "UPDATE fiscal_years SET" - " dividend_ore=CASE WHEN ?3<0 THEN dividend_ore" - " ELSE ?3 END," - " events=COALESCE(?4,events)," - " agm_date=COALESCE(?5,agm_date)," - " dividend_date=COALESCE(?6,dividend_date)," - " employees=COALESCE(?7,employees)," - " notes=COALESCE(?8,notes)" - " 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); - sqlite3_bind_int64(st, 3, have_div ? dividend : -1); - for (int i = 0; i < 4; i++) { - if (svals[i]) - sqlite3_bind_text(st, i + 4, svals[i], -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, i + 4); - } - if (notes) - sqlite3_bind_text(st, 8, notes, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 8); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "fiscal_year.update", reqjson, "OK", NULL); - free(reqjson); - if (sqlite3_prepare_v2(r->db, - "SELECT " FY_COLUMNS " 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, id); - yyjson_mut_val *o = NULL; - if (sqlite3_step(st) == SQLITE_ROW) - o = fy_json(r, st); - sqlite3_finalize(st); - if (!o) - return fail(r, "NOT_FOUND", "fiscal year not found"); - return o; -} - -static yyjson_mut_val *h_period_lock(struct req *r) -{ - int64_t fy = 0; - arg_int(r->args, "fiscal_year", &fy); - const char *until = arg_str(r->args, "until"); - if (fy <= 0 || !util_parse_iso_date(until)) - return fail(r, "INVALID_ARGS", - "fiscal_year and until (YYYY-MM-DD) are required"); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE fiscal_years SET locked_until=?3 WHERE org_id=?1 AND id=?2" - " AND ?3 BETWEEN start_date AND end_date", - -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); - sqlite3_bind_text(st, 3, until, -1, SQLITE_TRANSIENT); - 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", - "fiscal year not found or until is outside it"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "period.lock", reqjson, "OK", NULL); - free(reqjson); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year", fy); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", until); - return o; -} - -static yyjson_mut_val *h_period_unlock(struct req *r) -{ - int64_t fy = 0; - arg_int(r->args, "fiscal_year", &fy); - if (fy <= 0) - return fail(r, "INVALID_ARGS", "fiscal_year is required"); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE fiscal_years SET locked_until=NULL 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); - 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", "fiscal year not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "period.unlock", reqjson, "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -/* ------------------------------------------------------------------ */ -/* vouchers */ -/* ------------------------------------------------------------------ */ - -static int parse_rows(struct req *r, yyjson_val *rowsv, struct ledger_row **out, - size_t *out_n) -{ - if (!rowsv || !yyjson_is_arr(rowsv)) - return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1; - size_t n = yyjson_arr_size(rowsv); - struct ledger_row *rows = xcalloc(n ? n : 1, sizeof *rows); - size_t k = 0; - yyjson_arr_iter it = yyjson_arr_iter_with(rowsv); - yyjson_val *item; - while ((item = yyjson_arr_iter_next(&it))) { - if (!yyjson_is_obj(item)) { - free(rows); - fail(r, "INVALID_ARGS", "each row must be an object"); - return -1; - } - yyjson_val *av = yyjson_obj_get(item, "account"); - if (!av || !yyjson_is_str(av)) { - free(rows); - failf(r, "INVALID_ARGS", "row %zu: account is required", k + 1); - return -1; - } - yyjson_val *dv = yyjson_obj_get(item, "debit_ore"); - yyjson_val *cv = yyjson_obj_get(item, "credit_ore"); - if ((dv && !yyjson_is_int(dv)) || (cv && !yyjson_is_int(cv))) { - free(rows); - failf(r, "INVALID_ARGS", "row %zu: amounts must be integer öre", - k + 1); - return -1; - } - rows[k].account = yyjson_get_str(av); - rows[k].debit_ore = dv ? yyjson_get_int(dv) : 0; - rows[k].credit_ore = cv ? yyjson_get_int(cv) : 0; - yyjson_val *d = yyjson_obj_get(item, "description"); - rows[k].description = d && yyjson_is_str(d) ? yyjson_get_str(d) : NULL; - k++; - } - *out = rows; - *out_n = k; - return 0; -} - -static int parse_attachment_ids(struct req *r, int64_t **out, size_t *out_n) -{ - yyjson_val *av = r->args ? yyjson_obj_get(r->args, "attachment_ids") : NULL; - *out = NULL; - *out_n = 0; - if (!av) - return 0; - if (!yyjson_is_arr(av)) - return fail(r, "INVALID_ARGS", "attachment_ids must be an array") ? -1 - : -1; - size_t n = yyjson_arr_size(av); - int64_t *ids = xcalloc(n ? n : 1, sizeof *ids); - size_t k = 0; - yyjson_arr_iter it = yyjson_arr_iter_with(av); - yyjson_val *item; - while ((item = yyjson_arr_iter_next(&it))) { - if (!yyjson_is_int(item)) { - free(ids); - fail(r, "INVALID_ARGS", "attachment_ids must be integers"); - return -1; - } - ids[k++] = yyjson_get_int(item); - } - *out = ids; - *out_n = k; - return 0; -} - -/* ------------------------------------------------------------------ */ -/* settings */ -/* ------------------------------------------------------------------ */ - -static yyjson_mut_val *h_settings_get(struct req *r) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - int have_default = 0, have_bank = 0, have_receivable = 0, have_revenue = 0; - int have_password = 0, have_security = 0; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st, - NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, r->org_id); - while (sqlite3_step(st) == SQLITE_ROW) { - const char *k = (const char *)sqlite3_column_text(st, 0); - const char *v = (const char *)sqlite3_column_text(st, 1); - if (k) { - if (secret_key_is_secret(k)) { - have_password = 1; - continue; - } - yyjson_mut_obj_add(o, yyjson_mut_strcpy(r->rdoc, k), - yyjson_mut_strcpy(r->rdoc, v ? v : "")); - if (strcmp(k, "default_series") == 0) - have_default = 1; - if (strcmp(k, "bank_account") == 0) - have_bank = 1; - if (strcmp(k, "invoice_receivable_account") == 0) - have_receivable = 1; - if (strcmp(k, "invoice_revenue_account") == 0) - have_revenue = 1; - if (strcmp(k, "smtp_security") == 0) - have_security = 1; - } - } - sqlite3_finalize(st); - } - if (!have_default) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A"); - if (!have_bank) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "bank_account", "1930"); - if (!have_receivable) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_receivable_account", - "1510"); - if (!have_revenue) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_revenue_account", - "3001"); - if (!have_security) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "smtp_security", "starttls"); - yyjson_mut_obj_add_bool(r->rdoc, o, "smtp_password_set", have_password); - return o; -} - -static yyjson_mut_val *settings_result(struct req *r, const char *key, - const char *value, int dry) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); - if (dry) - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; -} - -static char *settings_secret_audit_json(const char *key) -{ - yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - yyjson_mut_val *o = yyjson_mut_obj(doc); - yyjson_mut_doc_set_root(doc, o); - yyjson_mut_obj_add_strcpy(doc, o, "key", key); - yyjson_mut_obj_add_strcpy(doc, o, "value", "[redacted]"); - char *json = yyjson_mut_write(doc, 0, NULL); - yyjson_mut_doc_free(doc); - return json ? json : xstrdup("{}"); -} - -static yyjson_mut_val *h_settings_set(struct req *r) -{ - const char *key = arg_str(r->args, "key"); - const char *value = arg_str(r->args, "value"); - if (!key || !value) - return fail(r, "INVALID_ARGS", "key and value are required"); - if (secret_key_is_secret(key)) { - if (!secret_available()) - return fail(r, "INTERNAL", - "BOKFD_SECRET_KEY is missing or invalid"); - char *stored = NULL; - if (*value && secret_encrypt(value, &stored) != 0) - return fail(r, "INTERNAL", - "BOKFD_SECRET_KEY is missing or invalid"); - if (r->dry_run) { - free(stored); - return settings_result(r, key, "[redacted]", 1); - } - sqlite3_stmt *st = NULL; - const char *sql = *value - ? "INSERT INTO settings(org_id,key,value)" - " VALUES(?1,?2,?3)" - " ON CONFLICT(org_id,key) DO UPDATE SET" - " value=excluded.value" - : "DELETE FROM settings WHERE org_id=?1" - " AND key=?2"; - if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) { - free(stored); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); - if (*value) - sqlite3_bind_text(st, 3, stored, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - free(stored); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - char *reqjson = settings_secret_audit_json(key); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "settings.set", reqjson, "OK", NULL); - free(reqjson); - return settings_result(r, key, "[redacted]", 0); - } - size_t maxlen; - int digits_only = 0, bankgiro = 0, port = 0, security = 0; - if (strcmp(key, "default_series") == 0) - maxlen = 8; - else if (strcmp(key, "attachment_dir") == 0 || - strcmp(key, "smtp_host") == 0 || - strcmp(key, "smtp_user") == 0) - maxlen = 255; - else if (strcmp(key, "smtp_from") == 0 || - strcmp(key, "smtp_reply_to") == 0) - maxlen = 254; - else if (strcmp(key, "smtp_port") == 0) { - maxlen = 5; - port = 1; - } else if (strcmp(key, "smtp_security") == 0) { - maxlen = 8; - security = 1; - } else if (strcmp(key, "bank_account") == 0 || - strcmp(key, "invoice_receivable_account") == 0 || - strcmp(key, "invoice_revenue_account") == 0) { - maxlen = 10; - digits_only = 1; - } else if (strcmp(key, "invoice_bankgiro") == 0) { - maxlen = 16; - bankgiro = 1; - } else if (strcmp(key, "invoice_our_ref") == 0) { - maxlen = 64; - } else - return fail(r, "UNSUPPORTED", "unknown setting"); - size_t len = strlen(value); - if (len == 0 || len > maxlen) - return failf(r, "INVALID_ARGS", "%s must be 1-%zu characters", key, - maxlen); - for (const char *p = value; *p; p++) { - if ((digits_only || port) && (*p < '0' || *p > '9')) - return failf(r, "INVALID_ARGS", "%s must be digits only", key); - if (bankgiro && !((*p >= '0' && *p <= '9') || *p == '-')) - return failf(r, "INVALID_ARGS", - "%s must be digits and hyphens", key); - if ((unsigned char)*p < 32) - return failf(r, "INVALID_ARGS", - "%s must not contain control characters", key); - } - if (port) { - long v = strtol(value, NULL, 10); - if (v < 1 || v > 65535) - return failf(r, "INVALID_ARGS", "%s must be 1-65535", key); - } - if (security && strcmp(value, "starttls") != 0 && - strcmp(value, "tls") != 0 && strcmp(value, "plain") != 0) - return failf(r, "INVALID_ARGS", "%s must be starttls, tls or plain", - key); - if (r->dry_run) - return settings_result(r, key, value, 1); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO settings(org_id,key,value) VALUES(?1,?2,?3)" - " ON CONFLICT(org_id,key) DO UPDATE SET value=excluded.value", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, value, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "settings.set", reqjson, "OK", NULL); - free(reqjson); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); - return o; -} - -/* ------------------------------------------------------------------ */ -/* customer register */ -/* ------------------------------------------------------------------ */ - -#define CUSTOMER_COLUMNS \ - "id,name,address,postal_code,city,country,vat_nr,email,your_ref," \ - "payment_days,notes,active,created_at,COALESCE(updated_at,'')" - -static yyjson_mut_val *customer_json(struct req *r, sqlite3_stmt *st) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", - sq(sqlite3_column_text(st, 3))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", - sq(sqlite3_column_text(st, 4))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", - sq(sqlite3_column_text(st, 5))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", - sq(sqlite3_column_text(st, 6))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", - sq(sqlite3_column_text(st, 7))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", - sq(sqlite3_column_text(st, 8))); - yyjson_mut_obj_add_int(r->rdoc, o, "payment_days", - sqlite3_column_int64(st, 9)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", - sq(sqlite3_column_text(st, 10))); - yyjson_mut_obj_add_bool(r->rdoc, o, "active", - sqlite3_column_int(st, 11) != 0); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", - sq(sqlite3_column_text(st, 12))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "updated_at", - sq(sqlite3_column_text(st, 13))); - return o; -} - -struct customer_input { - const char *name; - const char *address; - const char *postal_code; - const char *city; - const char *country; - const char *vat_nr; - const char *email; - const char *your_ref; - const char *notes; - int64_t payment_days; - int have_payment; - int active; - int have_active; -}; - -static void customer_input_read(struct req *r, struct customer_input *in) -{ - memset(in, 0, sizeof *in); - in->name = arg_str(r->args, "name"); - in->address = arg_str(r->args, "address"); - in->postal_code = arg_str(r->args, "postal_code"); - in->city = arg_str(r->args, "city"); - in->country = arg_str(r->args, "country"); - in->vat_nr = arg_str(r->args, "vat_nr"); - in->email = arg_str(r->args, "email"); - in->your_ref = arg_str(r->args, "your_ref"); - in->notes = arg_str(r->args, "notes"); - in->have_payment = arg_int(r->args, "payment_days", &in->payment_days); - in->have_active = arg_bool(r->args, "active", &in->active); -} - -static int customer_input_validate(struct req *r, - const struct customer_input *in, - int is_create) -{ - if (is_create && (!in->name || !*in->name)) { - fail(r, "INVALID_ARGS", "name is required"); - return -1; - } - if (in->name && !*in->name) { - fail(r, "INVALID_ARGS", "name cannot be empty"); - return -1; - } - if (in->name && strlen(in->name) > 200) { - fail(r, "INVALID_ARGS", "name is too long"); - return -1; - } - static const char *const names[] = { - "address", "postal_code", "city", "country", "vat_nr", - "email", "your_ref", "notes", - }; - static const size_t maxlen[] = { 500, 32, 120, 64, 64, 254, 120, 2000 }; - const char *values[] = { in->address, in->postal_code, in->city, - in->country, in->vat_nr, in->email, - in->your_ref, in->notes }; - for (size_t i = 0; i < sizeof maxlen / sizeof maxlen[0]; i++) { - if (values[i] && strlen(values[i]) > maxlen[i]) { - failf(r, "INVALID_ARGS", "%s is too long", names[i]); - return -1; - } - } - if (in->have_payment && in->payment_days < 0) { - fail(r, "INVALID_ARGS", "payment_days must be >= 0"); - return -1; - } - return 0; -} - -static yyjson_mut_val *customer_lookup(struct req *r, int64_t id, int *found) -{ - sqlite3_stmt *st = NULL; - *found = 0; - if (sqlite3_prepare_v2( - r->db, - "SELECT " CUSTOMER_COLUMNS " FROM customers" - " WHERE org_id=?1 AND id=?2", - -1, &st, NULL) != SQLITE_OK) { - *found = -1; - fail(r, "INTERNAL", "database error"); - return NULL; - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, id); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return NULL; - } - yyjson_mut_val *o = customer_json(r, st); - sqlite3_finalize(st); - *found = 1; - return o; -} - -static int customer_name_taken(struct req *r, const char *name, int64_t except_id) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT id FROM customers WHERE org_id=?1 AND name=?2" - " AND id<>?3", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 3, except_id); - int taken = sqlite3_step(st) == SQLITE_ROW; - sqlite3_finalize(st); - return taken; -} - -static yyjson_mut_val *h_customer_list(struct req *r) -{ - int active_only = 0; - arg_bool(r->args, "active_only", &active_only); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT " CUSTOMER_COLUMNS " FROM customers WHERE org_id=?1" - " AND (?2=0 OR active=1) ORDER BY name COLLATE NOCASE, id", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int(st, 2, active_only); - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - while (sqlite3_step(st) == SQLITE_ROW) - yyjson_mut_arr_add_val(items, customer_json(r, st)); - sqlite3_finalize(st); - yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - return out; -} - -static yyjson_mut_val *h_customer_get(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - int found = 0; - yyjson_mut_val *o = customer_lookup(r, id, &found); - if (found < 0) - return NULL; - if (!found) - return fail(r, "NOT_FOUND", "customer not found"); - return o; -} - -static yyjson_mut_val *h_customer_create(struct req *r) -{ - struct customer_input in; - customer_input_read(r, &in); - if (customer_input_validate(r, &in, 1) != 0) - return NULL; - if (!in.have_payment) - in.payment_days = 30; - const char *address = in.address ? in.address : ""; - const char *postal = in.postal_code ? in.postal_code : ""; - const char *city = in.city ? in.city : ""; - const char *country = in.country ? in.country : "SE"; - const char *vat = in.vat_nr ? in.vat_nr : ""; - const char *email = in.email ? in.email : ""; - const char *your_ref = in.your_ref ? in.your_ref : ""; - const char *notes = in.notes ? in.notes : ""; - int taken = customer_name_taken(r, in.name, 0); - if (taken < 0) - return NULL; - if (taken) - return fail(r, "CONFLICT", "a customer with this name already exists"); - - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", 0); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", in.name); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", address); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", postal); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", city); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", country); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", vat); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", email); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", your_ref); - yyjson_mut_obj_add_int(r->rdoc, o, "payment_days", in.payment_days); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes); - yyjson_mut_obj_add_bool(r->rdoc, o, "active", true); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; - } - - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO customers(org_id,name,address,postal_code,city,country," - "vat_nr,email,your_ref,payment_days,notes,created_at)" - " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, in.name, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, address, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, postal, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, city, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 6, country, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 7, vat, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 8, email, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 9, your_ref, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 10, in.payment_days); - sqlite3_bind_text(st, 11, notes, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 12, ts, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - if ((rc & 0xff) == SQLITE_CONSTRAINT) - return fail(r, "CONFLICT", "a customer with this name already exists"); - 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, - "customer.create", reqjson, "OK", NULL); - free(reqjson); - int found = 0; - yyjson_mut_val *o = customer_lookup(r, id, &found); - if (found < 0) - return NULL; - if (!found) - return fail(r, "INTERNAL", "could not read the new customer"); - return o; -} - -static yyjson_mut_val *h_customer_update(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - struct customer_input in; - customer_input_read(r, &in); - if (customer_input_validate(r, &in, 0) != 0) - return NULL; - if (!in.name && !in.address && !in.postal_code && !in.city && !in.country && - !in.vat_nr && !in.email && !in.your_ref && !in.notes && - !in.have_payment && !in.have_active) - return fail(r, "INVALID_ARGS", "nothing to update"); - - int found = 0; - yyjson_mut_val *existing = customer_lookup(r, id, &found); - (void)existing; - if (found < 0) - return NULL; - if (!found) - return fail(r, "NOT_FOUND", "customer not found"); - if (in.name) { - int taken = customer_name_taken(r, in.name, id); - if (taken < 0) - return NULL; - if (taken) - return fail(r, "CONFLICT", "a customer with this name already exists"); - } - - if (r->dry_run) { - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT id,COALESCE(?3,name),COALESCE(?4,address)," - "COALESCE(?5,postal_code),COALESCE(?6,city)," - "COALESCE(?7,country),COALESCE(?8,vat_nr)," - "COALESCE(?9,email),COALESCE(?10,your_ref)," - "CASE WHEN ?11<0 THEN payment_days ELSE ?11 END," - "COALESCE(?12,notes)," - "CASE WHEN ?13<0 THEN active ELSE ?13 END," - "created_at,COALESCE(updated_at,'')" - " FROM customers 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); - const char *texts[] = { in.name, in.address, in.postal_code, in.city, - in.country, in.vat_nr, in.email, in.your_ref, - in.notes }; - static const int bind_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 }; - for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) { - if (texts[i]) - sqlite3_bind_text(st, bind_index[i], texts[i], -1, - SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, bind_index[i]); - } - sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1); - sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1); - yyjson_mut_val *o = NULL; - if (sqlite3_step(st) == SQLITE_ROW) - o = customer_json(r, st); - sqlite3_finalize(st); - if (!o) - return fail(r, "NOT_FOUND", "customer not found"); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; - } - - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE customers SET" - " name=COALESCE(?3,name), address=COALESCE(?4,address)," - " postal_code=COALESCE(?5,postal_code), city=COALESCE(?6,city)," - " country=COALESCE(?7,country), vat_nr=COALESCE(?8,vat_nr)," - " email=COALESCE(?9,email), your_ref=COALESCE(?10,your_ref)," - " payment_days=CASE WHEN ?11<0 THEN payment_days ELSE ?11 END," - " notes=COALESCE(?12,notes)," - " active=CASE WHEN ?13<0 THEN active ELSE ?13 END," - " updated_at=?14 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); - const char *texts[] = { in.name, in.address, in.postal_code, in.city, - in.country, in.vat_nr, in.email, in.your_ref, - in.notes }; - static const int text_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 }; - for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) { - if (texts[i]) - sqlite3_bind_text(st, text_index[i], texts[i], -1, - SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, text_index[i]); - } - sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1); - sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1); - sqlite3_bind_text(st, 14, ts, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - if ((rc & 0xff) == SQLITE_CONSTRAINT) - return fail(r, "CONFLICT", "a customer with this name already exists"); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - if (sqlite3_changes(r->db) == 0) - return fail(r, "NOT_FOUND", "customer not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "customer.update", reqjson, "OK", NULL); - free(reqjson); - found = 0; - yyjson_mut_val *o = customer_lookup(r, id, &found); - if (found < 0) - return NULL; - if (!found) - return fail(r, "INTERNAL", "could not read the customer"); - return o; -} - -static yyjson_mut_val *h_customer_archive(struct req *r) -{ - int64_t id = 0; - int active = 0; - if (!arg_int(r->args, "id", &id) || id <= 0 || - !arg_bool(r->args, "active", &active)) - return fail(r, "INVALID_ARGS", "id and active are required"); - int found = 0; - yyjson_mut_val *existing = customer_lookup(r, id, &found); - (void)existing; - if (found < 0) - return NULL; - if (!found) - return fail(r, "NOT_FOUND", "customer 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, "active", active != 0); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; - } - - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE customers SET active=?3, updated_at=?4" - " 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); - sqlite3_bind_int(st, 3, active); - sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); - 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", "customer not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "customer.archive", 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, "active", active != 0); - return o; -} - -/* ------------------------------------------------------------------ */ -/* invoice sequence */ -/* ------------------------------------------------------------------ */ - -static int64_t invoice_next_number(struct req *r) -{ - sqlite3_stmt *st = NULL; - int64_t next = 1; - if (sqlite3_prepare_v2( - r->db, "SELECT next_number FROM invoice_sequence WHERE org_id=?1", - -1, &st, NULL) != SQLITE_OK) - return next; - sqlite3_bind_int64(st, 1, r->org_id); - if (sqlite3_step(st) == SQLITE_ROW) - next = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - return next > 0 ? next : 1; -} - -static int invoice_take_number(struct req *r, int64_t *out) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, "SELECT next_number FROM invoice_sequence WHERE org_id=?1", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - int have = sqlite3_step(st) == SQLITE_ROW; - int64_t number = have ? sqlite3_column_int64(st, 0) : 1; - sqlite3_finalize(st); - if (have) { - if (sqlite3_prepare_v2( - r->db, - "UPDATE invoice_sequence SET next_number=next_number+1" - " WHERE org_id=?1", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); - return -1; - } - } else { - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO invoice_sequence(org_id,next_number) VALUES(?1,2)", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); - return -1; - } - } - *out = number; - return 0; -} - -static yyjson_mut_val *h_invoice_sequence_get(struct req *r) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "next_number", invoice_next_number(r)); - return o; -} - -static yyjson_mut_val *h_invoice_sequence_set(struct req *r) -{ - int64_t next = 0; - if (!arg_int(r->args, "next_number", &next) || next <= 0) - return fail(r, "INVALID_ARGS", - "next_number must be a positive integer"); - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "next_number", next); - 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 invoice_sequence(org_id,next_number) VALUES(?1,?2)" - " ON CONFLICT(org_id) DO UPDATE SET next_number=excluded.next_number", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, next); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "invoice.sequence_set", reqjson, "OK", NULL); - free(reqjson); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "next_number", next); - return o; -} - -/* ------------------------------------------------------------------ */ -/* invoice drafts, rendering and issue */ -/* ------------------------------------------------------------------ */ - -struct draft_line { - const char *article_no; - const char *description; - int64_t quantity_milli; - const char *unit; - int64_t unit_price_ore; - int64_t amount_ore; - const char *note; - const char *vat_code; - char account[16]; -}; - -struct invoice_draft { - int64_t customer_id; - const char *invoice_date; - const char *due_date; - const char *delivery_date; - const char *your_ref; - const char *our_ref; - const char *notes; - struct draft_line *lines; - size_t nlines; -}; - -static void invoice_draft_free(struct invoice_draft *d) -{ - free(d->lines); - d->lines = NULL; - d->nlines = 0; -} - -static int parse_quantity(const char *s, int64_t *out) -{ - if (!s || !*s || strlen(s) > 24) - return -1; - int64_t whole = 0, frac = 0; - size_t int_digits = 0, frac_digits = 0; - int seen_sep = 0; - for (const char *p = s; *p; p++) { - char c = *p; - if (c == ',' || c == '.') { - if (seen_sep) - return -1; - seen_sep = 1; - continue; - } - if (c < '0' || c > '9') - return -1; - if (!seen_sep) { - if (int_digits >= 18) - return -1; - whole = whole * 10 + (c - '0'); - int_digits++; - } else { - if (frac_digits >= 3) - return -1; - frac = frac * 10 + (c - '0'); - frac_digits++; - } - } - if (!int_digits || (seen_sep && !frac_digits)) - return -1; - for (size_t i = frac_digits; i < 3; i++) - frac *= 10; - if (whole > (INT64_MAX - frac) / 1000) - return -1; - int64_t v = whole * 1000 + frac; - if (v <= 0) - return -1; - *out = v; - return 0; -} - -static int draft_line_parse(struct req *r, yyjson_val *item, size_t no, - const char *default_account, struct draft_line *l, - int64_t *net_total) -{ - const char *description = arg_str(item, "description"); - if (!description || !*description) { - failf(r, "INVALID_ARGS", "row %zu: description is required", no); - return -1; - } - const char *qty = arg_str(item, "quantity"); - int64_t quantity_milli = 0; - if (parse_quantity(qty, &quantity_milli) != 0) { - failf(r, "INVALID_ARGS", - "row %zu: quantity must be a positive decimal with at most 3" - " decimals", - no); - return -1; - } - int64_t price = 0; - if (!arg_int(item, "unit_price_ore", &price) || price < 0) { - failf(r, "INVALID_ARGS", - "row %zu: unit_price_ore must be a non-negative integer", no); - return -1; - } - const char *vat = arg_str(item, "vat_code"); - if (!vat || !*vat) - vat = "25"; - if (strcmp(vat, "25") != 0 && strcmp(vat, "12") != 0 && - strcmp(vat, "6") != 0 && strcmp(vat, "0") != 0 && - strcmp(vat, "rc") != 0 && strcmp(vat, "eu") != 0) { - failf(r, "INVALID_ARGS", - "row %zu: vat_code must be one of 25, 12, 6, 0, rc, eu", no); - return -1; - } - const char *account = arg_str(item, "account"); - if (account && !*account) - account = NULL; - if (account) { - if (!is_digits(account) || strlen(account) > 10) { - failf(r, "INVALID_ARGS", "row %zu: account must be 1-10 digits", - no); - return -1; - } - snprintf(l->account, sizeof l->account, "%s", account); - } else { - snprintf(l->account, sizeof l->account, "%s", default_account); - } - l->article_no = arg_str(item, "article_no"); - l->description = description; - l->quantity_milli = quantity_milli; - const char *unit = arg_str(item, "unit"); - l->unit = unit && *unit ? unit : "st"; - l->unit_price_ore = price; - l->note = arg_str(item, "note"); - l->vat_code = vat; - int64_t product = 0; - if (__builtin_mul_overflow(quantity_milli, price, &product) || - __builtin_add_overflow(product, (int64_t)500, &product)) { - failf(r, "INVALID_ARGS", "row %zu: amount overflows", no); - return -1; - } - l->amount_ore = product / 1000; - if (__builtin_add_overflow(*net_total, l->amount_ore, net_total) || - *net_total > INT64_MAX / 100) { - failf(r, "INVALID_ARGS", "row %zu: invoice total overflows", no); - return -1; - } - return 0; -} - -static int parse_invoice_draft(struct req *r, struct invoice_draft *d) -{ - memset(d, 0, sizeof *d); - if (!arg_int(r->args, "customer_id", &d->customer_id) || - d->customer_id <= 0) { - fail(r, "INVALID_ARGS", "customer_id is required"); - return -1; - } - d->invoice_date = arg_str(r->args, "invoice_date"); - d->due_date = arg_str(r->args, "due_date"); - d->delivery_date = arg_str(r->args, "delivery_date"); - if (!d->invoice_date || !util_parse_iso_date(d->invoice_date)) { - fail(r, "INVALID_ARGS", "invoice_date must be YYYY-MM-DD"); - return -1; - } - if (!d->due_date || !util_parse_iso_date(d->due_date)) { - fail(r, "INVALID_ARGS", "due_date must be YYYY-MM-DD"); - return -1; - } - if (!d->delivery_date) - d->delivery_date = ""; - if (*d->delivery_date && !util_parse_iso_date(d->delivery_date)) { - fail(r, "INVALID_ARGS", "delivery_date must be YYYY-MM-DD"); - return -1; - } - d->your_ref = arg_str(r->args, "your_ref"); - d->our_ref = arg_str(r->args, "our_ref"); - d->notes = arg_str(r->args, "notes"); - if (!d->your_ref) - d->your_ref = ""; - if (!d->our_ref) - d->our_ref = ""; - if (!d->notes) - d->notes = ""; - - yyjson_val *rows = r->args ? yyjson_obj_get(r->args, "rows") : NULL; - if (!rows || !yyjson_is_arr(rows) || yyjson_arr_size(rows) == 0) { - fail(r, "INVALID_ARGS", "rows must be a non-empty array"); - return -1; - } - char *revenue = db_setting(r->db, r->org_id, "invoice_revenue_account"); - const char *default_account = - revenue && *revenue ? revenue : "3001"; - size_t n = yyjson_arr_size(rows); - struct draft_line *lines = xcalloc(n, sizeof *lines); - size_t k = 0; - int64_t net_total = 0; - yyjson_arr_iter it = yyjson_arr_iter_with(rows); - yyjson_val *item; - while ((item = yyjson_arr_iter_next(&it))) { - if (!yyjson_is_obj(item)) { - failf(r, "INVALID_ARGS", "row %zu: must be an object", k + 1); - free(lines); - free(revenue); - return -1; - } - if (draft_line_parse(r, item, k + 1, default_account, &lines[k], - &net_total) != 0) { - free(lines); - free(revenue); - return -1; - } - k++; - } - free(revenue); - if (net_total <= 0) { - fail(r, "INVALID_ARGS", "invoice total must be greater than zero"); - free(lines); - return -1; - } - d->lines = lines; - d->nlines = k; - return 0; -} - -struct invoice_view { - struct invoice_doc doc; - struct invoice_line *lines; - char number_str[32]; - char ocr[40]; - char filename[600]; - char description[600]; - char seller_name[256]; - char seller_address[1024]; - char seller_postal[64]; - char seller_city[128]; - char seller_phone[64]; - char seller_email[256]; - char seller_org_nr[64]; - char seller_vat_nr[64]; - char bankgiro[64]; - char customer_name[256]; - char customer_address[1024]; - char customer_postal[64]; - char customer_city[128]; - char customer_vat_nr[64]; -}; - -static void invoice_view_free(struct invoice_view *v) -{ - free(v->lines); - v->lines = NULL; -} - -static int invoice_view_fill(struct req *r, const struct invoice_draft *d, - int64_t number, struct invoice_view *v) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT COALESCE(name,''),COALESCE(address,'')," - "COALESCE(postal_code,''),COALESCE(city,'')," - "COALESCE(phone,''),COALESCE(email,''),COALESCE(org_nr,'')," - "COALESCE(vat_nr,'') FROM orgs WHERE id=?1", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - int have_org = sqlite3_step(st) == SQLITE_ROW; - if (have_org) { - snprintf(v->seller_name, sizeof v->seller_name, "%s", - sq(sqlite3_column_text(st, 0))); - snprintf(v->seller_address, sizeof v->seller_address, "%s", - sq(sqlite3_column_text(st, 1))); - snprintf(v->seller_postal, sizeof v->seller_postal, "%s", - sq(sqlite3_column_text(st, 2))); - snprintf(v->seller_city, sizeof v->seller_city, "%s", - sq(sqlite3_column_text(st, 3))); - snprintf(v->seller_phone, sizeof v->seller_phone, "%s", - sq(sqlite3_column_text(st, 4))); - snprintf(v->seller_email, sizeof v->seller_email, "%s", - sq(sqlite3_column_text(st, 5))); - snprintf(v->seller_org_nr, sizeof v->seller_org_nr, "%s", - sq(sqlite3_column_text(st, 6))); - snprintf(v->seller_vat_nr, sizeof v->seller_vat_nr, "%s", - sq(sqlite3_column_text(st, 7))); - } - sqlite3_finalize(st); - if (!have_org) { - fail(r, "NOT_FOUND", "org not found"); - return -1; - } - - int64_t payment_days = 30; - if (sqlite3_prepare_v2( - r->db, - "SELECT name,address,postal_code,city,vat_nr,payment_days,active" - " FROM customers WHERE org_id=?1 AND id=?2", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, d->customer_id); - if (sqlite3_step(st) != SQLITE_ROW || !sqlite3_column_int(st, 6)) { - sqlite3_finalize(st); - fail(r, "NOT_FOUND", "customer not found"); - return -1; - } - snprintf(v->customer_name, sizeof v->customer_name, "%s", - sq(sqlite3_column_text(st, 0))); - snprintf(v->customer_address, sizeof v->customer_address, "%s", - sq(sqlite3_column_text(st, 1))); - snprintf(v->customer_postal, sizeof v->customer_postal, "%s", - sq(sqlite3_column_text(st, 2))); - snprintf(v->customer_city, sizeof v->customer_city, "%s", - sq(sqlite3_column_text(st, 3))); - snprintf(v->customer_vat_nr, sizeof v->customer_vat_nr, "%s", - sq(sqlite3_column_text(st, 4))); - payment_days = sqlite3_column_int64(st, 5); - sqlite3_finalize(st); - - char *bankgiro = db_setting(r->db, r->org_id, "invoice_bankgiro"); - snprintf(v->bankgiro, sizeof v->bankgiro, "%s", - bankgiro && *bankgiro ? bankgiro : ""); - free(bankgiro); - - v->lines = xcalloc(d->nlines, sizeof *v->lines); - for (size_t i = 0; i < d->nlines; i++) { - v->lines[i].article_no = d->lines[i].article_no; - v->lines[i].description = d->lines[i].description; - v->lines[i].quantity_milli = d->lines[i].quantity_milli; - v->lines[i].unit = d->lines[i].unit; - v->lines[i].unit_price_ore = d->lines[i].unit_price_ore; - v->lines[i].amount_ore = d->lines[i].amount_ore; - v->lines[i].note = d->lines[i].note; - v->lines[i].vat_code = d->lines[i].vat_code; - } - - v->doc.seller.name = v->seller_name; - v->doc.seller.address = v->seller_address; - v->doc.seller.postal_code = v->seller_postal; - v->doc.seller.city = v->seller_city; - v->doc.seller.phone = v->seller_phone; - v->doc.seller.email = v->seller_email; - v->doc.seller.org_nr = v->seller_org_nr; - v->doc.seller.vat_nr = v->seller_vat_nr; - v->doc.seller.bankgiro = v->bankgiro; - v->doc.customer.name = v->customer_name; - v->doc.customer.address = v->customer_address; - v->doc.customer.postal_code = v->customer_postal; - v->doc.customer.city = v->customer_city; - v->doc.customer.vat_nr = v->customer_vat_nr; - v->doc.number = number; - v->doc.ocr = v->ocr; - v->doc.invoice_date = d->invoice_date; - v->doc.due_date = d->due_date; - v->doc.delivery_date = d->delivery_date; - v->doc.our_ref = d->our_ref; - v->doc.your_ref = d->your_ref; - v->doc.notes = d->notes; - v->doc.payment_days = (int)payment_days; - v->doc.lines = v->lines; - v->doc.nlines = d->nlines; - - snprintf(v->number_str, sizeof v->number_str, "%lld", (long long)number); - int check = invoice_ocr_check(v->number_str); - snprintf(v->ocr, sizeof v->ocr, "%s%c", v->number_str, - (char)('0' + (check > 0 ? check : 0))); - char safe_name[256]; - snprintf(safe_name, sizeof safe_name, "%s", v->customer_name); - for (char *p = safe_name; *p; p++) - if (*p == '/') - *p = '-'; - snprintf(v->filename, sizeof v->filename, "Faktura %lld %s.pdf", - (long long)number, safe_name); - snprintf(v->description, sizeof v->description, "Faktura %lld %s", - (long long)number, safe_name); - return 0; -} - -/* Mirrors invoice.c vat_part(): round half up per rate base. */ -static int64_t invoice_vat_part(int64_t net, int64_t rate) -{ - int64_t v = net * rate; - if (v >= 0) - return (v + 50) / 100; - return -((-v + 50) / 100); -} - -static int invoice_build_pdf(struct req *r, struct invoice_view *v, - struct invoice_totals *t, unsigned char **out, - size_t *out_len) -{ - invoice_totals(&v->doc, t); - *out = NULL; - *out_len = 0; - if (invoice_render_pdf(&v->doc, out, out_len) != 0 || !*out) { - free(*out); - *out = NULL; - fail(r, "TOO_LARGE", "invoice does not fit on one page"); - return -1; - } - return 0; -} - -static int invoice_prepare(struct req *r, struct invoice_draft *d, - int64_t number, struct invoice_view *v, - struct invoice_totals *t, unsigned char **pdf, - size_t *pdf_len) -{ - memset(v, 0, sizeof *v); - if (invoice_view_fill(r, d, number, v) != 0) - return -1; - if (invoice_build_pdf(r, v, t, pdf, pdf_len) != 0) { - invoice_view_free(v); - return -1; - } - return 0; -} - -static yyjson_mut_val *h_invoice_preview(struct req *r) -{ - struct invoice_draft d; - if (parse_invoice_draft(r, &d) != 0) - return NULL; - int64_t number = invoice_next_number(r); - struct invoice_view v; - struct invoice_totals t; - unsigned char *pdf = NULL; - size_t pdf_len = 0; - int rc = invoice_prepare(r, &d, number, &v, &t, &pdf, &pdf_len); - invoice_draft_free(&d); - if (rc != 0) - return NULL; - char *b64 = util_b64(pdf, pdf_len); - free(pdf); - if (!b64) { - invoice_view_free(&v); - return fail(r, "INTERNAL", "could not encode the PDF"); - } - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); - yyjson_mut_obj_add_int(r->rdoc, o, "number", number); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", v.ocr); - yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", t.net_ore); - yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", t.vat_ore); - yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", t.total_ore); - free(b64); - invoice_view_free(&v); - return o; -} - -static int invoice_store_attachment(struct req *r, const struct invoice_view *v, - const unsigned char *pdf, size_t pdf_len, - int64_t *out_id) -{ - unsigned char hash[32]; - util_sha256(pdf, pdf_len, hash); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes," - "content,created_at,created_by)" - " VALUES(?1,?2,?3,'application/pdf',?4,?5,?6,?7)", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, v->filename, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 4, (int64_t)pdf_len); - sqlite3_bind_blob(st, 5, pdf, (int)pdf_len, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 7, r->sess->user_id); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); - return -1; - } - *out_id = db_last_id(r->db); - return 0; -} - -static int invoice_store_invoice(struct req *r, const struct invoice_draft *d, - const struct invoice_totals *t, int64_t number, - int64_t document_id, int64_t voucher_id, - int64_t *out_id) -{ - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO invoices(org_id,customer_id,number,ocr,invoice_date," - "due_date,delivery_date,your_ref,our_ref,notes,net_ore,vat_ore," - "total_ore,document_id,voucher_id,created_at,created_by)" - " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16," - "?17)", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, d->customer_id); - sqlite3_bind_int64(st, 3, number); - char number_str[32]; - snprintf(number_str, sizeof number_str, "%lld", (long long)number); - int check = invoice_ocr_check(number_str); - char ocr[40]; - snprintf(ocr, sizeof ocr, "%s%c", number_str, - (char)('0' + (check > 0 ? check : 0))); - sqlite3_bind_text(st, 4, ocr, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, d->invoice_date, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 6, d->due_date, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 7, d->delivery_date, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 8, d->your_ref, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 9, d->our_ref, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 10, d->notes, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 11, t->net_ore); - sqlite3_bind_int64(st, 12, t->vat_ore); - sqlite3_bind_int64(st, 13, t->total_ore); - sqlite3_bind_int64(st, 14, document_id); - sqlite3_bind_int64(st, 15, voucher_id); - sqlite3_bind_text(st, 16, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 17, r->sess->user_id); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - if ((rc & 0xff) == SQLITE_CONSTRAINT) { - fail(r, "CONFLICT", "invoice number already exists"); - return -1; - } - fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); - return -1; - } - int64_t id = db_last_id(r->db); - for (size_t i = 0; i < d->nlines; i++) { - const struct draft_line *l = &d->lines[i]; - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO invoice_rows(org_id,invoice_id,line_no,article_no," - "description,quantity_milli,unit,unit_price_ore,amount_ore,note," - "vat_code,account)" - " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, id); - sqlite3_bind_int64(st, 3, (int64_t)i + 1); - sqlite3_bind_text(st, 4, l->article_no ? l->article_no : "", -1, - SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, l->description, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 6, l->quantity_milli); - sqlite3_bind_text(st, 7, l->unit, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 8, l->unit_price_ore); - sqlite3_bind_int64(st, 9, l->amount_ore); - sqlite3_bind_text(st, 10, l->note ? l->note : "", -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 11, l->vat_code, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 12, l->account, -1, SQLITE_TRANSIENT); - rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); - return -1; - } - } - *out_id = id; - return 0; -} - -static yyjson_mut_val *invoice_issue_result(struct req *r, int dry_run, - int64_t id, int64_t number, - const char *ocr, int64_t document_id, - int64_t voucher_id, - const struct invoice_totals *t) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - if (dry_run) { - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - yyjson_mut_obj_add_int(r->rdoc, o, "id", 0); - } else { - yyjson_mut_obj_add_int(r->rdoc, o, "id", id); - } - yyjson_mut_obj_add_int(r->rdoc, o, "number", number); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", ocr); - if (dry_run) { - yyjson_mut_obj_add_null(r->rdoc, o, "document_id"); - yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); - } else { - yyjson_mut_obj_add_int(r->rdoc, o, "document_id", document_id); - yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); - } - yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", t->net_ore); - yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", t->vat_ore); - yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", t->total_ore); - return o; -} - -static yyjson_mut_val *h_invoice_issue(struct req *r) -{ - struct invoice_draft d; - if (parse_invoice_draft(r, &d) != 0) - return NULL; - char *rec_setting = - db_setting(r->db, r->org_id, "invoice_receivable_account"); - char receivable[16]; - snprintf(receivable, sizeof receivable, "%s", - rec_setting && *rec_setting ? rec_setting : "1510"); - free(rec_setting); - - yyjson_mut_val *res = NULL; - struct invoice_view v; - struct invoice_totals t; - memset(&v, 0, sizeof v); - memset(&t, 0, sizeof t); - unsigned char *pdf = NULL; - size_t pdf_len = 0; - char *voucher_json = NULL; - struct ledger_row *vrows = NULL; - int64_t number = 0, attachment_id = 0, voucher_id = 0, invoice_id = 0; - int in_tx = 0; - - if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { - invoice_draft_free(&d); - return fail(r, "DB_BUSY", "could not start transaction"); - } - in_tx = 1; - - if (r->dry_run) - number = invoice_next_number(r); - else if (invoice_take_number(r, &number) != 0) - goto done; - - if (invoice_prepare(r, &d, number, &v, &t, &pdf, &pdf_len) != 0) - goto done; - - size_t cap = 1 + 3 + d.nlines; - vrows = xcalloc(cap, sizeof *vrows); - size_t vn = 0; - vrows[vn].account = receivable; - vrows[vn].debit_ore = t.total_ore; - vrows[vn].description = NULL; - vn++; - struct { - int rate; - const char *account; - int64_t net; - } legs[3] = { - { 25, "2610", t.net_25 }, - { 12, "2620", t.net_12 }, - { 6, "2630", t.net_6 }, - }; - int64_t vat_sum = 0; - for (size_t i = 0; i < 3; i++) { - int64_t vat = invoice_vat_part(legs[i].net, legs[i].rate); - if (vat <= 0) - continue; - vrows[vn].account = legs[i].account; - vrows[vn].credit_ore = vat; - if (i == 0) - vrows[vn].description = "Moms 25%"; - else if (i == 1) - vrows[vn].description = "Moms 12%"; - else - vrows[vn].description = "Moms 6%"; - vat_sum += vat; - vn++; - } - for (size_t i = 0; i < d.nlines; i++) { - if (d.lines[i].amount_ore <= 0) - continue; - vrows[vn].account = d.lines[i].account; - vrows[vn].credit_ore = d.lines[i].amount_ore; - vrows[vn].description = d.lines[i].description; - vn++; - } - int64_t sum_debit = 0, sum_credit = 0; - for (size_t i = 0; i < vn; i++) { - sum_debit += vrows[i].debit_ore; - sum_credit += vrows[i].credit_ore; - } - if (vat_sum != t.vat_ore || sum_debit != t.total_ore || - sum_debit != sum_credit) { - fail(r, "INTERNAL", "invoice totals do not match the voucher"); - goto done; - } - - if (!r->dry_run && - invoice_store_attachment(r, &v, pdf, pdf_len, &attachment_id) != 0) - goto done; - - 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 = d.invoice_date; - o.description = v.description; - o.rows = vrows; - o.nrows = vn; - o.source = "invoice"; - o.dry_run = r->dry_run; - o.already_in_tx = 1; - struct ledger_error e; - if (ledger_post(r->db, &o, &e, &voucher_json) != 0) { - fail(r, e.code ? e.code : "INTERNAL", e.msg); - goto done; - } - - if (!r->dry_run) { - if (!voucher_json) { - fail(r, "INTERNAL", "empty voucher result"); - goto done; - } - yyjson_doc *vd = yyjson_read(voucher_json, strlen(voucher_json), 0); - if (vd) { - yyjson_val *idv = yyjson_obj_get(yyjson_doc_get_root(vd), "id"); - if (idv && yyjson_is_int(idv)) - voucher_id = yyjson_get_int(idv); - yyjson_doc_free(vd); - } - if (voucher_id <= 0) { - fail(r, "INTERNAL", "voucher id missing from the posting"); - goto done; - } - if (invoice_store_invoice(r, &d, &t, number, attachment_id, voucher_id, - &invoice_id) != 0) - goto done; - if (sqlite3_exec(r->db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { - fail(r, "DB_BUSY", "commit failed"); - goto done; - } - in_tx = 0; - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "invoice.issue", reqjson, "OK", NULL); - free(reqjson); - } - - res = invoice_issue_result(r, r->dry_run, invoice_id, number, v.ocr, - attachment_id, voucher_id, &t); - -done: - if (in_tx) - sqlite3_exec(r->db, "ROLLBACK", NULL, NULL, NULL); - free(pdf); - free(voucher_json); - free(vrows); - invoice_draft_free(&d); - invoice_view_free(&v); - return res; -} - -static yyjson_mut_val *invoice_row_json(struct req *r, sqlite3_stmt *st) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "line_no", - sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "article_no", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_int(r->rdoc, o, "quantity_milli", - sqlite3_column_int64(st, 3)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "unit", - sq(sqlite3_column_text(st, 4))); - yyjson_mut_obj_add_int(r->rdoc, o, "unit_price_ore", - sqlite3_column_int64(st, 5)); - yyjson_mut_obj_add_int(r->rdoc, o, "amount_ore", - sqlite3_column_int64(st, 6)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "note", - sq(sqlite3_column_text(st, 7))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_code", - sq(sqlite3_column_text(st, 8))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", - sq(sqlite3_column_text(st, 9))); - return o; -} - -#define INVOICE_ROW_COLUMNS \ - "line_no,article_no,description,quantity_milli,unit,unit_price_ore," \ - "amount_ore,note,vat_code,account" - -static yyjson_mut_val *h_invoice_get(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT i.id,i.customer_id,c.name,i.number,i.ocr,i.invoice_date," - "i.due_date,i.delivery_date,i.your_ref,i.our_ref,i.notes,i.net_ore," - "i.vat_ore,i.total_ore,i.status,i.document_id,i.voucher_id," - "i.last_sent_at,i.last_sent_to,i.created_at,i.created_by" - " FROM invoices i JOIN customers c" - " ON c.org_id=i.org_id AND c.id=i.customer_id" - " WHERE i.org_id=?1 AND i.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); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "invoice not found"); - } - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_int(r->rdoc, o, "customer_id", - sqlite3_column_int64(st, 1)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "customer_name", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", - sq(sqlite3_column_text(st, 4))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_date", - sq(sqlite3_column_text(st, 5))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "due_date", - sq(sqlite3_column_text(st, 6))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "delivery_date", - sq(sqlite3_column_text(st, 7))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", - sq(sqlite3_column_text(st, 8))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "our_ref", - sq(sqlite3_column_text(st, 9))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", - sq(sqlite3_column_text(st, 10))); - yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", - sqlite3_column_int64(st, 11)); - yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", - sqlite3_column_int64(st, 12)); - yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", - sqlite3_column_int64(st, 13)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", - sq(sqlite3_column_text(st, 14))); - if (sqlite3_column_type(st, 15) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "document_id"); - else - yyjson_mut_obj_add_int(r->rdoc, o, "document_id", - sqlite3_column_int64(st, 15)); - if (sqlite3_column_type(st, 16) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); - else - yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", - sqlite3_column_int64(st, 16)); - if (sqlite3_column_type(st, 17) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_at"); - else - yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_at", - sq(sqlite3_column_text(st, 17))); - if (sqlite3_column_type(st, 18) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_to"); - else - yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to", - sq(sqlite3_column_text(st, 18))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", - sq(sqlite3_column_text(st, 19))); - yyjson_mut_obj_add_int(r->rdoc, o, "created_by", - sqlite3_column_int64(st, 20)); - sqlite3_finalize(st); - - yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); - if (sqlite3_prepare_v2( - r->db, - "SELECT " INVOICE_ROW_COLUMNS " FROM invoice_rows" - " WHERE org_id=?1 AND invoice_id=?2 ORDER BY line_no", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, id); - while (sqlite3_step(st) == SQLITE_ROW) - yyjson_mut_arr_add_val(rows, invoice_row_json(r, st)); - sqlite3_finalize(st); - } - yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); - return o; -} - -static yyjson_mut_val *h_invoice_list(struct req *r) -{ - int64_t customer_id = 0, limit = 200; - arg_int(r->args, "customer_id", &customer_id); - arg_int(r->args, "limit", &limit); - const char *status = arg_str(r->args, "status"); - if (!status) - status = ""; - if (limit < 1) - limit = 200; - if (limit > 1000) - limit = 1000; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT i.id,i.number,i.ocr,i.customer_id,c.name,i.invoice_date," - "i.due_date,i.total_ore,i.status,i.document_id,i.voucher_id," - "i.last_sent_at,i.last_sent_to" - " FROM invoices i JOIN customers c" - " ON c.org_id=i.org_id AND c.id=i.customer_id" - " WHERE i.org_id=?1" - " AND (?2=0 OR i.customer_id=?2)" - " AND (?3='' OR i.status=?3)" - " ORDER BY i.number DESC, i.id DESC LIMIT ?4", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, customer_id); - sqlite3_bind_text(st, 3, status, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 4, limit); - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - while (sqlite3_step(st) == SQLITE_ROW) { - yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_int(r->rdoc, o, "number", - sqlite3_column_int64(st, 1)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_int(r->rdoc, o, "customer_id", - sqlite3_column_int64(st, 3)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "customer_name", - sq(sqlite3_column_text(st, 4))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_date", - sq(sqlite3_column_text(st, 5))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "due_date", - sq(sqlite3_column_text(st, 6))); - yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", - sqlite3_column_int64(st, 7)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", - sq(sqlite3_column_text(st, 8))); - if (sqlite3_column_type(st, 9) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "document_id"); - else - yyjson_mut_obj_add_int(r->rdoc, o, "document_id", - sqlite3_column_int64(st, 9)); - if (sqlite3_column_type(st, 10) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); - else - yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", - sqlite3_column_int64(st, 10)); - if (sqlite3_column_type(st, 11) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_at"); - else - yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_at", - sq(sqlite3_column_text(st, 11))); - if (sqlite3_column_type(st, 12) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_to"); - else - yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to", - sq(sqlite3_column_text(st, 12))); - } - sqlite3_finalize(st); - yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - return out; -} - -static yyjson_mut_val *h_invoice_pdf(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT document_id FROM invoices 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); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "invoice not found"); - } - int64_t document_id = sqlite3_column_type(st, 0) == SQLITE_NULL - ? 0 - : sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - if (document_id <= 0) - return fail(r, "NOT_FOUND", "invoice has no stored PDF"); - const void *content = NULL; - size_t len = 0; - if (sqlite3_prepare_v2( - r->db, - "SELECT content FROM attachments 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, document_id); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "invoice PDF not found"); - } - content = sqlite3_column_blob(st, 0); - len = (size_t)sqlite3_column_bytes(st, 0); - char *b64 = util_b64(content ? content : (const unsigned char *)"", len); - sqlite3_finalize(st); - if (!b64) - return fail(r, "INTERNAL", "could not encode the PDF"); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); - free(b64); - return o; -} - -/* Whole kronor with a space as thousands separator, e.g. 91500 -> "91 500". */ -static void invoice_send_amount(int64_t ore, char *out, size_t n) -{ - char digits[24]; - snprintf(digits, sizeof digits, "%lld", (long long)((ore + 50) / 100)); - size_t len = strlen(digits); - size_t o = 0; - for (size_t i = 0; i < len && o + 1 < n; i++) { - if (i > 0 && (len - i) % 3 == 0) - out[o++] = ' '; - out[o++] = digits[i]; - } - out[o] = '\0'; -} - -static yyjson_mut_val *h_invoice_send(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT i.number,i.ocr,i.due_date,i.document_id,i.total_ore," - "c.name,COALESCE(c.email,''),COALESCE(o.name,'')" - " FROM invoices i" - " JOIN customers c ON c.org_id=i.org_id AND c.id=i.customer_id" - " JOIN orgs o ON o.id=i.org_id" - " WHERE i.org_id=?1 AND i.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); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "invoice not found"); - } - int64_t number = sqlite3_column_int64(st, 0); - int64_t document_id = sqlite3_column_type(st, 3) == SQLITE_NULL - ? 0 - : sqlite3_column_int64(st, 3); - int64_t total_ore = sqlite3_column_int64(st, 4); - char ocr[40], due_date[16], customer_name[256], customer_email[256]; - char org_name[256]; - snprintf(ocr, sizeof ocr, "%s", sq(sqlite3_column_text(st, 1))); - snprintf(due_date, sizeof due_date, "%s", sq(sqlite3_column_text(st, 2))); - snprintf(customer_name, sizeof customer_name, "%s", - sq(sqlite3_column_text(st, 5))); - snprintf(customer_email, sizeof customer_email, "%s", - sq(sqlite3_column_text(st, 6))); - snprintf(org_name, sizeof org_name, "%s", sq(sqlite3_column_text(st, 7))); - sqlite3_finalize(st); - st = NULL; - - if (document_id <= 0) - return fail(r, "NOT_FOUND", "invoice has no stored PDF"); - if (sqlite3_prepare_v2( - r->db, "SELECT content FROM attachments 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, document_id); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "invoice PDF not found"); - } - const void *blob = sqlite3_column_blob(st, 0); - size_t pdf_len = (size_t)sqlite3_column_bytes(st, 0); - unsigned char *pdf = xmalloc(pdf_len ? pdf_len : 1); - if (blob && pdf_len) - memcpy(pdf, blob, pdf_len); - sqlite3_finalize(st); - st = NULL; - - const char *to = arg_str(r->args, "to"); - if (!to || !*to) - to = customer_email; - if (!*to) { - free(pdf); - return fail(r, "INVALID_ARGS", "customer has no e-mail address"); - } - - char *smtp_host = db_setting(r->db, r->org_id, "smtp_host"); - char *smtp_port = db_setting(r->db, r->org_id, "smtp_port"); - char *smtp_user = db_setting(r->db, r->org_id, "smtp_user"); - char *smtp_from = db_setting(r->db, r->org_id, "smtp_from"); - char *smtp_security = db_setting(r->db, r->org_id, "smtp_security"); - char *smtp_password = db_setting(r->db, r->org_id, "smtp_password"); - char *password = NULL; - char *body = NULL; - yyjson_mut_val *res = NULL; - - if (!smtp_host || !*smtp_host || !smtp_from || !*smtp_from) { - fail(r, "SMTP_NOT_CONFIGURED", "smtp_host and smtp_from must be set"); - goto done; - } - const char *user = smtp_user && *smtp_user ? smtp_user : ""; - if (*user) { - if (!smtp_password || !*smtp_password) { - fail(r, "SMTP_NOT_CONFIGURED", - "smtp_user is set but smtp_password is missing"); - goto done; - } - if (!secret_available()) { - fail(r, "SMTP_NOT_CONFIGURED", - "smtp_password is set but BOKFD_SECRET_KEY is missing or" - " invalid"); - goto done; - } - if (secret_decrypt(smtp_password, &password) != 0 || !password) { - fail(r, "SMTP_NOT_CONFIGURED", - "cannot decrypt smtp_password, check BOKFD_SECRET_KEY"); - goto done; - } - } - int port = 587; - if (smtp_port && *smtp_port) { - long v = strtol(smtp_port, NULL, 10); - if (v >= 1 && v <= 65535) - port = (int)v; - } - const char *security = - smtp_security && *smtp_security ? smtp_security : "starttls"; - - char subject[64]; - snprintf(subject, sizeof subject, "Faktura %lld", (long long)number); - char amount[32]; - invoice_send_amount(total_ore, amount, sizeof amount); - size_t body_len = strlen(org_name) + 320; - body = xmalloc(body_len); - snprintf(body, body_len, - "Hej,\n\nBifogat finner du faktura %lld på %s kr med" - " förfallodatum %s.\nAnge OCR %s vid betalning.\n\n" - "Med vänlig hälsning\n%s\n", - (long long)number, amount, due_date, ocr, org_name); - - char safe_name[256]; - snprintf(safe_name, sizeof safe_name, "%s", customer_name); - for (char *p = safe_name; *p; p++) - if (*p == '/') - *p = '-'; - char attach_name[320]; - snprintf(attach_name, sizeof attach_name, "Faktura %lld %s.pdf", - (long long)number, safe_name); - - 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_strcpy(r->rdoc, o, "to", to); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "subject", subject); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - res = o; - goto done; - } - - struct smtp_message m; - memset(&m, 0, sizeof m); - m.host = smtp_host; - m.port = port; - m.security = security; - m.user = user; - m.password = password ? password : ""; - m.from = smtp_from; - m.from_name = org_name; - m.to = to; - m.subject = subject; - m.body = body; - m.attach_name = attach_name; - m.attach = pdf; - m.attach_len = pdf_len; - - char errbuf[512]; - if (smtp_send(&m, errbuf, sizeof errbuf) != 0) { - fail(r, "SMTP_FAILED", errbuf[0] ? errbuf : "smtp send failed"); - goto done; - } - - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - if (sqlite3_prepare_v2( - r->db, - "UPDATE invoices SET last_sent_at=?1,last_sent_to=?2" - " WHERE org_id=?3 AND id=?4", - -1, &st, NULL) != SQLITE_OK) { - fail(r, "INTERNAL", "database error"); - goto done; - } - sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 2, to, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 3, r->org_id); - sqlite3_bind_int64(st, 4, id); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - st = NULL; - if (rc != SQLITE_DONE) { - fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); - goto done; - } - - yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - yyjson_mut_val *a = yyjson_mut_obj(doc); - yyjson_mut_doc_set_root(doc, a); - yyjson_mut_obj_add_int(doc, a, "id", id); - yyjson_mut_obj_add_strcpy(doc, a, "to", to); - yyjson_mut_obj_add_strcpy(doc, a, "subject", subject); - char *reqjson = yyjson_mut_write(doc, 0, NULL); - yyjson_mut_doc_free(doc); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "invoice.send", reqjson ? 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_strcpy(r->rdoc, o, "sent_to", to); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts); - res = o; - -done: - free(smtp_host); - free(smtp_port); - free(smtp_user); - free(smtp_from); - free(smtp_security); - free(smtp_password); - free(password); - free(body); - free(pdf); - return res; -} - -/* ------------------------------------------------------------------ */ -/* 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) */ -/* ------------------------------------------------------------------ */ - -struct tpl_head { - int64_t id; - char name[128]; - char series[16]; - char description[256]; -}; - -struct tpl_loaded { - char account[16]; - char formula[256]; - char desc[128]; -}; - -struct tpl_row_in { - int64_t account_id; - char account[16]; - char formula[256]; - char desc[128]; -}; - -static double parse_kr_double(const char *s, int *ok) -{ - *ok = 0; - if (!s) - return 0; - char buf[64]; - size_t j = 0; - for (const char *p = s; *p && j < sizeof buf - 1; p++) { - if (*p == ' ' || *p == '\t') - continue; - buf[j++] = (*p == ',') ? '.' : *p; - } - buf[j] = '\0'; - if (!j || (j == 1 && buf[0] == '-')) - return 0; - char *end = NULL; - double v = strtod(buf, &end); - if (!end || *end) - return 0; - *ok = 1; - return v; -} - -static char *replace_x(const char *text, double x) -{ - struct buf b; - buf_init(&b); - for (const char *p = text; *p;) { - if (p[0] == '{' && p[1] == 'x' && p[2] == '}') { - char tmp[64]; - snprintf(tmp, sizeof tmp, "%.2f", x); - buf_append(&b, tmp, strlen(tmp)); - p += 3; - } else { - buf_append(&b, p, 1); - p++; - } - } - buf_append(&b, "", 1); - return (char *)b.p; -} - -static int load_template(sqlite3 *db, int64_t org_id, int64_t id, - const char *name, struct tpl_head *head, char **err) -{ - sqlite3_stmt *st = NULL; - const char *sql = - id ? "SELECT id,name,series,description FROM voucher_templates" - " WHERE org_id=?1 AND id=?2 AND active=1" - : "SELECT id,name,series,description FROM voucher_templates" - " WHERE org_id=?1 AND name=?2 AND active=1"; - if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { - if (err) - *err = xstrdup("database error"); - return -1; - } - sqlite3_bind_int64(st, 1, org_id); - if (id) - sqlite3_bind_int64(st, 2, id); - else - sqlite3_bind_text(st, 2, name ? name : "", -1, SQLITE_TRANSIENT); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - if (err) - *err = xstrdup("template not found"); - return -1; - } - head->id = sqlite3_column_int64(st, 0); - snprintf(head->name, sizeof head->name, "%s", - sqlite3_column_text(st, 1)); - snprintf(head->series, sizeof head->series, "%s", - sqlite3_column_text(st, 2)); - snprintf(head->description, sizeof head->description, "%s", - sqlite3_column_text(st, 3)); - sqlite3_finalize(st); - return 0; -} - -static int load_template_rows(sqlite3 *db, int64_t org_id, int64_t tpl, - struct tpl_loaded **out, size_t *out_n, - char **err) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - db, - "SELECT a.number,tr.formula,COALESCE(tr.description,'')" - " FROM voucher_template_rows tr JOIN accounts a" - " ON a.org_id=tr.org_id AND a.id=tr.account_id" - " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no", - -1, &st, NULL) != SQLITE_OK) { - if (err) - *err = xstrdup("database error"); - return -1; - } - sqlite3_bind_int64(st, 1, org_id); - sqlite3_bind_int64(st, 2, tpl); - size_t cap = 0, n = 0; - struct tpl_loaded *rows = NULL; - while (sqlite3_step(st) == SQLITE_ROW) { - if (n == cap) { - cap = cap ? cap * 2 : 8; - rows = xrealloc(rows, cap * sizeof *rows); - } - memset(&rows[n], 0, sizeof rows[n]); - snprintf(rows[n].account, sizeof rows[n].account, "%s", - sqlite3_column_text(st, 0)); - snprintf(rows[n].formula, sizeof rows[n].formula, "%s", - sqlite3_column_text(st, 1)); - snprintf(rows[n].desc, sizeof rows[n].desc, "%s", - sqlite3_column_text(st, 2)); - n++; - } - sqlite3_finalize(st); - *out = rows; - *out_n = n; - return 0; -} - -static int parse_template_rows(struct req *r, yyjson_val *rowsv, - struct tpl_row_in **out, size_t *out_n) -{ - *out = NULL; - *out_n = 0; - if (!rowsv || !yyjson_is_arr(rowsv)) - return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1; - size_t n = yyjson_arr_size(rowsv); - if (n == 0 || n > 100) - return fail(r, "INVALID_ARGS", - "a template needs between 1 and 100 rows") - ? -1 - : -1; - struct tpl_row_in *rows = xcalloc(n, sizeof *rows); - size_t k = 0; - yyjson_arr_iter it = yyjson_arr_iter_with(rowsv); - yyjson_val *item; - while ((item = yyjson_arr_iter_next(&it))) { - if (!yyjson_is_obj(item)) { - free(rows); - fail(r, "INVALID_ARGS", "each row must be an object"); - return -1; - } - yyjson_val *av = yyjson_obj_get(item, "account"); - yyjson_val *fv = yyjson_obj_get(item, "formula"); - if (!av || !yyjson_is_str(av) || !fv || !yyjson_is_str(fv)) { - free(rows); - failf(r, "INVALID_ARGS", - "row %zu: account and formula are required", k + 1); - return -1; - } - const char *account = yyjson_get_str(av); - const char *formula = yyjson_get_str(fv); - if (!formula_valid(formula)) { - free(rows); - failf(r, "INVALID_ARGS", "row %zu: ogiltig formel '%s'", k + 1, - formula); - return -1; - } - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, - "SELECT id FROM accounts WHERE org_id=?1" - " AND number=?2", - -1, &st, NULL) != SQLITE_OK) { - free(rows); - return fail(r, "INTERNAL", "database error") ? -1 : -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - free(rows); - failf(r, "ACCOUNT_NOT_FOUND", "row %zu: account %s does not exist", - k + 1, account); - return -1; - } - rows[k].account_id = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - snprintf(rows[k].account, sizeof rows[k].account, "%s", account); - snprintf(rows[k].formula, sizeof rows[k].formula, "%s", formula); - yyjson_val *dv = yyjson_obj_get(item, "description"); - if (dv && yyjson_is_str(dv)) - snprintf(rows[k].desc, sizeof rows[k].desc, "%s", - yyjson_get_str(dv)); - k++; - } - *out = rows; - *out_n = k; - return 0; -} - -static yyjson_mut_val *tpl_rows_json(struct req *r, sqlite3_stmt *st) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "line_no", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "formula", - sq(sqlite3_column_text(st, 3))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", - sq(sqlite3_column_text(st, 4))); - return o; -} - -static yyjson_mut_val *h_template_list(struct req *r) -{ - int active_only = 0; - arg_bool(r->args, "active_only", &active_only); - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *st = NULL; - const char *sql = - active_only - ? "SELECT t.id,t.name,t.series,t.description,t.active," - "(SELECT count(*) FROM voucher_template_rows tr" - " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" - " FROM voucher_templates t WHERE t.org_id=?1 AND t.active=1" - " ORDER BY t.name" - : "SELECT t.id,t.name,t.series,t.description,t.active," - "(SELECT count(*) FROM voucher_template_rows tr" - " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" - " FROM voucher_templates t WHERE t.org_id=?1 ORDER BY t.name"; - 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); - while (sqlite3_step(st) == SQLITE_ROW) { - yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", - sq(sqlite3_column_text(st, 3))); - yyjson_mut_obj_add_bool(r->rdoc, o, "active", - sqlite3_column_int(st, 4) != 0); - yyjson_mut_obj_add_int(r->rdoc, o, "row_count", - sqlite3_column_int64(st, 5)); - } - sqlite3_finalize(st); - yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - return out; -} - -static yyjson_mut_val *h_template_get(struct req *r) -{ - int64_t id = 0; - arg_int(r->args, "id", &id); - const char *name = arg_str(r->args, "name"); - if (id <= 0 && !name) - return fail(r, "INVALID_ARGS", "id or name is required"); - sqlite3_stmt *st = NULL; - const char *sql = - id > 0 ? "SELECT id,name,series,description,active" - " FROM voucher_templates WHERE org_id=?1 AND id=?2" - : "SELECT id,name,series,description,active" - " FROM voucher_templates WHERE org_id=?1 AND name=?2"; - 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 (id > 0) - sqlite3_bind_int64(st, 2, id); - else - sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "template not found"); - } - int64_t tpl = sqlite3_column_int64(st, 0); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", - sq(sqlite3_column_text(st, 3))); - yyjson_mut_obj_add_bool(r->rdoc, o, "active", - sqlite3_column_int(st, 4) != 0); - sqlite3_finalize(st); - - yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); - if (sqlite3_prepare_v2( - r->db, - "SELECT tr.line_no,a.number,a.name,tr.formula," - "COALESCE(tr.description,'') FROM voucher_template_rows tr" - " JOIN accounts a ON a.org_id=tr.org_id AND a.id=tr.account_id" - " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, tpl); - while (sqlite3_step(st) == SQLITE_ROW) - yyjson_mut_arr_add_val(rows, tpl_rows_json(r, st)); - sqlite3_finalize(st); - } - yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); - return o; -} - -static yyjson_mut_val *h_template_create(struct req *r) -{ - const char *name = arg_str(r->args, "name"); - const char *series = arg_str(r->args, "series"); - const char *description = arg_str(r->args, "description"); - if (!name || !*name) - return fail(r, "INVALID_ARGS", "name is required"); - char *series_owned = NULL; - if (!series || !*series) { - series_owned = db_setting(r->db, r->org_id, "default_series"); - series = series_owned && *series_owned ? series_owned : "A"; - } - if (!description) - description = ""; - struct tpl_row_in *rows = NULL; - size_t nrows = 0; - if (parse_template_rows( - r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, &rows, - &nrows) != 0) - return NULL; - - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); - yyjson_mut_val *arr = yyjson_mut_arr(r->rdoc); - for (size_t i = 0; i < nrows; i++) { - yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, arr); - yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", rows[i].account); - yyjson_mut_obj_add_strcpy(r->rdoc, ro, "formula", rows[i].formula); - yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", rows[i].desc); - } - yyjson_mut_obj_add_val(r->rdoc, o, "rows", arr); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - free(rows); - free(series_owned); - return o; - } - - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { - free(rows); - free(series_owned); - return fail(r, "DB_BUSY", "could not start transaction"); - } - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(r->db, - "INSERT INTO voucher_templates(org_id,name,series," - "description,created_at) VALUES(?1,?2,?3,?4,?5)", - -1, &st, NULL) != SQLITE_OK) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - if ((rc & 0xff) == SQLITE_CONSTRAINT) - return fail(r, "CONFLICT", "a template with that name exists"); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - int64_t tpl = db_last_id(r->db); - for (size_t i = 0; i < nrows; i++) { - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO voucher_template_rows(org_id,template_id,line_no," - "account_id,formula,description) VALUES(?1,?2,?3,?4,?5,?6)", - -1, &st, NULL) != SQLITE_OK) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, tpl); - sqlite3_bind_int64(st, 3, (int64_t)i + 1); - sqlite3_bind_int64(st, 4, rows[i].account_id); - sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); - if (rows[i].desc[0]) - sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 6); - rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - } - free(rows); - if (db_exec(r->db, "COMMIT", NULL) != 0) - return fail(r, "INTERNAL", "commit failed"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "template.create", reqjson, "OK", NULL); - free(reqjson); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); - free(series_owned); - return o; -} - -static yyjson_mut_val *h_template_update(struct req *r) -{ - int64_t id = 0; - arg_int(r->args, "id", &id); - const char *name_arg = arg_str(r->args, "name"); - if (id <= 0 && !name_arg) - return fail(r, "INVALID_ARGS", "id or name is required"); - struct tpl_head head; - char *err = NULL; - if (load_template(r->db, r->org_id, id, name_arg, &head, &err) != 0) { - yyjson_mut_val *res = - fail(r, "NOT_FOUND", err ? err : "template not found"); - free(err); - return res; - } - const char *new_name = arg_str(r->args, "name"); - const char *series = arg_str(r->args, "series"); - const char *description = arg_str(r->args, "description"); - int active = -1; - arg_bool(r->args, "active", &active); - yyjson_val *rowsv = r->args ? yyjson_obj_get(r->args, "rows") : NULL; - struct tpl_row_in *rows = NULL; - size_t nrows = 0; - if (rowsv) { - if (parse_template_rows(r, rowsv, &rows, &nrows) != 0) - return NULL; - } - if (!new_name && !series && !description && active < 0 && !rowsv) { - free(rows); - return fail(r, "INVALID_ARGS", "nothing to update"); - } - - if (r->dry_run) { - free(rows); - return h_template_get(r); - } - - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { - free(rows); - return fail(r, "DB_BUSY", "could not start transaction"); - } - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "UPDATE voucher_templates SET" - " name=COALESCE(?2,name), series=COALESCE(?3,series)," - " description=COALESCE(?4,description)," - " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6" - " WHERE org_id=?1 AND id=?7", - -1, &st, NULL) != SQLITE_OK) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(st, 1, r->org_id); - if (new_name) - sqlite3_bind_text(st, 2, new_name, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 2); - if (series) - sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 3); - if (description) - sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 4); - sqlite3_bind_int(st, 5, active); - sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 7, head.id); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - if (rowsv) { - if (sqlite3_prepare_v2( - r->db, - "DELETE FROM voucher_template_rows WHERE org_id=?1" - " AND template_id=?2", - -1, &st, NULL) != SQLITE_OK) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, head.id); - rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - for (size_t i = 0; i < nrows; i++) { - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO voucher_template_rows(org_id,template_id," - "line_no,account_id,formula,description)" - " VALUES(?1,?2,?3,?4,?5,?6)", - -1, &st, NULL) != SQLITE_OK) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, head.id); - sqlite3_bind_int64(st, 3, (int64_t)i + 1); - sqlite3_bind_int64(st, 4, rows[i].account_id); - sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); - if (rows[i].desc[0]) - sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 6); - rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - db_exec(r->db, "ROLLBACK", NULL); - free(rows); - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - } - } - } - free(rows); - if (db_exec(r->db, "COMMIT", NULL) != 0) - return fail(r, "INTERNAL", "commit failed"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "template.update", reqjson, "OK", NULL); - free(reqjson); - return h_template_get(r); -} - -static yyjson_mut_val *h_template_archive(struct req *r) -{ - int64_t id = 0; - arg_int(r->args, "id", &id); - const char *name = arg_str(r->args, "name"); - if (id <= 0 && !name) - return fail(r, "INVALID_ARGS", "id or name is required"); - sqlite3_stmt *st = NULL; - const char *sql = - id > 0 ? "UPDATE voucher_templates SET active=0 WHERE org_id=?1" - " AND id=?2" - : "UPDATE voucher_templates SET active=0 WHERE org_id=?1" - " AND name=?2"; - 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 (id > 0) - sqlite3_bind_int64(st, 2, id); - else - sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); - 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", "template not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "template.archive", reqjson, "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -static yyjson_mut_val *h_voucher_post(struct req *r) -{ - const char *date = arg_str(r->args, "date"); - const char *description = arg_str(r->args, "description"); - const char *series = arg_str(r->args, "series"); - const char *client_ref = arg_str(r->args, "client_ref"); - int64_t corrects = 0; - arg_int(r->args, "corrects_voucher", &corrects); - if (!date) - return fail(r, "INVALID_ARGS", "date is required"); - - /* template application: {template, x} instead of {rows} */ - int64_t tpl_id = 0; - const char *tpl_name = NULL; - yyjson_val *tv = r->args ? yyjson_obj_get(r->args, "template") : NULL; - if (tv) { - if (yyjson_is_int(tv)) - tpl_id = yyjson_get_int(tv); - else if (yyjson_is_str(tv)) - tpl_name = yyjson_get_str(tv); - else - return fail(r, "INVALID_ARGS", "template must be an id or a name"); - if (r->args && yyjson_obj_get(r->args, "rows")) - return fail(r, "INVALID_ARGS", - "use either rows or template, not both"); - } - double x = 0; - yyjson_val *xv = r->args ? yyjson_obj_get(r->args, "x") : NULL; - if (xv) { - if (yyjson_is_str(xv)) { - int ok = 0; - x = parse_kr_double(yyjson_get_str(xv), &ok); - if (!ok) - return fail(r, "INVALID_ARGS", "x must be a number in kronor"); - } else if (yyjson_is_real(xv)) { - x = yyjson_get_real(xv); - } else if (yyjson_is_int(xv)) { - x = (double)yyjson_get_int(xv); - } else { - return fail(r, "INVALID_ARGS", "x must be a number in kronor"); - } - } - - struct ledger_row *rows = NULL; - size_t nrows = 0; - char *owned_desc = NULL; - char owned_series[16] = ""; - int rows_owned = 0; - - if (tv) { - struct tpl_head head; - char *err = NULL; - if (load_template(r->db, r->org_id, tpl_id, tpl_name, &head, &err) != - 0) { - yyjson_mut_val *res = - fail(r, "NOT_FOUND", err ? err : "template not found"); - free(err); - return res; - } - struct tpl_loaded *trows = NULL; - size_t ntrows = 0; - if (load_template_rows(r->db, r->org_id, head.id, &trows, &ntrows, - &err) != 0) { - yyjson_mut_val *res = - fail(r, "INTERNAL", err ? err : "could not load template"); - free(err); - return res; - } - struct template_row *in = - xcalloc(ntrows ? ntrows : 1, sizeof *in); - for (size_t i = 0; i < ntrows; i++) { - in[i].account = trows[i].account; - in[i].formula = trows[i].formula; - in[i].description = trows[i].desc[0] ? trows[i].desc : NULL; - } - struct resolved_row *resolved = - xcalloc(ntrows ? ntrows : 1, sizeof *resolved); - char ferr[256] = ""; - if (formula_resolve_rows(in, ntrows, x, resolved, &nrows, ferr, - sizeof ferr) != 0) { - free(in); - free(resolved); - free(trows); - return fail(r, "INVALID_ARGS", - ferr[0] ? ferr : "template could not be resolved"); - } - free(in); - free(trows); - rows = xcalloc(nrows, sizeof *rows); - for (size_t i = 0; i < nrows; i++) { - rows[i].account = xstrdup(resolved[i].account); - rows[i].debit_ore = resolved[i].debit_ore; - rows[i].credit_ore = resolved[i].credit_ore; - rows[i].description = - resolved[i].description[0] ? xstrdup(resolved[i].description) - : NULL; - } - free(resolved); - rows_owned = 1; - if (!series && head.series[0]) - snprintf(owned_series, sizeof owned_series, "%s", head.series); - if (!description && head.description[0]) - owned_desc = replace_x(head.description, x); - if (!description && !owned_desc) - owned_desc = xstrdup(head.name); - } else { - if (!description) - return fail(r, "INVALID_ARGS", - "date and description are required"); - if (parse_rows(r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, - &rows, &nrows) != 0) - return NULL; - } - - int64_t *att = NULL; - size_t natt = 0; - if (parse_attachment_ids(r, &att, &natt) != 0) { - if (rows_owned) - for (size_t i = 0; i < nrows; i++) { - free((char *)rows[i].account); - free((char *)rows[i].description); - } - free(rows); - free(owned_desc); - return NULL; - } - - 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 = description ? description : owned_desc; - o.series = series ? series - : (owned_series[0] ? owned_series : NULL); - o.rows = rows; - o.nrows = nrows; - o.corrects_voucher_id = corrects; - o.attachment_ids = att; - o.n_attachments = natt; - o.client_ref = client_ref; - o.dry_run = r->dry_run; - - struct ledger_error e; - char *json = NULL; - int rc = ledger_post(r->db, &o, &e, &json); - if (rows_owned) - for (size_t i = 0; i < nrows; i++) { - free((char *)rows[i].account); - free((char *)rows[i].description); - } - free(rows); - free(att); - free(owned_desc); - if (rc != 0) { - if (e.has_details) { - yyjson_mut_val *details = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, details, "difference_ore", - e.difference_ore); - r->err_details = details; - } - return fail(r, e.code ? e.code : "INTERNAL", e.msg); - } - - 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, - "voucher.post", reqjson, "OK", NULL); - free(reqjson); - } - yyjson_mut_val *res = json_to_mut(r->rdoc, json); - free(json); - if (!res) - return fail(r, "INTERNAL", "could not serialize result"); - return res; -} - -static yyjson_mut_val *voucher_json(struct req *r, sqlite3_stmt *st) -{ - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", - sqlite3_column_int64(st, 1)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", - sq(sqlite3_column_text(st, 4))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", - sq(sqlite3_column_text(st, 5))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "source", - sq(sqlite3_column_text(st, 6))); - if (sqlite3_column_type(st, 7) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id"); - else - yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id", - sqlite3_column_int64(st, 7)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", - sq(sqlite3_column_text(st, 8))); - yyjson_mut_obj_add_int(r->rdoc, o, "created_by_user", - sqlite3_column_int64(st, 9)); - char hex[65]; - const void *hb = sqlite3_column_blob(st, 10); - if (hb && sqlite3_column_bytes(st, 10) == 32) { - util_hex((const unsigned char *)hb, 32, hex); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash_prev", hex); - } - const void *vb = sqlite3_column_blob(st, 11); - if (vb && sqlite3_column_bytes(st, 11) == 32) { - util_hex((const unsigned char *)vb, 32, hex); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash", hex); - } - return o; -} - -#define VOUCHER_COLUMNS \ - "id,fiscal_year_id,series,number,date,description,source," \ - "corrects_voucher_id,created_at,created_by_user,hash_prev,hash" - -static yyjson_mut_val *h_voucher_get(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT " VOUCHER_COLUMNS " FROM vouchers 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); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "voucher not found"); - } - yyjson_mut_val *o = voucher_json(r, st); - sqlite3_finalize(st); - - yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); - if (sqlite3_prepare_v2( - r->db, - "SELECT a.number,a.name,r.debit_ore,r.credit_ore," - "COALESCE(r.description,'') FROM voucher_rows r" - " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" - " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, id); - while (sqlite3_step(st) == SQLITE_ROW) { - yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, rows); - yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", - sq(sqlite3_column_text(st, 0))); - yyjson_mut_obj_add_strcpy(r->rdoc, ro, "name", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_int(r->rdoc, ro, "debit_ore", - sqlite3_column_int64(st, 2)); - yyjson_mut_obj_add_int(r->rdoc, ro, "credit_ore", - sqlite3_column_int64(st, 3)); - yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", - sq(sqlite3_column_text(st, 4))); - } - sqlite3_finalize(st); - } - yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); - - yyjson_mut_val *atts = yyjson_mut_arr(r->rdoc); - if (sqlite3_prepare_v2( - r->db, - "SELECT a.id,a.filename FROM voucher_attachments va" - " JOIN attachments a ON a.org_id=va.org_id AND a.id=va.attachment_id" - " WHERE va.org_id=?1 AND va.voucher_id=?2 ORDER BY a.id", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, id); - while (sqlite3_step(st) == SQLITE_ROW) { - yyjson_mut_val *ao = yyjson_mut_arr_add_obj(r->rdoc, atts); - yyjson_mut_obj_add_int(r->rdoc, ao, "id", - sqlite3_column_int64(st, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, ao, "filename", - sq(sqlite3_column_text(st, 1))); - } - sqlite3_finalize(st); - } - yyjson_mut_obj_add_val(r->rdoc, o, "attachments", atts); - return o; -} - -static yyjson_mut_val *h_voucher_list(struct req *r) -{ - int64_t fy = 0, cursor = 0, limit = 100, account_id = 0; - arg_int(r->args, "fiscal_year", &fy); - arg_int(r->args, "cursor", &cursor); - arg_int(r->args, "limit", &limit); - if (limit < 1) - limit = 100; - if (limit > 1000) - limit = 1000; - const char *series = arg_str(r->args, "series"); - const char *from = arg_str(r->args, "from"); - const char *to = arg_str(r->args, "to"); - const char *text = arg_str(r->args, "text"); - const char *account = arg_str(r->args, "account"); - if (account) { - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT id FROM accounts WHERE org_id=?1 AND number=?2", -1, - &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "account not found"); - } - account_id = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - } - - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "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)," - "(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)" - " AND (?4='' OR v.date>=?4)" - " AND (?5='' OR v.date<=?5)" - " AND (?6=0 OR EXISTS(SELECT 1 FROM voucher_rows r2" - " WHERE r2.org_id=v.org_id AND r2.voucher_id=v.id" - " AND r2.account_id=?6))" - " AND (?7='' OR v.description LIKE '%'||?7||'%')" - " AND v.id>?8 ORDER BY v.id LIMIT ?9", - -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); - sqlite3_bind_text(st, 3, series ? series : "", -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, from ? from : "", -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, to ? to : "", -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 6, account_id); - sqlite3_bind_text(st, 7, text ? text : "", -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 8, cursor); - sqlite3_bind_int64(st, 9, limit); - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - int64_t last = cursor, n = 0; - while (sqlite3_step(st) == SQLITE_ROW) { - n++; - last = sqlite3_column_int64(st, 0); - yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, o, "id", last); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_int(r->rdoc, o, "number", - sqlite3_column_int64(st, 2)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", - sq(sqlite3_column_text(st, 3))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", - sq(sqlite3_column_text(st, 4))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "source", - sq(sqlite3_column_text(st, 5))); - if (sqlite3_column_type(st, 6) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id"); - else - yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id", - 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); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - if (n == limit) - yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last); - else - yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); - return out; -} - -static yyjson_mut_val *h_voucher_correct(struct req *r) -{ - int64_t id = 0; - const char *description = arg_str(r->args, "description"); - const char *date = arg_str(r->args, "date"); - const char *client_ref = arg_str(r->args, "client_ref"); - if (!arg_int(r->args, "voucher", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "voucher is required"); - if (!description || !*description) - return fail(r, "INVALID_ARGS", "description is required"); - - sqlite3_stmt *st = NULL; - char series[16] = "", orig_date[16] = ""; - if (sqlite3_prepare_v2( - r->db, - "SELECT series,date FROM vouchers 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); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "voucher not found"); - } - snprintf(series, sizeof series, "%s", sqlite3_column_text(st, 0)); - snprintf(orig_date, sizeof orig_date, "%s", sqlite3_column_text(st, 1)); - sqlite3_finalize(st); - - struct ledger_row *rows = NULL; - size_t nrows = 0, cap = 0; - if (sqlite3_prepare_v2( - r->db, - "SELECT a.number,r.debit_ore,r.credit_ore," - "COALESCE(r.description,'') FROM voucher_rows r" - " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" - " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", - -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); - while (sqlite3_step(st) == SQLITE_ROW) { - if (nrows == cap) { - cap = cap ? cap * 2 : 8; - rows = xrealloc(rows, cap * sizeof *rows); - } - memset(&rows[nrows], 0, sizeof rows[nrows]); - rows[nrows].account = xstrdup(sq(sqlite3_column_text(st, 0))); - rows[nrows].debit_ore = sqlite3_column_int64(st, 2); - rows[nrows].credit_ore = sqlite3_column_int64(st, 1); - const unsigned char *d = sqlite3_column_text(st, 3); - rows[nrows].description = d && *d ? xstrdup((const char *)d) : NULL; - nrows++; - } - sqlite3_finalize(st); - if (nrows == 0) - return fail(r, "INTERNAL", "voucher has no rows"); - - 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 ? date : orig_date; - o.description = description; - o.series = series; - o.rows = rows; - o.nrows = nrows; - o.corrects_voucher_id = id; - o.client_ref = client_ref; - o.dry_run = r->dry_run; - - struct ledger_error e; - char *json = NULL; - int rc = ledger_post(r->db, &o, &e, &json); - for (size_t i = 0; i < nrows; i++) { - free((char *)rows[i].account); - free((char *)rows[i].description); - } - free(rows); - if (rc != 0) - return fail(r, e.code ? e.code : "INTERNAL", e.msg); - 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, - "voucher.correct", reqjson, "OK", NULL); - free(reqjson); - } - yyjson_mut_val *res = json_to_mut(r->rdoc, json); - free(json); - if (!res) - return fail(r, "INTERNAL", "could not serialize result"); - return res; -} - -/* ------------------------------------------------------------------ */ -/* attachments */ -/* ------------------------------------------------------------------ */ - -static int link_attachment(struct req *r, int64_t aid, int64_t vid, - char **errmsg, const char **errcode) -{ - *errcode = "INTERNAL"; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT (SELECT count(*) FROM vouchers WHERE org_id=?1 AND id=?2)," - "(SELECT count(*) FROM voucher_attachments WHERE org_id=?1" - " AND voucher_id=?2 AND attachment_id=?3)," - "(SELECT count(*) FROM attachments WHERE org_id=?1 AND id=?3)", - -1, &st, NULL) != SQLITE_OK) { - *errmsg = xstrdup("database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, vid); - sqlite3_bind_int64(st, 3, aid); - int64_t vexists = 0, linked = 0, aexists = 0; - if (sqlite3_step(st) == SQLITE_ROW) { - vexists = sqlite3_column_int64(st, 0); - linked = sqlite3_column_int64(st, 1); - aexists = sqlite3_column_int64(st, 2); - } - sqlite3_finalize(st); - if (!aexists) { - *errmsg = xstrdup("attachment not found"); - *errcode = "NOT_FOUND"; - return -1; - } - if (!vexists) { - *errmsg = xstrdup("voucher not found"); - *errcode = "NOT_FOUND"; - return -1; - } - if (linked) { - *errmsg = xstrdup("attachment is already linked"); - *errcode = "CONFLICT"; - return -1; - } - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO voucher_attachments(org_id,voucher_id,attachment_id," - "created_at) VALUES(?1,?2,?3,?4)", - -1, &st, NULL) != SQLITE_OK) { - *errmsg = xstrdup("database error"); - return -1; - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, vid); - sqlite3_bind_int64(st, 3, aid); - sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - *errmsg = xstrdup("could not link attachment"); - return -1; - } - return 0; -} - -static yyjson_mut_val *h_attachment_put(struct req *r) -{ - const char *filename = arg_str(r->args, "filename"); - const char *mime = arg_str(r->args, "mime"); - const char *b64 = arg_str(r->args, "content_base64"); - int64_t voucher_id = 0; - arg_int(r->args, "voucher_id", &voucher_id); - if (!filename || !*filename || !b64) - return fail(r, "INVALID_ARGS", - "filename and content_base64 are required"); - if (!mime || !*mime) - mime = "application/octet-stream"; - unsigned char *content = NULL; - size_t len = 0; - if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) - return fail(r, "INVALID_ARGS", "content_base64 is not valid base64"); - if ((long)len > g_cfg.max_attachment_bytes) { - free(content); - return failf(r, "TOO_LARGE", "attachment exceeds %ld bytes", - g_cfg.max_attachment_bytes); - } - unsigned char hash[32]; - util_sha256(content, len, hash); - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT id FROM attachments WHERE org_id=?1 AND sha256=?2" - " AND filename=?3", - -1, &st, NULL) != SQLITE_OK) { - free(content); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); - int64_t existing = 0; - if (sqlite3_step(st) == SQLITE_ROW) - existing = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - - if (existing) { - free(content); - if (voucher_id) { - char *emsg = NULL; - const char *ecode = NULL; - if (link_attachment(r, existing, voucher_id, &emsg, &ecode) != 0) { - yyjson_mut_val *res = - fail(r, ecode ? ecode : "INTERNAL", emsg); - free(emsg); - return res; - } - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "attachment.link", reqjson, "OK", NULL); - free(reqjson); - } - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", existing); - yyjson_mut_obj_add_bool(r->rdoc, o, "replayed", true); - return o; - } - - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes," - "content,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", - -1, &st, NULL) != SQLITE_OK) { - free(content); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, mime, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 5, (int64_t)len); - sqlite3_bind_blob(st, 6, content, (int)len, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 8, r->sess->user_id); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - free(content); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); - int64_t id = db_last_id(r->db); - - if (voucher_id) { - char *emsg = NULL; - const char *ecode = NULL; - if (link_attachment(r, id, voucher_id, &emsg, &ecode) != 0) { - yyjson_mut_val *res = fail(r, ecode ? ecode : "INTERNAL", emsg); - free(emsg); - return res; - } - } - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "attachment.put", 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_int(r->rdoc, o, "size_bytes", (int64_t)len); - return o; -} - -static yyjson_mut_val *h_attachment_link(struct req *r) -{ - int64_t id = 0, voucher_id = 0; - arg_int(r->args, "id", &id); - arg_int(r->args, "voucher_id", &voucher_id); - if (id <= 0 || voucher_id <= 0) - return fail(r, "INVALID_ARGS", "id and voucher_id are required"); - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; - } - char *emsg = NULL; - const char *ecode = NULL; - if (link_attachment(r, id, voucher_id, &emsg, &ecode) != 0) { - yyjson_mut_val *e = fail(r, ecode ? ecode : "INTERNAL", emsg); - free(emsg); - return e; - } - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "attachment.link", 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_int(r->rdoc, o, "voucher_id", voucher_id); - return o; -} - -static yyjson_mut_val *h_attachment_unlink(struct req *r) -{ - int64_t id = 0, voucher_id = 0; - arg_int(r->args, "id", &id); - arg_int(r->args, "voucher_id", &voucher_id); - if (id <= 0 || voucher_id <= 0) - return fail(r, "INVALID_ARGS", "id and voucher_id are required"); - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - 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 voucher_attachments WHERE org_id=?1" - " AND voucher_id=?2 AND attachment_id=?3", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, voucher_id); - sqlite3_bind_int64(st, 3, 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", "link not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "attachment.unlink", reqjson, "OK", NULL); - free(reqjson); - return yyjson_mut_obj(r->rdoc); -} - -static yyjson_mut_val *h_attachment_get(struct req *r) -{ - int64_t id = 0; - if (!arg_int(r->args, "id", &id) || id <= 0) - return fail(r, "INVALID_ARGS", "id is required"); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT filename,mime,size_bytes,content,sha256,created_at" - " FROM attachments 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); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "attachment not found"); - } - const void *content = sqlite3_column_blob(st, 3); - size_t len = (size_t)sqlite3_column_bytes(st, 3); - char *b64 = util_b64(content ? content : (const unsigned char *)"", len); - char hex[65]; - const void *hb = sqlite3_column_blob(st, 4); - if (hb && sqlite3_column_bytes(st, 4) == 32) - util_hex((const unsigned char *)hb, 32, hex); - else - hex[0] = '\0'; - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "id", id); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", - sq(sqlite3_column_text(st, 0))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", - sqlite3_column_int64(st, 2)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", - sq(sqlite3_column_text(st, 5))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); - free(b64); - sqlite3_finalize(st); - return o; -} - -static yyjson_mut_val *h_attachment_list(struct req *r) -{ - int64_t voucher_id = 0, cursor = 0, limit = 100; - int unlinked = 0; - arg_int(r->args, "voucher_id", &voucher_id); - arg_int(r->args, "cursor", &cursor); - arg_int(r->args, "limit", &limit); - arg_bool(r->args, "unlinked", &unlinked); - if (limit < 1) - limit = 100; - if (limit > 1000) - limit = 1000; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT a.id,a.filename,a.mime,a.size_bytes,a.created_at,a.sha256," - "(SELECT va.voucher_id FROM voucher_attachments va" - " WHERE va.org_id=a.org_id AND va.attachment_id=a.id" - " AND (?2=0 OR va.voucher_id=?2)" - " ORDER BY va.voucher_id LIMIT 1)" - " FROM attachments a WHERE a.org_id=?1" - " AND (?2=0 OR EXISTS(SELECT 1 FROM voucher_attachments va2" - " WHERE va2.org_id=a.org_id AND va2.attachment_id=a.id" - " AND va2.voucher_id=?2))" - " AND (?3=0 OR NOT EXISTS(SELECT 1 FROM voucher_attachments va3" - " WHERE va3.org_id=a.org_id AND va3.attachment_id=a.id))" - " AND a.id>?4 ORDER BY a.id LIMIT ?5", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, voucher_id); - sqlite3_bind_int(st, 3, unlinked); - sqlite3_bind_int64(st, 4, cursor); - sqlite3_bind_int64(st, 5, limit); - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - int64_t n = 0, last = cursor; - while (sqlite3_step(st) == SQLITE_ROW) { - n++; - last = sqlite3_column_int64(st, 0); - yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, o, "id", last); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", - sq(sqlite3_column_text(st, 1))); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", - sq(sqlite3_column_text(st, 2))); - yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", - sqlite3_column_int64(st, 3)); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", - sq(sqlite3_column_text(st, 4))); - char hex[65]; - const void *hb = sqlite3_column_blob(st, 5); - if (hb && sqlite3_column_bytes(st, 5) == 32) - util_hex((const unsigned char *)hb, 32, hex); - else - hex[0] = '\0'; - yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); - if (sqlite3_column_type(st, 6) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); - else - yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", - sqlite3_column_int64(st, 6)); - } - sqlite3_finalize(st); - yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, out, "items", items); - if (n == limit) - yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last); - else - yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); - return out; -} - -/* ------------------------------------------------------------------ */ -/* reports and SIE */ -/* ------------------------------------------------------------------ */ - -static int64_t default_fy_id(sqlite3 *db, int64_t org_id) -{ - sqlite3_stmt *st = NULL; - int64_t id = 0; - if (sqlite3_prepare_v2( - db, - "SELECT id FROM fiscal_years WHERE org_id=?1" - " ORDER BY start_date DESC LIMIT 1", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, org_id); - if (sqlite3_step(st) == SQLITE_ROW) - id = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - } - return id; -} - -static int req_fy(struct req *r, int64_t *out) -{ - int64_t fy = 0; - arg_int(r->args, "fiscal_year", &fy); - if (!fy) - fy = default_fy_id(r->db, r->org_id); - if (!fy) { - fail(r, "NOT_FOUND", "no fiscal year; open one first"); - return -1; - } - *out = fy; - return 0; -} - -static yyjson_mut_val *h_report_trial_balance(struct req *r) -{ - int64_t fy = 0; - if (req_fy(r, &fy) != 0) - return NULL; - int include_zero = 0; - arg_bool(r->args, "include_zero", &include_zero); - char *err = NULL; - yyjson_mut_val *res = report_trial_balance( - r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), - arg_str(r->args, "to"), include_zero, &err); - if (!res) { - yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); - free(err); - return e; - } - return res; -} - -static yyjson_mut_val *h_report_income_statement(struct req *r) -{ - int64_t fy = 0; - if (req_fy(r, &fy) != 0) - return NULL; - char *err = NULL; - yyjson_mut_val *res = report_income_statement( - r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), - arg_str(r->args, "to"), &err); - if (!res) { - yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); - free(err); - return e; - } - return res; -} - -static yyjson_mut_val *h_report_balance_sheet(struct req *r) -{ - int64_t fy = 0; - if (req_fy(r, &fy) != 0) - return NULL; - char *err = NULL; - yyjson_mut_val *res = report_balance_sheet( - r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "to"), &err); - if (!res) { - yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); - free(err); - return e; - } - return res; -} - -static yyjson_mut_val *h_report_vat(struct req *r) -{ - const char *from = arg_str(r->args, "from"); - const char *to = arg_str(r->args, "to"); - if (!from || !to) - return fail(r, "INVALID_ARGS", "from and to are required"); - char *err = NULL; - yyjson_mut_val *res = - report_vat(r->rdoc, r->db, r->org_id, from, to, &err); - if (!res) { - yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); - free(err); - return e; - } - 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; - if (req_fy(r, &fy) != 0) - return NULL; - yyjson_val *accounts = yyjson_obj_get(r->args, "accounts"); - if (accounts && !yyjson_is_arr(accounts)) - return fail(r, "INVALID_ARGS", "accounts must be an array of strings"); - char *err = NULL; - yyjson_mut_val *res = report_general_ledger( - r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), - arg_str(r->args, "to"), accounts, &err); - if (!res) { - yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); - free(err); - return e; - } - return res; -} - -static yyjson_mut_val *h_report_voucher_list(struct req *r) -{ - int64_t fy = 0; - if (req_fy(r, &fy) != 0) - return NULL; - char *err = NULL; - yyjson_mut_val *res = report_voucher_list( - r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "series"), &err); - if (!res) { - yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); - free(err); - return e; - } - return res; -} - -static yyjson_mut_val *h_report_vat_eskd(struct req *r) -{ - const char *from = arg_str(r->args, "from"); - const char *to = arg_str(r->args, "to"); - if (!from || !to) - return fail(r, "INVALID_ARGS", "from and to are required"); - char *err = NULL; - yyjson_mut_val *res = - report_vat_eskd(r->rdoc, r->db, r->org_id, from, to, - arg_str(r->args, "upplysning"), &err); - if (!res) { - yyjson_mut_val *e = fail(r, "INVALID_ARGS", - err ? err : "could not build the eSKD file"); - free(err); - return e; - } - 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"); - if (!f) - return NULL; - struct buf b; - buf_init(&b); - unsigned char chunk[65536]; - size_t rn; - while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) - buf_append(&b, chunk, rn); - int bad = ferror(f); - fclose(f); - if (bad) { - buf_free(&b); - return NULL; - } - *out_len = b.len; - return b.p; -} - -static yyjson_mut_val *h_sie_export(struct req *r) -{ - int64_t fy = 0; - if (req_fy(r, &fy) != 0) - return NULL; - int inline_out = 0; - arg_bool(r->args, "inline", &inline_out); - - if (mkdir_p(g_cfg.export_dir, 0700) != 0) - return fail(r, "INTERNAL", "cannot create export directory"); - char label[64] = ""; - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT label 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); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "fiscal year not found"); - } - snprintf(label, sizeof label, "%s", sqlite3_column_text(st, 0)); - sqlite3_finalize(st); - for (char *p = label; *p; p++) - if (*p == '/') - *p = '-'; - char path[4096]; - snprintf(path, sizeof path, "%s/%lld_%s.se", g_cfg.export_dir, - (long long)r->org_id, label); - char *err = NULL; - if (sie_export_file(r->db, r->org_id, fy, path, &err) != 0) { - yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "export failed"); - free(err); - return e; - } - char hex[65]; - int64_t size = 0; - if (hash_file(path, hex, &size) != 0) - return fail(r, "INTERNAL", "could not read exported file"); - - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "sie.export", "{}", "OK", NULL); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); - yyjson_mut_obj_add_int(r->rdoc, o, "size", size); - if (inline_out) { - size_t flen = 0; - unsigned char *content = read_file(path, &flen); - if (!content) - return fail(r, "INTERNAL", "could not read exported file"); - char *b64 = util_b64(content, flen); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); - free(b64); - free(content); - } - return o; -} - -static yyjson_mut_val *h_sie_import(struct req *r) -{ - const char *b64 = arg_str(r->args, "content_base64"); - const char *path = arg_str(r->args, "path"); - unsigned char *content = NULL; - size_t len = 0; - if (b64) { - if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) - return fail(r, "INVALID_ARGS", - "content_base64 is not valid base64"); - } else if (path) { - content = read_file(path, &len); - if (!content) - return fail(r, "NOT_FOUND", "could not read file"); - } else { - return fail(r, "INVALID_ARGS", "content_base64 or path is required"); - } - if (len > 64u * 1024u * 1024u) { - free(content); - return fail(r, "TOO_LARGE", "SIE file is too large"); - } - int64_t fy = 0, vouchers = 0, accounts = 0; - char *err = NULL; - int rc = sie_import_content(r->db, r->org_id, r->sess->user_id, content, - len, r->dry_run, &fy, &vouchers, &accounts, - &err); - free(content); - if (rc != 0) { - yyjson_mut_val *e = fail(r, "INVALID_ARGS", err ? err : "import failed"); - free(err); - return e; - } - 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, - "sie.import", reqjson, "OK", NULL); - free(reqjson); - } - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy); - yyjson_mut_obj_add_int(r->rdoc, o, "vouchers", vouchers); - yyjson_mut_obj_add_int(r->rdoc, o, "accounts_created", accounts); - if (r->dry_run) - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; -} - -/* ------------------------------------------------------------------ */ -/* bank reconciliation (phase 1: import and match, never book) */ -/* ------------------------------------------------------------------ */ - -struct seb_row { - const char *booked_at; - const char *value_date; - const char *text; - const char *type; - int64_t amount_ore; - int has_balance; - int64_t balance_ore; -}; - -static int csv_split(char *line, char **fields, int max) -{ - int nf = 0, in_quotes = 0; - char *dst = line; - fields[nf++] = dst; - for (char *p = line; *p; p++) { - if (*p == '"') { - if (in_quotes && p[1] == '"') { - *dst++ = '"'; - p++; - } else { - in_quotes = !in_quotes; - } - } else if (*p == ';' && !in_quotes) { - *dst++ = '\0'; - if (nf >= max) - return -1; - fields[nf++] = dst; - } else { - *dst++ = *p; - } - } - *dst = '\0'; - return in_quotes ? -1 : nf; -} - -static int seb_amount(const char *s, int64_t *out) -{ - while (*s == ' ') - s++; - int neg = 0; - if (*s == '-') { - neg = 1; - s++; - } else if (*s == '+') { - s++; - } - int64_t whole = 0, frac = 0; - int digits = 0, fdigits = 0, comma = 0; - for (; *s; s++) { - if (*s == ' ' || *s == '.') - continue; - if (*s == ',') { - if (comma) - return -1; - comma = 1; - continue; - } - if (*s < '0' || *s > '9') - return -1; - if (comma) { - if (fdigits >= 2) - return -1; - frac = frac * 10 + (*s - '0'); - fdigits++; - } else { - if (whole > (INT64_MAX - 9) / 10) - return -1; - whole = whole * 10 + (*s - '0'); - digits++; - } - } - if (digits == 0) - return -1; - while (fdigits < 2) { - frac *= 10; - fdigits++; - } - if (whole > (INT64_MAX - frac) / 100) - return -1; - int64_t v = whole * 100 + frac; - *out = neg ? -v : v; - return 0; -} - -static int seb_parse(char *csv, struct seb_row **out, size_t *out_n, - char *err, size_t errlen) -{ - static const char *header[7] = { - "Bokförd", "Valutadatum", "Text", "Typ", - "Insättningar", "Uttag", "Bokfört saldo", - }; - struct seb_row *rows = NULL; - size_t n = 0, cap = 0; - char *cur = csv; - int lineno = 0; - while (*cur) { - lineno++; - char *nl = strchr(cur, '\n'); - if (nl) - *nl = '\0'; - size_t llen = strlen(cur); - if (llen && cur[llen - 1] == '\r') - cur[--llen] = '\0'; - char *fields[8]; - int nf = csv_split(cur, fields, 8); - if (nf < 0) { - snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); - goto bad; - } - if (lineno == 1) { - int ok = nf == 7; - for (int i = 0; ok && i < 7; i++) - if (strcmp(fields[i], header[i]) != 0) - ok = 0; - if (!ok) { - snprintf(err, errlen, "not a SEB CSV export: header mismatch"); - goto bad; - } - if (!nl) - break; - cur = nl + 1; - continue; - } - if (llen == 0) { - if (!nl) - break; - cur = nl + 1; - continue; - } - if (nf != 7) { - snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); - goto bad; - } - struct seb_row row; - row.booked_at = fields[0]; - row.value_date = fields[1]; - row.text = fields[2]; - row.type = fields[3]; - row.has_balance = fields[6][0] != '\0'; - row.balance_ore = 0; - int has_dep = fields[4][0] != '\0'; - int has_wd = fields[5][0] != '\0'; - if (!util_date_valid(fields[0]) || !util_date_valid(fields[1]) || - has_dep + has_wd != 1) { - snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); - goto bad; - } - if (has_dep) { - if (seb_amount(fields[4], &row.amount_ore) != 0) { - snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); - goto bad; - } - } else { - int64_t w = 0; - if (seb_amount(fields[5], &w) != 0) { - snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); - goto bad; - } - row.amount_ore = w > 0 ? -w : w; - } - if (row.has_balance && seb_amount(fields[6], &row.balance_ore) != 0) { - snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); - goto bad; - } - if (n == cap) { - cap = cap ? cap * 2 : 64; - rows = xrealloc(rows, cap * sizeof *rows); - } - rows[n++] = row; - if (!nl) - break; - cur = nl + 1; - } - *out = rows; - *out_n = n; - return 0; -bad: - free(rows); - return -1; -} - -static void bank_field(struct buf *b, const char *s, int wide) -{ - size_t n = strlen(s); - if (wide) - buf_append_u32be(b, (uint32_t)n); - else - buf_append_u16be(b, (uint16_t)n); - buf_append(b, s, n); -} - -static void bank_row_hash(const char *account, const struct seb_row *row, - unsigned char out[32]) -{ - static const char tag[] = "bokf-v1-bank-tx"; - struct buf b; - buf_init(&b); - buf_append(&b, tag, sizeof tag); - bank_field(&b, account, 0); - bank_field(&b, row->booked_at, 0); - bank_field(&b, row->value_date, 0); - bank_field(&b, row->text, 1); - bank_field(&b, row->type, 1); - buf_append_u64be(&b, (uint64_t)row->amount_ore); - unsigned char flag = row->has_balance ? 1 : 0; - buf_append(&b, &flag, 1); - if (row->has_balance) - buf_append_u64be(&b, (uint64_t)row->balance_ore); - util_sha256(b.p, b.len, out); - buf_free(&b); -} - -static int bank_hash_cmp(const void *a, const void *b) -{ - return memcmp(a, b, 32); -} - -static int bank_account_exists(struct req *r, const char *account) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, "SELECT 1 FROM accounts WHERE org_id=?1 AND number=?2", -1, - &st, NULL) != SQLITE_OK) - return -1; - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); - int found = sqlite3_step(st) == SQLITE_ROW; - sqlite3_finalize(st); - return found; -} - -static yyjson_mut_val *h_bank_import(struct req *r) -{ - const char *format = arg_str(r->args, "format"); - const char *b64 = arg_str(r->args, "content_base64"); - const char *path = arg_str(r->args, "path"); - const char *account = arg_str(r->args, "account"); - if (!format || strcmp(format, "seb") != 0) - return fail(r, "INVALID_ARGS", "format must be \"seb\""); - unsigned char *content = NULL; - size_t len = 0; - if (b64) { - if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) - return fail(r, "INVALID_ARGS", - "content_base64 is not valid base64"); - } else if (path) { - content = read_file(path, &len); - if (!content) - return fail(r, "NOT_FOUND", "could not read file"); - } else { - return fail(r, "INVALID_ARGS", "content_base64 or path is required"); - } - if (len > 64u * 1024u * 1024u) { - free(content); - return fail(r, "TOO_LARGE", "bank statement is too large"); - } - if (memchr(content, '\0', len) != NULL) { - free(content); - return fail(r, "INVALID_ARGS", "not a SEB CSV export: header mismatch"); - } - char *csv = xmalloc(len + 1); - memcpy(csv, content, len); - csv[len] = '\0'; - free(content); - char *start = csv; - if (len >= 3 && memcmp(start, "\xEF\xBB\xBF", 3) == 0) - start += 3; - char perr[128]; - struct seb_row *rows = NULL; - size_t nrows = 0; - if (seb_parse(start, &rows, &nrows, perr, sizeof perr) != 0) { - free(csv); - return fail(r, "INVALID_ARGS", perr); - } - - char *setting = account && *account - ? NULL - : db_setting(r->db, r->org_id, "bank_account"); - const char *acct = account && *account - ? account - : (setting ? setting : "1930"); - int found = bank_account_exists(r, acct); - if (found < 0) { - free(setting); - free(rows); - free(csv); - return fail(r, "INTERNAL", "database error"); - } - if (!found) { - yyjson_mut_val *res = - failf(r, "ACCOUNT_NOT_FOUND", "account %s not found", acct); - free(setting); - free(rows); - free(csv); - return res; - } - - unsigned char(*hashes)[32] = xmalloc((nrows ? nrows : 1) * 32); - for (size_t i = 0; i < nrows; i++) - bank_row_hash(acct, &rows[i], hashes[i]); - - int64_t imported = 0; - if (r->dry_run) { - unsigned char(*sorted)[32] = xmalloc((nrows ? nrows : 1) * 32); - memcpy(sorted, hashes, nrows * 32); - qsort(sorted, nrows, 32, bank_hash_cmp); - for (size_t i = 0; i < nrows;) { - size_t j = i + 1; - while (j < nrows && memcmp(sorted[i], sorted[j], 32) == 0) - j++; - sqlite3_stmt *q = NULL; - int exists = 0; - if (sqlite3_prepare_v2( - r->db, - "SELECT 1 FROM bank_transactions WHERE org_id=?1" - " AND source_hash=?2", - -1, &q, NULL) != SQLITE_OK) { - free(sorted); - free(hashes); - free(setting); - free(rows); - free(csv); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(q, 1, r->org_id); - sqlite3_bind_blob(q, 2, sorted[i], 32, SQLITE_TRANSIENT); - exists = sqlite3_step(q) == SQLITE_ROW; - sqlite3_finalize(q); - if (!exists) - imported++; - i = j; - } - free(sorted); - } else { - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - int failed = db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0; - for (size_t i = 0; i < nrows && !failed; i++) { - sqlite3_stmt *ins = NULL; - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO bank_transactions(org_id,account,booked_at," - "value_date,text,type,amount_ore,balance_ore,source," - "source_hash,imported_at,imported_by)" - " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,'seb-csv',?9,?10,?11)" - " ON CONFLICT(org_id,source_hash) DO NOTHING", - -1, &ins, NULL) != SQLITE_OK) { - failed = 1; - break; - } - sqlite3_bind_int64(ins, 1, r->org_id); - sqlite3_bind_text(ins, 2, acct, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(ins, 3, rows[i].booked_at, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(ins, 4, rows[i].value_date, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(ins, 5, rows[i].text, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(ins, 6, rows[i].type, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(ins, 7, rows[i].amount_ore); - if (rows[i].has_balance) - sqlite3_bind_int64(ins, 8, rows[i].balance_ore); - else - sqlite3_bind_null(ins, 8); - sqlite3_bind_blob(ins, 9, hashes[i], 32, SQLITE_TRANSIENT); - sqlite3_bind_text(ins, 10, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(ins, 11, r->sess->user_id); - int rc = sqlite3_step(ins); - sqlite3_finalize(ins); - if (rc != SQLITE_DONE) { - failed = 1; - break; - } - if (sqlite3_changes(r->db) > 0) - imported++; - } - if (failed) { - db_exec(r->db, "ROLLBACK", NULL); - free(hashes); - free(setting); - free(rows); - free(csv); - return fail(r, "INTERNAL", "database error"); - } - if (db_exec(r->db, "COMMIT", NULL) != 0) { - db_exec(r->db, "ROLLBACK", NULL); - free(hashes); - free(setting); - free(rows); - free(csv); - return fail(r, "INTERNAL", "database error"); - } - } - int64_t duplicates = (int64_t)nrows - imported; - const char *first = NULL, *last = NULL; - for (size_t i = 0; i < nrows; i++) { - if (!first || strcmp(rows[i].booked_at, first) < 0) - first = rows[i].booked_at; - if (!last || strcmp(rows[i].booked_at, last) > 0) - last = rows[i].booked_at; - } - 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, - "bank.import", reqjson, "OK", NULL); - free(reqjson); - } - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "format", "seb"); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", acct); - yyjson_mut_obj_add_int(r->rdoc, o, "total", (int64_t)nrows); - yyjson_mut_obj_add_int(r->rdoc, o, "imported", imported); - yyjson_mut_obj_add_int(r->rdoc, o, "duplicates", duplicates); - if (first) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "first_date", first); - else - yyjson_mut_obj_add_null(r->rdoc, o, "first_date"); - if (last) - yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_date", last); - else - yyjson_mut_obj_add_null(r->rdoc, o, "last_date"); - if (r->dry_run) - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - free(hashes); - free(setting); - free(rows); - free(csv); - return o; -} - -static yyjson_mut_val *h_bank_list(struct req *r) -{ - const char *status = arg_str(r->args, "status"); - const char *from = arg_str(r->args, "from"); - const char *to = arg_str(r->args, "to"); - const char *account = arg_str(r->args, "account"); - int64_t limit = 200; - arg_int(r->args, "limit", &limit); - if (limit < 1) - limit = 200; - if (limit > 1000) - limit = 1000; - int status_code = 0; - if (status && strcmp(status, "unmatched") == 0) - status_code = 1; - else if (status && strcmp(status, "matched") == 0) - status_code = 2; - - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT t.id,t.account,t.booked_at,t.value_date,t.text,t.type," - "t.amount_ore,t.balance_ore FROM bank_transactions t" - " WHERE t.org_id=?1" - " AND (?2 IS NULL OR t.account=?2)" - " AND (?3 IS NULL OR t.booked_at>=?3)" - " AND (?4 IS NULL OR t.booked_at<=?4)" - " AND (?5=0" - " OR (?5=1 AND NOT EXISTS (SELECT 1 FROM bank_matches m" - " WHERE m.org_id=t.org_id AND m.transaction_id=t.id))" - " OR (?5=2 AND EXISTS (SELECT 1 FROM bank_matches m" - " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)))" - " ORDER BY t.booked_at,t.id LIMIT ?6", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - if (account) - sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 2); - if (from) - sqlite3_bind_text(st, 3, from, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 3); - if (to) - sqlite3_bind_text(st, 4, to, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(st, 4); - sqlite3_bind_int64(st, 5, status_code); - sqlite3_bind_int64(st, 6, limit); - - yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); - while (sqlite3_step(st) == SQLITE_ROW) { - int64_t tx_id = sqlite3_column_int64(st, 0); - char tx_account[16]; - snprintf(tx_account, sizeof tx_account, "%s", - sq(sqlite3_column_text(st, 1))); - const char *booked_at = sq(sqlite3_column_text(st, 2)); - yyjson_mut_val *item = yyjson_mut_arr_add_obj(r->rdoc, items); - yyjson_mut_obj_add_int(r->rdoc, item, "id", tx_id); - yyjson_mut_obj_add_strcpy(r->rdoc, item, "account", tx_account); - yyjson_mut_obj_add_strcpy(r->rdoc, item, "booked_at", booked_at); - yyjson_mut_obj_add_strcpy(r->rdoc, item, "value_date", - sq(sqlite3_column_text(st, 3))); - yyjson_mut_obj_add_strcpy(r->rdoc, item, "text", - sq(sqlite3_column_text(st, 4))); - yyjson_mut_obj_add_strcpy(r->rdoc, item, "type", - sq(sqlite3_column_text(st, 5))); - yyjson_mut_obj_add_int(r->rdoc, item, "amount_ore", - sqlite3_column_int64(st, 6)); - if (sqlite3_column_type(st, 7) == SQLITE_NULL) - yyjson_mut_obj_add_null(r->rdoc, item, "balance_ore"); - else - yyjson_mut_obj_add_int(r->rdoc, item, "balance_ore", - sqlite3_column_int64(st, 7)); - - yyjson_mut_val *matches = yyjson_mut_arr(r->rdoc); - sqlite3_stmt *ms = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT v.id,v.series,v.number,v.date," - "COALESCE(SUM(r.debit_ore-r.credit_ore),0)" - " FROM bank_matches m" - " JOIN vouchers v ON v.org_id=m.org_id AND v.id=m.voucher_id" - " LEFT JOIN accounts a ON a.org_id=v.org_id AND a.number=?2" - " LEFT JOIN voucher_rows r ON r.org_id=v.org_id" - " AND r.voucher_id=v.id AND r.account_id=a.id" - " WHERE m.org_id=?1 AND m.transaction_id=?3" - " GROUP BY v.id,v.series,v.number,v.date ORDER BY v.id", - -1, &ms, NULL) != SQLITE_OK) { - sqlite3_finalize(st); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(ms, 1, r->org_id); - sqlite3_bind_text(ms, 2, tx_account, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(ms, 3, tx_id); - while (sqlite3_step(ms) == SQLITE_ROW) { - yyjson_mut_val *m = yyjson_mut_arr_add_obj(r->rdoc, matches); - yyjson_mut_obj_add_int(r->rdoc, m, "voucher_id", - sqlite3_column_int64(ms, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, m, "series", - sq(sqlite3_column_text(ms, 1))); - yyjson_mut_obj_add_int(r->rdoc, m, "number", - sqlite3_column_int64(ms, 2)); - yyjson_mut_obj_add_strcpy(r->rdoc, m, "date", - sq(sqlite3_column_text(ms, 3))); - yyjson_mut_obj_add_int(r->rdoc, m, "bank_amount_ore", - sqlite3_column_int64(ms, 4)); - } - sqlite3_finalize(ms); - yyjson_mut_obj_add_val(r->rdoc, item, "matches", matches); - - yyjson_mut_val *suggestions = yyjson_mut_arr(r->rdoc); - if (yyjson_mut_arr_size(matches) == 0) { - sqlite3_stmt *ss = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT v.id,v.series,v.number,v.date," - "SUM(r.debit_ore-r.credit_ore)" - " FROM vouchers v" - " JOIN voucher_rows r ON r.org_id=v.org_id" - " AND r.voucher_id=v.id" - " JOIN accounts a ON a.org_id=r.org_id" - " AND a.id=r.account_id" - " WHERE v.org_id=?1 AND a.number=?2" - " AND ABS(julianday(v.date)-julianday(?3))<=5" - " AND NOT EXISTS (SELECT 1 FROM bank_matches m" - " WHERE m.org_id=v.org_id AND m.voucher_id=v.id)" - " GROUP BY v.id,v.series,v.number,v.date" - " HAVING SUM(r.debit_ore-r.credit_ore)=?4" - " ORDER BY ABS(julianday(v.date)-julianday(?3)),v.date," - "v.id LIMIT 3", - -1, &ss, NULL) != SQLITE_OK) { - sqlite3_finalize(st); - return fail(r, "INTERNAL", "database error"); - } - sqlite3_bind_int64(ss, 1, r->org_id); - sqlite3_bind_text(ss, 2, tx_account, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(ss, 3, booked_at, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(ss, 4, sqlite3_column_int64(st, 6)); - while (sqlite3_step(ss) == SQLITE_ROW) { - yyjson_mut_val *s = - yyjson_mut_arr_add_obj(r->rdoc, suggestions); - yyjson_mut_obj_add_int(r->rdoc, s, "voucher_id", - sqlite3_column_int64(ss, 0)); - yyjson_mut_obj_add_strcpy(r->rdoc, s, "series", - sq(sqlite3_column_text(ss, 1))); - yyjson_mut_obj_add_int(r->rdoc, s, "number", - sqlite3_column_int64(ss, 2)); - yyjson_mut_obj_add_strcpy(r->rdoc, s, "date", - sq(sqlite3_column_text(ss, 3))); - yyjson_mut_obj_add_int(r->rdoc, s, "amount_ore", - sqlite3_column_int64(ss, 4)); - } - sqlite3_finalize(ss); - } - yyjson_mut_obj_add_val(r->rdoc, item, "suggestions", suggestions); - } - sqlite3_finalize(st); - - sqlite3_stmt *sum = NULL; - int64_t unmatched = 0, matched = 0, unmatched_amount = 0; - if (sqlite3_prepare_v2( - r->db, - "SELECT COUNT(*)," - "COALESCE(SUM(EXISTS (SELECT 1 FROM bank_matches m" - " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)),0)," - "COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM bank_matches m" - " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)" - " THEN 0 ELSE t.amount_ore END),0)" - " FROM bank_transactions t" - " WHERE t.org_id=?1 AND (?2 IS NULL OR t.account=?2)", - -1, &sum, NULL) == SQLITE_OK) { - sqlite3_bind_int64(sum, 1, r->org_id); - if (account) - sqlite3_bind_text(sum, 2, account, -1, SQLITE_TRANSIENT); - else - sqlite3_bind_null(sum, 2); - if (sqlite3_step(sum) == SQLITE_ROW) { - int64_t total = sqlite3_column_int64(sum, 0); - matched = sqlite3_column_int64(sum, 1); - unmatched = total - matched; - unmatched_amount = sqlite3_column_int64(sum, 2); - } - sqlite3_finalize(sum); - } - yyjson_mut_val *res = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_val(r->rdoc, res, "items", items); - yyjson_mut_val *s = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, s, "unmatched", unmatched); - yyjson_mut_obj_add_int(r->rdoc, s, "matched", matched); - yyjson_mut_obj_add_int(r->rdoc, s, "unmatched_amount_ore", - unmatched_amount); - yyjson_mut_obj_add_val(r->rdoc, res, "summary", s); - return res; -} - -static int bank_legs_sum(sqlite3 *db, int64_t org_id, int64_t tx_id, - const char *account, int64_t *out) -{ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - db, - "SELECT COALESCE(SUM(r.debit_ore-r.credit_ore),0)" - " FROM bank_matches m" - " JOIN voucher_rows r ON r.org_id=m.org_id" - " AND r.voucher_id=m.voucher_id" - " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" - " WHERE m.org_id=?1 AND m.transaction_id=?2 AND a.number=?3", - -1, &st, NULL) != SQLITE_OK) - return -1; - sqlite3_bind_int64(st, 1, org_id); - sqlite3_bind_int64(st, 2, tx_id); - sqlite3_bind_text(st, 3, account, -1, SQLITE_TRANSIENT); - int rc = sqlite3_step(st); - if (rc == SQLITE_ROW) - *out = sqlite3_column_int64(st, 0); - sqlite3_finalize(st); - return rc == SQLITE_ROW ? 0 : -1; -} - -static yyjson_mut_val *h_bank_match(struct req *r) -{ - int64_t tx_id = 0, voucher_id = 0; - if (!arg_int(r->args, "transaction_id", &tx_id) || tx_id <= 0 || - !arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) - return fail(r, "INVALID_ARGS", - "transaction_id and voucher_id are required"); - sqlite3_stmt *st = NULL; - char account[16]; - int64_t amount = 0; - if (sqlite3_prepare_v2( - r->db, - "SELECT account,amount_ore FROM bank_transactions" - " 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, tx_id); - if (sqlite3_step(st) != SQLITE_ROW) { - sqlite3_finalize(st); - return fail(r, "NOT_FOUND", "bank transaction not found"); - } - snprintf(account, sizeof account, "%s", sq(sqlite3_column_text(st, 0))); - amount = sqlite3_column_int64(st, 1); - sqlite3_finalize(st); - - int found = 0; - if (sqlite3_prepare_v2( - r->db, "SELECT 1 FROM vouchers 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, voucher_id); - found = sqlite3_step(st) == SQLITE_ROW; - sqlite3_finalize(st); - if (!found) - return fail(r, "NOT_FOUND", "voucher not found"); - - int touches = 0; - if (sqlite3_prepare_v2( - r->db, - "SELECT 1 FROM voucher_rows r" - " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" - " WHERE r.org_id=?1 AND r.voucher_id=?2 AND a.number=?3", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, voucher_id); - sqlite3_bind_text(st, 3, account, -1, SQLITE_TRANSIENT); - touches = sqlite3_step(st) == SQLITE_ROW; - sqlite3_finalize(st); - if (!touches) - return failf(r, "INVALID_ARGS", - "voucher does not post to account %s", account); - - int linked = 0; - if (sqlite3_prepare_v2( - r->db, - "SELECT 1 FROM bank_matches WHERE org_id=?1 AND transaction_id=?2" - " AND voucher_id=?3", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, tx_id); - sqlite3_bind_int64(st, 3, voucher_id); - linked = sqlite3_step(st) == SQLITE_ROW; - sqlite3_finalize(st); - if (linked) - return fail(r, "CONFLICT", - "transaction is already matched to this voucher"); - - int64_t legs = 0; - if (bank_legs_sum(r->db, r->org_id, tx_id, account, &legs) != 0) - return fail(r, "INTERNAL", "database error"); - if (r->dry_run) { - sqlite3_stmt *vs = NULL; - int64_t voucher_leg = 0; - if (sqlite3_prepare_v2( - r->db, - "SELECT COALESCE(SUM(r.debit_ore-r.credit_ore),0)" - " FROM voucher_rows r" - " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" - " WHERE r.org_id=?1 AND r.voucher_id=?2 AND a.number=?3", - -1, &vs, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(vs, 1, r->org_id); - sqlite3_bind_int64(vs, 2, voucher_id); - sqlite3_bind_text(vs, 3, account, -1, SQLITE_TRANSIENT); - if (sqlite3_step(vs) == SQLITE_ROW) - voucher_leg = sqlite3_column_int64(vs, 0); - sqlite3_finalize(vs); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); - yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); - yyjson_mut_obj_add_int(r->rdoc, o, "difference_ore", - amount - (legs + voucher_leg)); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; - } - - char ts[32]; - util_iso8601(util_now(), ts, sizeof ts); - if (sqlite3_prepare_v2( - r->db, - "INSERT INTO bank_matches(org_id,transaction_id,voucher_id," - "matched_at,matched_by,kind) VALUES(?1,?2,?3,?4,?5,'manual')", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, tx_id); - sqlite3_bind_int64(st, 3, voucher_id); - sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 5, r->sess->user_id); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", "database error"); - - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "bank.match", reqjson, "OK", NULL); - free(reqjson); - - if (bank_legs_sum(r->db, r->org_id, tx_id, account, &legs) != 0) - return fail(r, "INTERNAL", "database error"); - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); - yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); - yyjson_mut_obj_add_int(r->rdoc, o, "difference_ore", amount - legs); - return o; -} - -static yyjson_mut_val *h_bank_unmatch(struct req *r) -{ - int64_t tx_id = 0, voucher_id = 0; - if (!arg_int(r->args, "transaction_id", &tx_id) || tx_id <= 0 || - !arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) - return fail(r, "INVALID_ARGS", - "transaction_id and voucher_id are required"); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2( - r->db, - "SELECT 1 FROM bank_matches WHERE org_id=?1 AND transaction_id=?2" - " AND voucher_id=?3", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, tx_id); - sqlite3_bind_int64(st, 3, voucher_id); - int found = sqlite3_step(st) == SQLITE_ROW; - sqlite3_finalize(st); - if (!found) - return fail(r, "NOT_FOUND", "match not found"); - if (!r->dry_run) { - if (sqlite3_prepare_v2( - r->db, - "DELETE FROM bank_matches WHERE org_id=?1" - " AND transaction_id=?2 AND voucher_id=?3", - -1, &st, NULL) != SQLITE_OK) - return fail(r, "INTERNAL", "database error"); - sqlite3_bind_int64(st, 1, r->org_id); - sqlite3_bind_int64(st, 2, tx_id); - sqlite3_bind_int64(st, 3, voucher_id); - int rc = sqlite3_step(st); - sqlite3_finalize(st); - if (rc != SQLITE_DONE) - return fail(r, "INTERNAL", "database error"); - if (sqlite3_changes(r->db) == 0) - return fail(r, "NOT_FOUND", "match not found"); - char *reqjson = audit_args_json(r->args); - audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, - "bank.unmatch", reqjson, "OK", NULL); - free(reqjson); - } - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); - yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); - yyjson_mut_obj_add_bool(r->rdoc, o, "unmatched", true); - if (r->dry_run) - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; -} - -/* command table */ -/* ------------------------------------------------------------------ */ - -static const struct cmd_arg args_session_open[] = { - { "method", ARG_ENUM, 1, NULL, "password,token", - "Open with a password or an API token" }, - { "username", ARG_STR, 0, NULL, NULL, "Username for method=password" }, - { "password", ARG_STR, 0, NULL, NULL, "Password for method=password" }, - { "token", ARG_STR, 0, NULL, NULL, "API token for method=token" }, -}; - -static const struct cmd_arg args_session_use_org[] = { - { "org", ARG_INT, 1, NULL, NULL, "Org id" }, -}; - -static const struct cmd_arg args_org_create[] = { - { "name", ARG_STR, 1, NULL, NULL, "Org name" }, - { "org_nr", ARG_STR, 0, NULL, NULL, "Swedish org number" }, - { "fiscal_year_start_month", ARG_INT, 0, "1", NULL, - "Fiscal year start month 1-12" }, - { "moms_period", ARG_ENUM, 0, "month", "month,quarter,year", - "VAT reporting period" }, - { "framework", ARG_ENUM, 0, "K2", "K2,K3", "Accounting framework" }, -}; - -static const struct cmd_arg args_org_update[] = { - { "name", ARG_STR, 0, NULL, NULL, "Org name" }, - { "org_nr", ARG_STR, 0, NULL, NULL, "Swedish org number" }, - { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, - { "address", ARG_STR, 0, NULL, NULL, "Street address" }, - { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, - { "city", ARG_STR, 0, NULL, NULL, "City" }, - { "country", ARG_STR, 0, NULL, NULL, "Country" }, - { "email", ARG_STR, 0, NULL, NULL, "E-mail" }, - { "phone", ARG_STR, 0, NULL, NULL, "Phone" }, - { "moms_period", ARG_ENUM, 0, NULL, "month,quarter,year", - "VAT reporting period" }, - { "framework", ARG_ENUM, 0, NULL, "K2,K3", "Accounting framework" }, - { "description", ARG_STR, 0, NULL, NULL, "Free-text description" }, - { "fiscal_year_start_month", ARG_INT, 0, NULL, NULL, - "Fiscal year start month 1-12" }, - { "shares", ARG_INT, 0, NULL, NULL, "Number of shares" }, -}; - -static const struct cmd_arg args_member_role[] = { - { "username", ARG_STR, 1, NULL, NULL, "Username" }, - { "role", ARG_ENUM, 1, NULL, "owner,bookkeeper,viewer", - "Membership role" }, -}; - -static const struct cmd_arg args_member_username[] = { - { "username", ARG_STR, 1, NULL, NULL, "Username" }, -}; - -static const struct cmd_arg args_board_add[] = { - { "name", ARG_STR, 1, NULL, NULL, "Board member name" }, - { "title", ARG_STR, 0, "Styrelseledamot", NULL, "Role on the board" }, -}; - -static const struct cmd_arg args_board_update[] = { - { "id", ARG_INT, 1, NULL, NULL, "Board member id" }, - { "name", ARG_STR, 0, NULL, NULL, "Board member name" }, - { "title", ARG_STR, 0, NULL, NULL, "Role on the board" }, -}; - -static const struct cmd_arg args_board_remove[] = { - { "id", ARG_INT, 1, NULL, NULL, "Board member id" }, -}; - -static const struct cmd_arg args_user_create[] = { - { "username", ARG_STR, 1, NULL, NULL, "Username" }, - { "password", ARG_STR, 1, NULL, NULL, "Password" }, - { "display_name", ARG_STR, 0, NULL, NULL, "Display name" }, - { "is_admin", ARG_BOOL, 0, "false", NULL, "System admin" }, -}; - -static const struct cmd_arg args_token_create[] = { - { "label", ARG_STR, 1, NULL, NULL, "Label shown in token.list" }, - { "scopes", ARG_JSON, 0, NULL, NULL, "Array of read, write, admin" }, - { "expires_at", ARG_DATE, 0, NULL, NULL, "Expiry date" }, -}; - -static const struct cmd_arg args_token_revoke[] = { - { "id", ARG_INT, 1, NULL, NULL, "Token id" }, -}; - static const struct cmd_arg args_describe[] = { { "cmd", ARG_STR, 0, NULL, NULL, "Command name; omit for the full catalogue" }, }; -static const struct cmd_arg args_audit_list[] = { - { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, - { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, - { "action", ARG_STR, 0, NULL, NULL, "Filter by action" }, -}; - -static const struct cmd_arg args_audit_verify[] = { - { "full", ARG_BOOL, 0, "false", NULL, "Also re-hash attachments" }, -}; - -static const struct cmd_arg args_backup_snapshot[] = { - { "dest", ARG_STR, 0, NULL, NULL, - "Destination path; defaults to backup_dir with a timestamp" }, -}; - -static const struct cmd_arg args_account_list[] = { - { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active accounts" }, -}; - -static const struct cmd_arg args_account_get[] = { - { "id", ARG_INT, 0, NULL, NULL, "Account id" }, - { "number", ARG_STR, 0, NULL, NULL, "Account number (string)" }, -}; - -static const struct cmd_arg args_account_create[] = { - { "number", ARG_STR, 1, NULL, NULL, "Account number, 1-10 digits" }, - { "name", ARG_STR, 1, NULL, NULL, "Account name" }, - { "type", ARG_ENUM, 1, NULL, "asset,liability,equity,revenue,expense", - "Account type" }, - { "sru_code", ARG_STR, 0, NULL, NULL, "SRU code" }, - { "vat_code", ARG_STR, 0, NULL, NULL, "VAT code" }, -}; - -static const struct cmd_arg args_account_update[] = { - { "id", ARG_INT, 1, NULL, NULL, "Account id" }, - { "name", ARG_STR, 0, NULL, NULL, "Account name" }, - { "sru_code", ARG_STR, 0, NULL, NULL, "SRU code" }, - { "vat_code", ARG_STR, 0, NULL, NULL, "VAT code" }, - { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, -}; - -static const struct cmd_arg args_fiscal_year_get[] = { - { "id", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, -}; - -static const struct cmd_arg args_fiscal_year_open[] = { - { "label", ARG_STR, 1, NULL, NULL, "Fiscal year label" }, - { "start_date", ARG_DATE, 1, NULL, NULL, "Start date (YYYY-MM-DD)" }, - { "end_date", ARG_DATE, 1, NULL, NULL, "End date (YYYY-MM-DD)" }, -}; - -static const struct cmd_arg args_fiscal_year_close[] = { - { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, - { "confirm", ARG_BOOL, 1, NULL, NULL, "Must be true" }, -}; - -static const struct cmd_arg args_fiscal_year_reopen[] = { - { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, - { "confirm", ARG_BOOL, 1, NULL, NULL, "Must be true" }, -}; - -static const struct cmd_arg args_fiscal_year_update[] = { - { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, - { "dividend_ore", ARG_INT, 0, NULL, NULL, "Proposed dividend in öre" }, - { "events", ARG_STR, 0, NULL, NULL, "Material events during the year" }, - { "agm_date", ARG_STR, 0, NULL, NULL, "AGM date; empty clears" }, - { "dividend_date", ARG_STR, 0, NULL, NULL, - "Dividend payment date; empty clears" }, - { "employees", ARG_STR, 0, NULL, NULL, "Average number of employees" }, - { "notes", ARG_STR, 0, NULL, NULL, "Other notes" }, -}; - -static const struct cmd_arg args_period_lock[] = { - { "fiscal_year", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, - { "until", ARG_DATE, 1, NULL, NULL, "Lock through this date, inclusive" }, - { "reason", ARG_STR, 0, NULL, NULL, "Reason, recorded in the audit log" }, -}; - -static const struct cmd_arg args_period_unlock[] = { - { "fiscal_year", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, - { "reason", ARG_STR, 0, NULL, NULL, "Reason, recorded in the audit log" }, -}; - -static const struct cmd_arg args_voucher_post[] = { - { "date", ARG_DATE, 1, NULL, NULL, "Voucher date (YYYY-MM-DD)" }, - { "description", ARG_STR, 0, NULL, NULL, "Voucher text" }, - { "series", ARG_STR, 0, NULL, NULL, - "Number series; defaults to settings.default_series" }, - { "client_ref", ARG_STR, 0, NULL, NULL, - "Idempotency key, unique per org" }, - { "corrects_voucher", ARG_INT, 0, NULL, NULL, - "Voucher id this one corrects" }, - { "rows", ARG_JSON, 0, NULL, NULL, - "Array of {account,debit_ore,credit_ore,description?}" }, - { "template", ARG_JSON, 0, NULL, NULL, - "Template id or name; alternative to rows" }, - { "x", ARG_JSON, 0, NULL, NULL, "Template variable in kronor" }, - { "attachment_ids", ARG_JSON, 0, NULL, NULL, - "Array of attachment ids to link" }, -}; - -static const struct cmd_arg args_voucher_get[] = { - { "id", ARG_INT, 1, NULL, NULL, "Voucher id" }, -}; - -static const struct cmd_arg args_voucher_list[] = { - { "fiscal_year", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, - { "from", ARG_DATE, 0, NULL, NULL, "Earliest date" }, - { "to", ARG_DATE, 0, NULL, NULL, "Latest date" }, - { "series", ARG_STR, 0, NULL, NULL, "Number series filter" }, - { "account", ARG_STR, 0, NULL, NULL, "Account number filter" }, - { "text", ARG_STR, 0, NULL, NULL, "Description substring filter" }, - { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, - { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, -}; - -static const struct cmd_arg args_voucher_correct[] = { - { "voucher", ARG_INT, 1, NULL, NULL, "Voucher id to correct" }, - { "description", ARG_STR, 1, NULL, NULL, "Correction text" }, - { "date", ARG_DATE, 0, NULL, NULL, "Defaults to the original date" }, - { "client_ref", ARG_STR, 0, NULL, NULL, "Idempotency key" }, -}; - -static const struct cmd_arg args_bokslut_post[] = { - { "fiscal_year", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, - { "entries", ARG_JSON, 0, NULL, NULL, - "Array of {debit_account,credit_account,amount_ore,description?}" }, - { "periodiseringsfond_ore", ARG_INT, 0, NULL, NULL, - "Periodiseringsfond in öre" }, - { "tax_rate", ARG_JSON, 0, "20.6", NULL, "Tax rate percent, 0-100" }, - { "dispose", ARG_BOOL, 0, "true", NULL, - "Post the result transfer to 8999/2099" }, - { "date", ARG_DATE, 0, NULL, NULL, - "Posting date; defaults to the fiscal year end" }, -}; - -static const struct cmd_arg args_settings_set[] = { - { "key", ARG_STR, 1, NULL, NULL, - "default_series, attachment_dir, bank_account," - " invoice_receivable_account, invoice_revenue_account, smtp_host," - " smtp_port, smtp_user, smtp_from, smtp_reply_to, smtp_security or" - " smtp_password" }, - { "value", ARG_STR, 0, NULL, NULL, - "Setting value; an empty value clears smtp_password" }, -}; - -static const struct cmd_arg args_bank_import[] = { - { "format", ARG_ENUM, 1, NULL, "seb", "Statement format" }, - { "content_base64", ARG_STR, 0, NULL, NULL, - "Statement content, base64; alternative to path" }, - { "path", ARG_STR, 0, NULL, NULL, - "Server-side path; alternative to content_base64" }, - { "account", ARG_STR, 0, NULL, NULL, - "Bank account number; defaults to settings.bank_account" }, -}; - -static const struct cmd_arg args_bank_list[] = { - { "status", ARG_ENUM, 0, "all", "all,unmatched,matched", - "Match status filter" }, - { "from", ARG_DATE, 0, NULL, NULL, "Earliest booked date" }, - { "to", ARG_DATE, 0, NULL, NULL, "Latest booked date" }, - { "account", ARG_STR, 0, NULL, NULL, "Bank account number filter" }, - { "limit", ARG_INT, 0, "200", NULL, "Page size, 1-1000" }, -}; - -static const struct cmd_arg args_bank_match[] = { - { "transaction_id", ARG_INT, 1, NULL, NULL, "Bank transaction id" }, - { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, -}; - -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" }, -}; - -static const struct cmd_arg args_template_get[] = { - { "id", ARG_INT, 0, NULL, NULL, "Template id" }, - { "name", ARG_STR, 0, NULL, NULL, "Template name" }, -}; - -static const struct cmd_arg args_template_create[] = { - { "name", ARG_STR, 1, NULL, NULL, "Template name" }, - { "series", ARG_STR, 0, NULL, NULL, - "Number series; defaults to settings.default_series" }, - { "description", ARG_STR, 0, NULL, NULL, - "Voucher text; {x} is replaced with the amount" }, - { "rows", ARG_JSON, 1, NULL, NULL, - "Array of {account,formula,description?}" }, -}; - -static const struct cmd_arg args_template_update[] = { - { "id", ARG_INT, 0, NULL, NULL, "Template id" }, - { "name", ARG_STR, 0, NULL, NULL, - "Template name, or the new name when id is given" }, - { "series", ARG_STR, 0, NULL, NULL, "Number series" }, - { "description", ARG_STR, 0, NULL, NULL, "Voucher text" }, - { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, - { "rows", ARG_JSON, 0, NULL, NULL, - "Replacement rows, {account,formula,description?}" }, -}; - -static const struct cmd_arg args_template_archive[] = { - { "id", ARG_INT, 0, NULL, NULL, "Template id" }, - { "name", ARG_STR, 0, NULL, NULL, "Template name" }, -}; - -static const struct cmd_arg args_attachment_put[] = { - { "filename", ARG_STR, 1, NULL, NULL, "File name" }, - { "mime", ARG_STR, 0, "application/octet-stream", NULL, "MIME type" }, - { "content_base64", ARG_STR, 1, NULL, NULL, "File content, base64" }, - { "voucher_id", ARG_INT, 0, NULL, NULL, "Link to this voucher" }, -}; - -static const struct cmd_arg args_attachment_link[] = { - { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, - { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, -}; - -static const struct cmd_arg args_attachment_unlink[] = { - { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, - { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, -}; - -static const struct cmd_arg args_attachment_get[] = { - { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, -}; - -static const struct cmd_arg args_attachment_list[] = { - { "voucher_id", ARG_INT, 0, NULL, NULL, - "Only attachments on this voucher" }, - { "unlinked", ARG_BOOL, 0, NULL, NULL, - "Only unlinked attachments (inbox)" }, - { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, - { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, -}; - -static const struct cmd_arg args_report_trial_balance[] = { - { "fiscal_year", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, - { "from", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, - { "to", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, - { "include_zero", ARG_BOOL, 0, "false", NULL, - "Include accounts with no activity" }, -}; - -static const struct cmd_arg args_report_income_statement[] = { - { "fiscal_year", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, - { "from", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, - { "to", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, -}; - -static const struct cmd_arg args_report_balance_sheet[] = { - { "fiscal_year", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, - { "to", ARG_DATE, 0, NULL, NULL, "Report through this date" }, -}; - -static const struct cmd_arg args_report_vat[] = { - { "from", ARG_DATE, 1, NULL, NULL, "Period start" }, - { "to", ARG_DATE, 1, NULL, NULL, "Period end" }, -}; - -static const struct cmd_arg args_report_general_ledger[] = { - { "fiscal_year", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, - { "accounts", ARG_JSON, 0, NULL, NULL, - "Array of account numbers to include" }, - { "from", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, - { "to", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, -}; - -static const struct cmd_arg args_report_voucher_list[] = { - { "fiscal_year", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, - { "series", ARG_STR, 0, NULL, NULL, "Number series filter" }, -}; - -static const struct cmd_arg args_report_vat_eskd[] = { - { "from", ARG_DATE, 1, NULL, NULL, "Period start" }, - { "to", ARG_DATE, 1, NULL, NULL, "Period end" }, - { "upplysning", ARG_STR, 0, NULL, NULL, - "Free-text message to Skatteverket" }, -}; - -static const struct cmd_arg args_sru_export[] = { - { "fiscal_year", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, - { "adjustments", ARG_JSON, 0, NULL, NULL, - "Array of {code,amount_ore} tax adjustments" }, - { "submitter", ARG_JSON, 0, NULL, NULL, - "Object overriding the INFO.SRU submitter" }, - { "assisted", ARG_BOOL, 0, NULL, NULL, "Assisted declaration" }, - { "audited", ARG_BOOL, 0, NULL, NULL, "Audited declaration" }, - { "ignore_unmapped", ARG_BOOL, 0, "false", NULL, - "Proceed despite unmapped accounts" }, -}; - -static const struct cmd_arg args_sie_export[] = { - { "fiscal_year", ARG_INT, 0, NULL, NULL, - "Fiscal year id; defaults to the latest" }, - { "inline", ARG_BOOL, 0, "false", NULL, "Also return content_base64" }, -}; - -static const struct cmd_arg args_sie_import[] = { - { "content_base64", ARG_STR, 0, NULL, NULL, "SIE file content, base64" }, - { "path", ARG_STR, 0, NULL, NULL, - "Server-side path; alternative to content_base64" }, -}; - -static const struct cmd_arg args_customer_list[] = { - { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active customers" }, -}; - -static const struct cmd_arg args_customer_get[] = { - { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, -}; - -static const struct cmd_arg args_customer_create[] = { - { "name", ARG_STR, 1, NULL, NULL, "Customer name, unique per org" }, - { "address", ARG_STR, 0, NULL, NULL, "Street address; may contain newlines" }, - { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, - { "city", ARG_STR, 0, NULL, NULL, "City" }, - { "country", ARG_STR, 0, "SE", NULL, "Country code" }, - { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, - { "email", ARG_STR, 0, NULL, NULL, "E-mail address" }, - { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, - { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, - { "payment_days", ARG_INT, 0, "30", NULL, "Payment terms in days" }, -}; - -static const struct cmd_arg args_customer_update[] = { - { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, - { "name", ARG_STR, 0, NULL, NULL, "Customer name, unique per org" }, - { "address", ARG_STR, 0, NULL, NULL, "Street address" }, - { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, - { "city", ARG_STR, 0, NULL, NULL, "City" }, - { "country", ARG_STR, 0, NULL, NULL, "Country code" }, - { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, - { "email", ARG_STR, 0, NULL, NULL, "E-mail address" }, - { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, - { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, - { "payment_days", ARG_INT, 0, NULL, NULL, "Payment terms in days" }, - { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, -}; - -static const struct cmd_arg args_customer_archive[] = { - { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, - { "active", ARG_BOOL, 1, NULL, NULL, "false archives, true reactivates" }, -}; - -static const struct cmd_arg args_invoice_sequence_set[] = { - { "next_number", ARG_INT, 1, NULL, NULL, "Next invoice number" }, -}; - -static const struct cmd_arg args_invoice_draft[] = { - { "customer_id", ARG_INT, 1, NULL, NULL, "Customer id" }, - { "invoice_date", ARG_DATE, 1, NULL, NULL, "Invoice date (YYYY-MM-DD)" }, - { "due_date", ARG_DATE, 1, NULL, NULL, "Due date (YYYY-MM-DD)" }, - { "delivery_date", ARG_STR, 0, NULL, NULL, "Delivery date or empty" }, - { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, - { "our_ref", ARG_STR, 0, NULL, NULL, "Our reference" }, - { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, - { "rows", ARG_JSON, 1, NULL, NULL, - "Array of {article_no,description,quantity,unit,unit_price_ore,note," - "vat_code,account}" }, -}; - -static const struct cmd_arg args_invoice_get[] = { - { "id", ARG_INT, 1, NULL, NULL, "Invoice id" }, -}; - -static const struct cmd_arg args_invoice_list[] = { - { "customer_id", ARG_INT, 0, NULL, NULL, "Customer filter" }, - { "status", ARG_ENUM, 0, NULL, "issued,credited", "Status filter" }, - { "limit", ARG_INT, 0, "200", NULL, "Page size, 1-1000" }, -}; - -static const struct cmd_arg args_invoice_send[] = { - { "id", ARG_INT, 1, NULL, NULL, "Invoice id" }, - { "to", ARG_STR, 0, NULL, NULL, - "Recipient e-mail; defaults to the customer's address" }, -}; - -const struct command g_commands[] = { - { "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health, NULL, 0 }, - { "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta, NULL, - 0 }, - { "session.open", "Open a session (password or token)", PERM_PUBLIC, 0, 0, - 0, h_session_open, CMD_ARGS(args_session_open) }, - { "session.close", "Close the current session", PERM_READ, 0, 0, 0, - h_session_close, NULL, 0 }, - { "session.whoami", "Current user, org, role and scopes", PERM_READ, 0, 0, - 0, h_session_whoami, NULL, 0 }, - { "session.list_orgs", "Orgs the current user is a member of", PERM_READ, - 0, 0, 0, h_session_list_orgs, NULL, 0 }, - { "session.use_org", "Switch active org", PERM_READ, 0, 0, 0, - h_session_use_org, CMD_ARGS(args_session_use_org) }, - { "org.create", "Create an org and become its owner", PERM_READ, 0, 1, 1, - h_org_create, CMD_ARGS(args_org_create) }, - { "org.list", "List orgs for the current user", PERM_READ, 0, 0, 0, - h_org_list, NULL, 0 }, - { "org.get", "Get org details", PERM_READ, 1, 0, 0, h_org_get, NULL, 0 }, - { "org.update", "Update org details (owner)", PERM_OWNER, 1, 1, 1, - h_org_update, CMD_ARGS(args_org_update) }, - { "org.member_list", "List org members", PERM_READ, 1, 0, 0, - h_org_member_list, NULL, 0 }, - { "org.member_add", "Add a member to the org", PERM_OWNER, 1, 1, 1, - h_org_member_add, CMD_ARGS(args_member_role) }, - { "org.member_set_role", "Change a member's role", PERM_OWNER, 1, 1, 1, - h_org_member_set_role, CMD_ARGS(args_member_role) }, - { "org.member_remove", "Remove a member from the org", PERM_OWNER, 1, 1, 1, - h_org_member_remove, CMD_ARGS(args_member_username) }, - { "board.list", "List board members", PERM_READ, 1, 0, 0, h_board_list, - NULL, 0 }, - { "board.add", "Add a board member", PERM_OWNER, 1, 1, 1, h_board_add, - CMD_ARGS(args_board_add) }, - { "board.update", "Update a board member", PERM_OWNER, 1, 1, 1, - h_board_update, CMD_ARGS(args_board_update) }, - { "board.remove", "Remove a board member", PERM_OWNER, 1, 1, 1, - h_board_remove, CMD_ARGS(args_board_remove) }, - { "user.create", "Create a user (system admin)", PERM_ADMIN, 0, 1, 1, - h_user_create, CMD_ARGS(args_user_create) }, - { "user.list", "List users (system admin)", PERM_ADMIN, 0, 0, 0, - h_user_list, NULL, 0 }, - { "token.create", "Create an API token for the active org", PERM_READ, 1, - 1, 1, h_token_create, CMD_ARGS(args_token_create) }, - { "token.list", "List your API tokens in the active org", PERM_READ, 1, 0, - 0, h_token_list, NULL, 0 }, - { "token.revoke", "Revoke an API token", PERM_READ, 1, 1, 1, - h_token_revoke, CMD_ARGS(args_token_revoke) }, +const struct command g_cmd_discovery[] = { { "describe", "List implemented commands and permissions", PERM_READ, 0, 0, 0, h_describe, CMD_ARGS(args_describe) }, { "agent.instructions", "Workflow rules for agents", PERM_READ, 0, 0, 0, h_agent_instructions, NULL, 0 }, - { "audit.list", "Read the audit log for the active org", PERM_READ, 1, 0, - 0, h_audit_list, CMD_ARGS(args_audit_list) }, - { "audit.verify", "Verify the voucher and audit hash chains", PERM_READ, 0, - 0, 0, h_audit_verify, CMD_ARGS(args_audit_verify) }, - { "backup.snapshot", "Create a consistent database snapshot", PERM_ADMIN, - 0, 1, 0, h_backup_snapshot, CMD_ARGS(args_backup_snapshot) }, - { "account.list", "List the chart of accounts", PERM_READ, 1, 0, 0, - h_account_list, CMD_ARGS(args_account_list) }, - { "account.get", "Get one account", PERM_READ, 1, 0, 0, h_account_get, - CMD_ARGS(args_account_get) }, - { "account.create", "Add an account", PERM_WRITE, 1, 1, 1, - h_account_create, CMD_ARGS(args_account_create) }, - { "account.update", "Rename or deactivate an account", PERM_WRITE, 1, 1, 1, - h_account_update, CMD_ARGS(args_account_update) }, - { "fiscal_year.list", "List fiscal years", PERM_READ, 1, 0, 0, - h_fiscal_year_list, NULL, 0 }, - { "fiscal_year.get", "Get a fiscal year", PERM_READ, 1, 0, 0, - h_fiscal_year_get, CMD_ARGS(args_fiscal_year_get) }, - { "fiscal_year.open", "Open a new fiscal year", PERM_OWNER, 1, 1, 1, - h_fiscal_year_open, CMD_ARGS(args_fiscal_year_open) }, - { "fiscal_year.close", "Close a fiscal year", PERM_OWNER, 1, 1, 0, - h_fiscal_year_close, CMD_ARGS(args_fiscal_year_close) }, - { "fiscal_year.reopen", "Reopen a closed fiscal year", PERM_OWNER, 1, 1, 1, - h_fiscal_year_reopen, CMD_ARGS(args_fiscal_year_reopen) }, - { "fiscal_year.update", "Update fiscal-year metadata (dividend)", - PERM_WRITE, 1, 1, 1, h_fiscal_year_update, - CMD_ARGS(args_fiscal_year_update) }, - { "period.lock", "Lock a period through a date", PERM_OWNER, 1, 1, 1, - h_period_lock, CMD_ARGS(args_period_lock) }, - { "period.unlock", "Remove a period lock", PERM_OWNER, 1, 1, 1, - h_period_unlock, CMD_ARGS(args_period_unlock) }, - { "voucher.post", "Post an immutable voucher (rows or template+x)", - PERM_WRITE, 1, 1, 1, h_voucher_post, CMD_ARGS(args_voucher_post) }, - { "voucher.get", "Get a voucher with rows", PERM_READ, 1, 0, 0, - h_voucher_get, CMD_ARGS(args_voucher_get) }, - { "voucher.list", "List vouchers", PERM_READ, 1, 0, 0, h_voucher_list, - CMD_ARGS(args_voucher_list) }, - { "voucher.correct", "Post an ändringsverifikat", PERM_WRITE, 1, 1, 1, - h_voucher_correct, CMD_ARGS(args_voucher_correct) }, - { "bokslut.post", "Year-end: dispositions, tax and result transfer", - PERM_WRITE, 1, 1, 1, h_bokslut_post, CMD_ARGS(args_bokslut_post) }, - { "settings.get", "Read org settings", PERM_READ, 1, 0, 0, - h_settings_get, NULL, 0 }, - { "settings.set", "Change an org setting", PERM_WRITE, 1, 1, 1, - h_settings_set, CMD_ARGS(args_settings_set) }, - { "bank.import", "Import a bank statement (SEB CSV)", PERM_WRITE, 1, 1, 1, - h_bank_import, CMD_ARGS(args_bank_import) }, - { "bank.list", "List bank transactions, matches and suggestions", - PERM_READ, 1, 0, 0, h_bank_list, CMD_ARGS(args_bank_list) }, - { "bank.match", "Match a bank transaction to a voucher", PERM_WRITE, 1, 1, - 1, h_bank_match, CMD_ARGS(args_bank_match) }, - { "bank.unmatch", "Remove a transaction/voucher match", PERM_WRITE, 1, 1, - 1, h_bank_unmatch, CMD_ARGS(args_bank_match) }, - { "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, - 0, h_template_get, CMD_ARGS(args_template_get) }, - { "template.create", "Create a voucher template", PERM_WRITE, 1, 1, 1, - h_template_create, CMD_ARGS(args_template_create) }, - { "template.update", "Update a voucher template", PERM_WRITE, 1, 1, 1, - h_template_update, CMD_ARGS(args_template_update) }, - { "template.archive", "Archive a voucher template", PERM_WRITE, 1, 1, 1, - h_template_archive, CMD_ARGS(args_template_archive) }, - { "attachment.put", "Store an underlag (receipt, invoice)", PERM_WRITE, 1, - 1, 1, h_attachment_put, CMD_ARGS(args_attachment_put) }, - { "attachment.link", "Link an existing attachment to a voucher", - PERM_WRITE, 1, 1, 1, h_attachment_link, CMD_ARGS(args_attachment_link) }, - { "attachment.unlink", "Remove an attachment link", PERM_WRITE, 1, 1, 1, - h_attachment_unlink, CMD_ARGS(args_attachment_unlink) }, - { "attachment.get", "Fetch an attachment", PERM_READ, 1, 0, 0, - h_attachment_get, CMD_ARGS(args_attachment_get) }, - { "attachment.list", "List attachments / inbox", PERM_READ, 1, 0, 0, - h_attachment_list, CMD_ARGS(args_attachment_list) }, - { "report.trial_balance", "Saldobalans (IB, period, UB)", PERM_READ, 1, 0, - 0, h_report_trial_balance, CMD_ARGS(args_report_trial_balance) }, - { "report.income_statement", "Resultaträkning", PERM_READ, 1, 0, 0, - h_report_income_statement, CMD_ARGS(args_report_income_statement) }, - { "report.balance_sheet", "Balansräkning", PERM_READ, 1, 0, 0, - h_report_balance_sheet, CMD_ARGS(args_report_balance_sheet) }, - { "report.vat", "Momsdeklaration ruta för ruta", PERM_READ, 1, 0, 0, - h_report_vat, CMD_ARGS(args_report_vat) }, - { "report.general_ledger", "Huvudbok", PERM_READ, 1, 0, 0, - h_report_general_ledger, CMD_ARGS(args_report_general_ledger) }, - { "report.voucher_list", "Verifikationslista", PERM_READ, 1, 0, 0, - h_report_voucher_list, CMD_ARGS(args_report_voucher_list) }, - { "report.vat_eskd", "eSKD XML for the momsdeklaration (ISO-8859-1)", - PERM_READ, 1, 0, 0, h_report_vat_eskd, CMD_ARGS(args_report_vat_eskd) }, - { "sru.export", "INK2 SRU files (INFO.SRU + BLANKETTER.SRU)", - PERM_READ, 1, 0, 0, h_sru_export, CMD_ARGS(args_sru_export) }, - { "sie.export", "Export SIE 4 (PC8)", PERM_READ, 1, 0, 0, h_sie_export, - CMD_ARGS(args_sie_export) }, - { "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1, - 1, h_sie_import, CMD_ARGS(args_sie_import) }, - { "customer.list", "List customers ordered by name", PERM_READ, 1, 0, 0, - h_customer_list, CMD_ARGS(args_customer_list) }, - { "customer.get", "Get one customer", PERM_READ, 1, 0, 0, h_customer_get, - CMD_ARGS(args_customer_get) }, - { "customer.create", "Create a customer", PERM_WRITE, 1, 1, 1, - h_customer_create, CMD_ARGS(args_customer_create) }, - { "customer.update", "Update a customer (merged)", PERM_WRITE, 1, 1, 1, - h_customer_update, CMD_ARGS(args_customer_update) }, - { "customer.archive", "Archive or reactivate a customer", PERM_WRITE, 1, 1, - 1, h_customer_archive, CMD_ARGS(args_customer_archive) }, - { "invoice.sequence_get", "Read the next invoice number", PERM_READ, 1, 0, - 0, h_invoice_sequence_get, NULL, 0 }, - { "invoice.sequence_set", "Set the next invoice number (owner)", - PERM_OWNER, 1, 1, 1, h_invoice_sequence_set, - CMD_ARGS(args_invoice_sequence_set) }, - { "invoice.preview", "Render an invoice draft without storing it", - PERM_READ, 1, 0, 0, h_invoice_preview, CMD_ARGS(args_invoice_draft) }, - { "invoice.issue", "Issue an invoice: number, PDF and voucher", - PERM_WRITE, 1, 1, 1, h_invoice_issue, CMD_ARGS(args_invoice_draft) }, - { "invoice.get", "Get an invoice with rows", PERM_READ, 1, 0, 0, - h_invoice_get, CMD_ARGS(args_invoice_get) }, - { "invoice.list", "List invoices, newest first", PERM_READ, 1, 0, 0, - h_invoice_list, CMD_ARGS(args_invoice_list) }, - { "invoice.pdf", "Fetch the stored invoice PDF", PERM_READ, 1, 0, 0, - h_invoice_pdf, CMD_ARGS(args_invoice_get) }, - { "invoice.send", "E-mail the stored invoice PDF to the customer", - PERM_WRITE, 1, 1, 1, h_invoice_send, CMD_ARGS(args_invoice_send) }, }; -const size_t g_commands_count = sizeof g_commands / sizeof g_commands[0]; +const struct cmd_table g_cmd_table_discovery = { + g_cmd_discovery, sizeof g_cmd_discovery / sizeof g_cmd_discovery[0] +}; const struct command *command_find(const char *name) { if (!name) return NULL; - for (size_t i = 0; i < g_commands_count; i++) - if (strcmp(g_commands[i].name, name) == 0) - return &g_commands[i]; + for (size_t t = 0; t < g_command_tables_count; t++) { + const struct cmd_table *tab = g_command_tables[t]; + for (size_t i = 0; i < tab->n; i++) + if (strcmp(tab->cmds[i].name, name) == 0) + return &tab->cmds[i]; + } return NULL; } @@ -8468,3 +191,35 @@ const char *perm_scope(enum cmd_perm p) } return NULL; } + +extern const struct cmd_table g_cmd_table_auth; +extern const struct cmd_table g_cmd_table_org; +extern const struct cmd_table g_cmd_table_users; +extern const struct cmd_table g_cmd_table_audit; +extern const struct cmd_table g_cmd_table_accounts; +extern const struct cmd_table g_cmd_table_fiscal; +extern const struct cmd_table g_cmd_table_vouchers; +extern const struct cmd_table g_cmd_table_templates; +extern const struct cmd_table g_cmd_table_settings; +extern const struct cmd_table g_cmd_table_customers; +extern const struct cmd_table g_cmd_table_invoices; +extern const struct cmd_table g_cmd_table_rules; +extern const struct cmd_table g_cmd_table_attachments; +extern const struct cmd_table g_cmd_table_reports; +extern const struct cmd_table g_cmd_table_sru; +extern const struct cmd_table g_cmd_table_sie; +extern const struct cmd_table g_cmd_table_bokslut; +extern const struct cmd_table g_cmd_table_bank; + +const struct cmd_table *const g_command_tables[] = { + &g_cmd_table_auth, &g_cmd_table_org, &g_cmd_table_users, + &g_cmd_table_discovery, &g_cmd_table_audit, &g_cmd_table_accounts, + &g_cmd_table_fiscal, &g_cmd_table_vouchers, &g_cmd_table_bokslut, + &g_cmd_table_settings, &g_cmd_table_bank, &g_cmd_table_rules, + &g_cmd_table_templates, &g_cmd_table_attachments, &g_cmd_table_reports, + &g_cmd_table_sru, &g_cmd_table_sie, &g_cmd_table_customers, + &g_cmd_table_invoices, +}; + +const size_t g_command_tables_count = + sizeof g_command_tables / sizeof g_command_tables[0]; diff --git a/src/commands.h b/src/commands.h index e3168a5..e7e71f6 100644 --- a/src/commands.h +++ b/src/commands.h @@ -37,10 +37,15 @@ struct command { size_t nargs; }; +struct cmd_table { + const struct command *cmds; + size_t n; +}; + #define CMD_ARGS(a) (a), (sizeof (a) / sizeof (a)[0]) -extern const struct command g_commands[]; -extern const size_t g_commands_count; +extern const struct cmd_table *const g_command_tables[]; +extern const size_t g_command_tables_count; int command_validate_args(const struct command *cmd, yyjson_val *args, char *err, size_t errlen); |
