#include "commands.h" #include #include #include #include #include #include #include #include #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; }