diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-17 19:55:36 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-17 19:55:36 +0200 |
| commit | 380195f7cd5e57acf2c1cf2bc41069e6b0b979ed (patch) | |
| tree | 32a88fb22a7fbe8f1fd5c105156d1f928c93950d /src/commands.c | |
| download | bokf-380195f7cd5e57acf2c1cf2bc41069e6b0b979ed.tar.gz bokf-380195f7cd5e57acf2c1cf2bc41069e6b0b979ed.zip | |
Initial commit: daemon, clients, docs, Docker deploy pipelinev0.1.0
Diffstat (limited to 'src/commands.c')
| -rw-r--r-- | src/commands.c | 3705 |
1 files changed, 3705 insertions, 0 deletions
diff --git a/src/commands.c b/src/commands.c new file mode 100644 index 0000000..5ccadfc --- /dev/null +++ b/src/commands.c @@ -0,0 +1,3705 @@ +#include "commands.h" + +#include <errno.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <time.h> +#include <unistd.h> + +#include "audit.h" +#include "sha256.h" +#include "auth.h" +#include "config.h" +#include "db.h" +#include "formula.h" +#include "ledger.h" +#include "log.h" +#include "reports.h" +#include "seed.h" +#include "sie.h" +#include "util.h" +#include "version.h" + +/* ------------------------------------------------------------------ */ +/* small helpers */ +/* ------------------------------------------------------------------ */ + +static const char *sq(const unsigned char *p) +{ + return p ? (const char *)p : ""; +} + +static const char *arg_str(yyjson_val *args, const char *key) +{ + if (!args || !yyjson_is_obj(args)) + return NULL; + yyjson_val *v = yyjson_obj_get(args, key); + return v && yyjson_is_str(v) ? yyjson_get_str(v) : NULL; +} + +static int arg_int(yyjson_val *args, const char *key, int64_t *out) +{ + if (!args || !yyjson_is_obj(args)) + return 0; + yyjson_val *v = yyjson_obj_get(args, key); + if (!v || !yyjson_is_int(v)) + return 0; + *out = yyjson_get_int(v); + return 1; +} + +static int arg_bool(yyjson_val *args, const char *key, int *out) +{ + if (!args || !yyjson_is_obj(args)) + return 0; + yyjson_val *v = yyjson_obj_get(args, key); + if (!v || !yyjson_is_bool(v)) + return 0; + *out = yyjson_get_bool(v) ? 1 : 0; + return 1; +} + +static yyjson_mut_val *fail(struct req *r, const char *code, const char *msg) +{ + r->err_code = code; + snprintf(r->err_msg, sizeof r->err_msg, "%s", msg); + return NULL; +} + +static yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...) + __attribute__((format(printf, 3, 4))); + +static yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...) +{ + r->err_code = code; + va_list ap; + va_start(ap, fmt); + vsnprintf(r->err_msg, sizeof r->err_msg, fmt, ap); + va_end(ap); + return NULL; +} + +static int mkdir_p(const char *path, mode_t mode) +{ + char tmp[4096]; + if (!path || strlen(path) >= sizeof tmp) + return -1; + strcpy(tmp, path); + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (mkdir(tmp, mode) != 0 && errno != EEXIST) + return -1; + *p = '/'; + } + } + if (mkdir(tmp, mode) != 0 && errno != EEXIST) + return -1; + return 0; +} + +/* ------------------------------------------------------------------ */ +/* login rate limiting (in-memory, per key) */ +/* ------------------------------------------------------------------ */ + +#define RL_MAX_KEYS 16 +#define RL_MAX_FAILS 5 +#define RL_WINDOW 900 + +struct rl_entry { + char key[64]; + int fails; + int64_t window_end; +}; + +static struct rl_entry g_rl[RL_MAX_KEYS]; + +static struct rl_entry *rl_get(const char *key, int create) +{ + struct rl_entry *slot = NULL; + for (size_t i = 0; i < RL_MAX_KEYS; i++) { + if (g_rl[i].key[0] && strcmp(g_rl[i].key, key) == 0) + return &g_rl[i]; + if (create && !g_rl[i].key[0] && !slot) + slot = &g_rl[i]; + } + if (create && slot) { + snprintf(slot->key, sizeof slot->key, "%s", key); + slot->fails = 0; + slot->window_end = 0; + } + return slot; +} + +static int rl_blocked(const char *key, int64_t *retry_after) +{ + struct rl_entry *e = rl_get(key, 0); + if (!e || e->fails < RL_MAX_FAILS) + return 0; + int64_t now = util_now(); + if (e->window_end <= now) + return 0; + if (retry_after) + *retry_after = e->window_end - now; + return 1; +} + +static void rl_fail(const char *key) +{ + struct rl_entry *e = rl_get(key, 1); + if (!e) + return; + int64_t now = util_now(); + if (e->fails == 0 || e->window_end <= now) + e->window_end = now + RL_WINDOW; + e->fails++; +} + +static void rl_ok(const char *key) +{ + struct rl_entry *e = rl_get(key, 0); + if (e) { + e->fails = 0; + e->window_end = 0; + } +} + +/* ------------------------------------------------------------------ */ +/* public commands */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *h_health(struct req *r) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "ok"); + return o; +} + +static yyjson_mut_val *h_meta(struct req *r) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "server", "bokfd"); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "version", BOKF_VERSION); + yyjson_mut_obj_add_int(r->rdoc, o, "protocol", BOKF_PROTOCOL_VERSION); + yyjson_mut_val *features = yyjson_mut_arr(r->rdoc); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "describe"); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "agent.instructions"); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "orgs"); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "tokens"); + yyjson_mut_arr_add_strcpy(r->rdoc, features, "backup"); + yyjson_mut_obj_add_val(r->rdoc, o, "features", features); + yyjson_mut_val *limits = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, limits, "max_line_bytes", + g_cfg.max_line_bytes); + yyjson_mut_obj_add_int(r->rdoc, limits, "max_attachment_bytes", + g_cfg.max_attachment_bytes); + yyjson_mut_obj_add_int(r->rdoc, limits, "session_ttl_seconds", + g_cfg.session_ttl); + yyjson_mut_obj_add_val(r->rdoc, o, "limits", limits); + yyjson_mut_obj_add_bool(r->rdoc, o, "tcp_enabled", g_cfg.tcp_enabled != 0); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "time", ts); + return o; +} + +static yyjson_mut_val *orgs_for_user(sqlite3 *db, yyjson_mut_doc *doc, + int64_t user_id, int64_t only_org, + int64_t *first_org) +{ + yyjson_mut_val *arr = yyjson_mut_arr(doc); + const char *sql = only_org + ? "SELECT o.id,o.name,m.role FROM memberships m" + " JOIN orgs o ON o.id=m.org_id" + " WHERE m.user_id=?1 AND o.id=?2 ORDER BY o.id" + : "SELECT o.id,o.name,m.role FROM memberships m" + " JOIN orgs o ON o.id=m.org_id" + " WHERE m.user_id=?1 ORDER BY o.id"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) + return arr; + sqlite3_bind_int64(st, 1, user_id); + if (only_org) + sqlite3_bind_int64(st, 2, only_org); + while (sqlite3_step(st) == SQLITE_ROW) { + int64_t id = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr); + yyjson_mut_obj_add_int(doc, o, "id", id); + yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(doc, o, "role", sq(sqlite3_column_text(st, 2))); + if (first_org && *first_org == 0) + *first_org = id; + } + sqlite3_finalize(st); + return arr; +} + +static yyjson_mut_val *session_payload(struct req *r, struct session *s, + int64_t user_id, const char *username, + const char *display, + yyjson_mut_val *orgs, + int64_t active_org) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "session", s->id); + yyjson_mut_val *u = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, u, "id", user_id); + yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : ""); + yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name", + display ? display : ""); + yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", s->is_admin != 0); + yyjson_mut_obj_add_val(r->rdoc, o, "user", u); + yyjson_mut_obj_add_val(r->rdoc, o, "orgs", orgs); + if (active_org) + yyjson_mut_obj_add_int(r->rdoc, o, "active_org", active_org); + else + yyjson_mut_obj_add_null(r->rdoc, o, "active_org"); + return o; +} + +static yyjson_mut_val *h_session_open(struct req *r) +{ + const char *method = arg_str(r->args, "method"); + if (!method) + return fail(r, "INVALID_ARGS", "method is required"); + char *reqjson = audit_args_json(r->args); + + if (strcmp(method, "password") == 0) { + const char *username = arg_str(r->args, "username"); + const char *password = arg_str(r->args, "password"); + if (!username || !password) { + free(reqjson); + return fail(r, "INVALID_ARGS", "username and password are required"); + } + int64_t retry = 0; + if (rl_blocked("local", &retry)) { + free(reqjson); + return failf(r, "RATE_LIMITED", + "too many failed logins, retry in %lld seconds", + (long long)retry); + } + char *pwhash = NULL, *display = NULL; + int is_admin = 0, disabled = 0; + int64_t uid = auth_user_lookup(r->db, username, &pwhash, &display, + &is_admin, &disabled); + int ok = uid > 0 && !disabled && pwhash && + auth_verify_password(pwhash, password) == 0; + free(pwhash); + if (!ok) { + rl_fail("local"); + audit_append(r->db, 0, uid > 0 ? uid : 0, 0, "auth.fail", reqjson, + "AUTH_FAILED", NULL); + free(display); + free(reqjson); + return fail(r, "AUTH_FAILED", "invalid credentials"); + } + rl_ok("local"); + int64_t active = 0; + yyjson_mut_val *orgs = + orgs_for_user(r->db, r->rdoc, uid, 0, &active); + struct session *s = + sessions_create(uid, is_admin, active, 0, "read,write,admin"); + audit_append(r->db, active, uid, 0, "auth.open", reqjson, "OK", NULL); + yyjson_mut_val *out = session_payload(r, s, uid, username, display, + orgs, active); + free(display); + free(reqjson); + return out; + } + + if (strcmp(method, "token") == 0) { + const char *token = arg_str(r->args, "token"); + if (!token) { + free(reqjson); + return fail(r, "INVALID_ARGS", "token is required"); + } + unsigned char th[32]; + auth_hash_token(token, th); + sqlite3_stmt *st = NULL; + int rc = sqlite3_prepare_v2( + r->db, + "SELECT t.id,t.user_id,t.org_id,t.scopes,t.expires_at,t.revoked_at," + " t.label,u.username,u.display_name,u.is_admin,u.disabled_at" + " FROM api_tokens t JOIN users u ON u.id=t.user_id" + " WHERE t.token_hash=?1", + -1, &st, NULL); + if (rc != SQLITE_OK) { + free(reqjson); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_blob(st, 1, th, 32, SQLITE_TRANSIENT); + int found = sqlite3_step(st) == SQLITE_ROW; + int64_t token_id = 0, uid = 0, org_id = 0; + char *scopes = NULL, *expires = NULL, *revoked = NULL; + char *username = NULL, *display = NULL; + int is_admin = 0, disabled = 0; + if (found) { + token_id = sqlite3_column_int64(st, 0); + uid = sqlite3_column_int64(st, 1); + org_id = sqlite3_column_int64(st, 2); + scopes = xstrdup(sq(sqlite3_column_text(st, 3))); + if (sqlite3_column_type(st, 4) != SQLITE_NULL) + expires = xstrdup(sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) != SQLITE_NULL) + revoked = xstrdup(sq(sqlite3_column_text(st, 5))); + username = xstrdup(sq(sqlite3_column_text(st, 7))); + display = xstrdup(sq(sqlite3_column_text(st, 8))); + is_admin = sqlite3_column_int(st, 9); + disabled = sqlite3_column_type(st, 10) != SQLITE_NULL; + } + sqlite3_finalize(st); + + int expired = 0; + if (expires) { + char today[16]; + time_t t = (time_t)util_now(); + struct tm tm; + gmtime_r(&t, &tm); + strftime(today, sizeof today, "%Y-%m-%d", &tm); + expired = strcmp(expires, today) < 0; + } + if (!found || revoked || disabled || expired) { + audit_append(r->db, 0, uid, 0, "auth.fail", reqjson, "AUTH_FAILED", + NULL); + free(scopes); + free(expires); + free(revoked); + free(username); + free(display); + free(reqjson); + return fail(r, "AUTH_FAILED", "invalid token"); + } + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *up = NULL; + if (sqlite3_prepare_v2( + r->db, "UPDATE api_tokens SET last_used_at=?1 WHERE id=?2", -1, + &up, NULL) == SQLITE_OK) { + sqlite3_bind_text(up, 1, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(up, 2, token_id); + sqlite3_step(up); + sqlite3_finalize(up); + } + int64_t active = org_id; + yyjson_mut_val *orgs = + orgs_for_user(r->db, r->rdoc, uid, org_id, &active); + struct session *s = sessions_create(uid, is_admin, org_id, org_id, + scopes ? scopes : "read"); + s->active_org = org_id; + s->token_id = token_id; + audit_append(r->db, org_id, uid, token_id, "auth.open", reqjson, "OK", + NULL); + yyjson_mut_val *out = session_payload(r, s, uid, username, display, + orgs, org_id); + free(scopes); + free(expires); + free(revoked); + free(username); + free(display); + free(reqjson); + return out; + } + + free(reqjson); + return fail(r, "UNSUPPORTED", "unsupported auth method"); +} + +static yyjson_mut_val *h_session_close(struct req *r) +{ + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->sess->active_org, r->sess->user_id, 0, + "session.close", reqjson, "OK", NULL); + free(reqjson); + sessions_destroy(r->sess->id); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_session_whoami(struct req *r) +{ + char *display = NULL, *username = NULL; + int is_admin = 0; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT username,display_name,is_admin FROM users" + " WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->sess->user_id); + if (sqlite3_step(st) == SQLITE_ROW) { + username = xstrdup(sq(sqlite3_column_text(st, 0))); + display = xstrdup(sq(sqlite3_column_text(st, 1))); + is_admin = sqlite3_column_int(st, 2); + } + sqlite3_finalize(st); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_val *u = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, u, "id", r->sess->user_id); + yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : ""); + yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name", display ? display : ""); + yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", is_admin != 0); + yyjson_mut_obj_add_val(r->rdoc, o, "user", u); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", r->sess->scopes); + if (r->sess->active_org) { + yyjson_mut_obj_add_int(r->rdoc, o, "active_org", r->sess->active_org); + char *role = db_membership_role(r->db, r->sess->active_org, + r->sess->user_id); + if (role) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role); + else + yyjson_mut_obj_add_null(r->rdoc, o, "role"); + free(role); + } else { + yyjson_mut_obj_add_null(r->rdoc, o, "active_org"); + yyjson_mut_obj_add_null(r->rdoc, o, "role"); + } + free(username); + free(display); + return o; +} + +static yyjson_mut_val *h_session_list_orgs(struct req *r) +{ + int64_t first = 0; + yyjson_mut_val *orgs = orgs_for_user(r->db, r->rdoc, r->sess->user_id, 0, + &first); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, o, "items", orgs); + return o; +} + +static yyjson_mut_val *h_session_use_org(struct req *r) +{ + int64_t org = 0; + if (!arg_int(r->args, "org", &org) || org <= 0) + return fail(r, "INVALID_ARGS", "org is required"); + if (r->sess->bound_org && org != r->sess->bound_org) + return fail(r, "ORG_FORBIDDEN", "token is bound to another org"); + char *role = db_membership_role(r->db, org, r->sess->user_id); + if (!role) + return fail(r, "ORG_FORBIDDEN", "not a member of this org"); + r->sess->active_org = org; + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "active_org", org); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role); + free(role); + return o; +} + +/* ------------------------------------------------------------------ */ +/* orgs */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *org_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", sq(sqlite3_column_text(st, 6))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", + sq(sqlite3_column_text(st, 7))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", sq(sqlite3_column_text(st, 8))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "phone", sq(sqlite3_column_text(st, 9))); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month", + sqlite3_column_int(st, 10)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period", + sq(sqlite3_column_text(st, 11))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework", + sq(sqlite3_column_text(st, 12))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 13))); + return o; +} + +#define ORG_COLUMNS \ + "id,name,org_nr,vat_nr,address,postal_code,city,country,email," \ + "phone,fiscal_year_start_month,moms_period,framework,created_at" + +static yyjson_mut_val *h_org_create(struct req *r) +{ + if (!g_cfg.allow_org_create && !r->is_admin) + return fail(r, "FORBIDDEN", "org creation is disabled"); + const char *name = arg_str(r->args, "name"); + if (!name || !*name) + return fail(r, "INVALID_ARGS", "name is required"); + const char *org_nr = arg_str(r->args, "org_nr"); + int64_t fy_month = 1; + arg_int(r->args, "fiscal_year_start_month", &fy_month); + if (fy_month < 1 || fy_month > 12) + return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12"); + const char *moms = arg_str(r->args, "moms_period"); + if (!moms) + moms = "month"; + if (strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 && + strcmp(moms, "year") != 0) + return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year"); + const char *framework = arg_str(r->args, "framework"); + if (!framework) + framework = "K2"; + if (strcmp(framework, "K2") != 0 && strcmp(framework, "K3") != 0) + return fail(r, "INVALID_ARGS", "framework must be K2 or K3"); + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) + return fail(r, "DB_BUSY", "could not start transaction"); + + sqlite3_stmt *st = NULL; + int rc = sqlite3_prepare_v2( + r->db, + "INSERT INTO orgs(name,org_nr,fiscal_year_start_month,moms_period," + "framework,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7)", + -1, &st, NULL); + if (rc != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT); + if (org_nr) + sqlite3_bind_text(st, 2, org_nr, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 2); + sqlite3_bind_int64(st, 3, fy_month); + sqlite3_bind_text(st, 4, moms, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, framework, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, r->sess->user_id); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t org_id = db_last_id(r->db); + + rc = sqlite3_prepare_v2( + r->db, + "INSERT INTO memberships(org_id,user_id,role,created_at)" + " VALUES(?1,?2,'owner',?3)", + -1, &st, NULL); + if (rc != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_int64(st, 2, r->sess->user_id); + sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t fy_id = 0; + char *seed_err = NULL; + if (seed_org(r->db, org_id, framework, (int)fy_month, &fy_id, + &seed_err) != 0) { + db_exec(r->db, "ROLLBACK", NULL); + yyjson_mut_val *res = + fail(r, "INTERNAL", seed_err ? seed_err : "seeding failed"); + free(seed_err); + return res; + } + if (db_exec(r->db, "COMMIT", NULL) != 0) + return fail(r, "INTERNAL", "commit failed"); + + char *reqjson = audit_args_json(r->args); + audit_append(r->db, org_id, r->sess->user_id, r->sess->token_id, + "org.create", reqjson, "OK", NULL); + free(reqjson); + r->sess->active_org = org_id; + + st = NULL; + if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, org_id); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = org_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "INTERNAL", "created org not found"); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy_id); + return o; +} + +static yyjson_mut_val *h_org_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT o.id,o.name,o.org_nr,m.role FROM memberships m" + " JOIN orgs o ON o.id=m.org_id WHERE m.user_id=?1 ORDER BY o.id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->sess->user_id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", + sq(sqlite3_column_text(st, 3))); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static yyjson_mut_val *h_org_get(struct req *r) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = org_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "org not found"); + return o; +} + +static yyjson_mut_val *h_org_member_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT u.id,u.username,u.display_name,m.role FROM memberships m" + " JOIN users u ON u.id=m.user_id WHERE m.org_id=?1 ORDER BY u.id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "user_id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", + sq(sqlite3_column_text(st, 3))); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static int64_t username_to_id(sqlite3 *db, const char *username) +{ + sqlite3_stmt *st = NULL; + int64_t id = -1; + if (sqlite3_prepare_v2(db, "SELECT id FROM users WHERE username=?1", -1, + &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_text(st, 1, username, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) + id = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return id; +} + +static int64_t owner_count(sqlite3 *db, int64_t org_id) +{ + sqlite3_stmt *st = NULL; + int64_t n = -1; + if (sqlite3_prepare_v2( + db, "SELECT count(*) FROM memberships WHERE org_id=?1 AND role='owner'", + -1, &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_int64(st, 1, org_id); + if (sqlite3_step(st) == SQLITE_ROW) + n = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return n; +} + +static int valid_role(const char *role) +{ + return role && (strcmp(role, "owner") == 0 || + strcmp(role, "bookkeeper") == 0 || + strcmp(role, "viewer") == 0); +} + +static yyjson_mut_val *h_org_member_add(struct req *r) +{ + const char *username = arg_str(r->args, "username"); + const char *role = arg_str(r->args, "role"); + if (!username || !valid_role(role)) + return fail(r, "INVALID_ARGS", "username and a valid role are required"); + int64_t uid = username_to_id(r->db, username); + if (uid < 0) + return fail(r, "NOT_FOUND", "user not found"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO memberships(org_id,user_id,role,created_at)" + " VALUES(?1,?2,?3,?4)", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, uid); + sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "CONFLICT", "user is already a member"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.add", reqjson, + "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_org_member_set_role(struct req *r) +{ + const char *username = arg_str(r->args, "username"); + const char *role = arg_str(r->args, "role"); + if (!username || !valid_role(role)) + return fail(r, "INVALID_ARGS", "username and a valid role are required"); + int64_t uid = username_to_id(r->db, username); + if (uid < 0) + return fail(r, "NOT_FOUND", "user not found"); + char *current = db_membership_role(r->db, r->org_id, uid); + if (!current) + return fail(r, "NOT_FOUND", "user is not a member"); + if (strcmp(current, "owner") == 0 && strcmp(role, "owner") != 0 && + owner_count(r->db, r->org_id) <= 1) { + free(current); + return fail(r, "CONFLICT", "cannot demote the last owner"); + } + free(current); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE memberships SET role=?1 WHERE org_id=?2 AND user_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_text(st, 1, role, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 2, r->org_id); + sqlite3_bind_int64(st, 3, uid); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.set_role", + reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_org_member_remove(struct req *r) +{ + const char *username = arg_str(r->args, "username"); + if (!username) + return fail(r, "INVALID_ARGS", "username is required"); + int64_t uid = username_to_id(r->db, username); + if (uid < 0) + return fail(r, "NOT_FOUND", "user not found"); + char *current = db_membership_role(r->db, r->org_id, uid); + if (!current) + return fail(r, "NOT_FOUND", "user is not a member"); + if (strcmp(current, "owner") == 0 && owner_count(r->db, r->org_id) <= 1) { + free(current); + return fail(r, "CONFLICT", "cannot remove the last owner"); + } + free(current); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, "DELETE FROM memberships WHERE org_id=?1 AND user_id=?2", -1, + &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, uid); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.remove", + reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +/* ------------------------------------------------------------------ */ +/* users and tokens */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *h_user_create(struct req *r) +{ + const char *username = arg_str(r->args, "username"); + const char *password = arg_str(r->args, "password"); + const char *display = arg_str(r->args, "display_name"); + if (!username || !password) + return fail(r, "INVALID_ARGS", "username and password are required"); + int is_admin = 0; + arg_bool(r->args, "is_admin", &is_admin); + char *err = NULL; + int64_t uid = 0; + if (db_create_user(r->db, username, display, password, is_admin, &uid, + &err) != 0) { + const char *code = err && strstr(err, "already exists") ? "CONFLICT" + : "INTERNAL"; + yyjson_mut_val *res = fail(r, code, err ? err : "could not create user"); + free(err); + return res; + } + free(err); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, 0, r->sess->user_id, 0, "user.create", reqjson, "OK", + NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", uid); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", username); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", + display ? display : username); + yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", is_admin != 0); + return o; +} + +static yyjson_mut_val *h_user_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id,username,display_name,is_admin,created_at,disabled_at" + " FROM users ORDER BY id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", + sqlite3_column_int(st, 3) != 0); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "disabled_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "disabled_at", + sq(sqlite3_column_text(st, 5))); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static int scope_allowed(const char *role, const char *scope) +{ + int is_owner = strcmp(role, "owner") == 0; + int is_bookkeeper = is_owner || strcmp(role, "bookkeeper") == 0; + if (strcmp(scope, "read") == 0) + return 1; + if (strcmp(scope, "write") == 0) + return is_bookkeeper; + if (strcmp(scope, "admin") == 0) + return is_owner; + return 0; +} + +static yyjson_mut_val *h_token_create(struct req *r) +{ + const char *label = arg_str(r->args, "label"); + if (!label || !*label) + return fail(r, "INVALID_ARGS", "label is required"); + char scopes[64] = ""; + yyjson_val *sv = r->args && yyjson_is_obj(r->args) + ? yyjson_obj_get(r->args, "scopes") + : NULL; + if (sv) { + if (!yyjson_is_arr(sv)) + return fail(r, "INVALID_ARGS", "scopes must be an array"); + yyjson_val *item; + yyjson_arr_iter it = yyjson_arr_iter_with(sv); + while ((item = yyjson_arr_iter_next(&it))) { + const char *s = yyjson_get_str(item); + if (!s || !scope_allowed(r->role, s)) + return fail(r, "FORBIDDEN", "scope not allowed for your role"); + if (scopes[0]) + strncat(scopes, ",", sizeof scopes - strlen(scopes) - 1); + strncat(scopes, s, sizeof scopes - strlen(scopes) - 1); + } + } + if (!scopes[0]) + snprintf(scopes, sizeof scopes, "%s", + strcmp(r->role, "owner") == 0 + ? "read,write,admin" + : (strcmp(r->role, "bookkeeper") == 0 ? "read,write" + : "read")); + const char *expires_at = arg_str(r->args, "expires_at"); + if (expires_at && !util_parse_iso_date(expires_at)) + return fail(r, "INVALID_ARGS", "expires_at must be YYYY-MM-DD"); + char *token = auth_generate_token(); + unsigned char th[32]; + auth_hash_token(token, th); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO api_tokens(org_id,user_id,label,token_hash,scopes," + "created_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7)", + -1, &st, NULL) != SQLITE_OK) { + free(token); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, r->sess->user_id); + sqlite3_bind_text(st, 3, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_blob(st, 4, th, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, scopes, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + if (expires_at) + sqlite3_bind_text(st, 7, expires_at, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 7); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + free(token); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t token_id = db_last_id(r->db); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, token_id, "token.create", + reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", token_id); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", scopes); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "token", token); + if (expires_at) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", expires_at); + else + yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); + free(token); + return o; +} + +static yyjson_mut_val *h_token_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id,label,scopes,created_at,expires_at,last_used_at," + "revoked_at FROM api_tokens WHERE org_id=?1 AND user_id=?2" + " ORDER BY id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, r->sess->user_id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 3))); + if (sqlite3_column_type(st, 4) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", + sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "last_used_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_used_at", + sq(sqlite3_column_text(st, 5))); + if (sqlite3_column_type(st, 6) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "revoked_at"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "revoked_at", + sq(sqlite3_column_text(st, 6))); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static yyjson_mut_val *h_token_revoke(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + int is_owner = strcmp(r->role, "owner") == 0; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + is_owner + ? "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" + : "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" + " AND user_id=?4", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 2, id); + sqlite3_bind_int64(st, 3, r->org_id); + if (!is_owner) + sqlite3_bind_int64(st, 4, r->sess->user_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "token not found"); + audit_append(r->db, r->org_id, r->sess->user_id, id, "token.revoke", "{}", + "OK", NULL); + return yyjson_mut_obj(r->rdoc); +} + +/* ------------------------------------------------------------------ */ +/* discovery */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *command_json(struct req *r, const struct command *c) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", c->name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "summary", c->summary); + yyjson_mut_obj_add_bool(r->rdoc, o, "mutating", c->mutating != 0); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", c->dry_run != 0); + yyjson_mut_val *perm = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, perm, "role", perm_name(c->perm)); + const char *scope = perm_scope(c->perm); + if (scope) + yyjson_mut_obj_add_strcpy(r->rdoc, perm, "scope", scope); + else + yyjson_mut_obj_add_null(r->rdoc, perm, "scope"); + yyjson_mut_obj_add_bool(r->rdoc, perm, "require_org", c->need_org != 0); + yyjson_mut_obj_add_val(r->rdoc, o, "permission", perm); + return o; +} + +static yyjson_mut_val *h_describe(struct req *r) +{ + const char *name = arg_str(r->args, "cmd"); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "protocol", BOKF_PROTOCOL_VERSION); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "server_version", BOKF_VERSION); + if (name) { + const struct command *c = command_find(name); + if (!c) + return fail(r, "NOT_FOUND", "unknown command"); + yyjson_mut_obj_add_val(r->rdoc, o, "command", command_json(r, c)); + return o; + } + yyjson_mut_val *arr = yyjson_mut_arr(r->rdoc); + for (size_t i = 0; i < g_commands_count; i++) + yyjson_mut_arr_add_val(arr, command_json(r, &g_commands[i])); + yyjson_mut_obj_add_val(r->rdoc, o, "commands", arr); + return o; +} + +static const char AGENT_INSTRUCTIONS[] = + "# bokf agent instructions (protocol v1)\n" + "\n" + "You operate a Swedish bookkeeping system through its JSON API.\n" + "\n" + "## Authentication\n" + "- Open one session with `session.open` using an API token:\n" + " {\"method\":\"token\",\"token\":\"bokf_...\"}.\n" + "- Send the returned session id as `session` on every request.\n" + "- Never handle a password unless the human explicitly logs you in that way.\n" + "\n" + "## Ground rules\n" + "- Amounts are integer öre. Dates are YYYY-MM-DD. Account numbers are strings.\n" + "- For every mutating command: call it once with `dry_run:true`, inspect the\n" + " result, then call again with identical args and a `client_ref` to post.\n" + "- Posted data cannot be edited or deleted. Corrections are new vouchers via\n" + " `voucher.correct`; the original always remains visible.\n" + "- Postings must balance: at least two rows, sum debit == sum credit.\n" + "- Locked periods (`PERIOD_LOCKED`) and closed fiscal years\n" + " (`FISCAL_YEAR_CLOSED`) are hard stops. Ask the human.\n" + "- `fiscal_year.close`, `period.lock`, `sie.import` and `backup.snapshot`\n" + " are irreversible or sensitive: confirm with the human first.\n" + "- Underlag: upload with `attachment.put`, link with `attachment_ids` when\n" + " posting.\n" + "- On `CONFLICT` or `replayed:true`, fetch the existing object instead of\n" + " retrying. On `RATE_LIMITED`, back off and report.\n" + "- Konteringsmallar: list with `template.list`, apply with `voucher.post`\n" + " `{\"template\":\"name\",\"x\":1250}` where x is kronor; formulas use x,\n" + " numbers and + - * /. Positive results debit, negative credit.\n" + "\n" + "## Discovery\n" + "- `describe` lists every implemented command with permissions.\n" + "- Reports are read-only: `report.trial_balance`, `report.balance_sheet`,\n" + " `report.income_statement`, `report.general_ledger`, `report.vat`.\n"; + +static yyjson_mut_val *h_agent_instructions(struct req *r) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "format", "markdown"); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "content", AGENT_INSTRUCTIONS); + return o; +} + +/* ------------------------------------------------------------------ */ +/* audit and backup */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *h_audit_list(struct req *r) +{ + int64_t cursor = 0, limit = 100; + arg_int(r->args, "cursor", &cursor); + arg_int(r->args, "limit", &limit); + if (limit < 1) + limit = 100; + if (limit > 1000) + limit = 1000; + const char *action = arg_str(r->args, "action"); + + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + const char *sql = action + ? "SELECT seq,at,actor_user_id,actor_token_id,action," + "request_json,result_code FROM audit_log" + " WHERE org_id=?1 AND seq>?2 AND action=?3" + " ORDER BY seq LIMIT ?4" + : "SELECT seq,at,actor_user_id,actor_token_id,action," + "request_json,result_code FROM audit_log" + " WHERE org_id=?1 AND seq>?2 ORDER BY seq LIMIT ?4"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, cursor); + if (action) + sqlite3_bind_text(st, 3, action, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 4, limit); + int64_t last_seq = cursor; + int64_t n = 0; + while (sqlite3_step(st) == SQLITE_ROW) { + n++; + last_seq = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "seq", last_seq); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", + sq(sqlite3_column_text(st, 1))); + if (sqlite3_column_type(st, 2) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "actor_user_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "actor_user_id", + sqlite3_column_int64(st, 2)); + if (sqlite3_column_type(st, 3) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "actor_token_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "actor_token_id", + sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "action", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "result_code", + sq(sqlite3_column_text(st, 6))); + const char *req = (const char *)sqlite3_column_text(st, 5); + yyjson_doc *parsed = req ? yyjson_read(req, strlen(req), 0) : NULL; + if (parsed && yyjson_is_obj(yyjson_doc_get_root(parsed))) + yyjson_mut_obj_add_val(r->rdoc, o, "request", + yyjson_val_mut_copy(r->rdoc, + yyjson_doc_get_root(parsed))); + if (parsed) + yyjson_doc_free(parsed); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + if (n == limit) + yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last_seq); + else + yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); + return out; +} + +static yyjson_mut_val *h_audit_verify(struct req *r) +{ + int64_t checked = 0, bad_seq = 0; + char *err = NULL; + int rc = audit_verify(r->db, &checked, &bad_seq, &err); + if (rc < 0) { + yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); + free(err); + return res; + } + free(err); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "ok", rc == 0); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "kind", "audit"); + yyjson_mut_obj_add_int(r->rdoc, o, "checked", checked); + if (rc == 1) + yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", bad_seq); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_seq"); + return o; +} + +static yyjson_mut_val *h_backup_snapshot(struct req *r) +{ + const char *dest = arg_str(r->args, "dest"); + if (mkdir_p(g_cfg.backup_dir, 0700) != 0) + return fail(r, "INTERNAL", "cannot create backup directory"); + char path[4096]; + if (dest && *dest) { + snprintf(path, sizeof path, "%s", dest); + } else { + char stamp[32]; + time_t t = (time_t)util_now(); + struct tm tm; + gmtime_r(&t, &tm); + strftime(stamp, sizeof stamp, "%Y%m%dT%H%M%SZ", &tm); + snprintf(path, sizeof path, "%s/bokfd-%s.db", g_cfg.backup_dir, stamp); + } + if (access(path, F_OK) == 0) + return fail(r, "CONFLICT", "destination already exists"); + char *sql = sqlite3_mprintf("VACUUM INTO %Q", path); + int rc = db_exec(r->db, sql, NULL); + sqlite3_free(sql); + if (rc != 0) + return fail(r, "INTERNAL", "VACUUM INTO failed"); + + struct stat sb; + if (stat(path, &sb) != 0) + return fail(r, "INTERNAL", "backup file not found after snapshot"); + FILE *f = fopen(path, "rb"); + if (!f) + return fail(r, "INTERNAL", "cannot read backup file"); + SHA256_CTX ctx; + sha256_init(&ctx); + unsigned char chunk[65536]; + size_t rn; + while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) + sha256_update(&ctx, (const BYTE *)chunk, rn); + fclose(f); + unsigned char digest[32]; + sha256_final(&ctx, digest); + char hex[65]; + util_hex(digest, 32, hex); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + + audit_append(r->db, r->org_id, r->sess->user_id, 0, "backup.snapshot", + "{}", "OK", NULL); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + yyjson_mut_obj_add_int(r->rdoc, o, "size", (int64_t)sb.st_size); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts); + return o; +} + +/* ------------------------------------------------------------------ */ +/* ------------------------------------------------------------------ */ +/* shared helpers for M2 commands */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *json_to_mut(yyjson_mut_doc *doc, const char *json) +{ + if (!json) + return NULL; + yyjson_doc *d = yyjson_read(json, strlen(json), 0); + if (!d) + return NULL; + yyjson_mut_val *v = yyjson_val_mut_copy(doc, yyjson_doc_get_root(d)); + yyjson_doc_free(d); + return v; +} + +static int hash_file(const char *path, char hex[65], int64_t *size) +{ + FILE *f = fopen(path, "rb"); + if (!f) + return -1; + SHA256_CTX ctx; + sha256_init(&ctx); + unsigned char chunk[65536]; + size_t rn; + int64_t total = 0; + while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) { + sha256_update(&ctx, (const BYTE *)chunk, rn); + total += (int64_t)rn; + } + int bad = ferror(f); + fclose(f); + if (bad) + return -1; + unsigned char digest[32]; + sha256_final(&ctx, digest); + util_hex(digest, 32, hex); + if (size) + *size = total; + return 0; +} + +static int is_digits(const char *s) +{ + if (!s || !*s) + return 0; + for (; *s; s++) + if (*s < '0' || *s > '9') + return 0; + return 1; +} + +/* ------------------------------------------------------------------ */ +/* kontoplan */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *account_json(yyjson_mut_doc *doc, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(doc, o, "number", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(doc, o, "type", sq(sqlite3_column_text(st, 3))); + if (sqlite3_column_type(st, 4) == SQLITE_NULL) + yyjson_mut_obj_add_null(doc, o, "sru_code"); + else + yyjson_mut_obj_add_strcpy(doc, o, "sru_code", + sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) == SQLITE_NULL) + yyjson_mut_obj_add_null(doc, o, "vat_code"); + else + yyjson_mut_obj_add_strcpy(doc, o, "vat_code", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_bool(doc, o, "active", sqlite3_column_int(st, 6) != 0); + return o; +} + +#define ACCOUNT_COLUMNS "id,number,name,type,sru_code,vat_code,active" + +static yyjson_mut_val *h_account_list(struct req *r) +{ + int active_only = 0; + arg_bool(r->args, "active_only", &active_only); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + const char *sql = active_only + ? "SELECT " ACCOUNT_COLUMNS " FROM accounts" + " WHERE org_id=?1 AND active=1 ORDER BY number" + : "SELECT " ACCOUNT_COLUMNS " FROM accounts" + " WHERE org_id=?1 ORDER BY number"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(items, account_json(r->rdoc, st)); + sqlite3_finalize(st); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, o, "items", items); + return o; +} + +static yyjson_mut_val *h_account_get(struct req *r) +{ + int64_t id = 0; + const char *number = arg_str(r->args, "number"); + arg_int(r->args, "id", &id); + if (!id && !number) + return fail(r, "INVALID_ARGS", "id or number is required"); + sqlite3_stmt *st = NULL; + const char *sql = id ? "SELECT " ACCOUNT_COLUMNS " FROM accounts" + " WHERE org_id=?1 AND id=?2" + : "SELECT " ACCOUNT_COLUMNS " FROM accounts" + " WHERE org_id=?1 AND number=?2"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (id) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = account_json(r->rdoc, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "account not found"); + return o; +} + +static int valid_account_type(const char *t) +{ + return t && (!strcmp(t, "asset") || !strcmp(t, "liability") || + !strcmp(t, "equity") || !strcmp(t, "revenue") || + !strcmp(t, "expense")); +} + +static yyjson_mut_val *h_account_create(struct req *r) +{ + const char *number = arg_str(r->args, "number"); + const char *name = arg_str(r->args, "name"); + const char *type = arg_str(r->args, "type"); + const char *sru = arg_str(r->args, "sru_code"); + const char *vat = arg_str(r->args, "vat_code"); + if (!is_digits(number) || strlen(number) > 10) + return fail(r, "INVALID_ARGS", "number must be 1-10 digits"); + if (!name || !*name) + return fail(r, "INVALID_ARGS", "name is required"); + if (!valid_account_type(type)) + return fail(r, "INVALID_ARGS", + "type must be asset, liability, equity, revenue or expense"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO accounts(org_id,number,name,type,sru_code,vat_code," + "created_at) VALUES(?1,?2,?3,?4,?5,?6,?7)", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, type, -1, SQLITE_TRANSIENT); + if (sru) + sqlite3_bind_text(st, 5, sru, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 5); + if (vat) + sqlite3_bind_text(st, 6, vat, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 6); + sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + if ((rc & 0xff) == SQLITE_CONSTRAINT) + return fail(r, "CONFLICT", "account number already exists"); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t id = db_last_id(r->db); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "account.create", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "number", number); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "type", type); + return o; +} + +static yyjson_mut_val *h_account_update(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + const char *name = arg_str(r->args, "name"); + const char *sru = arg_str(r->args, "sru_code"); + const char *vat = arg_str(r->args, "vat_code"); + int active = -1; + arg_bool(r->args, "active", &active); + if (!name && !sru && !vat && active < 0) + return fail(r, "INVALID_ARGS", "nothing to update"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE accounts SET" + " name=COALESCE(?2,name), sru_code=COALESCE(?3,sru_code)," + " vat_code=COALESCE(?4,vat_code)," + " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6" + " WHERE org_id=?1 AND id=?7", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (name) + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 2); + if (sru) + sqlite3_bind_text(st, 3, sru, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 3); + if (vat) + sqlite3_bind_text(st, 4, vat, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 4); + sqlite3_bind_int(st, 5, active); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "account not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "account.update", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +/* ------------------------------------------------------------------ */ +/* fiscal years and period locks */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *fy_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", + sq(sqlite3_column_text(st, 4))); + if (sqlite3_column_type(st, 5) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "locked_until"); + else + yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", + sq(sqlite3_column_text(st, 5))); + return o; +} + +#define FY_COLUMNS "id,label,start_date,end_date,status,locked_until" + +static yyjson_mut_val *h_fiscal_year_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT " FY_COLUMNS " FROM fiscal_years WHERE org_id=?1" + " ORDER BY start_date", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(items, fy_json(r, st)); + sqlite3_finalize(st); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, o, "items", items); + return o; +} + +static yyjson_mut_val *h_fiscal_year_get(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + sqlite3_stmt *st = NULL; + const char *sql = id + ? "SELECT " FY_COLUMNS " FROM fiscal_years" + " WHERE org_id=?1 AND id=?2" + : "SELECT " FY_COLUMNS " FROM fiscal_years" + " WHERE org_id=?1 ORDER BY start_date DESC LIMIT 1"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (id) + sqlite3_bind_int64(st, 2, id); + yyjson_mut_val *o = NULL; + if (sqlite3_step(st) == SQLITE_ROW) + o = fy_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "fiscal year not found"); + return o; +} + +static int fy_exists_in_range(sqlite3 *db, int64_t org_id, const char *start, + const char *end) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT count(*) FROM fiscal_years WHERE org_id=?1" + " AND NOT (end_date < ?2 OR start_date > ?3)", + -1, &st, NULL) != SQLITE_OK) + return 1; + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, end, -1, SQLITE_TRANSIENT); + int64_t n = 0; + if (sqlite3_step(st) == SQLITE_ROW) + n = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return n > 0; +} + +static yyjson_mut_val *h_fiscal_year_open(struct req *r) +{ + const char *label = arg_str(r->args, "label"); + const char *start = arg_str(r->args, "start_date"); + const char *end = arg_str(r->args, "end_date"); + if (!label || !*label) + return fail(r, "INVALID_ARGS", "label is required"); + if (!util_parse_iso_date(start) || !util_parse_iso_date(end)) + return fail(r, "INVALID_ARGS", "start_date and end_date must be YYYY-MM-DD"); + if (strcmp(start, end) >= 0) + return fail(r, "INVALID_ARGS", "start_date must be before end_date"); + if (fy_exists_in_range(r->db, r->org_id, start, end)) + return fail(r, "CONFLICT", "fiscal year overlaps an existing one"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO fiscal_years(org_id,label,start_date,end_date," + "created_at) VALUES(?1,?2,?3,?4,?5)", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, start, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, end, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "CONFLICT", "could not create fiscal year"); + int64_t id = db_last_id(r->db); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "fiscal_year.open", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", start); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", end); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "open"); + return o; +} + +static yyjson_mut_val *h_fiscal_year_close(struct req *r) +{ + int64_t id = 0; + int confirm = 0; + arg_int(r->args, "id", &id); + arg_bool(r->args, "confirm", &confirm); + if (id <= 0 || !confirm) + return fail(r, "INVALID_ARGS", "id and confirm:true are required"); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE fiscal_years SET status='closed', closed_at=?3," + " closed_by=?4 WHERE org_id=?1 AND id=?2 AND status='open'", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 4, r->sess->user_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "open fiscal year not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "fiscal_year.close", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_period_lock(struct req *r) +{ + int64_t fy = 0; + arg_int(r->args, "fiscal_year", &fy); + const char *until = arg_str(r->args, "until"); + if (fy <= 0 || !util_parse_iso_date(until)) + return fail(r, "INVALID_ARGS", + "fiscal_year and until (YYYY-MM-DD) are required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE fiscal_years SET locked_until=?3 WHERE org_id=?1 AND id=?2" + " AND ?3 BETWEEN start_date AND end_date", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, fy); + sqlite3_bind_text(st, 3, until, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", + "fiscal year not found or until is outside it"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "period.lock", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year", fy); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", until); + return o; +} + +static yyjson_mut_val *h_period_unlock(struct req *r) +{ + int64_t fy = 0; + arg_int(r->args, "fiscal_year", &fy); + if (fy <= 0) + return fail(r, "INVALID_ARGS", "fiscal_year is required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE fiscal_years SET locked_until=NULL WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, fy); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "fiscal year not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "period.unlock", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +/* ------------------------------------------------------------------ */ +/* vouchers */ +/* ------------------------------------------------------------------ */ + +static int parse_rows(struct req *r, yyjson_val *rowsv, struct ledger_row **out, + size_t *out_n) +{ + if (!rowsv || !yyjson_is_arr(rowsv)) + return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1; + size_t n = yyjson_arr_size(rowsv); + struct ledger_row *rows = xcalloc(n ? n : 1, sizeof *rows); + size_t k = 0; + yyjson_arr_iter it = yyjson_arr_iter_with(rowsv); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it))) { + if (!yyjson_is_obj(item)) { + free(rows); + fail(r, "INVALID_ARGS", "each row must be an object"); + return -1; + } + yyjson_val *av = yyjson_obj_get(item, "account"); + if (!av || !yyjson_is_str(av)) { + free(rows); + failf(r, "INVALID_ARGS", "row %zu: account is required", k + 1); + return -1; + } + yyjson_val *dv = yyjson_obj_get(item, "debit_ore"); + yyjson_val *cv = yyjson_obj_get(item, "credit_ore"); + if ((dv && !yyjson_is_int(dv)) || (cv && !yyjson_is_int(cv))) { + free(rows); + failf(r, "INVALID_ARGS", "row %zu: amounts must be integer öre", + k + 1); + return -1; + } + rows[k].account = yyjson_get_str(av); + rows[k].debit_ore = dv ? yyjson_get_int(dv) : 0; + rows[k].credit_ore = cv ? yyjson_get_int(cv) : 0; + yyjson_val *d = yyjson_obj_get(item, "description"); + rows[k].description = d && yyjson_is_str(d) ? yyjson_get_str(d) : NULL; + k++; + } + *out = rows; + *out_n = k; + return 0; +} + +static int parse_attachment_ids(struct req *r, int64_t **out, size_t *out_n) +{ + yyjson_val *av = r->args ? yyjson_obj_get(r->args, "attachment_ids") : NULL; + *out = NULL; + *out_n = 0; + if (!av) + return 0; + if (!yyjson_is_arr(av)) + return fail(r, "INVALID_ARGS", "attachment_ids must be an array") ? -1 + : -1; + size_t n = yyjson_arr_size(av); + int64_t *ids = xcalloc(n ? n : 1, sizeof *ids); + size_t k = 0; + yyjson_arr_iter it = yyjson_arr_iter_with(av); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it))) { + if (!yyjson_is_int(item)) { + free(ids); + fail(r, "INVALID_ARGS", "attachment_ids must be integers"); + return -1; + } + ids[k++] = yyjson_get_int(item); + } + *out = ids; + *out_n = k; + return 0; +} + +/* ------------------------------------------------------------------ */ +/* settings */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *h_settings_get(struct req *r) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + int have_default = 0; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st, + NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *k = (const char *)sqlite3_column_text(st, 0); + const char *v = (const char *)sqlite3_column_text(st, 1); + if (k) { + yyjson_mut_obj_add(o, yyjson_mut_strcpy(r->rdoc, k), + yyjson_mut_strcpy(r->rdoc, v ? v : "")); + if (strcmp(k, "default_series") == 0) + have_default = 1; + } + } + sqlite3_finalize(st); + } + if (!have_default) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A"); + return o; +} + +static yyjson_mut_val *h_settings_set(struct req *r) +{ + const char *key = arg_str(r->args, "key"); + const char *value = arg_str(r->args, "value"); + if (!key || !value) + return fail(r, "INVALID_ARGS", "key and value are required"); + size_t maxlen; + if (strcmp(key, "default_series") == 0) + maxlen = 8; + else if (strcmp(key, "attachment_dir") == 0) + maxlen = 255; + else + return fail(r, "UNSUPPORTED", "unknown setting"); + size_t len = strlen(value); + if (len == 0 || len > maxlen) + return failf(r, "INVALID_ARGS", "%s must be 1-%zu characters", key, + maxlen); + for (const char *p = value; *p; p++) + if ((unsigned char)*p < 32) + return failf(r, "INVALID_ARGS", + "%s must not contain control characters", key); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO settings(org_id,key,value) VALUES(?1,?2,?3)" + " ON CONFLICT(org_id,key) DO UPDATE SET value=excluded.value", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, value, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "settings.set", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); + return o; +} + +/* ------------------------------------------------------------------ */ +/* voucher templates (konteringsmallar) */ +/* ------------------------------------------------------------------ */ + +struct tpl_head { + int64_t id; + char name[128]; + char series[16]; + char description[256]; +}; + +struct tpl_loaded { + char account[16]; + char formula[256]; + char desc[128]; +}; + +struct tpl_row_in { + int64_t account_id; + char account[16]; + char formula[256]; + char desc[128]; +}; + +static double parse_kr_double(const char *s, int *ok) +{ + *ok = 0; + if (!s) + return 0; + char buf[64]; + size_t j = 0; + for (const char *p = s; *p && j < sizeof buf - 1; p++) { + if (*p == ' ' || *p == '\t') + continue; + buf[j++] = (*p == ',') ? '.' : *p; + } + buf[j] = '\0'; + if (!j || (j == 1 && buf[0] == '-')) + return 0; + char *end = NULL; + double v = strtod(buf, &end); + if (!end || *end) + return 0; + *ok = 1; + return v; +} + +static char *replace_x(const char *text, double x) +{ + struct buf b; + buf_init(&b); + for (const char *p = text; *p;) { + if (p[0] == '{' && p[1] == 'x' && p[2] == '}') { + char tmp[64]; + snprintf(tmp, sizeof tmp, "%.2f", x); + buf_append(&b, tmp, strlen(tmp)); + p += 3; + } else { + buf_append(&b, p, 1); + p++; + } + } + buf_append(&b, "", 1); + return (char *)b.p; +} + +static int load_template(sqlite3 *db, int64_t org_id, int64_t id, + const char *name, struct tpl_head *head, char **err) +{ + sqlite3_stmt *st = NULL; + const char *sql = + id ? "SELECT id,name,series,description FROM voucher_templates" + " WHERE org_id=?1 AND id=?2 AND active=1" + : "SELECT id,name,series,description FROM voucher_templates" + " WHERE org_id=?1 AND name=?2 AND active=1"; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + if (err) + *err = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, org_id); + if (id) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, name ? name : "", -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + if (err) + *err = xstrdup("template not found"); + return -1; + } + head->id = sqlite3_column_int64(st, 0); + snprintf(head->name, sizeof head->name, "%s", + sqlite3_column_text(st, 1)); + snprintf(head->series, sizeof head->series, "%s", + sqlite3_column_text(st, 2)); + snprintf(head->description, sizeof head->description, "%s", + sqlite3_column_text(st, 3)); + sqlite3_finalize(st); + return 0; +} + +static int load_template_rows(sqlite3 *db, int64_t org_id, int64_t tpl, + struct tpl_loaded **out, size_t *out_n, + char **err) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT a.number,tr.formula,COALESCE(tr.description,'')" + " FROM voucher_template_rows tr JOIN accounts a" + " ON a.org_id=tr.org_id AND a.id=tr.account_id" + " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no", + -1, &st, NULL) != SQLITE_OK) { + if (err) + *err = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_int64(st, 2, tpl); + size_t cap = 0, n = 0; + struct tpl_loaded *rows = NULL; + while (sqlite3_step(st) == SQLITE_ROW) { + if (n == cap) { + cap = cap ? cap * 2 : 8; + rows = xrealloc(rows, cap * sizeof *rows); + } + memset(&rows[n], 0, sizeof rows[n]); + snprintf(rows[n].account, sizeof rows[n].account, "%s", + sqlite3_column_text(st, 0)); + snprintf(rows[n].formula, sizeof rows[n].formula, "%s", + sqlite3_column_text(st, 1)); + snprintf(rows[n].desc, sizeof rows[n].desc, "%s", + sqlite3_column_text(st, 2)); + n++; + } + sqlite3_finalize(st); + *out = rows; + *out_n = n; + return 0; +} + +static int parse_template_rows(struct req *r, yyjson_val *rowsv, + struct tpl_row_in **out, size_t *out_n) +{ + *out = NULL; + *out_n = 0; + if (!rowsv || !yyjson_is_arr(rowsv)) + return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1; + size_t n = yyjson_arr_size(rowsv); + if (n == 0 || n > 100) + return fail(r, "INVALID_ARGS", + "a template needs between 1 and 100 rows") + ? -1 + : -1; + struct tpl_row_in *rows = xcalloc(n, sizeof *rows); + size_t k = 0; + yyjson_arr_iter it = yyjson_arr_iter_with(rowsv); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it))) { + if (!yyjson_is_obj(item)) { + free(rows); + fail(r, "INVALID_ARGS", "each row must be an object"); + return -1; + } + yyjson_val *av = yyjson_obj_get(item, "account"); + yyjson_val *fv = yyjson_obj_get(item, "formula"); + if (!av || !yyjson_is_str(av) || !fv || !yyjson_is_str(fv)) { + free(rows); + failf(r, "INVALID_ARGS", + "row %zu: account and formula are required", k + 1); + return -1; + } + const char *account = yyjson_get_str(av); + const char *formula = yyjson_get_str(fv); + if (!formula_valid(formula)) { + free(rows); + failf(r, "INVALID_ARGS", "row %zu: ogiltig formel '%s'", k + 1, + formula); + return -1; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT id FROM accounts WHERE org_id=?1" + " AND number=?2", + -1, &st, NULL) != SQLITE_OK) { + free(rows); + return fail(r, "INTERNAL", "database error") ? -1 : -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + free(rows); + failf(r, "ACCOUNT_NOT_FOUND", "row %zu: account %s does not exist", + k + 1, account); + return -1; + } + rows[k].account_id = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + snprintf(rows[k].account, sizeof rows[k].account, "%s", account); + snprintf(rows[k].formula, sizeof rows[k].formula, "%s", formula); + yyjson_val *dv = yyjson_obj_get(item, "description"); + if (dv && yyjson_is_str(dv)) + snprintf(rows[k].desc, sizeof rows[k].desc, "%s", + yyjson_get_str(dv)); + k++; + } + *out = rows; + *out_n = k; + return 0; +} + +static yyjson_mut_val *tpl_rows_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "line_no", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "formula", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 4))); + return o; +} + +static yyjson_mut_val *h_template_list(struct req *r) +{ + int active_only = 0; + arg_bool(r->args, "active_only", &active_only); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + const char *sql = + active_only + ? "SELECT t.id,t.name,t.series,t.description,t.active," + "(SELECT count(*) FROM voucher_template_rows tr" + " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" + " FROM voucher_templates t WHERE t.org_id=?1 AND t.active=1" + " ORDER BY t.name" + : "SELECT t.id,t.name,t.series,t.description,t.active," + "(SELECT count(*) FROM voucher_template_rows tr" + " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" + " FROM voucher_templates t WHERE t.org_id=?1 ORDER BY t.name"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_bool(r->rdoc, o, "active", + sqlite3_column_int(st, 4) != 0); + yyjson_mut_obj_add_int(r->rdoc, o, "row_count", + sqlite3_column_int64(st, 5)); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + return out; +} + +static yyjson_mut_val *h_template_get(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + const char *name = arg_str(r->args, "name"); + if (id <= 0 && !name) + return fail(r, "INVALID_ARGS", "id or name is required"); + sqlite3_stmt *st = NULL; + const char *sql = + id > 0 ? "SELECT id,name,series,description,active" + " FROM voucher_templates WHERE org_id=?1 AND id=?2" + : "SELECT id,name,series,description,active" + " FROM voucher_templates WHERE org_id=?1 AND name=?2"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (id > 0) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "template not found"); + } + int64_t tpl = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_bool(r->rdoc, o, "active", + sqlite3_column_int(st, 4) != 0); + sqlite3_finalize(st); + + yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); + if (sqlite3_prepare_v2( + r->db, + "SELECT tr.line_no,a.number,a.name,tr.formula," + "COALESCE(tr.description,'') FROM voucher_template_rows tr" + " JOIN accounts a ON a.org_id=tr.org_id AND a.id=tr.account_id" + " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tpl); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(rows, tpl_rows_json(r, st)); + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); + return o; +} + +static yyjson_mut_val *h_template_create(struct req *r) +{ + const char *name = arg_str(r->args, "name"); + const char *series = arg_str(r->args, "series"); + const char *description = arg_str(r->args, "description"); + if (!name || !*name) + return fail(r, "INVALID_ARGS", "name is required"); + char *series_owned = NULL; + if (!series || !*series) { + series_owned = db_setting(r->db, r->org_id, "default_series"); + series = series_owned && *series_owned ? series_owned : "A"; + } + if (!description) + description = ""; + struct tpl_row_in *rows = NULL; + size_t nrows = 0; + if (parse_template_rows( + r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, &rows, + &nrows) != 0) + return NULL; + + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); + yyjson_mut_val *arr = yyjson_mut_arr(r->rdoc); + for (size_t i = 0; i < nrows; i++) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, arr); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", rows[i].account); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "formula", rows[i].formula); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", rows[i].desc); + } + yyjson_mut_obj_add_val(r->rdoc, o, "rows", arr); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + free(rows); + free(series_owned); + return o; + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { + free(rows); + free(series_owned); + return fail(r, "DB_BUSY", "could not start transaction"); + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "INSERT INTO voucher_templates(org_id,name,series," + "description,created_at) VALUES(?1,?2,?3,?4,?5)", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + if ((rc & 0xff) == SQLITE_CONSTRAINT) + return fail(r, "CONFLICT", "a template with that name exists"); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t tpl = db_last_id(r->db); + for (size_t i = 0; i < nrows; i++) { + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO voucher_template_rows(org_id,template_id,line_no," + "account_id,formula,description) VALUES(?1,?2,?3,?4,?5,?6)", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tpl); + sqlite3_bind_int64(st, 3, (int64_t)i + 1); + sqlite3_bind_int64(st, 4, rows[i].account_id); + sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); + if (rows[i].desc[0]) + sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 6); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + } + free(rows); + if (db_exec(r->db, "COMMIT", NULL) != 0) + return fail(r, "INTERNAL", "commit failed"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "template.create", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); + free(series_owned); + return o; +} + +static yyjson_mut_val *h_template_update(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + const char *name_arg = arg_str(r->args, "name"); + if (id <= 0 && !name_arg) + return fail(r, "INVALID_ARGS", "id or name is required"); + struct tpl_head head; + char *err = NULL; + if (load_template(r->db, r->org_id, id, name_arg, &head, &err) != 0) { + yyjson_mut_val *res = + fail(r, "NOT_FOUND", err ? err : "template not found"); + free(err); + return res; + } + const char *new_name = arg_str(r->args, "name"); + const char *series = arg_str(r->args, "series"); + const char *description = arg_str(r->args, "description"); + int active = -1; + arg_bool(r->args, "active", &active); + yyjson_val *rowsv = r->args ? yyjson_obj_get(r->args, "rows") : NULL; + struct tpl_row_in *rows = NULL; + size_t nrows = 0; + if (rowsv) { + if (parse_template_rows(r, rowsv, &rows, &nrows) != 0) + return NULL; + } + if (!new_name && !series && !description && active < 0 && !rowsv) { + free(rows); + return fail(r, "INVALID_ARGS", "nothing to update"); + } + + if (r->dry_run) { + free(rows); + return h_template_get(r); + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { + free(rows); + return fail(r, "DB_BUSY", "could not start transaction"); + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE voucher_templates SET" + " name=COALESCE(?2,name), series=COALESCE(?3,series)," + " description=COALESCE(?4,description)," + " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6" + " WHERE org_id=?1 AND id=?7", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + if (new_name) + sqlite3_bind_text(st, 2, new_name, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 2); + if (series) + sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 3); + if (description) + sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 4); + sqlite3_bind_int(st, 5, active); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, head.id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + if (rowsv) { + if (sqlite3_prepare_v2( + r->db, + "DELETE FROM voucher_template_rows WHERE org_id=?1" + " AND template_id=?2", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, head.id); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + for (size_t i = 0; i < nrows; i++) { + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO voucher_template_rows(org_id,template_id," + "line_no,account_id,formula,description)" + " VALUES(?1,?2,?3,?4,?5,?6)", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, head.id); + sqlite3_bind_int64(st, 3, (int64_t)i + 1); + sqlite3_bind_int64(st, 4, rows[i].account_id); + sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); + if (rows[i].desc[0]) + sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 6); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + } + } + free(rows); + if (db_exec(r->db, "COMMIT", NULL) != 0) + return fail(r, "INTERNAL", "commit failed"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "template.update", reqjson, "OK", NULL); + free(reqjson); + return h_template_get(r); +} + +static yyjson_mut_val *h_template_archive(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + const char *name = arg_str(r->args, "name"); + if (id <= 0 && !name) + return fail(r, "INVALID_ARGS", "id or name is required"); + sqlite3_stmt *st = NULL; + const char *sql = + id > 0 ? "UPDATE voucher_templates SET active=0 WHERE org_id=?1" + " AND id=?2" + : "UPDATE voucher_templates SET active=0 WHERE org_id=?1" + " AND name=?2"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (id > 0) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "template not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "template.archive", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_voucher_post(struct req *r) +{ + const char *date = arg_str(r->args, "date"); + const char *description = arg_str(r->args, "description"); + const char *series = arg_str(r->args, "series"); + const char *client_ref = arg_str(r->args, "client_ref"); + int64_t corrects = 0; + arg_int(r->args, "corrects_voucher", &corrects); + if (!date) + return fail(r, "INVALID_ARGS", "date is required"); + + /* template application: {template, x} instead of {rows} */ + int64_t tpl_id = 0; + const char *tpl_name = NULL; + yyjson_val *tv = r->args ? yyjson_obj_get(r->args, "template") : NULL; + if (tv) { + if (yyjson_is_int(tv)) + tpl_id = yyjson_get_int(tv); + else if (yyjson_is_str(tv)) + tpl_name = yyjson_get_str(tv); + else + return fail(r, "INVALID_ARGS", "template must be an id or a name"); + if (r->args && yyjson_obj_get(r->args, "rows")) + return fail(r, "INVALID_ARGS", + "use either rows or template, not both"); + } + double x = 0; + yyjson_val *xv = r->args ? yyjson_obj_get(r->args, "x") : NULL; + if (xv) { + if (yyjson_is_str(xv)) { + int ok = 0; + x = parse_kr_double(yyjson_get_str(xv), &ok); + if (!ok) + return fail(r, "INVALID_ARGS", "x must be a number in kronor"); + } else if (yyjson_is_real(xv)) { + x = yyjson_get_real(xv); + } else if (yyjson_is_int(xv)) { + x = (double)yyjson_get_int(xv); + } else { + return fail(r, "INVALID_ARGS", "x must be a number in kronor"); + } + } + + struct ledger_row *rows = NULL; + size_t nrows = 0; + char *owned_desc = NULL; + char owned_series[16] = ""; + int rows_owned = 0; + + if (tv) { + struct tpl_head head; + char *err = NULL; + if (load_template(r->db, r->org_id, tpl_id, tpl_name, &head, &err) != + 0) { + yyjson_mut_val *res = + fail(r, "NOT_FOUND", err ? err : "template not found"); + free(err); + return res; + } + struct tpl_loaded *trows = NULL; + size_t ntrows = 0; + if (load_template_rows(r->db, r->org_id, head.id, &trows, &ntrows, + &err) != 0) { + yyjson_mut_val *res = + fail(r, "INTERNAL", err ? err : "could not load template"); + free(err); + return res; + } + struct template_row *in = + xcalloc(ntrows ? ntrows : 1, sizeof *in); + for (size_t i = 0; i < ntrows; i++) { + in[i].account = trows[i].account; + in[i].formula = trows[i].formula; + in[i].description = trows[i].desc[0] ? trows[i].desc : NULL; + } + struct resolved_row *resolved = + xcalloc(ntrows ? ntrows : 1, sizeof *resolved); + char ferr[256] = ""; + if (formula_resolve_rows(in, ntrows, x, resolved, &nrows, ferr, + sizeof ferr) != 0) { + free(in); + free(resolved); + free(trows); + return fail(r, "INVALID_ARGS", + ferr[0] ? ferr : "template could not be resolved"); + } + free(in); + free(trows); + rows = xcalloc(nrows, sizeof *rows); + for (size_t i = 0; i < nrows; i++) { + rows[i].account = xstrdup(resolved[i].account); + rows[i].debit_ore = resolved[i].debit_ore; + rows[i].credit_ore = resolved[i].credit_ore; + rows[i].description = + resolved[i].description[0] ? xstrdup(resolved[i].description) + : NULL; + } + free(resolved); + rows_owned = 1; + if (!series && head.series[0]) + snprintf(owned_series, sizeof owned_series, "%s", head.series); + if (!description && head.description[0]) + owned_desc = replace_x(head.description, x); + if (!description && !owned_desc) + owned_desc = xstrdup(head.name); + } else { + if (!description) + return fail(r, "INVALID_ARGS", + "date and description are required"); + if (parse_rows(r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, + &rows, &nrows) != 0) + return NULL; + } + + int64_t *att = NULL; + size_t natt = 0; + if (parse_attachment_ids(r, &att, &natt) != 0) { + if (rows_owned) + for (size_t i = 0; i < nrows; i++) { + free((char *)rows[i].account); + free((char *)rows[i].description); + } + free(rows); + free(owned_desc); + return NULL; + } + + struct ledger_post_opts o; + memset(&o, 0, sizeof o); + o.org_id = r->org_id; + o.user_id = r->sess->user_id; + o.token_id = r->sess->token_id; + o.date = date; + o.description = description ? description : owned_desc; + o.series = series ? series + : (owned_series[0] ? owned_series : NULL); + o.rows = rows; + o.nrows = nrows; + o.corrects_voucher_id = corrects; + o.attachment_ids = att; + o.n_attachments = natt; + o.client_ref = client_ref; + o.dry_run = r->dry_run; + + struct ledger_error e; + char *json = NULL; + int rc = ledger_post(r->db, &o, &e, &json); + if (rows_owned) + for (size_t i = 0; i < nrows; i++) { + free((char *)rows[i].account); + free((char *)rows[i].description); + } + free(rows); + free(att); + free(owned_desc); + if (rc != 0) { + if (e.has_details) { + yyjson_mut_val *details = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, details, "difference_ore", + e.difference_ore); + r->err_details = details; + } + return fail(r, e.code ? e.code : "INTERNAL", e.msg); + } + + if (!r->dry_run) { + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "voucher.post", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *res = json_to_mut(r->rdoc, json); + free(json); + if (!res) + return fail(r, "INTERNAL", "could not serialize result"); + return res; +} + +static yyjson_mut_val *voucher_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", + sqlite3_column_int64(st, 1)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "source", + sq(sqlite3_column_text(st, 6))); + if (sqlite3_column_type(st, 7) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id", + sqlite3_column_int64(st, 7)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 8))); + yyjson_mut_obj_add_int(r->rdoc, o, "created_by_user", + sqlite3_column_int64(st, 9)); + char hex[65]; + const void *hb = sqlite3_column_blob(st, 10); + if (hb && sqlite3_column_bytes(st, 10) == 32) { + util_hex((const unsigned char *)hb, 32, hex); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash_prev", hex); + } + const void *vb = sqlite3_column_blob(st, 11); + if (vb && sqlite3_column_bytes(st, 11) == 32) { + util_hex((const unsigned char *)vb, 32, hex); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash", hex); + } + return o; +} + +#define VOUCHER_COLUMNS \ + "id,fiscal_year_id,series,number,date,description,source," \ + "corrects_voucher_id,created_at,created_by_user,hash_prev,hash" + +static yyjson_mut_val *h_voucher_get(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT " VOUCHER_COLUMNS " FROM vouchers WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "voucher not found"); + } + yyjson_mut_val *o = voucher_json(r, st); + sqlite3_finalize(st); + + yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); + if (sqlite3_prepare_v2( + r->db, + "SELECT a.number,a.name,r.debit_ore,r.credit_ore," + "COALESCE(r.description,'') FROM voucher_rows r" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, rows); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", + sq(sqlite3_column_text(st, 0))); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_int(r->rdoc, ro, "debit_ore", + sqlite3_column_int64(st, 2)); + yyjson_mut_obj_add_int(r->rdoc, ro, "credit_ore", + sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", + sq(sqlite3_column_text(st, 4))); + } + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); + + yyjson_mut_val *atts = yyjson_mut_arr(r->rdoc); + if (sqlite3_prepare_v2( + r->db, + "SELECT a.id,a.filename FROM voucher_attachments va" + " JOIN attachments a ON a.org_id=va.org_id AND a.id=va.attachment_id" + " WHERE va.org_id=?1 AND va.voucher_id=?2 ORDER BY a.id", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *ao = yyjson_mut_arr_add_obj(r->rdoc, atts); + yyjson_mut_obj_add_int(r->rdoc, ao, "id", + sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, ao, "filename", + sq(sqlite3_column_text(st, 1))); + } + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(r->rdoc, o, "attachments", atts); + return o; +} + +static yyjson_mut_val *h_voucher_list(struct req *r) +{ + int64_t fy = 0, cursor = 0, limit = 100, account_id = 0; + arg_int(r->args, "fiscal_year", &fy); + arg_int(r->args, "cursor", &cursor); + arg_int(r->args, "limit", &limit); + if (limit < 1) + limit = 100; + if (limit > 1000) + limit = 1000; + const char *series = arg_str(r->args, "series"); + const char *from = arg_str(r->args, "from"); + const char *to = arg_str(r->args, "to"); + const char *text = arg_str(r->args, "text"); + const char *account = arg_str(r->args, "account"); + if (account) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id FROM accounts WHERE org_id=?1 AND number=?2", -1, + &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "account not found"); + } + account_id = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT v.id,v.series,v.number,v.date,v.description,v.source," + "v.corrects_voucher_id," + "(SELECT count(*) FROM voucher_rows r WHERE r.org_id=v.org_id" + " AND r.voucher_id=v.id)" + " FROM vouchers v WHERE v.org_id=?1" + " AND (?2=0 OR v.fiscal_year_id=?2)" + " AND (?3='' OR v.series=?3)" + " AND (?4='' OR v.date>=?4)" + " AND (?5='' OR v.date<=?5)" + " AND (?6=0 OR EXISTS(SELECT 1 FROM voucher_rows r2" + " WHERE r2.org_id=v.org_id AND r2.voucher_id=v.id" + " AND r2.account_id=?6))" + " AND (?7='' OR v.description LIKE '%'||?7||'%')" + " AND v.id>?8 ORDER BY v.id LIMIT ?9", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, fy); + sqlite3_bind_text(st, 3, series ? series : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, from ? from : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, to ? to : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 6, account_id); + sqlite3_bind_text(st, 7, text ? text : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 8, cursor); + sqlite3_bind_int64(st, 9, limit); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + int64_t last = cursor, n = 0; + while (sqlite3_step(st) == SQLITE_ROW) { + n++; + last = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", last); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_int(r->rdoc, o, "number", + sqlite3_column_int64(st, 2)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "source", + sq(sqlite3_column_text(st, 5))); + if (sqlite3_column_type(st, 6) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id", + sqlite3_column_int64(st, 6)); + yyjson_mut_obj_add_int(r->rdoc, o, "row_count", + sqlite3_column_int64(st, 7)); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + if (n == limit) + yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last); + else + yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); + return out; +} + +static yyjson_mut_val *h_voucher_correct(struct req *r) +{ + int64_t id = 0; + const char *description = arg_str(r->args, "description"); + const char *date = arg_str(r->args, "date"); + const char *client_ref = arg_str(r->args, "client_ref"); + if (!arg_int(r->args, "voucher", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "voucher is required"); + if (!description || !*description) + return fail(r, "INVALID_ARGS", "description is required"); + + sqlite3_stmt *st = NULL; + char series[16] = "", orig_date[16] = ""; + if (sqlite3_prepare_v2( + r->db, + "SELECT series,date FROM vouchers WHERE org_id=?1 AND id=?2", -1, + &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "voucher not found"); + } + snprintf(series, sizeof series, "%s", sqlite3_column_text(st, 0)); + snprintf(orig_date, sizeof orig_date, "%s", sqlite3_column_text(st, 1)); + sqlite3_finalize(st); + + struct ledger_row *rows = NULL; + size_t nrows = 0, cap = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT a.number,r.debit_ore,r.credit_ore," + "COALESCE(r.description,'') FROM voucher_rows r" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + while (sqlite3_step(st) == SQLITE_ROW) { + if (nrows == cap) { + cap = cap ? cap * 2 : 8; + rows = xrealloc(rows, cap * sizeof *rows); + } + memset(&rows[nrows], 0, sizeof rows[nrows]); + rows[nrows].account = xstrdup(sq(sqlite3_column_text(st, 0))); + rows[nrows].debit_ore = sqlite3_column_int64(st, 2); + rows[nrows].credit_ore = sqlite3_column_int64(st, 1); + const unsigned char *d = sqlite3_column_text(st, 3); + rows[nrows].description = d && *d ? xstrdup((const char *)d) : NULL; + nrows++; + } + sqlite3_finalize(st); + if (nrows == 0) + return fail(r, "INTERNAL", "voucher has no rows"); + + struct ledger_post_opts o; + memset(&o, 0, sizeof o); + o.org_id = r->org_id; + o.user_id = r->sess->user_id; + o.token_id = r->sess->token_id; + o.date = date ? date : orig_date; + o.description = description; + o.series = series; + o.rows = rows; + o.nrows = nrows; + o.corrects_voucher_id = id; + o.client_ref = client_ref; + o.dry_run = r->dry_run; + + struct ledger_error e; + char *json = NULL; + int rc = ledger_post(r->db, &o, &e, &json); + for (size_t i = 0; i < nrows; i++) { + free((char *)rows[i].account); + free((char *)rows[i].description); + } + free(rows); + if (rc != 0) + return fail(r, e.code ? e.code : "INTERNAL", e.msg); + if (!r->dry_run) { + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "voucher.correct", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *res = json_to_mut(r->rdoc, json); + free(json); + if (!res) + return fail(r, "INTERNAL", "could not serialize result"); + return res; +} + +/* ------------------------------------------------------------------ */ +/* attachments */ +/* ------------------------------------------------------------------ */ + +static int link_attachment(struct req *r, int64_t aid, int64_t vid, + char **errmsg) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT (SELECT count(*) FROM vouchers WHERE org_id=?1 AND id=?2)," + "(SELECT count(*) FROM voucher_attachments WHERE org_id=?1" + " AND attachment_id=?3)," + "(SELECT count(*) FROM attachments WHERE org_id=?1 AND id=?3)", + -1, &st, NULL) != SQLITE_OK) { + *errmsg = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, vid); + sqlite3_bind_int64(st, 3, aid); + int64_t vexists = 0, linked = 0, aexists = 0; + if (sqlite3_step(st) == SQLITE_ROW) { + vexists = sqlite3_column_int64(st, 0); + linked = sqlite3_column_int64(st, 1); + aexists = sqlite3_column_int64(st, 2); + } + sqlite3_finalize(st); + if (!aexists) { + *errmsg = xstrdup("attachment not found"); + return -1; + } + if (!vexists) { + *errmsg = xstrdup("voucher not found"); + return -1; + } + if (linked) { + *errmsg = xstrdup("attachment is already linked"); + return -1; + } + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO voucher_attachments(org_id,voucher_id,attachment_id," + "created_at) VALUES(?1,?2,?3,?4)", + -1, &st, NULL) != SQLITE_OK) { + *errmsg = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, vid); + sqlite3_bind_int64(st, 3, aid); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + *errmsg = xstrdup("could not link attachment"); + return -1; + } + return 0; +} + +static yyjson_mut_val *h_attachment_put(struct req *r) +{ + const char *filename = arg_str(r->args, "filename"); + const char *mime = arg_str(r->args, "mime"); + const char *b64 = arg_str(r->args, "content_base64"); + int64_t voucher_id = 0; + arg_int(r->args, "voucher_id", &voucher_id); + if (!filename || !*filename || !b64) + return fail(r, "INVALID_ARGS", + "filename and content_base64 are required"); + if (!mime || !*mime) + mime = "application/octet-stream"; + unsigned char *content = NULL; + size_t len = 0; + if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) + return fail(r, "INVALID_ARGS", "content_base64 is not valid base64"); + if ((long)len > g_cfg.max_attachment_bytes) { + free(content); + return failf(r, "TOO_LARGE", "attachment exceeds %ld bytes", + g_cfg.max_attachment_bytes); + } + unsigned char hash[32]; + util_sha256(content, len, hash); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id FROM attachments WHERE org_id=?1 AND sha256=?2" + " AND filename=?3", + -1, &st, NULL) != SQLITE_OK) { + free(content); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); + int64_t existing = 0; + if (sqlite3_step(st) == SQLITE_ROW) + existing = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + + if (existing) { + free(content); + if (voucher_id) { + char *emsg = NULL; + if (link_attachment(r, existing, voucher_id, &emsg) != 0) { + yyjson_mut_val *res = fail(r, "CONFLICT", emsg); + free(emsg); + return res; + } + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.link", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", existing); + yyjson_mut_obj_add_bool(r->rdoc, o, "replayed", true); + return o; + } + + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes," + "content,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + -1, &st, NULL) != SQLITE_OK) { + free(content); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, mime, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 5, (int64_t)len); + sqlite3_bind_blob(st, 6, content, (int)len, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 8, r->sess->user_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + free(content); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + int64_t id = db_last_id(r->db); + + if (voucher_id) { + char *emsg = NULL; + if (link_attachment(r, id, voucher_id, &emsg) != 0) { + yyjson_mut_val *res = fail(r, "NOT_FOUND", emsg); + free(emsg); + return res; + } + } + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.put", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", (int64_t)len); + return o; +} + +static yyjson_mut_val *h_attachment_get(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT filename,mime,size_bytes,content,sha256,created_at" + " FROM attachments WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "attachment not found"); + } + const void *content = sqlite3_column_blob(st, 3); + size_t len = (size_t)sqlite3_column_bytes(st, 3); + char *b64 = util_b64(content ? content : (const unsigned char *)"", len); + char hex[65]; + const void *hb = sqlite3_column_blob(st, 4); + if (hb && sqlite3_column_bytes(st, 4) == 32) + util_hex((const unsigned char *)hb, 32, hex); + else + hex[0] = '\0'; + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", + sq(sqlite3_column_text(st, 0))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", + sqlite3_column_int64(st, 2)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); + free(b64); + sqlite3_finalize(st); + return o; +} + +static yyjson_mut_val *h_attachment_list(struct req *r) +{ + int64_t voucher_id = 0, cursor = 0, limit = 100; + int unlinked = 0; + arg_int(r->args, "voucher_id", &voucher_id); + arg_int(r->args, "cursor", &cursor); + arg_int(r->args, "limit", &limit); + arg_bool(r->args, "unlinked", &unlinked); + if (limit < 1) + limit = 100; + if (limit > 1000) + limit = 1000; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT a.id,a.filename,a.mime,a.size_bytes,a.created_at,a.sha256," + "(SELECT va.voucher_id FROM voucher_attachments va" + " WHERE va.org_id=a.org_id AND va.attachment_id=a.id LIMIT 1)" + " FROM attachments a WHERE a.org_id=?1" + " AND (?2=0 OR (SELECT va.voucher_id FROM voucher_attachments va" + " WHERE va.org_id=a.org_id AND va.attachment_id=a.id LIMIT 1)=?2)" + " AND (?3=0 OR NOT EXISTS(SELECT 1 FROM voucher_attachments va2" + " WHERE va2.org_id=a.org_id AND va2.attachment_id=a.id))" + " AND a.id>?4 ORDER BY a.id LIMIT ?5", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_int(st, 3, unlinked); + sqlite3_bind_int64(st, 4, cursor); + sqlite3_bind_int64(st, 5, limit); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + int64_t n = 0, last = cursor; + while (sqlite3_step(st) == SQLITE_ROW) { + n++; + last = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", last); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", + sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 4))); + char hex[65]; + const void *hb = sqlite3_column_blob(st, 5); + if (hb && sqlite3_column_bytes(st, 5) == 32) + util_hex((const unsigned char *)hb, 32, hex); + else + hex[0] = '\0'; + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + if (sqlite3_column_type(st, 6) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", + sqlite3_column_int64(st, 6)); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + if (n == limit) + yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last); + else + yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); + return out; +} + +/* ------------------------------------------------------------------ */ +/* reports and SIE */ +/* ------------------------------------------------------------------ */ + +static int64_t default_fy_id(sqlite3 *db, int64_t org_id) +{ + sqlite3_stmt *st = NULL; + int64_t id = 0; + if (sqlite3_prepare_v2( + db, + "SELECT id FROM fiscal_years WHERE org_id=?1" + " ORDER BY start_date DESC LIMIT 1", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, org_id); + if (sqlite3_step(st) == SQLITE_ROW) + id = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + return id; +} + +static int req_fy(struct req *r, int64_t *out) +{ + int64_t fy = 0; + arg_int(r->args, "fiscal_year", &fy); + if (!fy) + fy = default_fy_id(r->db, r->org_id); + if (!fy) { + fail(r, "NOT_FOUND", "no fiscal year; open one first"); + return -1; + } + *out = fy; + return 0; +} + +static yyjson_mut_val *h_report_trial_balance(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + int include_zero = 0; + arg_bool(r->args, "include_zero", &include_zero); + char *err = NULL; + yyjson_mut_val *res = report_trial_balance( + r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), + arg_str(r->args, "to"), include_zero, &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static yyjson_mut_val *h_report_income_statement(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + char *err = NULL; + yyjson_mut_val *res = report_income_statement( + r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), + arg_str(r->args, "to"), &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static yyjson_mut_val *h_report_balance_sheet(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + char *err = NULL; + yyjson_mut_val *res = report_balance_sheet( + r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "to"), &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static yyjson_mut_val *h_report_vat(struct req *r) +{ + const char *from = arg_str(r->args, "from"); + const char *to = arg_str(r->args, "to"); + if (!from || !to) + return fail(r, "INVALID_ARGS", "from and to are required"); + char *err = NULL; + yyjson_mut_val *res = + report_vat(r->rdoc, r->db, r->org_id, from, to, &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); + free(err); + return e; + } + return res; +} + +static unsigned char *read_file(const char *path, size_t *out_len) +{ + FILE *f = fopen(path, "rb"); + if (!f) + return NULL; + struct buf b; + buf_init(&b); + unsigned char chunk[65536]; + size_t rn; + while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) + buf_append(&b, chunk, rn); + int bad = ferror(f); + fclose(f); + if (bad) { + buf_free(&b); + return NULL; + } + *out_len = b.len; + return b.p; +} + +static yyjson_mut_val *h_sie_export(struct req *r) +{ + int64_t fy = 0; + if (req_fy(r, &fy) != 0) + return NULL; + int inline_out = 0; + arg_bool(r->args, "inline", &inline_out); + + if (mkdir_p(g_cfg.export_dir, 0700) != 0) + return fail(r, "INTERNAL", "cannot create export directory"); + char label[64] = ""; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT label FROM fiscal_years WHERE org_id=?1 AND id=?2", -1, + &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, fy); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "fiscal year not found"); + } + snprintf(label, sizeof label, "%s", sqlite3_column_text(st, 0)); + sqlite3_finalize(st); + for (char *p = label; *p; p++) + if (*p == '/') + *p = '-'; + char path[4096]; + snprintf(path, sizeof path, "%s/%lld_%s.se", g_cfg.export_dir, + (long long)r->org_id, label); + char *err = NULL; + if (sie_export_file(r->db, r->org_id, fy, path, &err) != 0) { + yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "export failed"); + free(err); + return e; + } + char hex[65]; + int64_t size = 0; + if (hash_file(path, hex, &size) != 0) + return fail(r, "INTERNAL", "could not read exported file"); + + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "sie.export", "{}", "OK", NULL); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + yyjson_mut_obj_add_int(r->rdoc, o, "size", size); + if (inline_out) { + size_t flen = 0; + unsigned char *content = read_file(path, &flen); + if (!content) + return fail(r, "INTERNAL", "could not read exported file"); + char *b64 = util_b64(content, flen); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); + free(b64); + free(content); + } + return o; +} + +static yyjson_mut_val *h_sie_import(struct req *r) +{ + const char *b64 = arg_str(r->args, "content_base64"); + const char *path = arg_str(r->args, "path"); + unsigned char *content = NULL; + size_t len = 0; + if (b64) { + if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) + return fail(r, "INVALID_ARGS", + "content_base64 is not valid base64"); + } else if (path) { + content = read_file(path, &len); + if (!content) + return fail(r, "NOT_FOUND", "could not read file"); + } else { + return fail(r, "INVALID_ARGS", "content_base64 or path is required"); + } + if (len > 64u * 1024u * 1024u) { + free(content); + return fail(r, "TOO_LARGE", "SIE file is too large"); + } + int64_t fy = 0, vouchers = 0, accounts = 0; + char *err = NULL; + int rc = sie_import_content(r->db, r->org_id, r->sess->user_id, content, + len, r->dry_run, &fy, &vouchers, &accounts, + &err); + free(content); + if (rc != 0) { + yyjson_mut_val *e = fail(r, "INVALID_ARGS", err ? err : "import failed"); + free(err); + return e; + } + if (!r->dry_run) { + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "sie.import", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy); + yyjson_mut_obj_add_int(r->rdoc, o, "vouchers", vouchers); + yyjson_mut_obj_add_int(r->rdoc, o, "accounts_created", accounts); + if (r->dry_run) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; +} + +/* command table */ +/* ------------------------------------------------------------------ */ + +const struct command g_commands[] = { + { "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health }, + { "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta }, + { "session.open", "Open a session (password or token)", PERM_PUBLIC, 0, 0, + 0, h_session_open }, + { "session.close", "Close the current session", PERM_READ, 0, 0, 0, + h_session_close }, + { "session.whoami", "Current user, org, role and scopes", PERM_READ, 0, 0, + 0, h_session_whoami }, + { "session.list_orgs", "Orgs the current user is a member of", PERM_READ, + 0, 0, 0, h_session_list_orgs }, + { "session.use_org", "Switch active org", PERM_READ, 0, 0, 0, + h_session_use_org }, + { "org.create", "Create an org and become its owner", PERM_READ, 0, 1, 1, + h_org_create }, + { "org.list", "List orgs for the current user", PERM_READ, 0, 0, 0, + h_org_list }, + { "org.get", "Get org details", PERM_READ, 1, 0, 0, h_org_get }, + { "org.member_list", "List org members", PERM_READ, 1, 0, 0, + h_org_member_list }, + { "org.member_add", "Add a member to the org", PERM_OWNER, 1, 1, 1, + h_org_member_add }, + { "org.member_set_role", "Change a member's role", PERM_OWNER, 1, 1, 1, + h_org_member_set_role }, + { "org.member_remove", "Remove a member from the org", PERM_OWNER, 1, 1, 1, + h_org_member_remove }, + { "user.create", "Create a user (system admin)", PERM_ADMIN, 0, 1, 1, + h_user_create }, + { "user.list", "List users (system admin)", PERM_ADMIN, 0, 0, 0, + h_user_list }, + { "token.create", "Create an API token for the active org", PERM_READ, 1, + 1, 1, h_token_create }, + { "token.list", "List your API tokens in the active org", PERM_READ, 1, 0, + 0, h_token_list }, + { "token.revoke", "Revoke an API token", PERM_READ, 1, 1, 1, + h_token_revoke }, + { "describe", "List implemented commands and permissions", PERM_READ, 0, 0, + 0, h_describe }, + { "agent.instructions", "Workflow rules for agents", PERM_READ, 0, 0, 0, + h_agent_instructions }, + { "audit.list", "Read the audit log for the active org", PERM_READ, 1, 0, + 0, h_audit_list }, + { "audit.verify", "Verify the audit hash chain", PERM_READ, 0, 0, 0, + h_audit_verify }, + { "backup.snapshot", "Create a consistent database snapshot", PERM_ADMIN, + 0, 1, 0, h_backup_snapshot }, + { "account.list", "List the chart of accounts", PERM_READ, 1, 0, 0, + h_account_list }, + { "account.get", "Get one account", PERM_READ, 1, 0, 0, h_account_get }, + { "account.create", "Add an account", PERM_WRITE, 1, 1, 1, + h_account_create }, + { "account.update", "Rename or deactivate an account", PERM_WRITE, 1, 1, 1, + h_account_update }, + { "fiscal_year.list", "List fiscal years", PERM_READ, 1, 0, 0, + h_fiscal_year_list }, + { "fiscal_year.get", "Get a fiscal year", PERM_READ, 1, 0, 0, + h_fiscal_year_get }, + { "fiscal_year.open", "Open a new fiscal year", PERM_OWNER, 1, 1, 1, + h_fiscal_year_open }, + { "fiscal_year.close", "Close a fiscal year (irreversible)", PERM_OWNER, 1, + 1, 0, h_fiscal_year_close }, + { "period.lock", "Lock a period through a date", PERM_OWNER, 1, 1, 1, + h_period_lock }, + { "period.unlock", "Remove a period lock", PERM_OWNER, 1, 1, 1, + h_period_unlock }, + { "voucher.post", "Post an immutable voucher (rows or template+x)", + PERM_WRITE, 1, 1, 1, h_voucher_post }, + { "voucher.get", "Get a voucher with rows", PERM_READ, 1, 0, 0, + h_voucher_get }, + { "voucher.list", "List vouchers", PERM_READ, 1, 0, 0, h_voucher_list }, + { "voucher.correct", "Post an ändringsverifikat", PERM_WRITE, 1, 1, 1, + h_voucher_correct }, + { "settings.get", "Read org settings", PERM_READ, 1, 0, 0, + h_settings_get }, + { "settings.set", "Change an org setting", PERM_WRITE, 1, 1, 1, + h_settings_set }, + { "template.list", "List voucher templates", PERM_READ, 1, 0, 0, + h_template_list }, + { "template.get", "Get a voucher template with its rows", PERM_READ, 1, 0, + 0, h_template_get }, + { "template.create", "Create a voucher template", PERM_WRITE, 1, 1, 1, + h_template_create }, + { "template.update", "Update a voucher template", PERM_WRITE, 1, 1, 1, + h_template_update }, + { "template.archive", "Archive a voucher template", PERM_WRITE, 1, 1, 1, + h_template_archive }, + { "attachment.put", "Store an underlag (receipt, invoice)", PERM_WRITE, 1, + 1, 1, h_attachment_put }, + { "attachment.get", "Fetch an attachment", PERM_READ, 1, 0, 0, + h_attachment_get }, + { "attachment.list", "List attachments / inbox", PERM_READ, 1, 0, 0, + h_attachment_list }, + { "report.trial_balance", "Saldobalans (IB, period, UB)", PERM_READ, 1, 0, + 0, h_report_trial_balance }, + { "report.income_statement", "Resultaträkning", PERM_READ, 1, 0, 0, + h_report_income_statement }, + { "report.balance_sheet", "Balansräkning", PERM_READ, 1, 0, 0, + h_report_balance_sheet }, + { "report.vat", "Momsdeklaration ruta för ruta", PERM_READ, 1, 0, 0, + h_report_vat }, + { "sie.export", "Export SIE 4 (PC8)", PERM_READ, 1, 0, 0, h_sie_export }, + { "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1, + 1, h_sie_import }, +}; + +const size_t g_commands_count = sizeof g_commands / sizeof g_commands[0]; + +const struct command *command_find(const char *name) +{ + if (!name) + return NULL; + for (size_t i = 0; i < g_commands_count; i++) + if (strcmp(g_commands[i].name, name) == 0) + return &g_commands[i]; + return NULL; +} + +const char *perm_name(enum cmd_perm p) +{ + switch (p) { + case PERM_PUBLIC: + return "public"; + case PERM_READ: + return "viewer"; + case PERM_WRITE: + return "bookkeeper"; + case PERM_OWNER: + return "owner"; + case PERM_ADMIN: + return "admin"; + } + return "unknown"; +} + +const char *perm_scope(enum cmd_perm p) +{ + switch (p) { + case PERM_PUBLIC: + return NULL; + case PERM_READ: + return "read"; + case PERM_WRITE: + return "write"; + case PERM_OWNER: + case PERM_ADMIN: + return "admin"; + } + return NULL; +} |
