summaryrefslogtreecommitdiff
path: root/src/commands.c
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-17 19:55:36 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-17 19:55:36 +0200
commit380195f7cd5e57acf2c1cf2bc41069e6b0b979ed (patch)
tree32a88fb22a7fbe8f1fd5c105156d1f928c93950d /src/commands.c
downloadbokf-380195f7cd5e57acf2c1cf2bc41069e6b0b979ed.tar.gz
bokf-380195f7cd5e57acf2c1cf2bc41069e6b0b979ed.zip
Initial commit: daemon, clients, docs, Docker deploy pipelinev0.1.0
Diffstat (limited to 'src/commands.c')
-rw-r--r--src/commands.c3705
1 files changed, 3705 insertions, 0 deletions
diff --git a/src/commands.c b/src/commands.c
new file mode 100644
index 0000000..5ccadfc
--- /dev/null
+++ b/src/commands.c
@@ -0,0 +1,3705 @@
+#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 "ledger.h"
+#include "log.h"
+#include "reports.h"
+#include "seed.h"
+#include "sie.h"
+#include "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 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)));
+ 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"
+
+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_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);
+}
+
+/* ------------------------------------------------------------------ */
+/* 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 */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *command_json(struct req *r, const struct command *c)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", c->name);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "summary", c->summary);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "mutating", c->mutating != 0);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", c->dry_run != 0);
+ yyjson_mut_val *perm = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, perm, "role", perm_name(c->perm));
+ const char *scope = perm_scope(c->perm);
+ if (scope)
+ yyjson_mut_obj_add_strcpy(r->rdoc, perm, "scope", scope);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, perm, "scope");
+ yyjson_mut_obj_add_bool(r->rdoc, perm, "require_org", c->need_org != 0);
+ yyjson_mut_obj_add_val(r->rdoc, o, "permission", perm);
+ return o;
+}
+
+static yyjson_mut_val *h_describe(struct req *r)
+{
+ const char *name = arg_str(r->args, "cmd");
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "protocol", BOKF_PROTOCOL_VERSION);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "server_version", BOKF_VERSION);
+ if (name) {
+ const struct command *c = command_find(name);
+ if (!c)
+ return fail(r, "NOT_FOUND", "unknown command");
+ yyjson_mut_obj_add_val(r->rdoc, o, "command", command_json(r, c));
+ 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]));
+ yyjson_mut_obj_add_val(r->rdoc, o, "commands", arr);
+ return o;
+}
+
+static const char AGENT_INSTRUCTIONS[] =
+ "# bokf agent instructions (protocol v1)\n"
+ "\n"
+ "You operate a Swedish bookkeeping system through its JSON API.\n"
+ "\n"
+ "## Authentication\n"
+ "- Open one session with `session.open` using an API token:\n"
+ " {\"method\":\"token\",\"token\":\"bokf_...\"}.\n"
+ "- Send the returned session id as `session` on every request.\n"
+ "- Never handle a password unless the human explicitly logs you in that way.\n"
+ "\n"
+ "## Ground rules\n"
+ "- Amounts are integer öre. Dates are YYYY-MM-DD. Account numbers are strings.\n"
+ "- For every mutating command: call it once with `dry_run:true`, inspect the\n"
+ " result, then call again with identical args and a `client_ref` to post.\n"
+ "- Posted data cannot be edited or deleted. Corrections are new vouchers via\n"
+ " `voucher.correct`; the original always remains visible.\n"
+ "- Postings must balance: at least two rows, sum debit == sum credit.\n"
+ "- Locked periods (`PERIOD_LOCKED`) and closed fiscal years\n"
+ " (`FISCAL_YEAR_CLOSED`) are hard stops. Ask the human.\n"
+ "- `fiscal_year.close`, `period.lock`, `sie.import` and `backup.snapshot`\n"
+ " are irreversible or sensitive: confirm with the human first.\n"
+ "- Underlag: upload with `attachment.put`, link with `attachment_ids` when\n"
+ " posting.\n"
+ "- On `CONFLICT` or `replayed:true`, fetch the existing object instead of\n"
+ " retrying. On `RATE_LIMITED`, back off and report.\n"
+ "- Konteringsmallar: list with `template.list`, apply with `voucher.post`\n"
+ " `{\"template\":\"name\",\"x\":1250}` where x is kronor; formulas use x,\n"
+ " numbers and + - * /. Positive results debit, negative credit.\n"
+ "\n"
+ "## Discovery\n"
+ "- `describe` lists every implemented command with permissions.\n"
+ "- Reports are read-only: `report.trial_balance`, `report.balance_sheet`,\n"
+ " `report.income_statement`, `report.general_ledger`, `report.vat`.\n";
+
+static yyjson_mut_val *h_agent_instructions(struct req *r)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "format", "markdown");
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "content", AGENT_INSTRUCTIONS);
+ 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 yyjson_mut_val *h_audit_verify(struct req *r)
+{
+ int64_t checked = 0, bad_seq = 0;
+ char *err = NULL;
+ int rc = audit_verify(r->db, &checked, &bad_seq, &err);
+ if (rc < 0) {
+ yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed");
+ free(err);
+ return res;
+ }
+ free(err);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "ok", rc == 0);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "kind", "audit");
+ yyjson_mut_obj_add_int(r->rdoc, o, "checked", checked);
+ if (rc == 1)
+ yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", bad_seq);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_seq");
+ 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)));
+ return o;
+}
+
+#define FY_COLUMNS "id,label,start_date,end_date,status,locked_until"
+
+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");
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO fiscal_years(org_id,label,start_date,end_date,"
+ "created_at) VALUES(?1,?2,?3,?4,?5)",
+ -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, 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_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;
+ 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) {
+ 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;
+ }
+ }
+ sqlite3_finalize(st);
+ }
+ if (!have_default)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A");
+ return o;
+}
+
+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");
+ size_t maxlen;
+ if (strcmp(key, "default_series") == 0)
+ maxlen = 8;
+ else if (strcmp(key, "attachment_dir") == 0)
+ maxlen = 255;
+ 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 ((unsigned char)*p < 32)
+ return failf(r, "INVALID_ARGS",
+ "%s must not contain control characters", key);
+ if (r->dry_run) {
+ 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);
+ 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 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;
+}
+
+/* ------------------------------------------------------------------ */
+/* 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)"
+ " 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));
+ }
+ 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)
+{
+ 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 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");
+ return -1;
+ }
+ if (!vexists) {
+ *errmsg = xstrdup("voucher not found");
+ return -1;
+ }
+ if (linked) {
+ *errmsg = xstrdup("attachment is already linked");
+ 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;
+ if (link_attachment(r, existing, voucher_id, &emsg) != 0) {
+ yyjson_mut_val *res = fail(r, "CONFLICT", 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;
+ if (link_attachment(r, id, voucher_id, &emsg) != 0) {
+ yyjson_mut_val *res = fail(r, "NOT_FOUND", 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_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 LIMIT 1)"
+ " FROM attachments a WHERE a.org_id=?1"
+ " AND (?2=0 OR (SELECT va.voucher_id FROM voucher_attachments va"
+ " WHERE va.org_id=a.org_id AND va.attachment_id=a.id LIMIT 1)=?2)"
+ " AND (?3=0 OR NOT EXISTS(SELECT 1 FROM voucher_attachments va2"
+ " WHERE va2.org_id=a.org_id AND va2.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 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;
+}
+
+/* command table */
+/* ------------------------------------------------------------------ */
+
+const struct command g_commands[] = {
+ { "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health },
+ { "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta },
+ { "session.open", "Open a session (password or token)", PERM_PUBLIC, 0, 0,
+ 0, h_session_open },
+ { "session.close", "Close the current session", PERM_READ, 0, 0, 0,
+ h_session_close },
+ { "session.whoami", "Current user, org, role and scopes", PERM_READ, 0, 0,
+ 0, h_session_whoami },
+ { "session.list_orgs", "Orgs the current user is a member of", PERM_READ,
+ 0, 0, 0, h_session_list_orgs },
+ { "session.use_org", "Switch active org", PERM_READ, 0, 0, 0,
+ h_session_use_org },
+ { "org.create", "Create an org and become its owner", PERM_READ, 0, 1, 1,
+ h_org_create },
+ { "org.list", "List orgs for the current user", PERM_READ, 0, 0, 0,
+ h_org_list },
+ { "org.get", "Get org details", PERM_READ, 1, 0, 0, h_org_get },
+ { "org.member_list", "List org members", PERM_READ, 1, 0, 0,
+ h_org_member_list },
+ { "org.member_add", "Add a member to the org", PERM_OWNER, 1, 1, 1,
+ h_org_member_add },
+ { "org.member_set_role", "Change a member's role", PERM_OWNER, 1, 1, 1,
+ h_org_member_set_role },
+ { "org.member_remove", "Remove a member from the org", PERM_OWNER, 1, 1, 1,
+ h_org_member_remove },
+ { "user.create", "Create a user (system admin)", PERM_ADMIN, 0, 1, 1,
+ h_user_create },
+ { "user.list", "List users (system admin)", PERM_ADMIN, 0, 0, 0,
+ h_user_list },
+ { "token.create", "Create an API token for the active org", PERM_READ, 1,
+ 1, 1, h_token_create },
+ { "token.list", "List your API tokens in the active org", PERM_READ, 1, 0,
+ 0, h_token_list },
+ { "token.revoke", "Revoke an API token", PERM_READ, 1, 1, 1,
+ h_token_revoke },
+ { "describe", "List implemented commands and permissions", PERM_READ, 0, 0,
+ 0, h_describe },
+ { "agent.instructions", "Workflow rules for agents", PERM_READ, 0, 0, 0,
+ h_agent_instructions },
+ { "audit.list", "Read the audit log for the active org", PERM_READ, 1, 0,
+ 0, h_audit_list },
+ { "audit.verify", "Verify the audit hash chain", PERM_READ, 0, 0, 0,
+ h_audit_verify },
+ { "backup.snapshot", "Create a consistent database snapshot", PERM_ADMIN,
+ 0, 1, 0, h_backup_snapshot },
+ { "account.list", "List the chart of accounts", PERM_READ, 1, 0, 0,
+ h_account_list },
+ { "account.get", "Get one account", PERM_READ, 1, 0, 0, h_account_get },
+ { "account.create", "Add an account", PERM_WRITE, 1, 1, 1,
+ h_account_create },
+ { "account.update", "Rename or deactivate an account", PERM_WRITE, 1, 1, 1,
+ h_account_update },
+ { "fiscal_year.list", "List fiscal years", PERM_READ, 1, 0, 0,
+ h_fiscal_year_list },
+ { "fiscal_year.get", "Get a fiscal year", PERM_READ, 1, 0, 0,
+ h_fiscal_year_get },
+ { "fiscal_year.open", "Open a new fiscal year", PERM_OWNER, 1, 1, 1,
+ h_fiscal_year_open },
+ { "fiscal_year.close", "Close a fiscal year (irreversible)", PERM_OWNER, 1,
+ 1, 0, h_fiscal_year_close },
+ { "period.lock", "Lock a period through a date", PERM_OWNER, 1, 1, 1,
+ h_period_lock },
+ { "period.unlock", "Remove a period lock", PERM_OWNER, 1, 1, 1,
+ h_period_unlock },
+ { "voucher.post", "Post an immutable voucher (rows or template+x)",
+ PERM_WRITE, 1, 1, 1, h_voucher_post },
+ { "voucher.get", "Get a voucher with rows", PERM_READ, 1, 0, 0,
+ h_voucher_get },
+ { "voucher.list", "List vouchers", PERM_READ, 1, 0, 0, h_voucher_list },
+ { "voucher.correct", "Post an ändringsverifikat", PERM_WRITE, 1, 1, 1,
+ h_voucher_correct },
+ { "settings.get", "Read org settings", PERM_READ, 1, 0, 0,
+ h_settings_get },
+ { "settings.set", "Change an org setting", PERM_WRITE, 1, 1, 1,
+ h_settings_set },
+ { "template.list", "List voucher templates", PERM_READ, 1, 0, 0,
+ h_template_list },
+ { "template.get", "Get a voucher template with its rows", PERM_READ, 1, 0,
+ 0, h_template_get },
+ { "template.create", "Create a voucher template", PERM_WRITE, 1, 1, 1,
+ h_template_create },
+ { "template.update", "Update a voucher template", PERM_WRITE, 1, 1, 1,
+ h_template_update },
+ { "template.archive", "Archive a voucher template", PERM_WRITE, 1, 1, 1,
+ h_template_archive },
+ { "attachment.put", "Store an underlag (receipt, invoice)", PERM_WRITE, 1,
+ 1, 1, h_attachment_put },
+ { "attachment.get", "Fetch an attachment", PERM_READ, 1, 0, 0,
+ h_attachment_get },
+ { "attachment.list", "List attachments / inbox", PERM_READ, 1, 0, 0,
+ h_attachment_list },
+ { "report.trial_balance", "Saldobalans (IB, period, UB)", PERM_READ, 1, 0,
+ 0, h_report_trial_balance },
+ { "report.income_statement", "Resultaträkning", PERM_READ, 1, 0, 0,
+ h_report_income_statement },
+ { "report.balance_sheet", "Balansräkning", PERM_READ, 1, 0, 0,
+ h_report_balance_sheet },
+ { "report.vat", "Momsdeklaration ruta för ruta", PERM_READ, 1, 0, 0,
+ h_report_vat },
+ { "sie.export", "Export SIE 4 (PC8)", PERM_READ, 1, 0, 0, h_sie_export },
+ { "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1,
+ 1, h_sie_import },
+};
+
+const size_t g_commands_count = sizeof g_commands / sizeof g_commands[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];
+ return NULL;
+}
+
+const char *perm_name(enum cmd_perm p)
+{
+ switch (p) {
+ case PERM_PUBLIC:
+ return "public";
+ case PERM_READ:
+ return "viewer";
+ case PERM_WRITE:
+ return "bookkeeper";
+ case PERM_OWNER:
+ return "owner";
+ case PERM_ADMIN:
+ return "admin";
+ }
+ return "unknown";
+}
+
+const char *perm_scope(enum cmd_perm p)
+{
+ switch (p) {
+ case PERM_PUBLIC:
+ return NULL;
+ case PERM_READ:
+ return "read";
+ case PERM_WRITE:
+ return "write";
+ case PERM_OWNER:
+ case PERM_ADMIN:
+ return "admin";
+ }
+ return NULL;
+}