#include "commands.h" #include #include #include #include #include #include #include #include #include "audit.h" #include "sha256.h" #include "auth.h" #include "config.h" #include "db.h" #include "formula.h" #include "invoice.h" #include "ledger.h" #include "log.h" #include "reports.h" #include "secret.h" #include "seed.h" #include "sie.h" #include "sru.h" #include "util.h" #include "version.h" /* src/secret.c is not part of CORE_SRC; compile it as part of this unit. */ #include "secret.c" /* ------------------------------------------------------------------ */ /* small helpers */ /* ------------------------------------------------------------------ */ static const char *sq(const unsigned char *p) { return p ? (const char *)p : ""; } static const char *arg_str(yyjson_val *args, const char *key) { if (!args || !yyjson_is_obj(args)) return NULL; yyjson_val *v = yyjson_obj_get(args, key); return v && yyjson_is_str(v) ? yyjson_get_str(v) : NULL; } static int arg_int(yyjson_val *args, const char *key, int64_t *out) { if (!args || !yyjson_is_obj(args)) return 0; yyjson_val *v = yyjson_obj_get(args, key); if (!v || !yyjson_is_int(v)) return 0; *out = yyjson_get_int(v); return 1; } static int arg_bool(yyjson_val *args, const char *key, int *out) { if (!args || !yyjson_is_obj(args)) return 0; yyjson_val *v = yyjson_obj_get(args, key); if (!v || !yyjson_is_bool(v)) return 0; *out = yyjson_get_bool(v) ? 1 : 0; return 1; } static const char *arg_type_name(enum arg_type type) { switch (type) { case ARG_STR: return "string"; case ARG_INT: return "int"; case ARG_BOOL: return "bool"; case ARG_ENUM: return "enum"; case ARG_DATE: return "date"; case ARG_JSON: return "json"; } return "json"; } static int enum_allowed(const char *values, const char *v) { size_t n = strlen(v); const char *p = values; while (p && *p) { const char *comma = strchr(p, ','); size_t len = comma ? (size_t)(comma - p) : strlen(p); if (len == n && strncmp(p, v, n) == 0) return 1; if (!comma) break; p = comma + 1; } return 0; } static void arg_error(char *err, size_t errlen, const char *fmt, ...) __attribute__((format(printf, 3, 4))); static void arg_error(char *err, size_t errlen, const char *fmt, ...) { if (!err || errlen == 0) return; va_list ap; va_start(ap, fmt); vsnprintf(err, errlen, fmt, ap); va_end(ap); } int command_validate_args(const struct command *cmd, yyjson_val *args, char *err, size_t errlen) { if (!cmd || !cmd->args) return 0; if (err && errlen) err[0] = '\0'; for (size_t i = 0; i < cmd->nargs; i++) { const struct cmd_arg *a = &cmd->args[i]; yyjson_val *v = args && yyjson_is_obj(args) ? yyjson_obj_get(args, a->name) : NULL; if (!v || yyjson_is_null(v)) { if (a->required) { arg_error(err, errlen, "missing required argument \"%s\"", a->name); return -1; } continue; } int bad = 0, empty = 0; switch (a->type) { case ARG_STR: bad = !yyjson_is_str(v); empty = !bad && a->required && !*yyjson_get_str(v); break; case ARG_INT: bad = !yyjson_is_int(v); break; case ARG_BOOL: bad = !yyjson_is_bool(v); break; case ARG_ENUM: bad = !yyjson_is_str(v) || !enum_allowed(a->values, yyjson_get_str(v)); empty = !bad && a->required && !*yyjson_get_str(v); break; case ARG_DATE: bad = !yyjson_is_str(v) || !util_parse_iso_date(yyjson_get_str(v)); break; case ARG_JSON: break; } if (empty) { arg_error(err, errlen, "missing required argument \"%s\"", a->name); return -1; } if (bad) { switch (a->type) { case ARG_STR: arg_error(err, errlen, "argument \"%s\" must be a string", a->name); break; case ARG_INT: arg_error(err, errlen, "argument \"%s\" must be an integer", a->name); break; case ARG_BOOL: arg_error(err, errlen, "argument \"%s\" must be a boolean", a->name); break; case ARG_ENUM: arg_error(err, errlen, "argument \"%s\" must be one of: %s", a->name, a->values ? a->values : ""); break; case ARG_DATE: arg_error(err, errlen, "argument \"%s\" must be YYYY-MM-DD", a->name); break; case ARG_JSON: break; } return -1; } } return 0; } static yyjson_mut_val *fail(struct req *r, const char *code, const char *msg) { r->err_code = code; snprintf(r->err_msg, sizeof r->err_msg, "%s", msg); return NULL; } static yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...) __attribute__((format(printf, 3, 4))); static yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...) { r->err_code = code; va_list ap; va_start(ap, fmt); vsnprintf(r->err_msg, sizeof r->err_msg, fmt, ap); va_end(ap); return NULL; } static int mkdir_p(const char *path, mode_t mode) { char tmp[4096]; if (!path || strlen(path) >= sizeof tmp) return -1; strcpy(tmp, path); for (char *p = tmp + 1; *p; p++) { if (*p == '/') { *p = '\0'; if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; *p = '/'; } } if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; return 0; } /* ------------------------------------------------------------------ */ /* login rate limiting (in-memory, per key) */ /* ------------------------------------------------------------------ */ #define RL_MAX_KEYS 16 #define RL_MAX_FAILS 5 #define RL_WINDOW 900 struct rl_entry { char key[64]; int fails; int64_t window_end; }; static struct rl_entry g_rl[RL_MAX_KEYS]; static struct rl_entry *rl_get(const char *key, int create) { struct rl_entry *slot = NULL; for (size_t i = 0; i < RL_MAX_KEYS; i++) { if (g_rl[i].key[0] && strcmp(g_rl[i].key, key) == 0) return &g_rl[i]; if (create && !g_rl[i].key[0] && !slot) slot = &g_rl[i]; } if (create && slot) { snprintf(slot->key, sizeof slot->key, "%s", key); slot->fails = 0; slot->window_end = 0; } return slot; } static int rl_blocked(const char *key, int64_t *retry_after) { struct rl_entry *e = rl_get(key, 0); if (!e || e->fails < RL_MAX_FAILS) return 0; int64_t now = util_now(); if (e->window_end <= now) return 0; if (retry_after) *retry_after = e->window_end - now; return 1; } static void rl_fail(const char *key) { struct rl_entry *e = rl_get(key, 1); if (!e) return; int64_t now = util_now(); if (e->fails == 0 || e->window_end <= now) e->window_end = now + RL_WINDOW; e->fails++; } static void rl_ok(const char *key) { struct rl_entry *e = rl_get(key, 0); if (e) { e->fails = 0; e->window_end = 0; } } /* ------------------------------------------------------------------ */ /* public commands */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *h_health(struct req *r) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "ok"); return o; } static yyjson_mut_val *h_meta(struct req *r) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "server", "bokfd"); yyjson_mut_obj_add_strcpy(r->rdoc, o, "version", BOKF_VERSION); yyjson_mut_obj_add_int(r->rdoc, o, "protocol", BOKF_PROTOCOL_VERSION); yyjson_mut_val *features = yyjson_mut_arr(r->rdoc); yyjson_mut_arr_add_strcpy(r->rdoc, features, "describe"); yyjson_mut_arr_add_strcpy(r->rdoc, features, "agent.instructions"); yyjson_mut_arr_add_strcpy(r->rdoc, features, "orgs"); yyjson_mut_arr_add_strcpy(r->rdoc, features, "tokens"); yyjson_mut_arr_add_strcpy(r->rdoc, features, "backup"); yyjson_mut_obj_add_val(r->rdoc, o, "features", features); yyjson_mut_val *limits = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, limits, "max_line_bytes", g_cfg.max_line_bytes); yyjson_mut_obj_add_int(r->rdoc, limits, "max_attachment_bytes", g_cfg.max_attachment_bytes); yyjson_mut_obj_add_int(r->rdoc, limits, "session_ttl_seconds", g_cfg.session_ttl); yyjson_mut_obj_add_val(r->rdoc, o, "limits", limits); yyjson_mut_obj_add_bool(r->rdoc, o, "tcp_enabled", g_cfg.tcp_enabled != 0); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); yyjson_mut_obj_add_strcpy(r->rdoc, o, "time", ts); return o; } static yyjson_mut_val *orgs_for_user(sqlite3 *db, yyjson_mut_doc *doc, int64_t user_id, int64_t only_org, int64_t *first_org) { yyjson_mut_val *arr = yyjson_mut_arr(doc); const char *sql = only_org ? "SELECT o.id,o.name,m.role FROM memberships m" " JOIN orgs o ON o.id=m.org_id" " WHERE m.user_id=?1 AND o.id=?2 ORDER BY o.id" : "SELECT o.id,o.name,m.role FROM memberships m" " JOIN orgs o ON o.id=m.org_id" " WHERE m.user_id=?1 ORDER BY o.id"; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return arr; sqlite3_bind_int64(st, 1, user_id); if (only_org) sqlite3_bind_int64(st, 2, only_org); while (sqlite3_step(st) == SQLITE_ROW) { int64_t id = sqlite3_column_int64(st, 0); yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr); yyjson_mut_obj_add_int(doc, o, "id", id); yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(doc, o, "role", sq(sqlite3_column_text(st, 2))); if (first_org && *first_org == 0) *first_org = id; } sqlite3_finalize(st); return arr; } static yyjson_mut_val *session_payload(struct req *r, struct session *s, int64_t user_id, const char *username, const char *display, yyjson_mut_val *orgs, int64_t active_org) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "session", s->id); yyjson_mut_val *u = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, u, "id", user_id); yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : ""); yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name", display ? display : ""); yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", s->is_admin != 0); yyjson_mut_obj_add_val(r->rdoc, o, "user", u); yyjson_mut_obj_add_val(r->rdoc, o, "orgs", orgs); if (active_org) yyjson_mut_obj_add_int(r->rdoc, o, "active_org", active_org); else yyjson_mut_obj_add_null(r->rdoc, o, "active_org"); return o; } static yyjson_mut_val *h_session_open(struct req *r) { const char *method = arg_str(r->args, "method"); if (!method) return fail(r, "INVALID_ARGS", "method is required"); char *reqjson = audit_args_json(r->args); if (strcmp(method, "password") == 0) { const char *username = arg_str(r->args, "username"); const char *password = arg_str(r->args, "password"); if (!username || !password) { free(reqjson); return fail(r, "INVALID_ARGS", "username and password are required"); } int64_t retry = 0; if (rl_blocked("local", &retry)) { free(reqjson); return failf(r, "RATE_LIMITED", "too many failed logins, retry in %lld seconds", (long long)retry); } char *pwhash = NULL, *display = NULL; int is_admin = 0, disabled = 0; int64_t uid = auth_user_lookup(r->db, username, &pwhash, &display, &is_admin, &disabled); int ok = uid > 0 && !disabled && pwhash && auth_verify_password(pwhash, password) == 0; free(pwhash); if (!ok) { rl_fail("local"); audit_append(r->db, 0, uid > 0 ? uid : 0, 0, "auth.fail", reqjson, "AUTH_FAILED", NULL); free(display); free(reqjson); return fail(r, "AUTH_FAILED", "invalid credentials"); } rl_ok("local"); int64_t active = 0; yyjson_mut_val *orgs = orgs_for_user(r->db, r->rdoc, uid, 0, &active); struct session *s = sessions_create(uid, is_admin, active, 0, "read,write,admin"); audit_append(r->db, active, uid, 0, "auth.open", reqjson, "OK", NULL); yyjson_mut_val *out = session_payload(r, s, uid, username, display, orgs, active); free(display); free(reqjson); return out; } if (strcmp(method, "token") == 0) { const char *token = arg_str(r->args, "token"); if (!token) { free(reqjson); return fail(r, "INVALID_ARGS", "token is required"); } unsigned char th[32]; auth_hash_token(token, th); sqlite3_stmt *st = NULL; int rc = sqlite3_prepare_v2( r->db, "SELECT t.id,t.user_id,t.org_id,t.scopes,t.expires_at,t.revoked_at," " t.label,u.username,u.display_name,u.is_admin,u.disabled_at" " FROM api_tokens t JOIN users u ON u.id=t.user_id" " WHERE t.token_hash=?1", -1, &st, NULL); if (rc != SQLITE_OK) { free(reqjson); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_blob(st, 1, th, 32, SQLITE_TRANSIENT); int found = sqlite3_step(st) == SQLITE_ROW; int64_t token_id = 0, uid = 0, org_id = 0; char *scopes = NULL, *expires = NULL, *revoked = NULL; char *username = NULL, *display = NULL; int is_admin = 0, disabled = 0; if (found) { token_id = sqlite3_column_int64(st, 0); uid = sqlite3_column_int64(st, 1); org_id = sqlite3_column_int64(st, 2); scopes = xstrdup(sq(sqlite3_column_text(st, 3))); if (sqlite3_column_type(st, 4) != SQLITE_NULL) expires = xstrdup(sq(sqlite3_column_text(st, 4))); if (sqlite3_column_type(st, 5) != SQLITE_NULL) revoked = xstrdup(sq(sqlite3_column_text(st, 5))); username = xstrdup(sq(sqlite3_column_text(st, 7))); display = xstrdup(sq(sqlite3_column_text(st, 8))); is_admin = sqlite3_column_int(st, 9); disabled = sqlite3_column_type(st, 10) != SQLITE_NULL; } sqlite3_finalize(st); int expired = 0; if (expires) { char today[16]; time_t t = (time_t)util_now(); struct tm tm; gmtime_r(&t, &tm); strftime(today, sizeof today, "%Y-%m-%d", &tm); expired = strcmp(expires, today) < 0; } if (!found || revoked || disabled || expired) { audit_append(r->db, 0, uid, 0, "auth.fail", reqjson, "AUTH_FAILED", NULL); free(scopes); free(expires); free(revoked); free(username); free(display); free(reqjson); return fail(r, "AUTH_FAILED", "invalid token"); } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *up = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE api_tokens SET last_used_at=?1 WHERE id=?2", -1, &up, NULL) == SQLITE_OK) { sqlite3_bind_text(up, 1, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(up, 2, token_id); sqlite3_step(up); sqlite3_finalize(up); } int64_t active = org_id; yyjson_mut_val *orgs = orgs_for_user(r->db, r->rdoc, uid, org_id, &active); struct session *s = sessions_create(uid, is_admin, org_id, org_id, scopes ? scopes : "read"); s->active_org = org_id; s->token_id = token_id; audit_append(r->db, org_id, uid, token_id, "auth.open", reqjson, "OK", NULL); yyjson_mut_val *out = session_payload(r, s, uid, username, display, orgs, org_id); free(scopes); free(expires); free(revoked); free(username); free(display); free(reqjson); return out; } free(reqjson); return fail(r, "UNSUPPORTED", "unsupported auth method"); } static yyjson_mut_val *h_session_close(struct req *r) { char *reqjson = audit_args_json(r->args); audit_append(r->db, r->sess->active_org, r->sess->user_id, 0, "session.close", reqjson, "OK", NULL); free(reqjson); sessions_destroy(r->sess->id); return yyjson_mut_obj(r->rdoc); } static yyjson_mut_val *h_session_whoami(struct req *r) { char *display = NULL, *username = NULL; int is_admin = 0; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT username,display_name,is_admin FROM users" " WHERE id=?1", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->sess->user_id); if (sqlite3_step(st) == SQLITE_ROW) { username = xstrdup(sq(sqlite3_column_text(st, 0))); display = xstrdup(sq(sqlite3_column_text(st, 1))); is_admin = sqlite3_column_int(st, 2); } sqlite3_finalize(st); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_val *u = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, u, "id", r->sess->user_id); yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : ""); yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name", display ? display : ""); yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", is_admin != 0); yyjson_mut_obj_add_val(r->rdoc, o, "user", u); yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", r->sess->scopes); if (r->sess->active_org) { yyjson_mut_obj_add_int(r->rdoc, o, "active_org", r->sess->active_org); char *role = db_membership_role(r->db, r->sess->active_org, r->sess->user_id); if (role) yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role); else yyjson_mut_obj_add_null(r->rdoc, o, "role"); free(role); } else { yyjson_mut_obj_add_null(r->rdoc, o, "active_org"); yyjson_mut_obj_add_null(r->rdoc, o, "role"); } free(username); free(display); return o; } static yyjson_mut_val *h_session_list_orgs(struct req *r) { int64_t first = 0; yyjson_mut_val *orgs = orgs_for_user(r->db, r->rdoc, r->sess->user_id, 0, &first); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, o, "items", orgs); return o; } static yyjson_mut_val *h_session_use_org(struct req *r) { int64_t org = 0; if (!arg_int(r->args, "org", &org) || org <= 0) return fail(r, "INVALID_ARGS", "org is required"); if (r->sess->bound_org && org != r->sess->bound_org) return fail(r, "ORG_FORBIDDEN", "token is bound to another org"); char *role = db_membership_role(r->db, org, r->sess->user_id); if (!role) return fail(r, "ORG_FORBIDDEN", "not a member of this org"); r->sess->active_org = org; yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "active_org", org); yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role); free(role); return o; } /* ------------------------------------------------------------------ */ /* orgs */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *org_json(struct req *r, sqlite3_stmt *st) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", sq(sqlite3_column_text(st, 6))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", sq(sqlite3_column_text(st, 7))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", sq(sqlite3_column_text(st, 8))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "phone", sq(sqlite3_column_text(st, 9))); yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month", sqlite3_column_int(st, 10)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period", sq(sqlite3_column_text(st, 11))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework", sq(sqlite3_column_text(st, 12))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 13))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", sq(sqlite3_column_text(st, 14))); yyjson_mut_obj_add_int(r->rdoc, o, "shares", sqlite3_column_int64(st, 15)); return o; } #define ORG_COLUMNS \ "id,name,org_nr,vat_nr,address,postal_code,city,country,email," \ "phone,fiscal_year_start_month,moms_period,framework,created_at," \ "description,shares" static yyjson_mut_val *h_org_create(struct req *r) { if (!g_cfg.allow_org_create && !r->is_admin) return fail(r, "FORBIDDEN", "org creation is disabled"); const char *name = arg_str(r->args, "name"); if (!name || !*name) return fail(r, "INVALID_ARGS", "name is required"); const char *org_nr = arg_str(r->args, "org_nr"); int64_t fy_month = 1; arg_int(r->args, "fiscal_year_start_month", &fy_month); if (fy_month < 1 || fy_month > 12) return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12"); const char *moms = arg_str(r->args, "moms_period"); if (!moms) moms = "month"; if (strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 && strcmp(moms, "year") != 0) return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year"); const char *framework = arg_str(r->args, "framework"); if (!framework) framework = "K2"; if (strcmp(framework, "K2") != 0 && strcmp(framework, "K3") != 0) return fail(r, "INVALID_ARGS", "framework must be K2 or K3"); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) return fail(r, "DB_BUSY", "could not start transaction"); sqlite3_stmt *st = NULL; int rc = sqlite3_prepare_v2( r->db, "INSERT INTO orgs(name,org_nr,fiscal_year_start_month,moms_period," "framework,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7)", -1, &st, NULL); if (rc != SQLITE_OK) { db_exec(r->db, "ROLLBACK", NULL); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT); if (org_nr) sqlite3_bind_text(st, 2, org_nr, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 2); sqlite3_bind_int64(st, 3, fy_month); sqlite3_bind_text(st, 4, moms, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, framework, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 7, r->sess->user_id); rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { db_exec(r->db, "ROLLBACK", NULL); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } int64_t org_id = db_last_id(r->db); rc = sqlite3_prepare_v2( r->db, "INSERT INTO memberships(org_id,user_id,role,created_at)" " VALUES(?1,?2,'owner',?3)", -1, &st, NULL); if (rc != SQLITE_OK) { db_exec(r->db, "ROLLBACK", NULL); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } sqlite3_bind_int64(st, 1, org_id); sqlite3_bind_int64(st, 2, r->sess->user_id); sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT); rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { db_exec(r->db, "ROLLBACK", NULL); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } int64_t fy_id = 0; char *seed_err = NULL; if (seed_org(r->db, org_id, framework, (int)fy_month, &fy_id, &seed_err) != 0) { db_exec(r->db, "ROLLBACK", NULL); yyjson_mut_val *res = fail(r, "INTERNAL", seed_err ? seed_err : "seeding failed"); free(seed_err); return res; } if (db_exec(r->db, "COMMIT", NULL) != 0) return fail(r, "INTERNAL", "commit failed"); char *reqjson = audit_args_json(r->args); audit_append(r->db, org_id, r->sess->user_id, r->sess->token_id, "org.create", reqjson, "OK", NULL); free(reqjson); r->sess->active_org = org_id; st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, org_id); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = org_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "INTERNAL", "created org not found"); yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy_id); return o; } static yyjson_mut_val *h_org_list(struct req *r) { yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT o.id,o.name,o.org_nr,m.role FROM memberships m" " JOIN orgs o ON o.id=m.org_id WHERE m.user_id=?1 ORDER BY o.id", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->sess->user_id); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", sq(sqlite3_column_text(st, 3))); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); return out; } static yyjson_mut_val *h_org_get(struct req *r) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = org_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "org not found"); return o; } static yyjson_mut_val *h_org_update(struct req *r) { const char *name = arg_str(r->args, "name"); const char *org_nr = arg_str(r->args, "org_nr"); const char *vat_nr = arg_str(r->args, "vat_nr"); const char *address = arg_str(r->args, "address"); const char *postal = arg_str(r->args, "postal_code"); const char *city = arg_str(r->args, "city"); const char *country = arg_str(r->args, "country"); const char *email = arg_str(r->args, "email"); const char *phone = arg_str(r->args, "phone"); const char *moms = arg_str(r->args, "moms_period"); const char *framework = arg_str(r->args, "framework"); const char *description = arg_str(r->args, "description"); int64_t fy_month = 0; arg_int(r->args, "fiscal_year_start_month", &fy_month); int64_t shares = -1; arg_int(r->args, "shares", &shares); if (!name && !org_nr && !vat_nr && !address && !postal && !city && !country && !email && !phone && !moms && !framework && fy_month == 0 && !description && shares < 0) return fail(r, "INVALID_ARGS", "nothing to update"); if (name && !*name) return fail(r, "INVALID_ARGS", "name cannot be empty"); if (fy_month && (fy_month < 1 || fy_month > 12)) return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12"); if (shares < -1) return fail(r, "INVALID_ARGS", "shares must be >= 0"); if (moms && strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 && strcmp(moms, "year") != 0) return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year"); if (framework && strcmp(framework, "K2") != 0 && strcmp(framework, "K3") != 0) return fail(r, "INVALID_ARGS", "framework must be K2 or K3"); static const char *const keys[10] = { "name", "org_nr", "vat_nr", "address", "postal_code", "city", "country", "email", "phone", "description" }; const char *texts[10] = { name, org_nr, vat_nr, address, postal, city, country, email, phone, description }; if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); for (int i = 0; i < 10; i++) if (texts[i]) yyjson_mut_obj_add_strcpy(r->rdoc, o, keys[i], texts[i]); if (fy_month) yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month", fy_month); if (moms) yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period", moms); if (framework) yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework", framework); if (shares >= 0) yyjson_mut_obj_add_int(r->rdoc, o, "shares", shares); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE orgs SET" " name=COALESCE(?2,name), org_nr=COALESCE(?3,org_nr)," " vat_nr=COALESCE(?4,vat_nr), address=COALESCE(?5,address)," " postal_code=COALESCE(?6,postal_code), city=COALESCE(?7,city)," " country=COALESCE(?8,country), email=COALESCE(?9,email)," " phone=COALESCE(?10,phone), description=COALESCE(?11," " description)," " fiscal_year_start_month=CASE WHEN ?12=0" " THEN fiscal_year_start_month ELSE ?12 END," " moms_period=COALESCE(?13,moms_period)," " framework=COALESCE(?14,framework)," " shares=CASE WHEN ?15<0 THEN shares ELSE ?15 END" " WHERE id=?1", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); for (int i = 0; i < 10; i++) { if (texts[i]) sqlite3_bind_text(st, i + 2, texts[i], -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, i + 2); } sqlite3_bind_int64(st, 12, fy_month); if (moms) sqlite3_bind_text(st, 13, moms, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 13); if (framework) sqlite3_bind_text(st, 14, framework, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 14); sqlite3_bind_int64(st, 15, shares); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "org.update", reqjson, "OK", NULL); free(reqjson); st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = org_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "org not found"); return o; } static yyjson_mut_val *h_org_member_list(struct req *r) { yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT u.id,u.username,u.display_name,m.role FROM memberships m" " JOIN users u ON u.id=m.user_id WHERE m.org_id=?1 ORDER BY u.id", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "user_id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", sq(sqlite3_column_text(st, 3))); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); return out; } static int64_t username_to_id(sqlite3 *db, const char *username) { sqlite3_stmt *st = NULL; int64_t id = -1; if (sqlite3_prepare_v2(db, "SELECT id FROM users WHERE username=?1", -1, &st, NULL) != SQLITE_OK) return -1; sqlite3_bind_text(st, 1, username, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) == SQLITE_ROW) id = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return id; } static int64_t owner_count(sqlite3 *db, int64_t org_id) { sqlite3_stmt *st = NULL; int64_t n = -1; if (sqlite3_prepare_v2( db, "SELECT count(*) FROM memberships WHERE org_id=?1 AND role='owner'", -1, &st, NULL) != SQLITE_OK) return -1; sqlite3_bind_int64(st, 1, org_id); if (sqlite3_step(st) == SQLITE_ROW) n = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return n; } static int valid_role(const char *role) { return role && (strcmp(role, "owner") == 0 || strcmp(role, "bookkeeper") == 0 || strcmp(role, "viewer") == 0); } static yyjson_mut_val *h_org_member_add(struct req *r) { const char *username = arg_str(r->args, "username"); const char *role = arg_str(r->args, "role"); if (!username || !valid_role(role)) return fail(r, "INVALID_ARGS", "username and a valid role are required"); int64_t uid = username_to_id(r->db, username); if (uid < 0) return fail(r, "NOT_FOUND", "user not found"); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO memberships(org_id,user_id,role,created_at)" " VALUES(?1,?2,?3,?4)", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, uid); sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "CONFLICT", "user is already a member"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.add", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } static yyjson_mut_val *h_org_member_set_role(struct req *r) { const char *username = arg_str(r->args, "username"); const char *role = arg_str(r->args, "role"); if (!username || !valid_role(role)) return fail(r, "INVALID_ARGS", "username and a valid role are required"); int64_t uid = username_to_id(r->db, username); if (uid < 0) return fail(r, "NOT_FOUND", "user not found"); char *current = db_membership_role(r->db, r->org_id, uid); if (!current) return fail(r, "NOT_FOUND", "user is not a member"); if (strcmp(current, "owner") == 0 && strcmp(role, "owner") != 0 && owner_count(r->db, r->org_id) <= 1) { free(current); return fail(r, "CONFLICT", "cannot demote the last owner"); } free(current); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE memberships SET role=?1 WHERE org_id=?2 AND user_id=?3", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_text(st, 1, role, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 2, r->org_id); sqlite3_bind_int64(st, 3, uid); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.set_role", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } static yyjson_mut_val *h_org_member_remove(struct req *r) { const char *username = arg_str(r->args, "username"); if (!username) return fail(r, "INVALID_ARGS", "username is required"); int64_t uid = username_to_id(r->db, username); if (uid < 0) return fail(r, "NOT_FOUND", "user not found"); char *current = db_membership_role(r->db, r->org_id, uid); if (!current) return fail(r, "NOT_FOUND", "user is not a member"); if (strcmp(current, "owner") == 0 && owner_count(r->db, r->org_id) <= 1) { free(current); return fail(r, "CONFLICT", "cannot remove the last owner"); } free(current); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "DELETE FROM memberships WHERE org_id=?1 AND user_id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, uid); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.remove", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } /* ------------------------------------------------------------------ */ /* board members (årsredovisning signatures) */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *board_json(struct req *r, sqlite3_stmt *st) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", sq(sqlite3_column_text(st, 2))); return o; } static yyjson_mut_val *h_board_list(struct req *r) { yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT id,name,title FROM board_members" " WHERE org_id=?1 ORDER BY id", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); while (sqlite3_step(st) == SQLITE_ROW) yyjson_mut_arr_add_val(items, board_json(r, st)); sqlite3_finalize(st); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, o, "items", items); return o; } static yyjson_mut_val *board_get(struct req *r, int64_t id) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT id,name,title FROM board_members" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = board_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "board member not found"); return o; } static yyjson_mut_val *h_board_add(struct req *r) { const char *name = arg_str(r->args, "name"); const char *title = arg_str(r->args, "title"); if (!name || !*name) return fail(r, "INVALID_ARGS", "name is required"); if (!title || !*title) title = "Styrelseledamot"; if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title); return o; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "INSERT INTO board_members(org_id,name,title," "created_at) VALUES(?1,?2,?3,?4)", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); int64_t id = db_last_id(r->db); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "board.add", reqjson, "OK", NULL); free(reqjson); return board_get(r, id); } static yyjson_mut_val *h_board_update(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); const char *name = arg_str(r->args, "name"); const char *title = arg_str(r->args, "title"); if (!name && !title) return fail(r, "INVALID_ARGS", "nothing to update"); if (name && !*name) return fail(r, "INVALID_ARGS", "name cannot be empty"); if (title && !*title) return fail(r, "INVALID_ARGS", "title cannot be empty"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); if (name) yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); if (title) yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "UPDATE board_members SET name=COALESCE(?3,name)," " title=COALESCE(?4,title)" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); if (name) sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 3); if (title) sqlite3_bind_text(st, 4, title, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 4); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "board member not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "board.update", reqjson, "OK", NULL); free(reqjson); return board_get(r, id); } static yyjson_mut_val *h_board_remove(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "DELETE FROM board_members" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "board member not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "board.remove", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } /* ------------------------------------------------------------------ */ /* users and tokens */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *h_user_create(struct req *r) { const char *username = arg_str(r->args, "username"); const char *password = arg_str(r->args, "password"); const char *display = arg_str(r->args, "display_name"); if (!username || !password) return fail(r, "INVALID_ARGS", "username and password are required"); int is_admin = 0; arg_bool(r->args, "is_admin", &is_admin); char *err = NULL; int64_t uid = 0; if (db_create_user(r->db, username, display, password, is_admin, &uid, &err) != 0) { const char *code = err && strstr(err, "already exists") ? "CONFLICT" : "INTERNAL"; yyjson_mut_val *res = fail(r, code, err ? err : "could not create user"); free(err); return res; } free(err); char *reqjson = audit_args_json(r->args); audit_append(r->db, 0, r->sess->user_id, 0, "user.create", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", uid); yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", username); yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", display ? display : username); yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", is_admin != 0); return o; } static yyjson_mut_val *h_user_list(struct req *r) { yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id,username,display_name,is_admin,created_at,disabled_at" " FROM users ORDER BY id", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", sqlite3_column_int(st, 3) != 0); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 4))); if (sqlite3_column_type(st, 5) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "disabled_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "disabled_at", sq(sqlite3_column_text(st, 5))); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); return out; } static int scope_allowed(const char *role, const char *scope) { int is_owner = strcmp(role, "owner") == 0; int is_bookkeeper = is_owner || strcmp(role, "bookkeeper") == 0; if (strcmp(scope, "read") == 0) return 1; if (strcmp(scope, "write") == 0) return is_bookkeeper; if (strcmp(scope, "admin") == 0) return is_owner; return 0; } static yyjson_mut_val *h_token_create(struct req *r) { const char *label = arg_str(r->args, "label"); if (!label || !*label) return fail(r, "INVALID_ARGS", "label is required"); char scopes[64] = ""; yyjson_val *sv = r->args && yyjson_is_obj(r->args) ? yyjson_obj_get(r->args, "scopes") : NULL; if (sv) { if (!yyjson_is_arr(sv)) return fail(r, "INVALID_ARGS", "scopes must be an array"); yyjson_val *item; yyjson_arr_iter it = yyjson_arr_iter_with(sv); while ((item = yyjson_arr_iter_next(&it))) { const char *s = yyjson_get_str(item); if (!s || !scope_allowed(r->role, s)) return fail(r, "FORBIDDEN", "scope not allowed for your role"); if (scopes[0]) strncat(scopes, ",", sizeof scopes - strlen(scopes) - 1); strncat(scopes, s, sizeof scopes - strlen(scopes) - 1); } } if (!scopes[0]) snprintf(scopes, sizeof scopes, "%s", strcmp(r->role, "owner") == 0 ? "read,write,admin" : (strcmp(r->role, "bookkeeper") == 0 ? "read,write" : "read")); const char *expires_at = arg_str(r->args, "expires_at"); if (expires_at && !util_parse_iso_date(expires_at)) return fail(r, "INVALID_ARGS", "expires_at must be YYYY-MM-DD"); char *token = auth_generate_token(); unsigned char th[32]; auth_hash_token(token, th); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO api_tokens(org_id,user_id,label,token_hash,scopes," "created_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7)", -1, &st, NULL) != SQLITE_OK) { free(token); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, r->sess->user_id); sqlite3_bind_text(st, 3, label, -1, SQLITE_TRANSIENT); sqlite3_bind_blob(st, 4, th, 32, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, scopes, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); if (expires_at) sqlite3_bind_text(st, 7, expires_at, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 7); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { free(token); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } int64_t token_id = db_last_id(r->db); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, token_id, "token.create", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", token_id); yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label); yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", scopes); yyjson_mut_obj_add_strcpy(r->rdoc, o, "token", token); if (expires_at) yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", expires_at); else yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); free(token); return o; } static yyjson_mut_val *h_token_list(struct req *r) { yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id,label,scopes,created_at,expires_at,last_used_at," "revoked_at FROM api_tokens WHERE org_id=?1 AND user_id=?2" " ORDER BY id", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, r->sess->user_id); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 3))); if (sqlite3_column_type(st, 4) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "expires_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", sq(sqlite3_column_text(st, 4))); if (sqlite3_column_type(st, 5) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "last_used_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_used_at", sq(sqlite3_column_text(st, 5))); if (sqlite3_column_type(st, 6) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "revoked_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "revoked_at", sq(sqlite3_column_text(st, 6))); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); return out; } static yyjson_mut_val *h_token_revoke(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); int is_owner = strcmp(r->role, "owner") == 0; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, is_owner ? "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" : "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3" " AND user_id=?4", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 2, id); sqlite3_bind_int64(st, 3, r->org_id); if (!is_owner) sqlite3_bind_int64(st, 4, r->sess->user_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "token not found"); audit_append(r->db, r->org_id, r->sess->user_id, id, "token.revoke", "{}", "OK", NULL); return yyjson_mut_obj(r->rdoc); } /* ------------------------------------------------------------------ */ /* discovery */ /* ------------------------------------------------------------------ */ 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); yyjson_mut_val *args = yyjson_mut_arr(r->rdoc); for (size_t i = 0; i < c->nargs; i++) { const struct cmd_arg *a = &c->args[i]; yyjson_mut_val *ao = yyjson_mut_arr_add_obj(r->rdoc, args); yyjson_mut_obj_add_strcpy(r->rdoc, ao, "name", a->name); yyjson_mut_obj_add_strcpy(r->rdoc, ao, "type", arg_type_name(a->type)); yyjson_mut_obj_add_bool(r->rdoc, ao, "required", a->required != 0); if (a->def) yyjson_mut_obj_add_strcpy(r->rdoc, ao, "default", a->def); if (a->values) { yyjson_mut_val *vals = yyjson_mut_arr(r->rdoc); const char *p = a->values; while (*p) { const char *comma = strchr(p, ','); size_t len = comma ? (size_t)(comma - p) : strlen(p); yyjson_mut_arr_add_strn(r->rdoc, vals, p, len); if (!comma) break; p = comma + 1; } yyjson_mut_obj_add_val(r->rdoc, ao, "values", vals); } if (a->desc) yyjson_mut_obj_add_strcpy(r->rdoc, ao, "description", a->desc); } yyjson_mut_obj_add_val(r->rdoc, o, "args", args); 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" "- Bank: `bank.import` stores a SEB CSV statement as read-only evidence and\n" " `bank.list` suggests already-posted vouchers to match with `bank.match`;\n" " reconciliation never posts vouchers by itself.\n" "- Fakturering: `customer.create` keeps the customer register; `invoice.preview`\n" " renders a draft without consuming a number, and `invoice.issue` takes the\n" " next number, stores the PDF and posts the voucher in one transaction.\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 int verify_attachments(sqlite3 *db, int64_t *checked, int64_t *bad_id, char **err) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(db, "SELECT id,sha256,size_bytes,content FROM attachments" " ORDER BY id", -1, &st, NULL) != SQLITE_OK) { if (err && !*err) *err = xstrdup(sqlite3_errmsg(db)); return -1; } int ret = 0; for (;;) { int step = sqlite3_step(st); if (step == SQLITE_DONE) break; if (step != SQLITE_ROW) { if (err && !*err) *err = xstrdup(sqlite3_errmsg(db)); ret = -1; break; } int64_t id = sqlite3_column_int64(st, 0); const void *sha = sqlite3_column_blob(st, 1); int sha_n = sqlite3_column_bytes(st, 1); int64_t size = sqlite3_column_int64(st, 2); const void *content = sqlite3_column_blob(st, 3); int content_n = sqlite3_column_bytes(st, 3); unsigned char digest[32]; util_sha256(content, (size_t)content_n, digest); (*checked)++; if (sha_n != 32 || memcmp(sha, digest, 32) != 0 || size != content_n) { if (!*bad_id) *bad_id = id; ret = 1; } } sqlite3_finalize(st); return ret; } static yyjson_mut_val *h_audit_verify(struct req *r) { int full = 0; arg_bool(r->args, "full", &full); int64_t audit_checked = 0, audit_bad = 0; char *err = NULL; int audit_rc = audit_verify(r->db, &audit_checked, &audit_bad, &err); if (audit_rc < 0) { yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); free(err); return res; } free(err); err = NULL; struct ledger_verify_result lv; int ledger_rc = ledger_verify(r->db, &lv, &err); if (ledger_rc < 0) { yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); free(err); return res; } free(err); err = NULL; int64_t att_checked = 0, att_bad = 0; int att_rc = 0; if (full) { att_rc = verify_attachments(r->db, &att_checked, &att_bad, &err); if (att_rc < 0) { yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); free(err); return res; } free(err); } int ok = audit_rc == 0 && ledger_rc == 0 && att_rc == 0; yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "ok", ok); yyjson_mut_obj_add_int(r->rdoc, o, "checked", audit_checked); yyjson_mut_obj_add_int(r->rdoc, o, "vouchers_checked", lv.vouchers_checked); yyjson_mut_obj_add_int(r->rdoc, o, "unbalanced_vouchers", lv.unbalanced_vouchers); if (audit_bad) yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", audit_bad); else yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_seq"); if (lv.first_bad_voucher_id) yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_voucher_id", lv.first_bad_voucher_id); else yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_voucher_id"); if (lv.first_unbalanced_voucher_id) yyjson_mut_obj_add_int(r->rdoc, o, "first_unbalanced_voucher_id", lv.first_unbalanced_voucher_id); else yyjson_mut_obj_add_null(r->rdoc, o, "first_unbalanced_voucher_id"); if (full) { yyjson_mut_obj_add_int(r->rdoc, o, "attachments_checked", att_checked); if (att_bad) yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_attachment_id", att_bad); else yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_attachment_id"); } return o; } static yyjson_mut_val *h_backup_snapshot(struct req *r) { const char *dest = arg_str(r->args, "dest"); if (mkdir_p(g_cfg.backup_dir, 0700) != 0) return fail(r, "INTERNAL", "cannot create backup directory"); char path[4096]; if (dest && *dest) { snprintf(path, sizeof path, "%s", dest); } else { char stamp[32]; time_t t = (time_t)util_now(); struct tm tm; gmtime_r(&t, &tm); strftime(stamp, sizeof stamp, "%Y%m%dT%H%M%SZ", &tm); snprintf(path, sizeof path, "%s/bokfd-%s.db", g_cfg.backup_dir, stamp); } if (access(path, F_OK) == 0) return fail(r, "CONFLICT", "destination already exists"); char *sql = sqlite3_mprintf("VACUUM INTO %Q", path); int rc = db_exec(r->db, sql, NULL); sqlite3_free(sql); if (rc != 0) return fail(r, "INTERNAL", "VACUUM INTO failed"); struct stat sb; if (stat(path, &sb) != 0) return fail(r, "INTERNAL", "backup file not found after snapshot"); FILE *f = fopen(path, "rb"); if (!f) return fail(r, "INTERNAL", "cannot read backup file"); SHA256_CTX ctx; sha256_init(&ctx); unsigned char chunk[65536]; size_t rn; while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) sha256_update(&ctx, (const BYTE *)chunk, rn); fclose(f); unsigned char digest[32]; sha256_final(&ctx, digest); char hex[65]; util_hex(digest, 32, hex); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); audit_append(r->db, r->org_id, r->sess->user_id, 0, "backup.snapshot", "{}", "OK", NULL); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path); yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); yyjson_mut_obj_add_int(r->rdoc, o, "size", (int64_t)sb.st_size); yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts); return o; } /* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */ /* shared helpers for M2 commands */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *json_to_mut(yyjson_mut_doc *doc, const char *json) { if (!json) return NULL; yyjson_doc *d = yyjson_read(json, strlen(json), 0); if (!d) return NULL; yyjson_mut_val *v = yyjson_val_mut_copy(doc, yyjson_doc_get_root(d)); yyjson_doc_free(d); return v; } static int hash_file(const char *path, char hex[65], int64_t *size) { FILE *f = fopen(path, "rb"); if (!f) return -1; SHA256_CTX ctx; sha256_init(&ctx); unsigned char chunk[65536]; size_t rn; int64_t total = 0; while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) { sha256_update(&ctx, (const BYTE *)chunk, rn); total += (int64_t)rn; } int bad = ferror(f); fclose(f); if (bad) return -1; unsigned char digest[32]; sha256_final(&ctx, digest); util_hex(digest, 32, hex); if (size) *size = total; return 0; } static int is_digits(const char *s) { if (!s || !*s) return 0; for (; *s; s++) if (*s < '0' || *s > '9') return 0; return 1; } /* ------------------------------------------------------------------ */ /* kontoplan */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *account_json(yyjson_mut_doc *doc, sqlite3_stmt *st) { yyjson_mut_val *o = yyjson_mut_obj(doc); yyjson_mut_obj_add_int(doc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(doc, o, "number", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(doc, o, "type", sq(sqlite3_column_text(st, 3))); if (sqlite3_column_type(st, 4) == SQLITE_NULL) yyjson_mut_obj_add_null(doc, o, "sru_code"); else yyjson_mut_obj_add_strcpy(doc, o, "sru_code", sq(sqlite3_column_text(st, 4))); if (sqlite3_column_type(st, 5) == SQLITE_NULL) yyjson_mut_obj_add_null(doc, o, "vat_code"); else yyjson_mut_obj_add_strcpy(doc, o, "vat_code", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_bool(doc, o, "active", sqlite3_column_int(st, 6) != 0); return o; } #define ACCOUNT_COLUMNS "id,number,name,type,sru_code,vat_code,active" static yyjson_mut_val *h_account_list(struct req *r) { int active_only = 0; arg_bool(r->args, "active_only", &active_only); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; const char *sql = active_only ? "SELECT " ACCOUNT_COLUMNS " FROM accounts" " WHERE org_id=?1 AND active=1 ORDER BY number" : "SELECT " ACCOUNT_COLUMNS " FROM accounts" " WHERE org_id=?1 ORDER BY number"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); while (sqlite3_step(st) == SQLITE_ROW) yyjson_mut_arr_add_val(items, account_json(r->rdoc, st)); sqlite3_finalize(st); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, o, "items", items); return o; } static yyjson_mut_val *h_account_get(struct req *r) { int64_t id = 0; const char *number = arg_str(r->args, "number"); arg_int(r->args, "id", &id); if (!id && !number) return fail(r, "INVALID_ARGS", "id or number is required"); sqlite3_stmt *st = NULL; const char *sql = id ? "SELECT " ACCOUNT_COLUMNS " FROM accounts" " WHERE org_id=?1 AND id=?2" : "SELECT " ACCOUNT_COLUMNS " FROM accounts" " WHERE org_id=?1 AND number=?2"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); if (id) sqlite3_bind_int64(st, 2, id); else sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = account_json(r->rdoc, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "account not found"); return o; } static int valid_account_type(const char *t) { return t && (!strcmp(t, "asset") || !strcmp(t, "liability") || !strcmp(t, "equity") || !strcmp(t, "revenue") || !strcmp(t, "expense")); } static yyjson_mut_val *h_account_create(struct req *r) { const char *number = arg_str(r->args, "number"); const char *name = arg_str(r->args, "name"); const char *type = arg_str(r->args, "type"); const char *sru = arg_str(r->args, "sru_code"); const char *vat = arg_str(r->args, "vat_code"); if (!is_digits(number) || strlen(number) > 10) return fail(r, "INVALID_ARGS", "number must be 1-10 digits"); if (!name || !*name) return fail(r, "INVALID_ARGS", "name is required"); if (!valid_account_type(type)) return fail(r, "INVALID_ARGS", "type must be asset, liability, equity, revenue or expense"); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO accounts(org_id,number,name,type,sru_code,vat_code," "created_at) VALUES(?1,?2,?3,?4,?5,?6,?7)", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, type, -1, SQLITE_TRANSIENT); if (sru) sqlite3_bind_text(st, 5, sru, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 5); if (vat) sqlite3_bind_text(st, 6, vat, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 6); sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { if ((rc & 0xff) == SQLITE_CONSTRAINT) return fail(r, "CONFLICT", "account number already exists"); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } int64_t id = db_last_id(r->db); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "account.create", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_strcpy(r->rdoc, o, "number", number); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); yyjson_mut_obj_add_strcpy(r->rdoc, o, "type", type); return o; } static yyjson_mut_val *h_account_update(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); const char *name = arg_str(r->args, "name"); const char *sru = arg_str(r->args, "sru_code"); const char *vat = arg_str(r->args, "vat_code"); int active = -1; arg_bool(r->args, "active", &active); if (!name && !sru && !vat && active < 0) return fail(r, "INVALID_ARGS", "nothing to update"); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE accounts SET" " name=COALESCE(?2,name), sru_code=COALESCE(?3,sru_code)," " vat_code=COALESCE(?4,vat_code)," " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6" " WHERE org_id=?1 AND id=?7", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); if (name) sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 2); if (sru) sqlite3_bind_text(st, 3, sru, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 3); if (vat) sqlite3_bind_text(st, 4, vat, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 4); sqlite3_bind_int(st, 5, active); sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 7, id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "account not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "account.update", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } /* ------------------------------------------------------------------ */ /* fiscal years and period locks */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *fy_json(struct req *r, sqlite3_stmt *st) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", sq(sqlite3_column_text(st, 4))); if (sqlite3_column_type(st, 5) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "locked_until"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", sqlite3_column_int64(st, 6)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "events", sq(sqlite3_column_text(st, 7))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "agm_date", sq(sqlite3_column_text(st, 8))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "dividend_date", sq(sqlite3_column_text(st, 9))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "employees", sq(sqlite3_column_text(st, 10))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", sq(sqlite3_column_text(st, 11))); return o; } #define FY_COLUMNS \ "id,label,start_date,end_date,status,locked_until,dividend_ore," \ "events,agm_date,dividend_date,employees,notes" static yyjson_mut_val *h_fiscal_year_list(struct req *r) { yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT " FY_COLUMNS " FROM fiscal_years WHERE org_id=?1" " ORDER BY start_date", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); while (sqlite3_step(st) == SQLITE_ROW) yyjson_mut_arr_add_val(items, fy_json(r, st)); sqlite3_finalize(st); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, o, "items", items); return o; } static yyjson_mut_val *h_fiscal_year_get(struct req *r) { int64_t id = 0; arg_int(r->args, "id", &id); sqlite3_stmt *st = NULL; const char *sql = id ? "SELECT " FY_COLUMNS " FROM fiscal_years" " WHERE org_id=?1 AND id=?2" : "SELECT " FY_COLUMNS " FROM fiscal_years" " WHERE org_id=?1 ORDER BY start_date DESC LIMIT 1"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); if (id) sqlite3_bind_int64(st, 2, id); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = fy_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "fiscal year not found"); return o; } static int fy_exists_in_range(sqlite3 *db, int64_t org_id, const char *start, const char *end) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( db, "SELECT count(*) FROM fiscal_years WHERE org_id=?1" " AND NOT (end_date < ?2 OR start_date > ?3)", -1, &st, NULL) != SQLITE_OK) return 1; sqlite3_bind_int64(st, 1, org_id); sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, end, -1, SQLITE_TRANSIENT); int64_t n = 0; if (sqlite3_step(st) == SQLITE_ROW) n = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return n > 0; } static yyjson_mut_val *h_fiscal_year_open(struct req *r) { const char *label = arg_str(r->args, "label"); const char *start = arg_str(r->args, "start_date"); const char *end = arg_str(r->args, "end_date"); if (!label || !*label) return fail(r, "INVALID_ARGS", "label is required"); if (!util_parse_iso_date(start) || !util_parse_iso_date(end)) return fail(r, "INVALID_ARGS", "start_date and end_date must be YYYY-MM-DD"); if (strcmp(start, end) >= 0) return fail(r, "INVALID_ARGS", "start_date must be before end_date"); if (fy_exists_in_range(r->db, r->org_id, start, end)) return fail(r, "CONFLICT", "fiscal year overlaps an existing one"); /* "Information om året" carries over from the latest earlier year, so only what changes needs to be edited. Dates and the dividend are year-specific and start empty. */ char prev_events[600] = "", prev_emp[64] = "", prev_notes[600] = ""; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT events,employees,notes FROM fiscal_years" " WHERE org_id=?1 AND end_date < ?2" " ORDER BY end_date DESC LIMIT 1", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) == SQLITE_ROW) { snprintf(prev_events, sizeof prev_events, "%s", (const char *)sqlite3_column_text(st, 0)); snprintf(prev_emp, sizeof prev_emp, "%s", (const char *)sqlite3_column_text(st, 1)); snprintf(prev_notes, sizeof prev_notes, "%s", (const char *)sqlite3_column_text(st, 2)); } sqlite3_finalize(st); } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); if (sqlite3_prepare_v2( r->db, "INSERT INTO fiscal_years(org_id,label,start_date,end_date," "events,employees,notes,created_at)" " VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, label, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, start, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, end, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, prev_events, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, prev_emp, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 7, prev_notes, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 8, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "CONFLICT", "could not create fiscal year"); int64_t id = db_last_id(r->db); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "fiscal_year.open", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label); yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", start); yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", end); yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "open"); return o; } static yyjson_mut_val *h_fiscal_year_close(struct req *r) { int64_t id = 0; int confirm = 0; arg_int(r->args, "id", &id); arg_bool(r->args, "confirm", &confirm); if (id <= 0 || !confirm) return fail(r, "INVALID_ARGS", "id and confirm:true are required"); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE fiscal_years SET status='closed', closed_at=?3," " closed_by=?4 WHERE org_id=?1 AND id=?2 AND status='open'", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 4, r->sess->user_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "open fiscal year not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "fiscal_year.close", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } static yyjson_mut_val *h_fiscal_year_reopen(struct req *r) { int64_t id = 0; int confirm = 0; arg_int(r->args, "id", &id); arg_bool(r->args, "confirm", &confirm); if (id <= 0 || !confirm) return fail(r, "INVALID_ARGS", "id and confirm:true are required"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE fiscal_years SET status='open', closed_at=NULL," " closed_by=NULL WHERE org_id=?1 AND id=?2 AND status='closed'", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "closed fiscal year not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "fiscal_year.reopen", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } /* Fiscal-year metadata that is not a posting: the board's proposed dividend and the year's material events, kept for the årsredovisning draft. Both fields are optional; at least one must be given. */ static yyjson_mut_val *h_fiscal_year_update(struct req *r) { int64_t id = 0, dividend = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); int have_div = arg_int(r->args, "dividend_ore", ÷nd); const char *events = arg_str(r->args, "events"); const char *agm = arg_str(r->args, "agm_date"); const char *pay = arg_str(r->args, "dividend_date"); const char *employees = arg_str(r->args, "employees"); const char *notes = arg_str(r->args, "notes"); if (!have_div && !events && !agm && !pay && !employees && !notes) return fail(r, "INVALID_ARGS", "nothing to update"); if (have_div && dividend < 0) return fail(r, "INVALID_ARGS", "dividend_ore must be >= 0"); if (agm && *agm && !util_parse_iso_date(agm)) return fail(r, "INVALID_ARGS", "agm_date must be YYYY-MM-DD"); if (pay && *pay && !util_parse_iso_date(pay)) return fail(r, "INVALID_ARGS", "dividend_date must be YYYY-MM-DD"); static const char *const skeys[4] = { "events", "agm_date", "dividend_date", "employees" }; const char *svals[4] = { events, agm, pay, employees }; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT count(*) FROM fiscal_years" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); int64_t exists = 0; if (sqlite3_step(st) == SQLITE_ROW) exists = sqlite3_column_int64(st, 0); sqlite3_finalize(st); if (!exists) return fail(r, "NOT_FOUND", "fiscal year not found"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); if (have_div) yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", dividend); for (int i = 0; i < 4; i++) if (svals[i]) yyjson_mut_obj_add_strcpy(r->rdoc, o, skeys[i], svals[i]); if (notes) yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes); return o; } if (sqlite3_prepare_v2(r->db, "UPDATE fiscal_years SET" " dividend_ore=CASE WHEN ?3<0 THEN dividend_ore" " ELSE ?3 END," " events=COALESCE(?4,events)," " agm_date=COALESCE(?5,agm_date)," " dividend_date=COALESCE(?6,dividend_date)," " employees=COALESCE(?7,employees)," " notes=COALESCE(?8,notes)" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); sqlite3_bind_int64(st, 3, have_div ? dividend : -1); for (int i = 0; i < 4; i++) { if (svals[i]) sqlite3_bind_text(st, i + 4, svals[i], -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, i + 4); } if (notes) sqlite3_bind_text(st, 8, notes, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 8); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "fiscal_year.update", reqjson, "OK", NULL); free(reqjson); if (sqlite3_prepare_v2(r->db, "SELECT " FY_COLUMNS " FROM fiscal_years" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = fy_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "fiscal year not found"); return o; } static yyjson_mut_val *h_period_lock(struct req *r) { int64_t fy = 0; arg_int(r->args, "fiscal_year", &fy); const char *until = arg_str(r->args, "until"); if (fy <= 0 || !util_parse_iso_date(until)) return fail(r, "INVALID_ARGS", "fiscal_year and until (YYYY-MM-DD) are required"); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE fiscal_years SET locked_until=?3 WHERE org_id=?1 AND id=?2" " AND ?3 BETWEEN start_date AND end_date", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, fy); sqlite3_bind_text(st, 3, until, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "fiscal year not found or until is outside it"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "period.lock", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year", fy); yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", until); return o; } static yyjson_mut_val *h_period_unlock(struct req *r) { int64_t fy = 0; arg_int(r->args, "fiscal_year", &fy); if (fy <= 0) return fail(r, "INVALID_ARGS", "fiscal_year is required"); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE fiscal_years SET locked_until=NULL WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, fy); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "fiscal year not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "period.unlock", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } /* ------------------------------------------------------------------ */ /* vouchers */ /* ------------------------------------------------------------------ */ static int parse_rows(struct req *r, yyjson_val *rowsv, struct ledger_row **out, size_t *out_n) { if (!rowsv || !yyjson_is_arr(rowsv)) return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1; size_t n = yyjson_arr_size(rowsv); struct ledger_row *rows = xcalloc(n ? n : 1, sizeof *rows); size_t k = 0; yyjson_arr_iter it = yyjson_arr_iter_with(rowsv); yyjson_val *item; while ((item = yyjson_arr_iter_next(&it))) { if (!yyjson_is_obj(item)) { free(rows); fail(r, "INVALID_ARGS", "each row must be an object"); return -1; } yyjson_val *av = yyjson_obj_get(item, "account"); if (!av || !yyjson_is_str(av)) { free(rows); failf(r, "INVALID_ARGS", "row %zu: account is required", k + 1); return -1; } yyjson_val *dv = yyjson_obj_get(item, "debit_ore"); yyjson_val *cv = yyjson_obj_get(item, "credit_ore"); if ((dv && !yyjson_is_int(dv)) || (cv && !yyjson_is_int(cv))) { free(rows); failf(r, "INVALID_ARGS", "row %zu: amounts must be integer öre", k + 1); return -1; } rows[k].account = yyjson_get_str(av); rows[k].debit_ore = dv ? yyjson_get_int(dv) : 0; rows[k].credit_ore = cv ? yyjson_get_int(cv) : 0; yyjson_val *d = yyjson_obj_get(item, "description"); rows[k].description = d && yyjson_is_str(d) ? yyjson_get_str(d) : NULL; k++; } *out = rows; *out_n = k; return 0; } static int parse_attachment_ids(struct req *r, int64_t **out, size_t *out_n) { yyjson_val *av = r->args ? yyjson_obj_get(r->args, "attachment_ids") : NULL; *out = NULL; *out_n = 0; if (!av) return 0; if (!yyjson_is_arr(av)) return fail(r, "INVALID_ARGS", "attachment_ids must be an array") ? -1 : -1; size_t n = yyjson_arr_size(av); int64_t *ids = xcalloc(n ? n : 1, sizeof *ids); size_t k = 0; yyjson_arr_iter it = yyjson_arr_iter_with(av); yyjson_val *item; while ((item = yyjson_arr_iter_next(&it))) { if (!yyjson_is_int(item)) { free(ids); fail(r, "INVALID_ARGS", "attachment_ids must be integers"); return -1; } ids[k++] = yyjson_get_int(item); } *out = ids; *out_n = k; return 0; } /* ------------------------------------------------------------------ */ /* settings */ /* ------------------------------------------------------------------ */ static yyjson_mut_val *h_settings_get(struct req *r) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); int have_default = 0, have_bank = 0, have_receivable = 0, have_revenue = 0; int have_password = 0, have_security = 0; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, r->org_id); while (sqlite3_step(st) == SQLITE_ROW) { const char *k = (const char *)sqlite3_column_text(st, 0); const char *v = (const char *)sqlite3_column_text(st, 1); if (k) { if (secret_key_is_secret(k)) { have_password = 1; continue; } yyjson_mut_obj_add(o, yyjson_mut_strcpy(r->rdoc, k), yyjson_mut_strcpy(r->rdoc, v ? v : "")); if (strcmp(k, "default_series") == 0) have_default = 1; if (strcmp(k, "bank_account") == 0) have_bank = 1; if (strcmp(k, "invoice_receivable_account") == 0) have_receivable = 1; if (strcmp(k, "invoice_revenue_account") == 0) have_revenue = 1; if (strcmp(k, "smtp_security") == 0) have_security = 1; } } sqlite3_finalize(st); } if (!have_default) yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A"); if (!have_bank) yyjson_mut_obj_add_strcpy(r->rdoc, o, "bank_account", "1930"); if (!have_receivable) yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_receivable_account", "1510"); if (!have_revenue) yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_revenue_account", "3001"); if (!have_security) yyjson_mut_obj_add_strcpy(r->rdoc, o, "smtp_security", "starttls"); yyjson_mut_obj_add_bool(r->rdoc, o, "smtp_password_set", have_password); return o; } static yyjson_mut_val *settings_result(struct req *r, const char *key, const char *value, int dry) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); if (dry) yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } static char *settings_secret_audit_json(const char *key) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, o); yyjson_mut_obj_add_strcpy(doc, o, "key", key); yyjson_mut_obj_add_strcpy(doc, o, "value", "[redacted]"); char *json = yyjson_mut_write(doc, 0, NULL); yyjson_mut_doc_free(doc); return json ? json : xstrdup("{}"); } static yyjson_mut_val *h_settings_set(struct req *r) { const char *key = arg_str(r->args, "key"); const char *value = arg_str(r->args, "value"); if (!key || !value) return fail(r, "INVALID_ARGS", "key and value are required"); if (secret_key_is_secret(key)) { if (!secret_available()) return fail(r, "INTERNAL", "BOKFD_SECRET_KEY is missing or invalid"); char *stored = NULL; if (*value && secret_encrypt(value, &stored) != 0) return fail(r, "INTERNAL", "BOKFD_SECRET_KEY is missing or invalid"); if (r->dry_run) { free(stored); return settings_result(r, key, "[redacted]", 1); } sqlite3_stmt *st = NULL; const char *sql = *value ? "INSERT INTO settings(org_id,key,value)" " VALUES(?1,?2,?3)" " ON CONFLICT(org_id,key) DO UPDATE SET" " value=excluded.value" : "DELETE FROM settings WHERE org_id=?1" " AND key=?2"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) { free(stored); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); if (*value) sqlite3_bind_text(st, 3, stored, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); free(stored); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); char *reqjson = settings_secret_audit_json(key); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "settings.set", reqjson, "OK", NULL); free(reqjson); return settings_result(r, key, "[redacted]", 0); } size_t maxlen; int digits_only = 0, bankgiro = 0, port = 0, security = 0; if (strcmp(key, "default_series") == 0) maxlen = 8; else if (strcmp(key, "attachment_dir") == 0 || strcmp(key, "smtp_host") == 0 || strcmp(key, "smtp_user") == 0) maxlen = 255; else if (strcmp(key, "smtp_from") == 0 || strcmp(key, "smtp_reply_to") == 0) maxlen = 254; else if (strcmp(key, "smtp_port") == 0) { maxlen = 5; port = 1; } else if (strcmp(key, "smtp_security") == 0) { maxlen = 8; security = 1; } else if (strcmp(key, "bank_account") == 0 || strcmp(key, "invoice_receivable_account") == 0 || strcmp(key, "invoice_revenue_account") == 0) { maxlen = 10; digits_only = 1; } else if (strcmp(key, "invoice_bankgiro") == 0) { maxlen = 16; bankgiro = 1; } else return fail(r, "UNSUPPORTED", "unknown setting"); size_t len = strlen(value); if (len == 0 || len > maxlen) return failf(r, "INVALID_ARGS", "%s must be 1-%zu characters", key, maxlen); for (const char *p = value; *p; p++) { if ((digits_only || port) && (*p < '0' || *p > '9')) return failf(r, "INVALID_ARGS", "%s must be digits only", key); if (bankgiro && !((*p >= '0' && *p <= '9') || *p == '-')) return failf(r, "INVALID_ARGS", "%s must be digits and hyphens", key); if ((unsigned char)*p < 32) return failf(r, "INVALID_ARGS", "%s must not contain control characters", key); } if (port) { long v = strtol(value, NULL, 10); if (v < 1 || v > 65535) return failf(r, "INVALID_ARGS", "%s must be 1-65535", key); } if (security && strcmp(value, "starttls") != 0 && strcmp(value, "tls") != 0 && strcmp(value, "plain") != 0) return failf(r, "INVALID_ARGS", "%s must be starttls, tls or plain", key); if (r->dry_run) return settings_result(r, key, value, 1); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO settings(org_id,key,value) VALUES(?1,?2,?3)" " ON CONFLICT(org_id,key) DO UPDATE SET value=excluded.value", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, value, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "settings.set", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); return o; } /* ------------------------------------------------------------------ */ /* customer register */ /* ------------------------------------------------------------------ */ #define CUSTOMER_COLUMNS \ "id,name,address,postal_code,city,country,vat_nr,email,your_ref," \ "payment_days,notes,active,created_at,COALESCE(updated_at,'')" static yyjson_mut_val *customer_json(struct req *r, sqlite3_stmt *st) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", sq(sqlite3_column_text(st, 6))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", sq(sqlite3_column_text(st, 7))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", sq(sqlite3_column_text(st, 8))); yyjson_mut_obj_add_int(r->rdoc, o, "payment_days", sqlite3_column_int64(st, 9)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", sq(sqlite3_column_text(st, 10))); yyjson_mut_obj_add_bool(r->rdoc, o, "active", sqlite3_column_int(st, 11) != 0); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 12))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "updated_at", sq(sqlite3_column_text(st, 13))); return o; } struct customer_input { const char *name; const char *address; const char *postal_code; const char *city; const char *country; const char *vat_nr; const char *email; const char *your_ref; const char *notes; int64_t payment_days; int have_payment; int active; int have_active; }; static void customer_input_read(struct req *r, struct customer_input *in) { memset(in, 0, sizeof *in); in->name = arg_str(r->args, "name"); in->address = arg_str(r->args, "address"); in->postal_code = arg_str(r->args, "postal_code"); in->city = arg_str(r->args, "city"); in->country = arg_str(r->args, "country"); in->vat_nr = arg_str(r->args, "vat_nr"); in->email = arg_str(r->args, "email"); in->your_ref = arg_str(r->args, "your_ref"); in->notes = arg_str(r->args, "notes"); in->have_payment = arg_int(r->args, "payment_days", &in->payment_days); in->have_active = arg_bool(r->args, "active", &in->active); } static int customer_input_validate(struct req *r, const struct customer_input *in, int is_create) { if (is_create && (!in->name || !*in->name)) { fail(r, "INVALID_ARGS", "name is required"); return -1; } if (in->name && !*in->name) { fail(r, "INVALID_ARGS", "name cannot be empty"); return -1; } if (in->name && strlen(in->name) > 200) { fail(r, "INVALID_ARGS", "name is too long"); return -1; } static const char *const names[] = { "address", "postal_code", "city", "country", "vat_nr", "email", "your_ref", "notes", }; static const size_t maxlen[] = { 500, 32, 120, 64, 64, 254, 120, 2000 }; const char *values[] = { in->address, in->postal_code, in->city, in->country, in->vat_nr, in->email, in->your_ref, in->notes }; for (size_t i = 0; i < sizeof maxlen / sizeof maxlen[0]; i++) { if (values[i] && strlen(values[i]) > maxlen[i]) { failf(r, "INVALID_ARGS", "%s is too long", names[i]); return -1; } } if (in->have_payment && in->payment_days < 0) { fail(r, "INVALID_ARGS", "payment_days must be >= 0"); return -1; } return 0; } static yyjson_mut_val *customer_lookup(struct req *r, int64_t id, int *found) { sqlite3_stmt *st = NULL; *found = 0; if (sqlite3_prepare_v2( r->db, "SELECT " CUSTOMER_COLUMNS " FROM customers" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) { *found = -1; fail(r, "INTERNAL", "database error"); return NULL; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return NULL; } yyjson_mut_val *o = customer_json(r, st); sqlite3_finalize(st); *found = 1; return o; } static int customer_name_taken(struct req *r, const char *name, int64_t except_id) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id FROM customers WHERE org_id=?1 AND name=?2" " AND id<>?3", -1, &st, NULL) != SQLITE_OK) { fail(r, "INTERNAL", "database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 3, except_id); int taken = sqlite3_step(st) == SQLITE_ROW; sqlite3_finalize(st); return taken; } static yyjson_mut_val *h_customer_list(struct req *r) { int active_only = 0; arg_bool(r->args, "active_only", &active_only); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT " CUSTOMER_COLUMNS " FROM customers WHERE org_id=?1" " AND (?2=0 OR active=1) ORDER BY name COLLATE NOCASE, id", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int(st, 2, active_only); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); while (sqlite3_step(st) == SQLITE_ROW) yyjson_mut_arr_add_val(items, customer_json(r, st)); sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); return out; } static yyjson_mut_val *h_customer_get(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); int found = 0; yyjson_mut_val *o = customer_lookup(r, id, &found); if (found < 0) return NULL; if (!found) return fail(r, "NOT_FOUND", "customer not found"); return o; } static yyjson_mut_val *h_customer_create(struct req *r) { struct customer_input in; customer_input_read(r, &in); if (customer_input_validate(r, &in, 1) != 0) return NULL; if (!in.have_payment) in.payment_days = 30; const char *address = in.address ? in.address : ""; const char *postal = in.postal_code ? in.postal_code : ""; const char *city = in.city ? in.city : ""; const char *country = in.country ? in.country : "SE"; const char *vat = in.vat_nr ? in.vat_nr : ""; const char *email = in.email ? in.email : ""; const char *your_ref = in.your_ref ? in.your_ref : ""; const char *notes = in.notes ? in.notes : ""; int taken = customer_name_taken(r, in.name, 0); if (taken < 0) return NULL; if (taken) return fail(r, "CONFLICT", "a customer with this name already exists"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", 0); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", in.name); yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", address); yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", postal); yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", city); yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", country); yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", vat); yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", email); yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", your_ref); yyjson_mut_obj_add_int(r->rdoc, o, "payment_days", in.payment_days); yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes); yyjson_mut_obj_add_bool(r->rdoc, o, "active", true); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO customers(org_id,name,address,postal_code,city,country," "vat_nr,email,your_ref,payment_days,notes,created_at)" " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, in.name, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, address, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, postal, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, city, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, country, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 7, vat, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 8, email, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 9, your_ref, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 10, in.payment_days); sqlite3_bind_text(st, 11, notes, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 12, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { if ((rc & 0xff) == SQLITE_CONSTRAINT) return fail(r, "CONFLICT", "a customer with this name already exists"); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } int64_t id = db_last_id(r->db); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "customer.create", reqjson, "OK", NULL); free(reqjson); int found = 0; yyjson_mut_val *o = customer_lookup(r, id, &found); if (found < 0) return NULL; if (!found) return fail(r, "INTERNAL", "could not read the new customer"); return o; } static yyjson_mut_val *h_customer_update(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); struct customer_input in; customer_input_read(r, &in); if (customer_input_validate(r, &in, 0) != 0) return NULL; if (!in.name && !in.address && !in.postal_code && !in.city && !in.country && !in.vat_nr && !in.email && !in.your_ref && !in.notes && !in.have_payment && !in.have_active) return fail(r, "INVALID_ARGS", "nothing to update"); int found = 0; yyjson_mut_val *existing = customer_lookup(r, id, &found); (void)existing; if (found < 0) return NULL; if (!found) return fail(r, "NOT_FOUND", "customer not found"); if (in.name) { int taken = customer_name_taken(r, in.name, id); if (taken < 0) return NULL; if (taken) return fail(r, "CONFLICT", "a customer with this name already exists"); } if (r->dry_run) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id,COALESCE(?3,name),COALESCE(?4,address)," "COALESCE(?5,postal_code),COALESCE(?6,city)," "COALESCE(?7,country),COALESCE(?8,vat_nr)," "COALESCE(?9,email),COALESCE(?10,your_ref)," "CASE WHEN ?11<0 THEN payment_days ELSE ?11 END," "COALESCE(?12,notes)," "CASE WHEN ?13<0 THEN active ELSE ?13 END," "created_at,COALESCE(updated_at,'')" " FROM customers WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); const char *texts[] = { in.name, in.address, in.postal_code, in.city, in.country, in.vat_nr, in.email, in.your_ref, in.notes }; static const int bind_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 }; for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) { if (texts[i]) sqlite3_bind_text(st, bind_index[i], texts[i], -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, bind_index[i]); } sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1); sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = customer_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "customer not found"); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE customers SET" " name=COALESCE(?3,name), address=COALESCE(?4,address)," " postal_code=COALESCE(?5,postal_code), city=COALESCE(?6,city)," " country=COALESCE(?7,country), vat_nr=COALESCE(?8,vat_nr)," " email=COALESCE(?9,email), your_ref=COALESCE(?10,your_ref)," " payment_days=CASE WHEN ?11<0 THEN payment_days ELSE ?11 END," " notes=COALESCE(?12,notes)," " active=CASE WHEN ?13<0 THEN active ELSE ?13 END," " updated_at=?14 WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); const char *texts[] = { in.name, in.address, in.postal_code, in.city, in.country, in.vat_nr, in.email, in.your_ref, in.notes }; static const int text_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 }; for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) { if (texts[i]) sqlite3_bind_text(st, text_index[i], texts[i], -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, text_index[i]); } sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1); sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1); sqlite3_bind_text(st, 14, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { if ((rc & 0xff) == SQLITE_CONSTRAINT) return fail(r, "CONFLICT", "a customer with this name already exists"); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "customer not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "customer.update", reqjson, "OK", NULL); free(reqjson); found = 0; yyjson_mut_val *o = customer_lookup(r, id, &found); if (found < 0) return NULL; if (!found) return fail(r, "INTERNAL", "could not read the customer"); return o; } static yyjson_mut_val *h_customer_archive(struct req *r) { int64_t id = 0; int active = 0; if (!arg_int(r->args, "id", &id) || id <= 0 || !arg_bool(r->args, "active", &active)) return fail(r, "INVALID_ARGS", "id and active are required"); int found = 0; yyjson_mut_val *existing = customer_lookup(r, id, &found); (void)existing; if (found < 0) return NULL; if (!found) return fail(r, "NOT_FOUND", "customer not found"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_bool(r->rdoc, o, "active", active != 0); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE customers SET active=?3, updated_at=?4" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); sqlite3_bind_int(st, 3, active); sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "customer not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "customer.archive", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_bool(r->rdoc, o, "active", active != 0); return o; } /* ------------------------------------------------------------------ */ /* invoice sequence */ /* ------------------------------------------------------------------ */ static int64_t invoice_next_number(struct req *r) { sqlite3_stmt *st = NULL; int64_t next = 1; if (sqlite3_prepare_v2( r->db, "SELECT next_number FROM invoice_sequence WHERE org_id=?1", -1, &st, NULL) != SQLITE_OK) return next; sqlite3_bind_int64(st, 1, r->org_id); if (sqlite3_step(st) == SQLITE_ROW) next = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return next > 0 ? next : 1; } static int invoice_take_number(struct req *r, int64_t *out) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT next_number FROM invoice_sequence WHERE org_id=?1", -1, &st, NULL) != SQLITE_OK) { fail(r, "INTERNAL", "database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); int have = sqlite3_step(st) == SQLITE_ROW; int64_t number = have ? sqlite3_column_int64(st, 0) : 1; sqlite3_finalize(st); if (have) { if (sqlite3_prepare_v2( r->db, "UPDATE invoice_sequence SET next_number=next_number+1" " WHERE org_id=?1", -1, &st, NULL) != SQLITE_OK) { fail(r, "INTERNAL", "database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); return -1; } } else { if (sqlite3_prepare_v2( r->db, "INSERT INTO invoice_sequence(org_id,next_number) VALUES(?1,2)", -1, &st, NULL) != SQLITE_OK) { fail(r, "INTERNAL", "database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); return -1; } } *out = number; return 0; } static yyjson_mut_val *h_invoice_sequence_get(struct req *r) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "next_number", invoice_next_number(r)); return o; } static yyjson_mut_val *h_invoice_sequence_set(struct req *r) { int64_t next = 0; if (!arg_int(r->args, "next_number", &next) || next <= 0) return fail(r, "INVALID_ARGS", "next_number must be a positive integer"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "next_number", next); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO invoice_sequence(org_id,next_number) VALUES(?1,?2)" " ON CONFLICT(org_id) DO UPDATE SET next_number=excluded.next_number", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, next); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "invoice.sequence_set", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "next_number", next); return o; } /* ------------------------------------------------------------------ */ /* invoice drafts, rendering and issue */ /* ------------------------------------------------------------------ */ struct draft_line { const char *article_no; const char *description; int64_t quantity_milli; const char *unit; int64_t unit_price_ore; int64_t amount_ore; const char *note; const char *vat_code; char account[16]; }; struct invoice_draft { int64_t customer_id; const char *invoice_date; const char *due_date; const char *delivery_date; const char *your_ref; const char *our_ref; const char *notes; struct draft_line *lines; size_t nlines; }; static void invoice_draft_free(struct invoice_draft *d) { free(d->lines); d->lines = NULL; d->nlines = 0; } static int parse_quantity(const char *s, int64_t *out) { if (!s || !*s || strlen(s) > 24) return -1; int64_t whole = 0, frac = 0; size_t int_digits = 0, frac_digits = 0; int seen_sep = 0; for (const char *p = s; *p; p++) { char c = *p; if (c == ',' || c == '.') { if (seen_sep) return -1; seen_sep = 1; continue; } if (c < '0' || c > '9') return -1; if (!seen_sep) { if (int_digits >= 18) return -1; whole = whole * 10 + (c - '0'); int_digits++; } else { if (frac_digits >= 3) return -1; frac = frac * 10 + (c - '0'); frac_digits++; } } if (!int_digits || (seen_sep && !frac_digits)) return -1; for (size_t i = frac_digits; i < 3; i++) frac *= 10; if (whole > (INT64_MAX - frac) / 1000) return -1; int64_t v = whole * 1000 + frac; if (v <= 0) return -1; *out = v; return 0; } static int draft_line_parse(struct req *r, yyjson_val *item, size_t no, const char *default_account, struct draft_line *l, int64_t *net_total) { const char *description = arg_str(item, "description"); if (!description || !*description) { failf(r, "INVALID_ARGS", "row %zu: description is required", no); return -1; } const char *qty = arg_str(item, "quantity"); int64_t quantity_milli = 0; if (parse_quantity(qty, &quantity_milli) != 0) { failf(r, "INVALID_ARGS", "row %zu: quantity must be a positive decimal with at most 3" " decimals", no); return -1; } int64_t price = 0; if (!arg_int(item, "unit_price_ore", &price) || price < 0) { failf(r, "INVALID_ARGS", "row %zu: unit_price_ore must be a non-negative integer", no); return -1; } const char *vat = arg_str(item, "vat_code"); if (!vat || !*vat) vat = "25"; if (strcmp(vat, "25") != 0 && strcmp(vat, "12") != 0 && strcmp(vat, "6") != 0 && strcmp(vat, "0") != 0 && strcmp(vat, "rc") != 0 && strcmp(vat, "eu") != 0) { failf(r, "INVALID_ARGS", "row %zu: vat_code must be one of 25, 12, 6, 0, rc, eu", no); return -1; } const char *account = arg_str(item, "account"); if (account && !*account) account = NULL; if (account) { if (!is_digits(account) || strlen(account) > 10) { failf(r, "INVALID_ARGS", "row %zu: account must be 1-10 digits", no); return -1; } snprintf(l->account, sizeof l->account, "%s", account); } else { snprintf(l->account, sizeof l->account, "%s", default_account); } l->article_no = arg_str(item, "article_no"); l->description = description; l->quantity_milli = quantity_milli; const char *unit = arg_str(item, "unit"); l->unit = unit && *unit ? unit : "st"; l->unit_price_ore = price; l->note = arg_str(item, "note"); l->vat_code = vat; int64_t product = 0; if (__builtin_mul_overflow(quantity_milli, price, &product) || __builtin_add_overflow(product, (int64_t)500, &product)) { failf(r, "INVALID_ARGS", "row %zu: amount overflows", no); return -1; } l->amount_ore = product / 1000; if (__builtin_add_overflow(*net_total, l->amount_ore, net_total) || *net_total > INT64_MAX / 100) { failf(r, "INVALID_ARGS", "row %zu: invoice total overflows", no); return -1; } return 0; } static int parse_invoice_draft(struct req *r, struct invoice_draft *d) { memset(d, 0, sizeof *d); if (!arg_int(r->args, "customer_id", &d->customer_id) || d->customer_id <= 0) { fail(r, "INVALID_ARGS", "customer_id is required"); return -1; } d->invoice_date = arg_str(r->args, "invoice_date"); d->due_date = arg_str(r->args, "due_date"); d->delivery_date = arg_str(r->args, "delivery_date"); if (!d->invoice_date || !util_parse_iso_date(d->invoice_date)) { fail(r, "INVALID_ARGS", "invoice_date must be YYYY-MM-DD"); return -1; } if (!d->due_date || !util_parse_iso_date(d->due_date)) { fail(r, "INVALID_ARGS", "due_date must be YYYY-MM-DD"); return -1; } if (!d->delivery_date) d->delivery_date = ""; if (*d->delivery_date && !util_parse_iso_date(d->delivery_date)) { fail(r, "INVALID_ARGS", "delivery_date must be YYYY-MM-DD"); return -1; } d->your_ref = arg_str(r->args, "your_ref"); d->our_ref = arg_str(r->args, "our_ref"); d->notes = arg_str(r->args, "notes"); if (!d->your_ref) d->your_ref = ""; if (!d->our_ref) d->our_ref = ""; if (!d->notes) d->notes = ""; yyjson_val *rows = r->args ? yyjson_obj_get(r->args, "rows") : NULL; if (!rows || !yyjson_is_arr(rows) || yyjson_arr_size(rows) == 0) { fail(r, "INVALID_ARGS", "rows must be a non-empty array"); return -1; } char *revenue = db_setting(r->db, r->org_id, "invoice_revenue_account"); const char *default_account = revenue && *revenue ? revenue : "3001"; size_t n = yyjson_arr_size(rows); struct draft_line *lines = xcalloc(n, sizeof *lines); size_t k = 0; int64_t net_total = 0; yyjson_arr_iter it = yyjson_arr_iter_with(rows); yyjson_val *item; while ((item = yyjson_arr_iter_next(&it))) { if (!yyjson_is_obj(item)) { failf(r, "INVALID_ARGS", "row %zu: must be an object", k + 1); free(lines); free(revenue); return -1; } if (draft_line_parse(r, item, k + 1, default_account, &lines[k], &net_total) != 0) { free(lines); free(revenue); return -1; } k++; } free(revenue); if (net_total <= 0) { fail(r, "INVALID_ARGS", "invoice total must be greater than zero"); free(lines); return -1; } d->lines = lines; d->nlines = k; return 0; } struct invoice_view { struct invoice_doc doc; struct invoice_line *lines; char number_str[32]; char ocr[40]; char filename[600]; char description[600]; char seller_name[256]; char seller_address[1024]; char seller_postal[64]; char seller_city[128]; char seller_phone[64]; char seller_email[256]; char seller_org_nr[64]; char seller_vat_nr[64]; char bankgiro[64]; char customer_name[256]; char customer_address[1024]; char customer_postal[64]; char customer_city[128]; char customer_vat_nr[64]; }; static void invoice_view_free(struct invoice_view *v) { free(v->lines); v->lines = NULL; } static int invoice_view_fill(struct req *r, const struct invoice_draft *d, int64_t number, struct invoice_view *v) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT COALESCE(name,''),COALESCE(address,'')," "COALESCE(postal_code,''),COALESCE(city,'')," "COALESCE(phone,''),COALESCE(email,''),COALESCE(org_nr,'')," "COALESCE(vat_nr,'') FROM orgs WHERE id=?1", -1, &st, NULL) != SQLITE_OK) { fail(r, "INTERNAL", "database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); int have_org = sqlite3_step(st) == SQLITE_ROW; if (have_org) { snprintf(v->seller_name, sizeof v->seller_name, "%s", sq(sqlite3_column_text(st, 0))); snprintf(v->seller_address, sizeof v->seller_address, "%s", sq(sqlite3_column_text(st, 1))); snprintf(v->seller_postal, sizeof v->seller_postal, "%s", sq(sqlite3_column_text(st, 2))); snprintf(v->seller_city, sizeof v->seller_city, "%s", sq(sqlite3_column_text(st, 3))); snprintf(v->seller_phone, sizeof v->seller_phone, "%s", sq(sqlite3_column_text(st, 4))); snprintf(v->seller_email, sizeof v->seller_email, "%s", sq(sqlite3_column_text(st, 5))); snprintf(v->seller_org_nr, sizeof v->seller_org_nr, "%s", sq(sqlite3_column_text(st, 6))); snprintf(v->seller_vat_nr, sizeof v->seller_vat_nr, "%s", sq(sqlite3_column_text(st, 7))); } sqlite3_finalize(st); if (!have_org) { fail(r, "NOT_FOUND", "org not found"); return -1; } int64_t payment_days = 30; if (sqlite3_prepare_v2( r->db, "SELECT name,address,postal_code,city,vat_nr,payment_days,active" " FROM customers WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) { fail(r, "INTERNAL", "database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, d->customer_id); if (sqlite3_step(st) != SQLITE_ROW || !sqlite3_column_int(st, 6)) { sqlite3_finalize(st); fail(r, "NOT_FOUND", "customer not found"); return -1; } snprintf(v->customer_name, sizeof v->customer_name, "%s", sq(sqlite3_column_text(st, 0))); snprintf(v->customer_address, sizeof v->customer_address, "%s", sq(sqlite3_column_text(st, 1))); snprintf(v->customer_postal, sizeof v->customer_postal, "%s", sq(sqlite3_column_text(st, 2))); snprintf(v->customer_city, sizeof v->customer_city, "%s", sq(sqlite3_column_text(st, 3))); snprintf(v->customer_vat_nr, sizeof v->customer_vat_nr, "%s", sq(sqlite3_column_text(st, 4))); payment_days = sqlite3_column_int64(st, 5); sqlite3_finalize(st); char *bankgiro = db_setting(r->db, r->org_id, "invoice_bankgiro"); snprintf(v->bankgiro, sizeof v->bankgiro, "%s", bankgiro && *bankgiro ? bankgiro : ""); free(bankgiro); v->lines = xcalloc(d->nlines, sizeof *v->lines); for (size_t i = 0; i < d->nlines; i++) { v->lines[i].article_no = d->lines[i].article_no; v->lines[i].description = d->lines[i].description; v->lines[i].quantity_milli = d->lines[i].quantity_milli; v->lines[i].unit = d->lines[i].unit; v->lines[i].unit_price_ore = d->lines[i].unit_price_ore; v->lines[i].amount_ore = d->lines[i].amount_ore; v->lines[i].note = d->lines[i].note; v->lines[i].vat_code = d->lines[i].vat_code; } v->doc.seller.name = v->seller_name; v->doc.seller.address = v->seller_address; v->doc.seller.postal_code = v->seller_postal; v->doc.seller.city = v->seller_city; v->doc.seller.phone = v->seller_phone; v->doc.seller.email = v->seller_email; v->doc.seller.org_nr = v->seller_org_nr; v->doc.seller.vat_nr = v->seller_vat_nr; v->doc.seller.bankgiro = v->bankgiro; v->doc.customer.name = v->customer_name; v->doc.customer.address = v->customer_address; v->doc.customer.postal_code = v->customer_postal; v->doc.customer.city = v->customer_city; v->doc.customer.vat_nr = v->customer_vat_nr; v->doc.number = number; v->doc.ocr = v->ocr; v->doc.invoice_date = d->invoice_date; v->doc.due_date = d->due_date; v->doc.delivery_date = d->delivery_date; v->doc.our_ref = d->our_ref; v->doc.your_ref = d->your_ref; v->doc.notes = d->notes; v->doc.payment_days = (int)payment_days; v->doc.lines = v->lines; v->doc.nlines = d->nlines; snprintf(v->number_str, sizeof v->number_str, "%lld", (long long)number); int check = invoice_ocr_check(v->number_str); snprintf(v->ocr, sizeof v->ocr, "%s%c", v->number_str, (char)('0' + (check > 0 ? check : 0))); char safe_name[256]; snprintf(safe_name, sizeof safe_name, "%s", v->customer_name); for (char *p = safe_name; *p; p++) if (*p == '/') *p = '-'; snprintf(v->filename, sizeof v->filename, "Faktura %lld %s.pdf", (long long)number, safe_name); snprintf(v->description, sizeof v->description, "Faktura %lld %s", (long long)number, safe_name); return 0; } /* Mirrors invoice.c vat_part(): round half up per rate base. */ static int64_t invoice_vat_part(int64_t net, int64_t rate) { int64_t v = net * rate; if (v >= 0) return (v + 50) / 100; return -((-v + 50) / 100); } static int invoice_build_pdf(struct req *r, struct invoice_view *v, struct invoice_totals *t, unsigned char **out, size_t *out_len) { invoice_totals(&v->doc, t); *out = NULL; *out_len = 0; if (invoice_render_pdf(&v->doc, out, out_len) != 0 || !*out) { free(*out); *out = NULL; fail(r, "TOO_LARGE", "invoice does not fit on one page"); return -1; } return 0; } static int invoice_prepare(struct req *r, struct invoice_draft *d, int64_t number, struct invoice_view *v, struct invoice_totals *t, unsigned char **pdf, size_t *pdf_len) { memset(v, 0, sizeof *v); if (invoice_view_fill(r, d, number, v) != 0) return -1; if (invoice_build_pdf(r, v, t, pdf, pdf_len) != 0) { invoice_view_free(v); return -1; } return 0; } static yyjson_mut_val *h_invoice_preview(struct req *r) { struct invoice_draft d; if (parse_invoice_draft(r, &d) != 0) return NULL; int64_t number = invoice_next_number(r); struct invoice_view v; struct invoice_totals t; unsigned char *pdf = NULL; size_t pdf_len = 0; int rc = invoice_prepare(r, &d, number, &v, &t, &pdf, &pdf_len); invoice_draft_free(&d); if (rc != 0) return NULL; char *b64 = util_b64(pdf, pdf_len); free(pdf); if (!b64) { invoice_view_free(&v); return fail(r, "INTERNAL", "could not encode the PDF"); } yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); yyjson_mut_obj_add_int(r->rdoc, o, "number", number); yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", v.ocr); yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", t.net_ore); yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", t.vat_ore); yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", t.total_ore); free(b64); invoice_view_free(&v); return o; } static int invoice_store_attachment(struct req *r, const struct invoice_view *v, const unsigned char *pdf, size_t pdf_len, int64_t *out_id) { unsigned char hash[32]; util_sha256(pdf, pdf_len, hash); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes," "content,created_at,created_by)" " VALUES(?1,?2,?3,'application/pdf',?4,?5,?6,?7)", -1, &st, NULL) != SQLITE_OK) { fail(r, "INTERNAL", "database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, v->filename, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 4, (int64_t)pdf_len); sqlite3_bind_blob(st, 5, pdf, (int)pdf_len, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 7, r->sess->user_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); return -1; } *out_id = db_last_id(r->db); return 0; } static int invoice_store_invoice(struct req *r, const struct invoice_draft *d, const struct invoice_totals *t, int64_t number, int64_t document_id, int64_t voucher_id, int64_t *out_id) { char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO invoices(org_id,customer_id,number,ocr,invoice_date," "due_date,delivery_date,your_ref,our_ref,notes,net_ore,vat_ore," "total_ore,document_id,voucher_id,created_at,created_by)" " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16," "?17)", -1, &st, NULL) != SQLITE_OK) { fail(r, "INTERNAL", "database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, d->customer_id); sqlite3_bind_int64(st, 3, number); char number_str[32]; snprintf(number_str, sizeof number_str, "%lld", (long long)number); int check = invoice_ocr_check(number_str); char ocr[40]; snprintf(ocr, sizeof ocr, "%s%c", number_str, (char)('0' + (check > 0 ? check : 0))); sqlite3_bind_text(st, 4, ocr, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, d->invoice_date, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, d->due_date, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 7, d->delivery_date, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 8, d->your_ref, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 9, d->our_ref, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 10, d->notes, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 11, t->net_ore); sqlite3_bind_int64(st, 12, t->vat_ore); sqlite3_bind_int64(st, 13, t->total_ore); sqlite3_bind_int64(st, 14, document_id); sqlite3_bind_int64(st, 15, voucher_id); sqlite3_bind_text(st, 16, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 17, r->sess->user_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { if ((rc & 0xff) == SQLITE_CONSTRAINT) { fail(r, "CONFLICT", "invoice number already exists"); return -1; } fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); return -1; } int64_t id = db_last_id(r->db); for (size_t i = 0; i < d->nlines; i++) { const struct draft_line *l = &d->lines[i]; if (sqlite3_prepare_v2( r->db, "INSERT INTO invoice_rows(org_id,invoice_id,line_no,article_no," "description,quantity_milli,unit,unit_price_ore,amount_ore,note," "vat_code,account)" " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", -1, &st, NULL) != SQLITE_OK) { fail(r, "INTERNAL", "database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); sqlite3_bind_int64(st, 3, (int64_t)i + 1); sqlite3_bind_text(st, 4, l->article_no ? l->article_no : "", -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, l->description, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 6, l->quantity_milli); sqlite3_bind_text(st, 7, l->unit, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 8, l->unit_price_ore); sqlite3_bind_int64(st, 9, l->amount_ore); sqlite3_bind_text(st, 10, l->note ? l->note : "", -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 11, l->vat_code, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 12, l->account, -1, SQLITE_TRANSIENT); rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { fail(r, "DB_BUSY", sqlite3_errmsg(r->db)); return -1; } } *out_id = id; return 0; } static yyjson_mut_val *invoice_issue_result(struct req *r, int dry_run, int64_t id, int64_t number, const char *ocr, int64_t document_id, int64_t voucher_id, const struct invoice_totals *t) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); if (dry_run) { yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); yyjson_mut_obj_add_int(r->rdoc, o, "id", 0); } else { yyjson_mut_obj_add_int(r->rdoc, o, "id", id); } yyjson_mut_obj_add_int(r->rdoc, o, "number", number); yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", ocr); if (dry_run) { yyjson_mut_obj_add_null(r->rdoc, o, "document_id"); yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); } else { yyjson_mut_obj_add_int(r->rdoc, o, "document_id", document_id); yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); } yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", t->net_ore); yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", t->vat_ore); yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", t->total_ore); return o; } static yyjson_mut_val *h_invoice_issue(struct req *r) { struct invoice_draft d; if (parse_invoice_draft(r, &d) != 0) return NULL; char *rec_setting = db_setting(r->db, r->org_id, "invoice_receivable_account"); char receivable[16]; snprintf(receivable, sizeof receivable, "%s", rec_setting && *rec_setting ? rec_setting : "1510"); free(rec_setting); yyjson_mut_val *res = NULL; struct invoice_view v; struct invoice_totals t; memset(&v, 0, sizeof v); memset(&t, 0, sizeof t); unsigned char *pdf = NULL; size_t pdf_len = 0; char *voucher_json = NULL; struct ledger_row *vrows = NULL; int64_t number = 0, attachment_id = 0, voucher_id = 0, invoice_id = 0; int in_tx = 0; if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { invoice_draft_free(&d); return fail(r, "DB_BUSY", "could not start transaction"); } in_tx = 1; if (r->dry_run) number = invoice_next_number(r); else if (invoice_take_number(r, &number) != 0) goto done; if (invoice_prepare(r, &d, number, &v, &t, &pdf, &pdf_len) != 0) goto done; size_t cap = 1 + 3 + d.nlines; vrows = xcalloc(cap, sizeof *vrows); size_t vn = 0; vrows[vn].account = receivable; vrows[vn].debit_ore = t.total_ore; vrows[vn].description = NULL; vn++; struct { int rate; const char *account; int64_t net; } legs[3] = { { 25, "2610", t.net_25 }, { 12, "2620", t.net_12 }, { 6, "2630", t.net_6 }, }; int64_t vat_sum = 0; for (size_t i = 0; i < 3; i++) { int64_t vat = invoice_vat_part(legs[i].net, legs[i].rate); if (vat <= 0) continue; vrows[vn].account = legs[i].account; vrows[vn].credit_ore = vat; if (i == 0) vrows[vn].description = "Moms 25%"; else if (i == 1) vrows[vn].description = "Moms 12%"; else vrows[vn].description = "Moms 6%"; vat_sum += vat; vn++; } for (size_t i = 0; i < d.nlines; i++) { if (d.lines[i].amount_ore <= 0) continue; vrows[vn].account = d.lines[i].account; vrows[vn].credit_ore = d.lines[i].amount_ore; vrows[vn].description = d.lines[i].description; vn++; } int64_t sum_debit = 0, sum_credit = 0; for (size_t i = 0; i < vn; i++) { sum_debit += vrows[i].debit_ore; sum_credit += vrows[i].credit_ore; } if (vat_sum != t.vat_ore || sum_debit != t.total_ore || sum_debit != sum_credit) { fail(r, "INTERNAL", "invoice totals do not match the voucher"); goto done; } if (!r->dry_run && invoice_store_attachment(r, &v, pdf, pdf_len, &attachment_id) != 0) goto done; struct ledger_post_opts o; memset(&o, 0, sizeof o); o.org_id = r->org_id; o.user_id = r->sess->user_id; o.token_id = r->sess->token_id; o.date = d.invoice_date; o.description = v.description; o.rows = vrows; o.nrows = vn; o.source = "invoice"; o.dry_run = r->dry_run; o.already_in_tx = 1; struct ledger_error e; if (ledger_post(r->db, &o, &e, &voucher_json) != 0) { fail(r, e.code ? e.code : "INTERNAL", e.msg); goto done; } if (!r->dry_run) { if (!voucher_json) { fail(r, "INTERNAL", "empty voucher result"); goto done; } yyjson_doc *vd = yyjson_read(voucher_json, strlen(voucher_json), 0); if (vd) { yyjson_val *idv = yyjson_obj_get(yyjson_doc_get_root(vd), "id"); if (idv && yyjson_is_int(idv)) voucher_id = yyjson_get_int(idv); yyjson_doc_free(vd); } if (voucher_id <= 0) { fail(r, "INTERNAL", "voucher id missing from the posting"); goto done; } if (invoice_store_invoice(r, &d, &t, number, attachment_id, voucher_id, &invoice_id) != 0) goto done; if (sqlite3_exec(r->db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { fail(r, "DB_BUSY", "commit failed"); goto done; } in_tx = 0; char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "invoice.issue", reqjson, "OK", NULL); free(reqjson); } res = invoice_issue_result(r, r->dry_run, invoice_id, number, v.ocr, attachment_id, voucher_id, &t); done: if (in_tx) sqlite3_exec(r->db, "ROLLBACK", NULL, NULL, NULL); free(pdf); free(voucher_json); free(vrows); invoice_draft_free(&d); invoice_view_free(&v); return res; } static yyjson_mut_val *invoice_row_json(struct req *r, sqlite3_stmt *st) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "line_no", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "article_no", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_int(r->rdoc, o, "quantity_milli", sqlite3_column_int64(st, 3)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "unit", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_int(r->rdoc, o, "unit_price_ore", sqlite3_column_int64(st, 5)); yyjson_mut_obj_add_int(r->rdoc, o, "amount_ore", sqlite3_column_int64(st, 6)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "note", sq(sqlite3_column_text(st, 7))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_code", sq(sqlite3_column_text(st, 8))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", sq(sqlite3_column_text(st, 9))); return o; } #define INVOICE_ROW_COLUMNS \ "line_no,article_no,description,quantity_milli,unit,unit_price_ore," \ "amount_ore,note,vat_code,account" static yyjson_mut_val *h_invoice_get(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT i.id,i.customer_id,c.name,i.number,i.ocr,i.invoice_date," "i.due_date,i.delivery_date,i.your_ref,i.our_ref,i.notes,i.net_ore," "i.vat_ore,i.total_ore,i.status,i.document_id,i.voucher_id," "i.last_sent_at,i.last_sent_to,i.created_at,i.created_by" " FROM invoices i JOIN customers c" " ON c.org_id=i.org_id AND c.id=i.customer_id" " WHERE i.org_id=?1 AND i.id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "invoice not found"); } yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_int(r->rdoc, o, "customer_id", sqlite3_column_int64(st, 1)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "customer_name", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_date", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "due_date", sq(sqlite3_column_text(st, 6))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "delivery_date", sq(sqlite3_column_text(st, 7))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", sq(sqlite3_column_text(st, 8))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "our_ref", sq(sqlite3_column_text(st, 9))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", sq(sqlite3_column_text(st, 10))); yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", sqlite3_column_int64(st, 11)); yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", sqlite3_column_int64(st, 12)); yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", sqlite3_column_int64(st, 13)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", sq(sqlite3_column_text(st, 14))); if (sqlite3_column_type(st, 15) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "document_id"); else yyjson_mut_obj_add_int(r->rdoc, o, "document_id", sqlite3_column_int64(st, 15)); if (sqlite3_column_type(st, 16) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); else yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", sqlite3_column_int64(st, 16)); if (sqlite3_column_type(st, 17) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_at", sq(sqlite3_column_text(st, 17))); if (sqlite3_column_type(st, 18) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_to"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to", sq(sqlite3_column_text(st, 18))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 19))); yyjson_mut_obj_add_int(r->rdoc, o, "created_by", sqlite3_column_int64(st, 20)); sqlite3_finalize(st); yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); if (sqlite3_prepare_v2( r->db, "SELECT " INVOICE_ROW_COLUMNS " FROM invoice_rows" " WHERE org_id=?1 AND invoice_id=?2 ORDER BY line_no", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); while (sqlite3_step(st) == SQLITE_ROW) yyjson_mut_arr_add_val(rows, invoice_row_json(r, st)); sqlite3_finalize(st); } yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); return o; } static yyjson_mut_val *h_invoice_list(struct req *r) { int64_t customer_id = 0, limit = 200; arg_int(r->args, "customer_id", &customer_id); arg_int(r->args, "limit", &limit); const char *status = arg_str(r->args, "status"); if (!status) status = ""; if (limit < 1) limit = 200; if (limit > 1000) limit = 1000; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT i.id,i.number,i.ocr,i.customer_id,c.name,i.invoice_date," "i.due_date,i.total_ore,i.status,i.document_id,i.voucher_id," "i.last_sent_at,i.last_sent_to" " FROM invoices i JOIN customers c" " ON c.org_id=i.org_id AND c.id=i.customer_id" " WHERE i.org_id=?1" " AND (?2=0 OR i.customer_id=?2)" " AND (?3='' OR i.status=?3)" " ORDER BY i.number DESC, i.id DESC LIMIT ?4", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, customer_id); sqlite3_bind_text(st, 3, status, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 4, limit); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 1)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_int(r->rdoc, o, "customer_id", sqlite3_column_int64(st, 3)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "customer_name", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_date", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "due_date", sq(sqlite3_column_text(st, 6))); yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", sqlite3_column_int64(st, 7)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", sq(sqlite3_column_text(st, 8))); if (sqlite3_column_type(st, 9) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "document_id"); else yyjson_mut_obj_add_int(r->rdoc, o, "document_id", sqlite3_column_int64(st, 9)); if (sqlite3_column_type(st, 10) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); else yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", sqlite3_column_int64(st, 10)); if (sqlite3_column_type(st, 11) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_at"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_at", sq(sqlite3_column_text(st, 11))); if (sqlite3_column_type(st, 12) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_to"); else yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to", sq(sqlite3_column_text(st, 12))); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); return out; } static yyjson_mut_val *h_invoice_pdf(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT document_id FROM invoices WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "invoice not found"); } int64_t document_id = sqlite3_column_type(st, 0) == SQLITE_NULL ? 0 : sqlite3_column_int64(st, 0); sqlite3_finalize(st); if (document_id <= 0) return fail(r, "NOT_FOUND", "invoice has no stored PDF"); const void *content = NULL; size_t len = 0; if (sqlite3_prepare_v2( r->db, "SELECT content FROM attachments WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, document_id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "invoice PDF not found"); } content = sqlite3_column_blob(st, 0); len = (size_t)sqlite3_column_bytes(st, 0); char *b64 = util_b64(content ? content : (const unsigned char *)"", len); sqlite3_finalize(st); if (!b64) return fail(r, "INTERNAL", "could not encode the PDF"); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); free(b64); return o; } /* ------------------------------------------------------------------ */ /* report rules (per-org moms mapping) */ /* ------------------------------------------------------------------ */ struct rule_row { int64_t id; char report[16]; char box[8]; char match_type[16]; char pattern[64]; int sign; int64_t sort_order; }; static int rule_digits(const char *s, size_t min, size_t max) { size_t n = s ? strlen(s) : 0; if (n < min || n > max) return 0; for (const char *p = s; *p; p++) if (*p < '0' || *p > '9') return 0; return 1; } static int rule_validate(const char *box, const char *match_type, const char *pattern, int64_t sign, char *err, size_t errlen) { if (!rule_digits(box, 1, 3)) { snprintf(err, errlen, "box must be 1-3 digits"); return -1; } if (strcmp(match_type, "account") == 0) { if (!rule_digits(pattern, 1, 10)) { snprintf(err, errlen, "pattern must be 1-10 digits when match_type is account"); return -1; } } else if (strcmp(match_type, "range") == 0) { const char *dash = strchr(pattern, '-'); if (!dash || dash == pattern || !dash[1] || strchr(dash + 1, '-')) { snprintf(err, errlen, "pattern must be LO-HI when match_type is range"); return -1; } char lo[16], hi[16]; size_t lon = (size_t)(dash - pattern); size_t hin = strlen(dash + 1); if (lon >= sizeof lo || hin >= sizeof hi) { snprintf(err, errlen, "pattern must be LO-HI with digits"); return -1; } memcpy(lo, pattern, lon); lo[lon] = '\0'; memcpy(hi, dash + 1, hin + 1); if (!rule_digits(lo, 1, 10) || !rule_digits(hi, 1, 10)) { snprintf(err, errlen, "pattern must be LO-HI with 1-10 digits each"); return -1; } size_t llo = strlen(lo), lhi = strlen(hi); if (llo > lhi || (llo == lhi && strcmp(lo, hi) > 0)) { snprintf(err, errlen, "range start must not exceed range end"); return -1; } } else if (strcmp(match_type, "type") == 0) { if (strcmp(pattern, "asset") != 0 && strcmp(pattern, "liability") != 0 && strcmp(pattern, "equity") != 0 && strcmp(pattern, "revenue") != 0 && strcmp(pattern, "expense") != 0) { snprintf(err, errlen, "pattern must be asset, liability, equity, revenue or" " expense when match_type is type"); return -1; } } else { snprintf(err, errlen, "match_type must be account, range or type"); return -1; } if (sign != 1 && sign != -1) { snprintf(err, errlen, "sign must be 1 or -1"); return -1; } return 0; } static int rule_load(sqlite3 *db, int64_t org_id, int64_t id, struct rule_row *row) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( db, "SELECT id,report,box,match_type,pattern,sign,sort_order" " FROM report_rules WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return -1; sqlite3_bind_int64(st, 1, org_id); sqlite3_bind_int64(st, 2, id); int found = 0; if (sqlite3_step(st) == SQLITE_ROW) { memset(row, 0, sizeof *row); row->id = sqlite3_column_int64(st, 0); snprintf(row->report, sizeof row->report, "%s", sq(sqlite3_column_text(st, 1))); snprintf(row->box, sizeof row->box, "%s", sq(sqlite3_column_text(st, 2))); snprintf(row->match_type, sizeof row->match_type, "%s", sq(sqlite3_column_text(st, 3))); snprintf(row->pattern, sizeof row->pattern, "%s", sq(sqlite3_column_text(st, 4))); row->sign = sqlite3_column_int(st, 5); row->sort_order = sqlite3_column_int64(st, 6); found = 1; } sqlite3_finalize(st); return found ? 0 : -1; } static void rule_to_json(yyjson_mut_doc *doc, yyjson_mut_val *o, const struct rule_row *row) { yyjson_mut_obj_add_int(doc, o, "id", row->id); yyjson_mut_obj_add_strcpy(doc, o, "report", row->report); yyjson_mut_obj_add_strcpy(doc, o, "box", row->box); yyjson_mut_obj_add_strcpy(doc, o, "match_type", row->match_type); yyjson_mut_obj_add_strcpy(doc, o, "pattern", row->pattern); yyjson_mut_obj_add_int(doc, o, "sign", row->sign); yyjson_mut_obj_add_int(doc, o, "sort_order", row->sort_order); } static yyjson_mut_val *h_report_rule_list(struct req *r) { const char *report = arg_str(r->args, "report"); sqlite3_stmt *st = NULL; const char *sql = report ? "SELECT id,report,box,match_type,pattern,sign,sort_order" " FROM report_rules WHERE org_id=?1 AND report=?2" " ORDER BY report,sort_order,box,id" : "SELECT id,report,box,match_type,pattern,sign,sort_order" " FROM report_rules WHERE org_id=?1" " ORDER BY report,sort_order,box,id"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); if (report) sqlite3_bind_text(st, 2, report, -1, SQLITE_TRANSIENT); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); while (sqlite3_step(st) == SQLITE_ROW) { struct rule_row row; memset(&row, 0, sizeof row); row.id = sqlite3_column_int64(st, 0); snprintf(row.report, sizeof row.report, "%s", sq(sqlite3_column_text(st, 1))); snprintf(row.box, sizeof row.box, "%s", sq(sqlite3_column_text(st, 2))); snprintf(row.match_type, sizeof row.match_type, "%s", sq(sqlite3_column_text(st, 3))); snprintf(row.pattern, sizeof row.pattern, "%s", sq(sqlite3_column_text(st, 4))); row.sign = sqlite3_column_int(st, 5); row.sort_order = sqlite3_column_int64(st, 6); yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); rule_to_json(r->rdoc, o, &row); } sqlite3_finalize(st); yyjson_mut_val *res = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, res, "items", items); return res; } static yyjson_mut_val *h_report_rule_create(struct req *r) { const char *report = arg_str(r->args, "report"); const char *box = arg_str(r->args, "box"); const char *match_type = arg_str(r->args, "match_type"); const char *pattern = arg_str(r->args, "pattern"); int64_t sign = 1, sort_order = 0; arg_int(r->args, "sign", &sign); arg_int(r->args, "sort_order", &sort_order); if (!report || !box || !match_type || !pattern) return fail(r, "INVALID_ARGS", "report, box, match_type and pattern are required"); if (strcmp(report, "vat") != 0) return fail(r, "INVALID_ARGS", "report must be \"vat\""); char verr[256]; if (rule_validate(box, match_type, pattern, sign, verr, sizeof verr) != 0) return fail(r, "INVALID_ARGS", verr); if (r->dry_run) { struct rule_row row; memset(&row, 0, sizeof row); snprintf(row.report, sizeof row.report, "%s", report); snprintf(row.box, sizeof row.box, "%s", box); snprintf(row.match_type, sizeof row.match_type, "%s", match_type); snprintf(row.pattern, sizeof row.pattern, "%s", pattern); row.sign = (int)sign; row.sort_order = sort_order; yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); rule_to_json(r->rdoc, o, &row); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO report_rules(org_id,report,box,match_type,pattern," "sign,sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7)", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, report, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, box, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, match_type, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, pattern, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 6, sign); sqlite3_bind_int64(st, 7, sort_order); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); int64_t id = db_last_id(r->db); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "report_rule.create", reqjson, "OK", NULL); free(reqjson); struct rule_row row; memset(&row, 0, sizeof row); row.id = id; snprintf(row.report, sizeof row.report, "%s", report); snprintf(row.box, sizeof row.box, "%s", box); snprintf(row.match_type, sizeof row.match_type, "%s", match_type); snprintf(row.pattern, sizeof row.pattern, "%s", pattern); row.sign = (int)sign; row.sort_order = sort_order; yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); rule_to_json(r->rdoc, o, &row); return o; } static yyjson_mut_val *h_report_rule_update(struct req *r) { int64_t id = 0; arg_int(r->args, "id", &id); if (id <= 0) return fail(r, "INVALID_ARGS", "id is required"); struct rule_row row; if (rule_load(r->db, r->org_id, id, &row) != 0) return fail(r, "NOT_FOUND", "report rule not found"); const char *box = arg_str(r->args, "box"); const char *match_type = arg_str(r->args, "match_type"); const char *pattern = arg_str(r->args, "pattern"); int64_t sign = row.sign, sort_order = row.sort_order; arg_int(r->args, "sign", &sign); arg_int(r->args, "sort_order", &sort_order); if (box) snprintf(row.box, sizeof row.box, "%s", box); if (match_type) snprintf(row.match_type, sizeof row.match_type, "%s", match_type); if (pattern) snprintf(row.pattern, sizeof row.pattern, "%s", pattern); row.sign = (int)sign; row.sort_order = sort_order; char verr[256]; if (rule_validate(row.box, row.match_type, row.pattern, row.sign, verr, sizeof verr) != 0) return fail(r, "INVALID_ARGS", verr); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); rule_to_json(r->rdoc, o, &row); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE report_rules SET box=?3,match_type=?4,pattern=?5,sign=?6," "sort_order=?7 WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, row.id); sqlite3_bind_text(st, 3, row.box, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, row.match_type, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, row.pattern, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 6, row.sign); sqlite3_bind_int64(st, 7, row.sort_order); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "report rule not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "report_rule.update", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); rule_to_json(r->rdoc, o, &row); return o; } static yyjson_mut_val *h_report_rule_delete(struct req *r) { int64_t id = 0; arg_int(r->args, "id", &id); if (id <= 0) return fail(r, "INVALID_ARGS", "id is required"); struct rule_row row; if (rule_load(r->db, r->org_id, id, &row) != 0) return fail(r, "NOT_FOUND", "report rule not found"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_bool(r->rdoc, o, "deleted", true); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "DELETE FROM report_rules WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "report rule not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "report_rule.delete", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_bool(r->rdoc, o, "deleted", true); return o; } /* ------------------------------------------------------------------ */ /* voucher templates (konteringsmallar) */ /* ------------------------------------------------------------------ */ struct tpl_head { int64_t id; char name[128]; char series[16]; char description[256]; }; struct tpl_loaded { char account[16]; char formula[256]; char desc[128]; }; struct tpl_row_in { int64_t account_id; char account[16]; char formula[256]; char desc[128]; }; static double parse_kr_double(const char *s, int *ok) { *ok = 0; if (!s) return 0; char buf[64]; size_t j = 0; for (const char *p = s; *p && j < sizeof buf - 1; p++) { if (*p == ' ' || *p == '\t') continue; buf[j++] = (*p == ',') ? '.' : *p; } buf[j] = '\0'; if (!j || (j == 1 && buf[0] == '-')) return 0; char *end = NULL; double v = strtod(buf, &end); if (!end || *end) return 0; *ok = 1; return v; } static char *replace_x(const char *text, double x) { struct buf b; buf_init(&b); for (const char *p = text; *p;) { if (p[0] == '{' && p[1] == 'x' && p[2] == '}') { char tmp[64]; snprintf(tmp, sizeof tmp, "%.2f", x); buf_append(&b, tmp, strlen(tmp)); p += 3; } else { buf_append(&b, p, 1); p++; } } buf_append(&b, "", 1); return (char *)b.p; } static int load_template(sqlite3 *db, int64_t org_id, int64_t id, const char *name, struct tpl_head *head, char **err) { sqlite3_stmt *st = NULL; const char *sql = id ? "SELECT id,name,series,description FROM voucher_templates" " WHERE org_id=?1 AND id=?2 AND active=1" : "SELECT id,name,series,description FROM voucher_templates" " WHERE org_id=?1 AND name=?2 AND active=1"; if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { if (err) *err = xstrdup("database error"); return -1; } sqlite3_bind_int64(st, 1, org_id); if (id) sqlite3_bind_int64(st, 2, id); else sqlite3_bind_text(st, 2, name ? name : "", -1, SQLITE_TRANSIENT); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); if (err) *err = xstrdup("template not found"); return -1; } head->id = sqlite3_column_int64(st, 0); snprintf(head->name, sizeof head->name, "%s", sqlite3_column_text(st, 1)); snprintf(head->series, sizeof head->series, "%s", sqlite3_column_text(st, 2)); snprintf(head->description, sizeof head->description, "%s", sqlite3_column_text(st, 3)); sqlite3_finalize(st); return 0; } static int load_template_rows(sqlite3 *db, int64_t org_id, int64_t tpl, struct tpl_loaded **out, size_t *out_n, char **err) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( db, "SELECT a.number,tr.formula,COALESCE(tr.description,'')" " FROM voucher_template_rows tr JOIN accounts a" " ON a.org_id=tr.org_id AND a.id=tr.account_id" " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no", -1, &st, NULL) != SQLITE_OK) { if (err) *err = xstrdup("database error"); return -1; } sqlite3_bind_int64(st, 1, org_id); sqlite3_bind_int64(st, 2, tpl); size_t cap = 0, n = 0; struct tpl_loaded *rows = NULL; while (sqlite3_step(st) == SQLITE_ROW) { if (n == cap) { cap = cap ? cap * 2 : 8; rows = xrealloc(rows, cap * sizeof *rows); } memset(&rows[n], 0, sizeof rows[n]); snprintf(rows[n].account, sizeof rows[n].account, "%s", sqlite3_column_text(st, 0)); snprintf(rows[n].formula, sizeof rows[n].formula, "%s", sqlite3_column_text(st, 1)); snprintf(rows[n].desc, sizeof rows[n].desc, "%s", sqlite3_column_text(st, 2)); n++; } sqlite3_finalize(st); *out = rows; *out_n = n; return 0; } static int parse_template_rows(struct req *r, yyjson_val *rowsv, struct tpl_row_in **out, size_t *out_n) { *out = NULL; *out_n = 0; if (!rowsv || !yyjson_is_arr(rowsv)) return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1; size_t n = yyjson_arr_size(rowsv); if (n == 0 || n > 100) return fail(r, "INVALID_ARGS", "a template needs between 1 and 100 rows") ? -1 : -1; struct tpl_row_in *rows = xcalloc(n, sizeof *rows); size_t k = 0; yyjson_arr_iter it = yyjson_arr_iter_with(rowsv); yyjson_val *item; while ((item = yyjson_arr_iter_next(&it))) { if (!yyjson_is_obj(item)) { free(rows); fail(r, "INVALID_ARGS", "each row must be an object"); return -1; } yyjson_val *av = yyjson_obj_get(item, "account"); yyjson_val *fv = yyjson_obj_get(item, "formula"); if (!av || !yyjson_is_str(av) || !fv || !yyjson_is_str(fv)) { free(rows); failf(r, "INVALID_ARGS", "row %zu: account and formula are required", k + 1); return -1; } const char *account = yyjson_get_str(av); const char *formula = yyjson_get_str(fv); if (!formula_valid(formula)) { free(rows); failf(r, "INVALID_ARGS", "row %zu: ogiltig formel '%s'", k + 1, formula); return -1; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT id FROM accounts WHERE org_id=?1" " AND number=?2", -1, &st, NULL) != SQLITE_OK) { free(rows); return fail(r, "INTERNAL", "database error") ? -1 : -1; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); free(rows); failf(r, "ACCOUNT_NOT_FOUND", "row %zu: account %s does not exist", k + 1, account); return -1; } rows[k].account_id = sqlite3_column_int64(st, 0); sqlite3_finalize(st); snprintf(rows[k].account, sizeof rows[k].account, "%s", account); snprintf(rows[k].formula, sizeof rows[k].formula, "%s", formula); yyjson_val *dv = yyjson_obj_get(item, "description"); if (dv && yyjson_is_str(dv)) snprintf(rows[k].desc, sizeof rows[k].desc, "%s", yyjson_get_str(dv)); k++; } *out = rows; *out_n = k; return 0; } static yyjson_mut_val *tpl_rows_json(struct req *r, sqlite3_stmt *st) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "line_no", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "formula", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", sq(sqlite3_column_text(st, 4))); return o; } static yyjson_mut_val *h_template_list(struct req *r) { int active_only = 0; arg_bool(r->args, "active_only", &active_only); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); sqlite3_stmt *st = NULL; const char *sql = active_only ? "SELECT t.id,t.name,t.series,t.description,t.active," "(SELECT count(*) FROM voucher_template_rows tr" " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" " FROM voucher_templates t WHERE t.org_id=?1 AND t.active=1" " ORDER BY t.name" : "SELECT t.id,t.name,t.series,t.description,t.active," "(SELECT count(*) FROM voucher_template_rows tr" " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" " FROM voucher_templates t WHERE t.org_id=?1 ORDER BY t.name"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_bool(r->rdoc, o, "active", sqlite3_column_int(st, 4) != 0); yyjson_mut_obj_add_int(r->rdoc, o, "row_count", sqlite3_column_int64(st, 5)); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); return out; } static yyjson_mut_val *h_template_get(struct req *r) { int64_t id = 0; arg_int(r->args, "id", &id); const char *name = arg_str(r->args, "name"); if (id <= 0 && !name) return fail(r, "INVALID_ARGS", "id or name is required"); sqlite3_stmt *st = NULL; const char *sql = id > 0 ? "SELECT id,name,series,description,active" " FROM voucher_templates WHERE org_id=?1 AND id=?2" : "SELECT id,name,series,description,active" " FROM voucher_templates WHERE org_id=?1 AND name=?2"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); if (id > 0) sqlite3_bind_int64(st, 2, id); else sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "template not found"); } int64_t tpl = sqlite3_column_int64(st, 0); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_bool(r->rdoc, o, "active", sqlite3_column_int(st, 4) != 0); sqlite3_finalize(st); yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); if (sqlite3_prepare_v2( r->db, "SELECT tr.line_no,a.number,a.name,tr.formula," "COALESCE(tr.description,'') FROM voucher_template_rows tr" " JOIN accounts a ON a.org_id=tr.org_id AND a.id=tr.account_id" " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, tpl); while (sqlite3_step(st) == SQLITE_ROW) yyjson_mut_arr_add_val(rows, tpl_rows_json(r, st)); sqlite3_finalize(st); } yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); return o; } static yyjson_mut_val *h_template_create(struct req *r) { const char *name = arg_str(r->args, "name"); const char *series = arg_str(r->args, "series"); const char *description = arg_str(r->args, "description"); if (!name || !*name) return fail(r, "INVALID_ARGS", "name is required"); char *series_owned = NULL; if (!series || !*series) { series_owned = db_setting(r->db, r->org_id, "default_series"); series = series_owned && *series_owned ? series_owned : "A"; } if (!description) description = ""; struct tpl_row_in *rows = NULL; size_t nrows = 0; if (parse_template_rows( r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, &rows, &nrows) != 0) return NULL; if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); yyjson_mut_val *arr = yyjson_mut_arr(r->rdoc); for (size_t i = 0; i < nrows; i++) { yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, arr); yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", rows[i].account); yyjson_mut_obj_add_strcpy(r->rdoc, ro, "formula", rows[i].formula); yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", rows[i].desc); } yyjson_mut_obj_add_val(r->rdoc, o, "rows", arr); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); free(rows); free(series_owned); return o; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { free(rows); free(series_owned); return fail(r, "DB_BUSY", "could not start transaction"); } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "INSERT INTO voucher_templates(org_id,name,series," "description,created_at) VALUES(?1,?2,?3,?4,?5)", -1, &st, NULL) != SQLITE_OK) { db_exec(r->db, "ROLLBACK", NULL); free(rows); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { db_exec(r->db, "ROLLBACK", NULL); free(rows); if ((rc & 0xff) == SQLITE_CONSTRAINT) return fail(r, "CONFLICT", "a template with that name exists"); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } int64_t tpl = db_last_id(r->db); for (size_t i = 0; i < nrows; i++) { if (sqlite3_prepare_v2( r->db, "INSERT INTO voucher_template_rows(org_id,template_id,line_no," "account_id,formula,description) VALUES(?1,?2,?3,?4,?5,?6)", -1, &st, NULL) != SQLITE_OK) { db_exec(r->db, "ROLLBACK", NULL); free(rows); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, tpl); sqlite3_bind_int64(st, 3, (int64_t)i + 1); sqlite3_bind_int64(st, 4, rows[i].account_id); sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); if (rows[i].desc[0]) sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 6); rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { db_exec(r->db, "ROLLBACK", NULL); free(rows); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } } free(rows); if (db_exec(r->db, "COMMIT", NULL) != 0) return fail(r, "INTERNAL", "commit failed"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "template.create", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); free(series_owned); return o; } static yyjson_mut_val *h_template_update(struct req *r) { int64_t id = 0; arg_int(r->args, "id", &id); const char *name_arg = arg_str(r->args, "name"); if (id <= 0 && !name_arg) return fail(r, "INVALID_ARGS", "id or name is required"); struct tpl_head head; char *err = NULL; if (load_template(r->db, r->org_id, id, name_arg, &head, &err) != 0) { yyjson_mut_val *res = fail(r, "NOT_FOUND", err ? err : "template not found"); free(err); return res; } const char *new_name = arg_str(r->args, "name"); const char *series = arg_str(r->args, "series"); const char *description = arg_str(r->args, "description"); int active = -1; arg_bool(r->args, "active", &active); yyjson_val *rowsv = r->args ? yyjson_obj_get(r->args, "rows") : NULL; struct tpl_row_in *rows = NULL; size_t nrows = 0; if (rowsv) { if (parse_template_rows(r, rowsv, &rows, &nrows) != 0) return NULL; } if (!new_name && !series && !description && active < 0 && !rowsv) { free(rows); return fail(r, "INVALID_ARGS", "nothing to update"); } if (r->dry_run) { free(rows); return h_template_get(r); } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { free(rows); return fail(r, "DB_BUSY", "could not start transaction"); } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE voucher_templates SET" " name=COALESCE(?2,name), series=COALESCE(?3,series)," " description=COALESCE(?4,description)," " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6" " WHERE org_id=?1 AND id=?7", -1, &st, NULL) != SQLITE_OK) { db_exec(r->db, "ROLLBACK", NULL); free(rows); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); if (new_name) sqlite3_bind_text(st, 2, new_name, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 2); if (series) sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 3); if (description) sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 4); sqlite3_bind_int(st, 5, active); sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 7, head.id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { db_exec(r->db, "ROLLBACK", NULL); free(rows); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } if (rowsv) { if (sqlite3_prepare_v2( r->db, "DELETE FROM voucher_template_rows WHERE org_id=?1" " AND template_id=?2", -1, &st, NULL) != SQLITE_OK) { db_exec(r->db, "ROLLBACK", NULL); free(rows); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, head.id); rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { db_exec(r->db, "ROLLBACK", NULL); free(rows); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } for (size_t i = 0; i < nrows; i++) { if (sqlite3_prepare_v2( r->db, "INSERT INTO voucher_template_rows(org_id,template_id," "line_no,account_id,formula,description)" " VALUES(?1,?2,?3,?4,?5,?6)", -1, &st, NULL) != SQLITE_OK) { db_exec(r->db, "ROLLBACK", NULL); free(rows); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, head.id); sqlite3_bind_int64(st, 3, (int64_t)i + 1); sqlite3_bind_int64(st, 4, rows[i].account_id); sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); if (rows[i].desc[0]) sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 6); rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { db_exec(r->db, "ROLLBACK", NULL); free(rows); return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); } } } free(rows); if (db_exec(r->db, "COMMIT", NULL) != 0) return fail(r, "INTERNAL", "commit failed"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "template.update", reqjson, "OK", NULL); free(reqjson); return h_template_get(r); } static yyjson_mut_val *h_template_archive(struct req *r) { int64_t id = 0; arg_int(r->args, "id", &id); const char *name = arg_str(r->args, "name"); if (id <= 0 && !name) return fail(r, "INVALID_ARGS", "id or name is required"); sqlite3_stmt *st = NULL; const char *sql = id > 0 ? "UPDATE voucher_templates SET active=0 WHERE org_id=?1" " AND id=?2" : "UPDATE voucher_templates SET active=0 WHERE org_id=?1" " AND name=?2"; if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); if (id > 0) sqlite3_bind_int64(st, 2, id); else sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "template not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "template.archive", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } static yyjson_mut_val *h_voucher_post(struct req *r) { const char *date = arg_str(r->args, "date"); const char *description = arg_str(r->args, "description"); const char *series = arg_str(r->args, "series"); const char *client_ref = arg_str(r->args, "client_ref"); int64_t corrects = 0; arg_int(r->args, "corrects_voucher", &corrects); if (!date) return fail(r, "INVALID_ARGS", "date is required"); /* template application: {template, x} instead of {rows} */ int64_t tpl_id = 0; const char *tpl_name = NULL; yyjson_val *tv = r->args ? yyjson_obj_get(r->args, "template") : NULL; if (tv) { if (yyjson_is_int(tv)) tpl_id = yyjson_get_int(tv); else if (yyjson_is_str(tv)) tpl_name = yyjson_get_str(tv); else return fail(r, "INVALID_ARGS", "template must be an id or a name"); if (r->args && yyjson_obj_get(r->args, "rows")) return fail(r, "INVALID_ARGS", "use either rows or template, not both"); } double x = 0; yyjson_val *xv = r->args ? yyjson_obj_get(r->args, "x") : NULL; if (xv) { if (yyjson_is_str(xv)) { int ok = 0; x = parse_kr_double(yyjson_get_str(xv), &ok); if (!ok) return fail(r, "INVALID_ARGS", "x must be a number in kronor"); } else if (yyjson_is_real(xv)) { x = yyjson_get_real(xv); } else if (yyjson_is_int(xv)) { x = (double)yyjson_get_int(xv); } else { return fail(r, "INVALID_ARGS", "x must be a number in kronor"); } } struct ledger_row *rows = NULL; size_t nrows = 0; char *owned_desc = NULL; char owned_series[16] = ""; int rows_owned = 0; if (tv) { struct tpl_head head; char *err = NULL; if (load_template(r->db, r->org_id, tpl_id, tpl_name, &head, &err) != 0) { yyjson_mut_val *res = fail(r, "NOT_FOUND", err ? err : "template not found"); free(err); return res; } struct tpl_loaded *trows = NULL; size_t ntrows = 0; if (load_template_rows(r->db, r->org_id, head.id, &trows, &ntrows, &err) != 0) { yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "could not load template"); free(err); return res; } struct template_row *in = xcalloc(ntrows ? ntrows : 1, sizeof *in); for (size_t i = 0; i < ntrows; i++) { in[i].account = trows[i].account; in[i].formula = trows[i].formula; in[i].description = trows[i].desc[0] ? trows[i].desc : NULL; } struct resolved_row *resolved = xcalloc(ntrows ? ntrows : 1, sizeof *resolved); char ferr[256] = ""; if (formula_resolve_rows(in, ntrows, x, resolved, &nrows, ferr, sizeof ferr) != 0) { free(in); free(resolved); free(trows); return fail(r, "INVALID_ARGS", ferr[0] ? ferr : "template could not be resolved"); } free(in); free(trows); rows = xcalloc(nrows, sizeof *rows); for (size_t i = 0; i < nrows; i++) { rows[i].account = xstrdup(resolved[i].account); rows[i].debit_ore = resolved[i].debit_ore; rows[i].credit_ore = resolved[i].credit_ore; rows[i].description = resolved[i].description[0] ? xstrdup(resolved[i].description) : NULL; } free(resolved); rows_owned = 1; if (!series && head.series[0]) snprintf(owned_series, sizeof owned_series, "%s", head.series); if (!description && head.description[0]) owned_desc = replace_x(head.description, x); if (!description && !owned_desc) owned_desc = xstrdup(head.name); } else { if (!description) return fail(r, "INVALID_ARGS", "date and description are required"); if (parse_rows(r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, &rows, &nrows) != 0) return NULL; } int64_t *att = NULL; size_t natt = 0; if (parse_attachment_ids(r, &att, &natt) != 0) { if (rows_owned) for (size_t i = 0; i < nrows; i++) { free((char *)rows[i].account); free((char *)rows[i].description); } free(rows); free(owned_desc); return NULL; } struct ledger_post_opts o; memset(&o, 0, sizeof o); o.org_id = r->org_id; o.user_id = r->sess->user_id; o.token_id = r->sess->token_id; o.date = date; o.description = description ? description : owned_desc; o.series = series ? series : (owned_series[0] ? owned_series : NULL); o.rows = rows; o.nrows = nrows; o.corrects_voucher_id = corrects; o.attachment_ids = att; o.n_attachments = natt; o.client_ref = client_ref; o.dry_run = r->dry_run; struct ledger_error e; char *json = NULL; int rc = ledger_post(r->db, &o, &e, &json); if (rows_owned) for (size_t i = 0; i < nrows; i++) { free((char *)rows[i].account); free((char *)rows[i].description); } free(rows); free(att); free(owned_desc); if (rc != 0) { if (e.has_details) { yyjson_mut_val *details = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, details, "difference_ore", e.difference_ore); r->err_details = details; } return fail(r, e.code ? e.code : "INTERNAL", e.msg); } if (!r->dry_run) { char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "voucher.post", reqjson, "OK", NULL); free(reqjson); } yyjson_mut_val *res = json_to_mut(r->rdoc, json); free(json); if (!res) return fail(r, "INTERNAL", "could not serialize result"); return res; } static yyjson_mut_val *voucher_json(struct req *r, sqlite3_stmt *st) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", sqlite3_column_int64(st, 1)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "source", sq(sqlite3_column_text(st, 6))); if (sqlite3_column_type(st, 7) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id"); else yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id", sqlite3_column_int64(st, 7)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 8))); yyjson_mut_obj_add_int(r->rdoc, o, "created_by_user", sqlite3_column_int64(st, 9)); char hex[65]; const void *hb = sqlite3_column_blob(st, 10); if (hb && sqlite3_column_bytes(st, 10) == 32) { util_hex((const unsigned char *)hb, 32, hex); yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash_prev", hex); } const void *vb = sqlite3_column_blob(st, 11); if (vb && sqlite3_column_bytes(st, 11) == 32) { util_hex((const unsigned char *)vb, 32, hex); yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash", hex); } return o; } #define VOUCHER_COLUMNS \ "id,fiscal_year_id,series,number,date,description,source," \ "corrects_voucher_id,created_at,created_by_user,hash_prev,hash" static yyjson_mut_val *h_voucher_get(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT " VOUCHER_COLUMNS " FROM vouchers WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "voucher not found"); } yyjson_mut_val *o = voucher_json(r, st); sqlite3_finalize(st); yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); if (sqlite3_prepare_v2( r->db, "SELECT a.number,a.name,r.debit_ore,r.credit_ore," "COALESCE(r.description,'') FROM voucher_rows r" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, rows); yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", sq(sqlite3_column_text(st, 0))); yyjson_mut_obj_add_strcpy(r->rdoc, ro, "name", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_int(r->rdoc, ro, "debit_ore", sqlite3_column_int64(st, 2)); yyjson_mut_obj_add_int(r->rdoc, ro, "credit_ore", sqlite3_column_int64(st, 3)); yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", sq(sqlite3_column_text(st, 4))); } sqlite3_finalize(st); } yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); yyjson_mut_val *atts = yyjson_mut_arr(r->rdoc); if (sqlite3_prepare_v2( r->db, "SELECT a.id,a.filename FROM voucher_attachments va" " JOIN attachments a ON a.org_id=va.org_id AND a.id=va.attachment_id" " WHERE va.org_id=?1 AND va.voucher_id=?2 ORDER BY a.id", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); while (sqlite3_step(st) == SQLITE_ROW) { yyjson_mut_val *ao = yyjson_mut_arr_add_obj(r->rdoc, atts); yyjson_mut_obj_add_int(r->rdoc, ao, "id", sqlite3_column_int64(st, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, ao, "filename", sq(sqlite3_column_text(st, 1))); } sqlite3_finalize(st); } yyjson_mut_obj_add_val(r->rdoc, o, "attachments", atts); return o; } static yyjson_mut_val *h_voucher_list(struct req *r) { int64_t fy = 0, cursor = 0, limit = 100, account_id = 0; arg_int(r->args, "fiscal_year", &fy); arg_int(r->args, "cursor", &cursor); arg_int(r->args, "limit", &limit); if (limit < 1) limit = 100; if (limit > 1000) limit = 1000; const char *series = arg_str(r->args, "series"); const char *from = arg_str(r->args, "from"); const char *to = arg_str(r->args, "to"); const char *text = arg_str(r->args, "text"); const char *account = arg_str(r->args, "account"); if (account) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id FROM accounts WHERE org_id=?1 AND number=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "account not found"); } account_id = sqlite3_column_int64(st, 0); sqlite3_finalize(st); } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT v.id,v.series,v.number,v.date,v.description,v.source," "v.corrects_voucher_id," "(SELECT count(*) FROM voucher_rows r WHERE r.org_id=v.org_id" " AND r.voucher_id=v.id)," "(SELECT count(*) FROM voucher_attachments va WHERE va.org_id=v.org_id" " AND va.voucher_id=v.id)" " FROM vouchers v WHERE v.org_id=?1" " AND (?2=0 OR v.fiscal_year_id=?2)" " AND (?3='' OR v.series=?3)" " AND (?4='' OR v.date>=?4)" " AND (?5='' OR v.date<=?5)" " AND (?6=0 OR EXISTS(SELECT 1 FROM voucher_rows r2" " WHERE r2.org_id=v.org_id AND r2.voucher_id=v.id" " AND r2.account_id=?6))" " AND (?7='' OR v.description LIKE '%'||?7||'%')" " AND v.id>?8 ORDER BY v.id LIMIT ?9", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, fy); sqlite3_bind_text(st, 3, series ? series : "", -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, from ? from : "", -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, to ? to : "", -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 6, account_id); sqlite3_bind_text(st, 7, text ? text : "", -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 8, cursor); sqlite3_bind_int64(st, 9, limit); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); int64_t last = cursor, n = 0; while (sqlite3_step(st) == SQLITE_ROW) { n++; last = sqlite3_column_int64(st, 0); yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "id", last); yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 2)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "source", sq(sqlite3_column_text(st, 5))); if (sqlite3_column_type(st, 6) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id"); else yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id", sqlite3_column_int64(st, 6)); yyjson_mut_obj_add_int(r->rdoc, o, "row_count", sqlite3_column_int64(st, 7)); yyjson_mut_obj_add_int(r->rdoc, o, "attachment_count", sqlite3_column_int64(st, 8)); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); if (n == limit) yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last); else yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); return out; } static yyjson_mut_val *h_voucher_correct(struct req *r) { int64_t id = 0; const char *description = arg_str(r->args, "description"); const char *date = arg_str(r->args, "date"); const char *client_ref = arg_str(r->args, "client_ref"); if (!arg_int(r->args, "voucher", &id) || id <= 0) return fail(r, "INVALID_ARGS", "voucher is required"); if (!description || !*description) return fail(r, "INVALID_ARGS", "description is required"); sqlite3_stmt *st = NULL; char series[16] = "", orig_date[16] = ""; if (sqlite3_prepare_v2( r->db, "SELECT series,date FROM vouchers WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "voucher not found"); } snprintf(series, sizeof series, "%s", sqlite3_column_text(st, 0)); snprintf(orig_date, sizeof orig_date, "%s", sqlite3_column_text(st, 1)); sqlite3_finalize(st); struct ledger_row *rows = NULL; size_t nrows = 0, cap = 0; if (sqlite3_prepare_v2( r->db, "SELECT a.number,r.debit_ore,r.credit_ore," "COALESCE(r.description,'') FROM voucher_rows r" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); while (sqlite3_step(st) == SQLITE_ROW) { if (nrows == cap) { cap = cap ? cap * 2 : 8; rows = xrealloc(rows, cap * sizeof *rows); } memset(&rows[nrows], 0, sizeof rows[nrows]); rows[nrows].account = xstrdup(sq(sqlite3_column_text(st, 0))); rows[nrows].debit_ore = sqlite3_column_int64(st, 2); rows[nrows].credit_ore = sqlite3_column_int64(st, 1); const unsigned char *d = sqlite3_column_text(st, 3); rows[nrows].description = d && *d ? xstrdup((const char *)d) : NULL; nrows++; } sqlite3_finalize(st); if (nrows == 0) return fail(r, "INTERNAL", "voucher has no rows"); struct ledger_post_opts o; memset(&o, 0, sizeof o); o.org_id = r->org_id; o.user_id = r->sess->user_id; o.token_id = r->sess->token_id; o.date = date ? date : orig_date; o.description = description; o.series = series; o.rows = rows; o.nrows = nrows; o.corrects_voucher_id = id; o.client_ref = client_ref; o.dry_run = r->dry_run; struct ledger_error e; char *json = NULL; int rc = ledger_post(r->db, &o, &e, &json); for (size_t i = 0; i < nrows; i++) { free((char *)rows[i].account); free((char *)rows[i].description); } free(rows); if (rc != 0) return fail(r, e.code ? e.code : "INTERNAL", e.msg); if (!r->dry_run) { char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "voucher.correct", reqjson, "OK", NULL); free(reqjson); } yyjson_mut_val *res = json_to_mut(r->rdoc, json); free(json); if (!res) return fail(r, "INTERNAL", "could not serialize result"); return res; } /* ------------------------------------------------------------------ */ /* attachments */ /* ------------------------------------------------------------------ */ static int link_attachment(struct req *r, int64_t aid, int64_t vid, char **errmsg, const char **errcode) { *errcode = "INTERNAL"; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT (SELECT count(*) FROM vouchers WHERE org_id=?1 AND id=?2)," "(SELECT count(*) FROM voucher_attachments WHERE org_id=?1" " AND voucher_id=?2 AND attachment_id=?3)," "(SELECT count(*) FROM attachments WHERE org_id=?1 AND id=?3)", -1, &st, NULL) != SQLITE_OK) { *errmsg = xstrdup("database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, vid); sqlite3_bind_int64(st, 3, aid); int64_t vexists = 0, linked = 0, aexists = 0; if (sqlite3_step(st) == SQLITE_ROW) { vexists = sqlite3_column_int64(st, 0); linked = sqlite3_column_int64(st, 1); aexists = sqlite3_column_int64(st, 2); } sqlite3_finalize(st); if (!aexists) { *errmsg = xstrdup("attachment not found"); *errcode = "NOT_FOUND"; return -1; } if (!vexists) { *errmsg = xstrdup("voucher not found"); *errcode = "NOT_FOUND"; return -1; } if (linked) { *errmsg = xstrdup("attachment is already linked"); *errcode = "CONFLICT"; return -1; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); if (sqlite3_prepare_v2( r->db, "INSERT INTO voucher_attachments(org_id,voucher_id,attachment_id," "created_at) VALUES(?1,?2,?3,?4)", -1, &st, NULL) != SQLITE_OK) { *errmsg = xstrdup("database error"); return -1; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, vid); sqlite3_bind_int64(st, 3, aid); sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { *errmsg = xstrdup("could not link attachment"); return -1; } return 0; } static yyjson_mut_val *h_attachment_put(struct req *r) { const char *filename = arg_str(r->args, "filename"); const char *mime = arg_str(r->args, "mime"); const char *b64 = arg_str(r->args, "content_base64"); int64_t voucher_id = 0; arg_int(r->args, "voucher_id", &voucher_id); if (!filename || !*filename || !b64) return fail(r, "INVALID_ARGS", "filename and content_base64 are required"); if (!mime || !*mime) mime = "application/octet-stream"; unsigned char *content = NULL; size_t len = 0; if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) return fail(r, "INVALID_ARGS", "content_base64 is not valid base64"); if ((long)len > g_cfg.max_attachment_bytes) { free(content); return failf(r, "TOO_LARGE", "attachment exceeds %ld bytes", g_cfg.max_attachment_bytes); } unsigned char hash[32]; util_sha256(content, len, hash); char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id FROM attachments WHERE org_id=?1 AND sha256=?2" " AND filename=?3", -1, &st, NULL) != SQLITE_OK) { free(content); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); int64_t existing = 0; if (sqlite3_step(st) == SQLITE_ROW) existing = sqlite3_column_int64(st, 0); sqlite3_finalize(st); if (existing) { free(content); if (voucher_id) { char *emsg = NULL; const char *ecode = NULL; if (link_attachment(r, existing, voucher_id, &emsg, &ecode) != 0) { yyjson_mut_val *res = fail(r, ecode ? ecode : "INTERNAL", emsg); free(emsg); return res; } char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "attachment.link", reqjson, "OK", NULL); free(reqjson); } yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", existing); yyjson_mut_obj_add_bool(r->rdoc, o, "replayed", true); return o; } if (sqlite3_prepare_v2( r->db, "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes," "content,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", -1, &st, NULL) != SQLITE_OK) { free(content); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, mime, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 5, (int64_t)len); sqlite3_bind_blob(st, 6, content, (int)len, SQLITE_TRANSIENT); sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 8, r->sess->user_id); int rc = sqlite3_step(st); sqlite3_finalize(st); free(content); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); int64_t id = db_last_id(r->db); if (voucher_id) { char *emsg = NULL; const char *ecode = NULL; if (link_attachment(r, id, voucher_id, &emsg, &ecode) != 0) { yyjson_mut_val *res = fail(r, ecode ? ecode : "INTERNAL", emsg); free(emsg); return res; } } char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "attachment.put", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", (int64_t)len); return o; } static yyjson_mut_val *h_attachment_link(struct req *r) { int64_t id = 0, voucher_id = 0; arg_int(r->args, "id", &id); arg_int(r->args, "voucher_id", &voucher_id); if (id <= 0 || voucher_id <= 0) return fail(r, "INVALID_ARGS", "id and voucher_id are required"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } char *emsg = NULL; const char *ecode = NULL; if (link_attachment(r, id, voucher_id, &emsg, &ecode) != 0) { yyjson_mut_val *e = fail(r, ecode ? ecode : "INTERNAL", emsg); free(emsg); return e; } char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "attachment.link", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); return o; } static yyjson_mut_val *h_attachment_unlink(struct req *r) { int64_t id = 0, voucher_id = 0; arg_int(r->args, "id", &id); arg_int(r->args, "voucher_id", &voucher_id); if (id <= 0 || voucher_id <= 0) return fail(r, "INVALID_ARGS", "id and voucher_id are required"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "DELETE FROM voucher_attachments WHERE org_id=?1" " AND voucher_id=?2 AND attachment_id=?3", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, voucher_id); sqlite3_bind_int64(st, 3, id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "link not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "attachment.unlink", reqjson, "OK", NULL); free(reqjson); return yyjson_mut_obj(r->rdoc); } static yyjson_mut_val *h_attachment_get(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT filename,mime,size_bytes,content,sha256,created_at" " FROM attachments WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "attachment not found"); } const void *content = sqlite3_column_blob(st, 3); size_t len = (size_t)sqlite3_column_bytes(st, 3); char *b64 = util_b64(content ? content : (const unsigned char *)"", len); char hex[65]; const void *hb = sqlite3_column_blob(st, 4); if (hb && sqlite3_column_bytes(st, 4) == 32) util_hex((const unsigned char *)hb, 32, hex); else hex[0] = '\0'; yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", sq(sqlite3_column_text(st, 0))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", sqlite3_column_int64(st, 2)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); free(b64); sqlite3_finalize(st); return o; } static yyjson_mut_val *h_attachment_list(struct req *r) { int64_t voucher_id = 0, cursor = 0, limit = 100; int unlinked = 0; arg_int(r->args, "voucher_id", &voucher_id); arg_int(r->args, "cursor", &cursor); arg_int(r->args, "limit", &limit); arg_bool(r->args, "unlinked", &unlinked); if (limit < 1) limit = 100; if (limit > 1000) limit = 1000; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT a.id,a.filename,a.mime,a.size_bytes,a.created_at,a.sha256," "(SELECT va.voucher_id FROM voucher_attachments va" " WHERE va.org_id=a.org_id AND va.attachment_id=a.id" " AND (?2=0 OR va.voucher_id=?2)" " ORDER BY va.voucher_id LIMIT 1)" " FROM attachments a WHERE a.org_id=?1" " AND (?2=0 OR EXISTS(SELECT 1 FROM voucher_attachments va2" " WHERE va2.org_id=a.org_id AND va2.attachment_id=a.id" " AND va2.voucher_id=?2))" " AND (?3=0 OR NOT EXISTS(SELECT 1 FROM voucher_attachments va3" " WHERE va3.org_id=a.org_id AND va3.attachment_id=a.id))" " AND a.id>?4 ORDER BY a.id LIMIT ?5", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, voucher_id); sqlite3_bind_int(st, 3, unlinked); sqlite3_bind_int64(st, 4, cursor); sqlite3_bind_int64(st, 5, limit); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); int64_t n = 0, last = cursor; while (sqlite3_step(st) == SQLITE_ROW) { n++; last = sqlite3_column_int64(st, 0); yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, o, "id", last); yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", sq(sqlite3_column_text(st, 1))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", sqlite3_column_int64(st, 3)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 4))); char hex[65]; const void *hb = sqlite3_column_blob(st, 5); if (hb && sqlite3_column_bytes(st, 5) == 32) util_hex((const unsigned char *)hb, 32, hex); else hex[0] = '\0'; yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); if (sqlite3_column_type(st, 6) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); else yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", sqlite3_column_int64(st, 6)); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, out, "items", items); if (n == limit) yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last); else yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); return out; } /* ------------------------------------------------------------------ */ /* reports and SIE */ /* ------------------------------------------------------------------ */ static int64_t default_fy_id(sqlite3 *db, int64_t org_id) { sqlite3_stmt *st = NULL; int64_t id = 0; if (sqlite3_prepare_v2( db, "SELECT id FROM fiscal_years WHERE org_id=?1" " ORDER BY start_date DESC LIMIT 1", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, org_id); if (sqlite3_step(st) == SQLITE_ROW) id = sqlite3_column_int64(st, 0); sqlite3_finalize(st); } return id; } static int req_fy(struct req *r, int64_t *out) { int64_t fy = 0; arg_int(r->args, "fiscal_year", &fy); if (!fy) fy = default_fy_id(r->db, r->org_id); if (!fy) { fail(r, "NOT_FOUND", "no fiscal year; open one first"); return -1; } *out = fy; return 0; } static yyjson_mut_val *h_report_trial_balance(struct req *r) { int64_t fy = 0; if (req_fy(r, &fy) != 0) return NULL; int include_zero = 0; arg_bool(r->args, "include_zero", &include_zero); char *err = NULL; yyjson_mut_val *res = report_trial_balance( r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), arg_str(r->args, "to"), include_zero, &err); if (!res) { yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); free(err); return e; } return res; } static yyjson_mut_val *h_report_income_statement(struct req *r) { int64_t fy = 0; if (req_fy(r, &fy) != 0) return NULL; char *err = NULL; yyjson_mut_val *res = report_income_statement( r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), arg_str(r->args, "to"), &err); if (!res) { yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); free(err); return e; } return res; } static yyjson_mut_val *h_report_balance_sheet(struct req *r) { int64_t fy = 0; if (req_fy(r, &fy) != 0) return NULL; char *err = NULL; yyjson_mut_val *res = report_balance_sheet( r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "to"), &err); if (!res) { yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); free(err); return e; } return res; } static yyjson_mut_val *h_report_vat(struct req *r) { const char *from = arg_str(r->args, "from"); const char *to = arg_str(r->args, "to"); if (!from || !to) return fail(r, "INVALID_ARGS", "from and to are required"); char *err = NULL; yyjson_mut_val *res = report_vat(r->rdoc, r->db, r->org_id, from, to, &err); if (!res) { yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); free(err); return e; } return res; } static yyjson_mut_val *h_sru_export(struct req *r) { int64_t fy = 0; if (req_fy(r, &fy) != 0) return NULL; char *err = NULL; yyjson_mut_val *res = sru_export(r->rdoc, r->db, r->org_id, fy, r->args, &err); if (!res) { yyjson_mut_val *e = fail(r, "INVALID_ARGS", err ? err : "could not build the SRU files"); free(err); return e; } return res; } static yyjson_mut_val *h_report_general_ledger(struct req *r) { int64_t fy = 0; if (req_fy(r, &fy) != 0) return NULL; yyjson_val *accounts = yyjson_obj_get(r->args, "accounts"); if (accounts && !yyjson_is_arr(accounts)) return fail(r, "INVALID_ARGS", "accounts must be an array of strings"); char *err = NULL; yyjson_mut_val *res = report_general_ledger( r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"), arg_str(r->args, "to"), accounts, &err); if (!res) { yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); free(err); return e; } return res; } static yyjson_mut_val *h_report_voucher_list(struct req *r) { int64_t fy = 0; if (req_fy(r, &fy) != 0) return NULL; char *err = NULL; yyjson_mut_val *res = report_voucher_list( r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "series"), &err); if (!res) { yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed"); free(err); return e; } return res; } static yyjson_mut_val *h_report_vat_eskd(struct req *r) { const char *from = arg_str(r->args, "from"); const char *to = arg_str(r->args, "to"); if (!from || !to) return fail(r, "INVALID_ARGS", "from and to are required"); char *err = NULL; yyjson_mut_val *res = report_vat_eskd(r->rdoc, r->db, r->org_id, from, to, arg_str(r->args, "upplysning"), &err); if (!res) { yyjson_mut_val *e = fail(r, "INVALID_ARGS", err ? err : "could not build the eSKD file"); free(err); return e; } return res; } /* True for P&L accounts that take part in the taxable result (3xxx-88xx). */ static int bokslut_pl_account(const char *number) { int n = atoi(number); return n >= 3000 && n <= 8899; } static yyjson_mut_val *h_bokslut_post(struct req *r) { int64_t fy_id = 0; if (req_fy(r, &fy_id) != 0) return NULL; char fy_label[64] = "", fy_start[16] = "", fy_end[16] = ""; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT label,start_date,end_date FROM fiscal_years" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, fy_id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "fiscal year not found"); } snprintf(fy_label, sizeof fy_label, "%s", (const char *)sqlite3_column_text(st, 0)); snprintf(fy_start, sizeof fy_start, "%s", (const char *)sqlite3_column_text(st, 1)); snprintf(fy_end, sizeof fy_end, "%s", (const char *)sqlite3_column_text(st, 2)); sqlite3_finalize(st); const char *date = arg_str(r->args, "date"); if (!date) date = fy_end; else if (!util_parse_iso_date(date)) return fail(r, "INVALID_ARGS", "date must be YYYY-MM-DD"); yyjson_val *earr = yyjson_obj_get(r->args, "entries"); if (earr && !yyjson_is_arr(earr)) return fail(r, "INVALID_ARGS", "entries must be an array"); size_t nent = earr ? yyjson_arr_size(earr) : 0; if (nent > 31) return fail(r, "INVALID_ARGS", "too many entries"); struct ledger_row erows[64]; size_t nerows = 0; memset(erows, 0, sizeof erows); for (size_t i = 0; i < nent; i++) { yyjson_val *e = yyjson_arr_get(earr, i); const char *da = yyjson_get_str(yyjson_obj_get(e, "debit_account")); const char *ca = yyjson_get_str(yyjson_obj_get(e, "credit_account")); const char *ed = yyjson_get_str(yyjson_obj_get(e, "description")); int64_t amt = 0; yyjson_val *av = yyjson_obj_get(e, "amount_ore"); if (av && yyjson_is_int(av)) amt = yyjson_get_sint(av); if (!da || !*da || !ca || !*ca || amt <= 0) return fail(r, "INVALID_ARGS", "each entry needs debit_account, credit_account and" " amount_ore > 0"); erows[nerows++] = (struct ledger_row){ da, amt, 0, ed }; erows[nerows++] = (struct ledger_row){ ca, 0, amt, ed }; } int64_t fond = 0; arg_int(r->args, "periodiseringsfond_ore", &fond); if (fond < 0) return fail(r, "INVALID_ARGS", "periodiseringsfond_ore must be >= 0"); if (fond > 0) { if (nerows + 2 > 64) return fail(r, "INVALID_ARGS", "too many entries"); erows[nerows++] = (struct ledger_row){ "8811", fond, 0, NULL }; erows[nerows++] = (struct ledger_row){ "2110", 0, fond, NULL }; } int64_t result_before = 0; if (sqlite3_prepare_v2( r->db, "SELECT COALESCE(SUM(r.credit_ore)-SUM(r.debit_ore),0)" " FROM voucher_rows r" " JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND v.fiscal_year_id=?2 AND v.series<>'IB'" " AND a.type IN ('revenue','expense') AND a.number NOT LIKE '89%'", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, fy_id); if (sqlite3_step(st) == SQLITE_ROW) result_before = sqlite3_column_int64(st, 0); sqlite3_finalize(st); int64_t delta = 0; for (size_t i = 0; i < nerows; i++) if (bokslut_pl_account(erows[i].account)) delta += erows[i].credit_ore - erows[i].debit_ore; double tax_rate = 20.6; yyjson_val *trv = yyjson_obj_get(r->args, "tax_rate"); if (trv) { if (yyjson_is_real(trv)) tax_rate = yyjson_get_real(trv); else if (yyjson_is_int(trv)) tax_rate = (double)yyjson_get_sint(trv); else return fail(r, "INVALID_ARGS", "tax_rate must be a number"); if (tax_rate < 0 || tax_rate > 100) return fail(r, "INVALID_ARGS", "tax_rate must be 0-100"); } long bp = (long)(tax_rate * 100.0 + 0.5); int64_t taxable = result_before + delta; int64_t tax = taxable > 0 ? taxable * bp / 10000 : 0; int64_t after = taxable - tax; int dispose = 1; arg_bool(r->args, "dispose", &dispose); struct { const char *desc; const struct ledger_row *rows; size_t nrows; } plan[3]; size_t np = 0; if (nerows > 0) plan[np++] = (typeof(plan[0])){ "Bokslutsdispositioner", erows, nerows }; struct ledger_row taxrows[2] = { { "8910", tax, 0, NULL }, { "2512", 0, tax, NULL } }; if (tax != 0) plan[np++] = (typeof(plan[0])){ "Skatt på årets resultat", taxrows, 2 }; struct ledger_row drows[2]; if (dispose && after != 0) { if (after > 0) { drows[0] = (struct ledger_row){ "8999", after, 0, NULL }; drows[1] = (struct ledger_row){ "2099", 0, after, NULL }; } else { drows[0] = (struct ledger_row){ "2099", -after, 0, NULL }; drows[1] = (struct ledger_row){ "8999", 0, -after, NULL }; } plan[np++] = (typeof(plan[0])){ "Resultatdisposition", drows, 2 }; } yyjson_mut_val *vouchers = yyjson_mut_arr(r->rdoc); for (size_t i = 0; i < np; i++) { struct ledger_post_opts o; memset(&o, 0, sizeof o); o.org_id = r->org_id; o.user_id = r->sess->user_id; o.token_id = r->sess->token_id; o.date = date; o.description = plan[i].desc; o.rows = plan[i].rows; o.nrows = plan[i].nrows; o.dry_run = r->dry_run; struct ledger_error e; char *json = NULL; if (ledger_post(r->db, &o, &e, &json) != 0) { yyjson_mut_val *res = fail(r, e.code ? e.code : "INTERNAL", e.msg); free(json); return res; } yyjson_mut_val *vo = yyjson_mut_arr_add_obj(r->rdoc, vouchers); yyjson_mut_obj_add_strcpy(r->rdoc, vo, "description", plan[i].desc); yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); for (size_t k = 0; k < plan[i].nrows; k++) { yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, rows); yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", plan[i].rows[k].account); yyjson_mut_obj_add_int(r->rdoc, ro, "debit_ore", plan[i].rows[k].debit_ore); yyjson_mut_obj_add_int(r->rdoc, ro, "credit_ore", plan[i].rows[k].credit_ore); } yyjson_mut_obj_add_val(r->rdoc, vo, "rows", rows); yyjson_mut_val *pv = json_to_mut(r->rdoc, json); free(json); if (pv) yyjson_mut_obj_add_val(r->rdoc, vo, "voucher", pv); } if (!r->dry_run) { char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "bokslut.post", reqjson, "OK", NULL); free(reqjson); } yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_val *fy = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, fy, "id", fy_id); yyjson_mut_obj_add_strcpy(r->rdoc, fy, "label", fy_label); yyjson_mut_obj_add_strcpy(r->rdoc, fy, "start_date", fy_start); yyjson_mut_obj_add_strcpy(r->rdoc, fy, "end_date", fy_end); yyjson_mut_obj_add_val(r->rdoc, o, "fiscal_year", fy); yyjson_mut_obj_add_strcpy(r->rdoc, o, "date", date); yyjson_mut_obj_add_int(r->rdoc, o, "result_before_ore", result_before); yyjson_mut_obj_add_int(r->rdoc, o, "entries_ore", delta); yyjson_mut_obj_add_int(r->rdoc, o, "taxable_ore", taxable); yyjson_mut_obj_add_int(r->rdoc, o, "tax_ore", tax); yyjson_mut_obj_add_int(r->rdoc, o, "result_after_ore", after); yyjson_mut_obj_add_val(r->rdoc, o, "vouchers", vouchers); return o; } static unsigned char *read_file(const char *path, size_t *out_len) { FILE *f = fopen(path, "rb"); if (!f) return NULL; struct buf b; buf_init(&b); unsigned char chunk[65536]; size_t rn; while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) buf_append(&b, chunk, rn); int bad = ferror(f); fclose(f); if (bad) { buf_free(&b); return NULL; } *out_len = b.len; return b.p; } static yyjson_mut_val *h_sie_export(struct req *r) { int64_t fy = 0; if (req_fy(r, &fy) != 0) return NULL; int inline_out = 0; arg_bool(r->args, "inline", &inline_out); if (mkdir_p(g_cfg.export_dir, 0700) != 0) return fail(r, "INTERNAL", "cannot create export directory"); char label[64] = ""; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT label FROM fiscal_years WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, fy); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "fiscal year not found"); } snprintf(label, sizeof label, "%s", sqlite3_column_text(st, 0)); sqlite3_finalize(st); for (char *p = label; *p; p++) if (*p == '/') *p = '-'; char path[4096]; snprintf(path, sizeof path, "%s/%lld_%s.se", g_cfg.export_dir, (long long)r->org_id, label); char *err = NULL; if (sie_export_file(r->db, r->org_id, fy, path, &err) != 0) { yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "export failed"); free(err); return e; } char hex[65]; int64_t size = 0; if (hash_file(path, hex, &size) != 0) return fail(r, "INTERNAL", "could not read exported file"); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "sie.export", "{}", "OK", NULL); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy); yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path); yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); yyjson_mut_obj_add_int(r->rdoc, o, "size", size); if (inline_out) { size_t flen = 0; unsigned char *content = read_file(path, &flen); if (!content) return fail(r, "INTERNAL", "could not read exported file"); char *b64 = util_b64(content, flen); yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); free(b64); free(content); } return o; } static yyjson_mut_val *h_sie_import(struct req *r) { const char *b64 = arg_str(r->args, "content_base64"); const char *path = arg_str(r->args, "path"); unsigned char *content = NULL; size_t len = 0; if (b64) { if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) return fail(r, "INVALID_ARGS", "content_base64 is not valid base64"); } else if (path) { content = read_file(path, &len); if (!content) return fail(r, "NOT_FOUND", "could not read file"); } else { return fail(r, "INVALID_ARGS", "content_base64 or path is required"); } if (len > 64u * 1024u * 1024u) { free(content); return fail(r, "TOO_LARGE", "SIE file is too large"); } int64_t fy = 0, vouchers = 0, accounts = 0; char *err = NULL; int rc = sie_import_content(r->db, r->org_id, r->sess->user_id, content, len, r->dry_run, &fy, &vouchers, &accounts, &err); free(content); if (rc != 0) { yyjson_mut_val *e = fail(r, "INVALID_ARGS", err ? err : "import failed"); free(err); return e; } if (!r->dry_run) { char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "sie.import", reqjson, "OK", NULL); free(reqjson); } yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy); yyjson_mut_obj_add_int(r->rdoc, o, "vouchers", vouchers); yyjson_mut_obj_add_int(r->rdoc, o, "accounts_created", accounts); if (r->dry_run) yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } /* ------------------------------------------------------------------ */ /* bank reconciliation (phase 1: import and match, never book) */ /* ------------------------------------------------------------------ */ struct seb_row { const char *booked_at; const char *value_date; const char *text; const char *type; int64_t amount_ore; int has_balance; int64_t balance_ore; }; static int csv_split(char *line, char **fields, int max) { int nf = 0, in_quotes = 0; char *dst = line; fields[nf++] = dst; for (char *p = line; *p; p++) { if (*p == '"') { if (in_quotes && p[1] == '"') { *dst++ = '"'; p++; } else { in_quotes = !in_quotes; } } else if (*p == ';' && !in_quotes) { *dst++ = '\0'; if (nf >= max) return -1; fields[nf++] = dst; } else { *dst++ = *p; } } *dst = '\0'; return in_quotes ? -1 : nf; } static int seb_amount(const char *s, int64_t *out) { while (*s == ' ') s++; int neg = 0; if (*s == '-') { neg = 1; s++; } else if (*s == '+') { s++; } int64_t whole = 0, frac = 0; int digits = 0, fdigits = 0, comma = 0; for (; *s; s++) { if (*s == ' ' || *s == '.') continue; if (*s == ',') { if (comma) return -1; comma = 1; continue; } if (*s < '0' || *s > '9') return -1; if (comma) { if (fdigits >= 2) return -1; frac = frac * 10 + (*s - '0'); fdigits++; } else { if (whole > (INT64_MAX - 9) / 10) return -1; whole = whole * 10 + (*s - '0'); digits++; } } if (digits == 0) return -1; while (fdigits < 2) { frac *= 10; fdigits++; } if (whole > (INT64_MAX - frac) / 100) return -1; int64_t v = whole * 100 + frac; *out = neg ? -v : v; return 0; } static int seb_parse(char *csv, struct seb_row **out, size_t *out_n, char *err, size_t errlen) { static const char *header[7] = { "Bokförd", "Valutadatum", "Text", "Typ", "Insättningar", "Uttag", "Bokfört saldo", }; struct seb_row *rows = NULL; size_t n = 0, cap = 0; char *cur = csv; int lineno = 0; while (*cur) { lineno++; char *nl = strchr(cur, '\n'); if (nl) *nl = '\0'; size_t llen = strlen(cur); if (llen && cur[llen - 1] == '\r') cur[--llen] = '\0'; char *fields[8]; int nf = csv_split(cur, fields, 8); if (nf < 0) { snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); goto bad; } if (lineno == 1) { int ok = nf == 7; for (int i = 0; ok && i < 7; i++) if (strcmp(fields[i], header[i]) != 0) ok = 0; if (!ok) { snprintf(err, errlen, "not a SEB CSV export: header mismatch"); goto bad; } if (!nl) break; cur = nl + 1; continue; } if (llen == 0) { if (!nl) break; cur = nl + 1; continue; } if (nf != 7) { snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); goto bad; } struct seb_row row; row.booked_at = fields[0]; row.value_date = fields[1]; row.text = fields[2]; row.type = fields[3]; row.has_balance = fields[6][0] != '\0'; row.balance_ore = 0; int has_dep = fields[4][0] != '\0'; int has_wd = fields[5][0] != '\0'; if (!util_date_valid(fields[0]) || !util_date_valid(fields[1]) || has_dep + has_wd != 1) { snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); goto bad; } if (has_dep) { if (seb_amount(fields[4], &row.amount_ore) != 0) { snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); goto bad; } } else { int64_t w = 0; if (seb_amount(fields[5], &w) != 0) { snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); goto bad; } row.amount_ore = w > 0 ? -w : w; } if (row.has_balance && seb_amount(fields[6], &row.balance_ore) != 0) { snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); goto bad; } if (n == cap) { cap = cap ? cap * 2 : 64; rows = xrealloc(rows, cap * sizeof *rows); } rows[n++] = row; if (!nl) break; cur = nl + 1; } *out = rows; *out_n = n; return 0; bad: free(rows); return -1; } static void bank_field(struct buf *b, const char *s, int wide) { size_t n = strlen(s); if (wide) buf_append_u32be(b, (uint32_t)n); else buf_append_u16be(b, (uint16_t)n); buf_append(b, s, n); } static void bank_row_hash(const char *account, const struct seb_row *row, unsigned char out[32]) { static const char tag[] = "bokf-v1-bank-tx"; struct buf b; buf_init(&b); buf_append(&b, tag, sizeof tag); bank_field(&b, account, 0); bank_field(&b, row->booked_at, 0); bank_field(&b, row->value_date, 0); bank_field(&b, row->text, 1); bank_field(&b, row->type, 1); buf_append_u64be(&b, (uint64_t)row->amount_ore); unsigned char flag = row->has_balance ? 1 : 0; buf_append(&b, &flag, 1); if (row->has_balance) buf_append_u64be(&b, (uint64_t)row->balance_ore); util_sha256(b.p, b.len, out); buf_free(&b); } static int bank_hash_cmp(const void *a, const void *b) { return memcmp(a, b, 32); } static int bank_account_exists(struct req *r, const char *account) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT 1 FROM accounts WHERE org_id=?1 AND number=?2", -1, &st, NULL) != SQLITE_OK) return -1; sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); int found = sqlite3_step(st) == SQLITE_ROW; sqlite3_finalize(st); return found; } static yyjson_mut_val *h_bank_import(struct req *r) { const char *format = arg_str(r->args, "format"); const char *b64 = arg_str(r->args, "content_base64"); const char *path = arg_str(r->args, "path"); const char *account = arg_str(r->args, "account"); if (!format || strcmp(format, "seb") != 0) return fail(r, "INVALID_ARGS", "format must be \"seb\""); unsigned char *content = NULL; size_t len = 0; if (b64) { if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) return fail(r, "INVALID_ARGS", "content_base64 is not valid base64"); } else if (path) { content = read_file(path, &len); if (!content) return fail(r, "NOT_FOUND", "could not read file"); } else { return fail(r, "INVALID_ARGS", "content_base64 or path is required"); } if (len > 64u * 1024u * 1024u) { free(content); return fail(r, "TOO_LARGE", "bank statement is too large"); } if (memchr(content, '\0', len) != NULL) { free(content); return fail(r, "INVALID_ARGS", "not a SEB CSV export: header mismatch"); } char *csv = xmalloc(len + 1); memcpy(csv, content, len); csv[len] = '\0'; free(content); char *start = csv; if (len >= 3 && memcmp(start, "\xEF\xBB\xBF", 3) == 0) start += 3; char perr[128]; struct seb_row *rows = NULL; size_t nrows = 0; if (seb_parse(start, &rows, &nrows, perr, sizeof perr) != 0) { free(csv); return fail(r, "INVALID_ARGS", perr); } char *setting = account && *account ? NULL : db_setting(r->db, r->org_id, "bank_account"); const char *acct = account && *account ? account : (setting ? setting : "1930"); int found = bank_account_exists(r, acct); if (found < 0) { free(setting); free(rows); free(csv); return fail(r, "INTERNAL", "database error"); } if (!found) { yyjson_mut_val *res = failf(r, "ACCOUNT_NOT_FOUND", "account %s not found", acct); free(setting); free(rows); free(csv); return res; } unsigned char(*hashes)[32] = xmalloc((nrows ? nrows : 1) * 32); for (size_t i = 0; i < nrows; i++) bank_row_hash(acct, &rows[i], hashes[i]); int64_t imported = 0; if (r->dry_run) { unsigned char(*sorted)[32] = xmalloc((nrows ? nrows : 1) * 32); memcpy(sorted, hashes, nrows * 32); qsort(sorted, nrows, 32, bank_hash_cmp); for (size_t i = 0; i < nrows;) { size_t j = i + 1; while (j < nrows && memcmp(sorted[i], sorted[j], 32) == 0) j++; sqlite3_stmt *q = NULL; int exists = 0; if (sqlite3_prepare_v2( r->db, "SELECT 1 FROM bank_transactions WHERE org_id=?1" " AND source_hash=?2", -1, &q, NULL) != SQLITE_OK) { free(sorted); free(hashes); free(setting); free(rows); free(csv); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(q, 1, r->org_id); sqlite3_bind_blob(q, 2, sorted[i], 32, SQLITE_TRANSIENT); exists = sqlite3_step(q) == SQLITE_ROW; sqlite3_finalize(q); if (!exists) imported++; i = j; } free(sorted); } else { char ts[32]; util_iso8601(util_now(), ts, sizeof ts); int failed = db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0; for (size_t i = 0; i < nrows && !failed; i++) { sqlite3_stmt *ins = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO bank_transactions(org_id,account,booked_at," "value_date,text,type,amount_ore,balance_ore,source," "source_hash,imported_at,imported_by)" " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,'seb-csv',?9,?10,?11)" " ON CONFLICT(org_id,source_hash) DO NOTHING", -1, &ins, NULL) != SQLITE_OK) { failed = 1; break; } sqlite3_bind_int64(ins, 1, r->org_id); sqlite3_bind_text(ins, 2, acct, -1, SQLITE_TRANSIENT); sqlite3_bind_text(ins, 3, rows[i].booked_at, -1, SQLITE_TRANSIENT); sqlite3_bind_text(ins, 4, rows[i].value_date, -1, SQLITE_TRANSIENT); sqlite3_bind_text(ins, 5, rows[i].text, -1, SQLITE_TRANSIENT); sqlite3_bind_text(ins, 6, rows[i].type, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(ins, 7, rows[i].amount_ore); if (rows[i].has_balance) sqlite3_bind_int64(ins, 8, rows[i].balance_ore); else sqlite3_bind_null(ins, 8); sqlite3_bind_blob(ins, 9, hashes[i], 32, SQLITE_TRANSIENT); sqlite3_bind_text(ins, 10, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(ins, 11, r->sess->user_id); int rc = sqlite3_step(ins); sqlite3_finalize(ins); if (rc != SQLITE_DONE) { failed = 1; break; } if (sqlite3_changes(r->db) > 0) imported++; } if (failed) { db_exec(r->db, "ROLLBACK", NULL); free(hashes); free(setting); free(rows); free(csv); return fail(r, "INTERNAL", "database error"); } if (db_exec(r->db, "COMMIT", NULL) != 0) { db_exec(r->db, "ROLLBACK", NULL); free(hashes); free(setting); free(rows); free(csv); return fail(r, "INTERNAL", "database error"); } } int64_t duplicates = (int64_t)nrows - imported; const char *first = NULL, *last = NULL; for (size_t i = 0; i < nrows; i++) { if (!first || strcmp(rows[i].booked_at, first) < 0) first = rows[i].booked_at; if (!last || strcmp(rows[i].booked_at, last) > 0) last = rows[i].booked_at; } if (!r->dry_run) { char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "bank.import", reqjson, "OK", NULL); free(reqjson); } yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "format", "seb"); yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", acct); yyjson_mut_obj_add_int(r->rdoc, o, "total", (int64_t)nrows); yyjson_mut_obj_add_int(r->rdoc, o, "imported", imported); yyjson_mut_obj_add_int(r->rdoc, o, "duplicates", duplicates); if (first) yyjson_mut_obj_add_strcpy(r->rdoc, o, "first_date", first); else yyjson_mut_obj_add_null(r->rdoc, o, "first_date"); if (last) yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_date", last); else yyjson_mut_obj_add_null(r->rdoc, o, "last_date"); if (r->dry_run) yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); free(hashes); free(setting); free(rows); free(csv); return o; } static yyjson_mut_val *h_bank_list(struct req *r) { const char *status = arg_str(r->args, "status"); const char *from = arg_str(r->args, "from"); const char *to = arg_str(r->args, "to"); const char *account = arg_str(r->args, "account"); int64_t limit = 200; arg_int(r->args, "limit", &limit); if (limit < 1) limit = 200; if (limit > 1000) limit = 1000; int status_code = 0; if (status && strcmp(status, "unmatched") == 0) status_code = 1; else if (status && strcmp(status, "matched") == 0) status_code = 2; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT t.id,t.account,t.booked_at,t.value_date,t.text,t.type," "t.amount_ore,t.balance_ore FROM bank_transactions t" " WHERE t.org_id=?1" " AND (?2 IS NULL OR t.account=?2)" " AND (?3 IS NULL OR t.booked_at>=?3)" " AND (?4 IS NULL OR t.booked_at<=?4)" " AND (?5=0" " OR (?5=1 AND NOT EXISTS (SELECT 1 FROM bank_matches m" " WHERE m.org_id=t.org_id AND m.transaction_id=t.id))" " OR (?5=2 AND EXISTS (SELECT 1 FROM bank_matches m" " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)))" " ORDER BY t.booked_at,t.id LIMIT ?6", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); if (account) sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 2); if (from) sqlite3_bind_text(st, 3, from, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 3); if (to) sqlite3_bind_text(st, 4, to, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, 4); sqlite3_bind_int64(st, 5, status_code); sqlite3_bind_int64(st, 6, limit); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); while (sqlite3_step(st) == SQLITE_ROW) { int64_t tx_id = sqlite3_column_int64(st, 0); char tx_account[16]; snprintf(tx_account, sizeof tx_account, "%s", sq(sqlite3_column_text(st, 1))); const char *booked_at = sq(sqlite3_column_text(st, 2)); yyjson_mut_val *item = yyjson_mut_arr_add_obj(r->rdoc, items); yyjson_mut_obj_add_int(r->rdoc, item, "id", tx_id); yyjson_mut_obj_add_strcpy(r->rdoc, item, "account", tx_account); yyjson_mut_obj_add_strcpy(r->rdoc, item, "booked_at", booked_at); yyjson_mut_obj_add_strcpy(r->rdoc, item, "value_date", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_strcpy(r->rdoc, item, "text", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_strcpy(r->rdoc, item, "type", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_int(r->rdoc, item, "amount_ore", sqlite3_column_int64(st, 6)); if (sqlite3_column_type(st, 7) == SQLITE_NULL) yyjson_mut_obj_add_null(r->rdoc, item, "balance_ore"); else yyjson_mut_obj_add_int(r->rdoc, item, "balance_ore", sqlite3_column_int64(st, 7)); yyjson_mut_val *matches = yyjson_mut_arr(r->rdoc); sqlite3_stmt *ms = NULL; if (sqlite3_prepare_v2( r->db, "SELECT v.id,v.series,v.number,v.date," "COALESCE(SUM(r.debit_ore-r.credit_ore),0)" " FROM bank_matches m" " JOIN vouchers v ON v.org_id=m.org_id AND v.id=m.voucher_id" " LEFT JOIN accounts a ON a.org_id=v.org_id AND a.number=?2" " LEFT JOIN voucher_rows r ON r.org_id=v.org_id" " AND r.voucher_id=v.id AND r.account_id=a.id" " WHERE m.org_id=?1 AND m.transaction_id=?3" " GROUP BY v.id,v.series,v.number,v.date ORDER BY v.id", -1, &ms, NULL) != SQLITE_OK) { sqlite3_finalize(st); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(ms, 1, r->org_id); sqlite3_bind_text(ms, 2, tx_account, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(ms, 3, tx_id); while (sqlite3_step(ms) == SQLITE_ROW) { yyjson_mut_val *m = yyjson_mut_arr_add_obj(r->rdoc, matches); yyjson_mut_obj_add_int(r->rdoc, m, "voucher_id", sqlite3_column_int64(ms, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, m, "series", sq(sqlite3_column_text(ms, 1))); yyjson_mut_obj_add_int(r->rdoc, m, "number", sqlite3_column_int64(ms, 2)); yyjson_mut_obj_add_strcpy(r->rdoc, m, "date", sq(sqlite3_column_text(ms, 3))); yyjson_mut_obj_add_int(r->rdoc, m, "bank_amount_ore", sqlite3_column_int64(ms, 4)); } sqlite3_finalize(ms); yyjson_mut_obj_add_val(r->rdoc, item, "matches", matches); yyjson_mut_val *suggestions = yyjson_mut_arr(r->rdoc); if (yyjson_mut_arr_size(matches) == 0) { sqlite3_stmt *ss = NULL; if (sqlite3_prepare_v2( r->db, "SELECT v.id,v.series,v.number,v.date," "SUM(r.debit_ore-r.credit_ore)" " FROM vouchers v" " JOIN voucher_rows r ON r.org_id=v.org_id" " AND r.voucher_id=v.id" " JOIN accounts a ON a.org_id=r.org_id" " AND a.id=r.account_id" " WHERE v.org_id=?1 AND a.number=?2" " AND ABS(julianday(v.date)-julianday(?3))<=5" " AND NOT EXISTS (SELECT 1 FROM bank_matches m" " WHERE m.org_id=v.org_id AND m.voucher_id=v.id)" " GROUP BY v.id,v.series,v.number,v.date" " HAVING SUM(r.debit_ore-r.credit_ore)=?4" " ORDER BY ABS(julianday(v.date)-julianday(?3)),v.date," "v.id LIMIT 3", -1, &ss, NULL) != SQLITE_OK) { sqlite3_finalize(st); return fail(r, "INTERNAL", "database error"); } sqlite3_bind_int64(ss, 1, r->org_id); sqlite3_bind_text(ss, 2, tx_account, -1, SQLITE_TRANSIENT); sqlite3_bind_text(ss, 3, booked_at, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(ss, 4, sqlite3_column_int64(st, 6)); while (sqlite3_step(ss) == SQLITE_ROW) { yyjson_mut_val *s = yyjson_mut_arr_add_obj(r->rdoc, suggestions); yyjson_mut_obj_add_int(r->rdoc, s, "voucher_id", sqlite3_column_int64(ss, 0)); yyjson_mut_obj_add_strcpy(r->rdoc, s, "series", sq(sqlite3_column_text(ss, 1))); yyjson_mut_obj_add_int(r->rdoc, s, "number", sqlite3_column_int64(ss, 2)); yyjson_mut_obj_add_strcpy(r->rdoc, s, "date", sq(sqlite3_column_text(ss, 3))); yyjson_mut_obj_add_int(r->rdoc, s, "amount_ore", sqlite3_column_int64(ss, 4)); } sqlite3_finalize(ss); } yyjson_mut_obj_add_val(r->rdoc, item, "suggestions", suggestions); } sqlite3_finalize(st); sqlite3_stmt *sum = NULL; int64_t unmatched = 0, matched = 0, unmatched_amount = 0; if (sqlite3_prepare_v2( r->db, "SELECT COUNT(*)," "COALESCE(SUM(EXISTS (SELECT 1 FROM bank_matches m" " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)),0)," "COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM bank_matches m" " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)" " THEN 0 ELSE t.amount_ore END),0)" " FROM bank_transactions t" " WHERE t.org_id=?1 AND (?2 IS NULL OR t.account=?2)", -1, &sum, NULL) == SQLITE_OK) { sqlite3_bind_int64(sum, 1, r->org_id); if (account) sqlite3_bind_text(sum, 2, account, -1, SQLITE_TRANSIENT); else sqlite3_bind_null(sum, 2); if (sqlite3_step(sum) == SQLITE_ROW) { int64_t total = sqlite3_column_int64(sum, 0); matched = sqlite3_column_int64(sum, 1); unmatched = total - matched; unmatched_amount = sqlite3_column_int64(sum, 2); } sqlite3_finalize(sum); } yyjson_mut_val *res = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_val(r->rdoc, res, "items", items); yyjson_mut_val *s = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, s, "unmatched", unmatched); yyjson_mut_obj_add_int(r->rdoc, s, "matched", matched); yyjson_mut_obj_add_int(r->rdoc, s, "unmatched_amount_ore", unmatched_amount); yyjson_mut_obj_add_val(r->rdoc, res, "summary", s); return res; } static int bank_legs_sum(sqlite3 *db, int64_t org_id, int64_t tx_id, const char *account, int64_t *out) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( db, "SELECT COALESCE(SUM(r.debit_ore-r.credit_ore),0)" " FROM bank_matches m" " JOIN voucher_rows r ON r.org_id=m.org_id" " AND r.voucher_id=m.voucher_id" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE m.org_id=?1 AND m.transaction_id=?2 AND a.number=?3", -1, &st, NULL) != SQLITE_OK) return -1; sqlite3_bind_int64(st, 1, org_id); sqlite3_bind_int64(st, 2, tx_id); sqlite3_bind_text(st, 3, account, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); if (rc == SQLITE_ROW) *out = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return rc == SQLITE_ROW ? 0 : -1; } static yyjson_mut_val *h_bank_match(struct req *r) { int64_t tx_id = 0, voucher_id = 0; if (!arg_int(r->args, "transaction_id", &tx_id) || tx_id <= 0 || !arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) return fail(r, "INVALID_ARGS", "transaction_id and voucher_id are required"); sqlite3_stmt *st = NULL; char account[16]; int64_t amount = 0; if (sqlite3_prepare_v2( r->db, "SELECT account,amount_ore FROM bank_transactions" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, tx_id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return fail(r, "NOT_FOUND", "bank transaction not found"); } snprintf(account, sizeof account, "%s", sq(sqlite3_column_text(st, 0))); amount = sqlite3_column_int64(st, 1); sqlite3_finalize(st); int found = 0; if (sqlite3_prepare_v2( r->db, "SELECT 1 FROM vouchers WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, voucher_id); found = sqlite3_step(st) == SQLITE_ROW; sqlite3_finalize(st); if (!found) return fail(r, "NOT_FOUND", "voucher not found"); int touches = 0; if (sqlite3_prepare_v2( r->db, "SELECT 1 FROM voucher_rows r" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND r.voucher_id=?2 AND a.number=?3", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, voucher_id); sqlite3_bind_text(st, 3, account, -1, SQLITE_TRANSIENT); touches = sqlite3_step(st) == SQLITE_ROW; sqlite3_finalize(st); if (!touches) return failf(r, "INVALID_ARGS", "voucher does not post to account %s", account); int linked = 0; if (sqlite3_prepare_v2( r->db, "SELECT 1 FROM bank_matches WHERE org_id=?1 AND transaction_id=?2" " AND voucher_id=?3", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, tx_id); sqlite3_bind_int64(st, 3, voucher_id); linked = sqlite3_step(st) == SQLITE_ROW; sqlite3_finalize(st); if (linked) return fail(r, "CONFLICT", "transaction is already matched to this voucher"); int64_t legs = 0; if (bank_legs_sum(r->db, r->org_id, tx_id, account, &legs) != 0) return fail(r, "INTERNAL", "database error"); if (r->dry_run) { sqlite3_stmt *vs = NULL; int64_t voucher_leg = 0; if (sqlite3_prepare_v2( r->db, "SELECT COALESCE(SUM(r.debit_ore-r.credit_ore),0)" " FROM voucher_rows r" " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" " WHERE r.org_id=?1 AND r.voucher_id=?2 AND a.number=?3", -1, &vs, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(vs, 1, r->org_id); sqlite3_bind_int64(vs, 2, voucher_id); sqlite3_bind_text(vs, 3, account, -1, SQLITE_TRANSIENT); if (sqlite3_step(vs) == SQLITE_ROW) voucher_leg = sqlite3_column_int64(vs, 0); sqlite3_finalize(vs); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); yyjson_mut_obj_add_int(r->rdoc, o, "difference_ore", amount - (legs + voucher_leg)); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); if (sqlite3_prepare_v2( r->db, "INSERT INTO bank_matches(org_id,transaction_id,voucher_id," "matched_at,matched_by,kind) VALUES(?1,?2,?3,?4,?5,'manual')", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, tx_id); sqlite3_bind_int64(st, 3, voucher_id); sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 5, r->sess->user_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", "database error"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "bank.match", reqjson, "OK", NULL); free(reqjson); if (bank_legs_sum(r->db, r->org_id, tx_id, account, &legs) != 0) return fail(r, "INTERNAL", "database error"); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); yyjson_mut_obj_add_int(r->rdoc, o, "difference_ore", amount - legs); return o; } static yyjson_mut_val *h_bank_unmatch(struct req *r) { int64_t tx_id = 0, voucher_id = 0; if (!arg_int(r->args, "transaction_id", &tx_id) || tx_id <= 0 || !arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) return fail(r, "INVALID_ARGS", "transaction_id and voucher_id are required"); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT 1 FROM bank_matches WHERE org_id=?1 AND transaction_id=?2" " AND voucher_id=?3", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, tx_id); sqlite3_bind_int64(st, 3, voucher_id); int found = sqlite3_step(st) == SQLITE_ROW; sqlite3_finalize(st); if (!found) return fail(r, "NOT_FOUND", "match not found"); if (!r->dry_run) { if (sqlite3_prepare_v2( r->db, "DELETE FROM bank_matches WHERE org_id=?1" " AND transaction_id=?2 AND voucher_id=?3", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, tx_id); sqlite3_bind_int64(st, 3, voucher_id); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fail(r, "INTERNAL", "database error"); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "match not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "bank.unmatch", reqjson, "OK", NULL); free(reqjson); } yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); yyjson_mut_obj_add_bool(r->rdoc, o, "unmatched", true); if (r->dry_run) yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } /* command table */ /* ------------------------------------------------------------------ */ static const struct cmd_arg args_session_open[] = { { "method", ARG_ENUM, 1, NULL, "password,token", "Open with a password or an API token" }, { "username", ARG_STR, 0, NULL, NULL, "Username for method=password" }, { "password", ARG_STR, 0, NULL, NULL, "Password for method=password" }, { "token", ARG_STR, 0, NULL, NULL, "API token for method=token" }, }; static const struct cmd_arg args_session_use_org[] = { { "org", ARG_INT, 1, NULL, NULL, "Org id" }, }; static const struct cmd_arg args_org_create[] = { { "name", ARG_STR, 1, NULL, NULL, "Org name" }, { "org_nr", ARG_STR, 0, NULL, NULL, "Swedish org number" }, { "fiscal_year_start_month", ARG_INT, 0, "1", NULL, "Fiscal year start month 1-12" }, { "moms_period", ARG_ENUM, 0, "month", "month,quarter,year", "VAT reporting period" }, { "framework", ARG_ENUM, 0, "K2", "K2,K3", "Accounting framework" }, }; static const struct cmd_arg args_org_update[] = { { "name", ARG_STR, 0, NULL, NULL, "Org name" }, { "org_nr", ARG_STR, 0, NULL, NULL, "Swedish org number" }, { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, { "address", ARG_STR, 0, NULL, NULL, "Street address" }, { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, { "city", ARG_STR, 0, NULL, NULL, "City" }, { "country", ARG_STR, 0, NULL, NULL, "Country" }, { "email", ARG_STR, 0, NULL, NULL, "E-mail" }, { "phone", ARG_STR, 0, NULL, NULL, "Phone" }, { "moms_period", ARG_ENUM, 0, NULL, "month,quarter,year", "VAT reporting period" }, { "framework", ARG_ENUM, 0, NULL, "K2,K3", "Accounting framework" }, { "description", ARG_STR, 0, NULL, NULL, "Free-text description" }, { "fiscal_year_start_month", ARG_INT, 0, NULL, NULL, "Fiscal year start month 1-12" }, { "shares", ARG_INT, 0, NULL, NULL, "Number of shares" }, }; static const struct cmd_arg args_member_role[] = { { "username", ARG_STR, 1, NULL, NULL, "Username" }, { "role", ARG_ENUM, 1, NULL, "owner,bookkeeper,viewer", "Membership role" }, }; static const struct cmd_arg args_member_username[] = { { "username", ARG_STR, 1, NULL, NULL, "Username" }, }; static const struct cmd_arg args_board_add[] = { { "name", ARG_STR, 1, NULL, NULL, "Board member name" }, { "title", ARG_STR, 0, "Styrelseledamot", NULL, "Role on the board" }, }; static const struct cmd_arg args_board_update[] = { { "id", ARG_INT, 1, NULL, NULL, "Board member id" }, { "name", ARG_STR, 0, NULL, NULL, "Board member name" }, { "title", ARG_STR, 0, NULL, NULL, "Role on the board" }, }; static const struct cmd_arg args_board_remove[] = { { "id", ARG_INT, 1, NULL, NULL, "Board member id" }, }; static const struct cmd_arg args_user_create[] = { { "username", ARG_STR, 1, NULL, NULL, "Username" }, { "password", ARG_STR, 1, NULL, NULL, "Password" }, { "display_name", ARG_STR, 0, NULL, NULL, "Display name" }, { "is_admin", ARG_BOOL, 0, "false", NULL, "System admin" }, }; static const struct cmd_arg args_token_create[] = { { "label", ARG_STR, 1, NULL, NULL, "Label shown in token.list" }, { "scopes", ARG_JSON, 0, NULL, NULL, "Array of read, write, admin" }, { "expires_at", ARG_DATE, 0, NULL, NULL, "Expiry date" }, }; static const struct cmd_arg args_token_revoke[] = { { "id", ARG_INT, 1, NULL, NULL, "Token id" }, }; static const struct cmd_arg args_describe[] = { { "cmd", ARG_STR, 0, NULL, NULL, "Command name; omit for the full catalogue" }, }; static const struct cmd_arg args_audit_list[] = { { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, { "action", ARG_STR, 0, NULL, NULL, "Filter by action" }, }; static const struct cmd_arg args_audit_verify[] = { { "full", ARG_BOOL, 0, "false", NULL, "Also re-hash attachments" }, }; static const struct cmd_arg args_backup_snapshot[] = { { "dest", ARG_STR, 0, NULL, NULL, "Destination path; defaults to backup_dir with a timestamp" }, }; static const struct cmd_arg args_account_list[] = { { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active accounts" }, }; static const struct cmd_arg args_account_get[] = { { "id", ARG_INT, 0, NULL, NULL, "Account id" }, { "number", ARG_STR, 0, NULL, NULL, "Account number (string)" }, }; static const struct cmd_arg args_account_create[] = { { "number", ARG_STR, 1, NULL, NULL, "Account number, 1-10 digits" }, { "name", ARG_STR, 1, NULL, NULL, "Account name" }, { "type", ARG_ENUM, 1, NULL, "asset,liability,equity,revenue,expense", "Account type" }, { "sru_code", ARG_STR, 0, NULL, NULL, "SRU code" }, { "vat_code", ARG_STR, 0, NULL, NULL, "VAT code" }, }; static const struct cmd_arg args_account_update[] = { { "id", ARG_INT, 1, NULL, NULL, "Account id" }, { "name", ARG_STR, 0, NULL, NULL, "Account name" }, { "sru_code", ARG_STR, 0, NULL, NULL, "SRU code" }, { "vat_code", ARG_STR, 0, NULL, NULL, "VAT code" }, { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, }; static const struct cmd_arg args_fiscal_year_get[] = { { "id", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, }; static const struct cmd_arg args_fiscal_year_open[] = { { "label", ARG_STR, 1, NULL, NULL, "Fiscal year label" }, { "start_date", ARG_DATE, 1, NULL, NULL, "Start date (YYYY-MM-DD)" }, { "end_date", ARG_DATE, 1, NULL, NULL, "End date (YYYY-MM-DD)" }, }; static const struct cmd_arg args_fiscal_year_close[] = { { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "confirm", ARG_BOOL, 1, NULL, NULL, "Must be true" }, }; static const struct cmd_arg args_fiscal_year_reopen[] = { { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "confirm", ARG_BOOL, 1, NULL, NULL, "Must be true" }, }; static const struct cmd_arg args_fiscal_year_update[] = { { "id", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "dividend_ore", ARG_INT, 0, NULL, NULL, "Proposed dividend in öre" }, { "events", ARG_STR, 0, NULL, NULL, "Material events during the year" }, { "agm_date", ARG_STR, 0, NULL, NULL, "AGM date; empty clears" }, { "dividend_date", ARG_STR, 0, NULL, NULL, "Dividend payment date; empty clears" }, { "employees", ARG_STR, 0, NULL, NULL, "Average number of employees" }, { "notes", ARG_STR, 0, NULL, NULL, "Other notes" }, }; static const struct cmd_arg args_period_lock[] = { { "fiscal_year", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "until", ARG_DATE, 1, NULL, NULL, "Lock through this date, inclusive" }, { "reason", ARG_STR, 0, NULL, NULL, "Reason, recorded in the audit log" }, }; static const struct cmd_arg args_period_unlock[] = { { "fiscal_year", ARG_INT, 1, NULL, NULL, "Fiscal year id" }, { "reason", ARG_STR, 0, NULL, NULL, "Reason, recorded in the audit log" }, }; static const struct cmd_arg args_voucher_post[] = { { "date", ARG_DATE, 1, NULL, NULL, "Voucher date (YYYY-MM-DD)" }, { "description", ARG_STR, 0, NULL, NULL, "Voucher text" }, { "series", ARG_STR, 0, NULL, NULL, "Number series; defaults to settings.default_series" }, { "client_ref", ARG_STR, 0, NULL, NULL, "Idempotency key, unique per org" }, { "corrects_voucher", ARG_INT, 0, NULL, NULL, "Voucher id this one corrects" }, { "rows", ARG_JSON, 0, NULL, NULL, "Array of {account,debit_ore,credit_ore,description?}" }, { "template", ARG_JSON, 0, NULL, NULL, "Template id or name; alternative to rows" }, { "x", ARG_JSON, 0, NULL, NULL, "Template variable in kronor" }, { "attachment_ids", ARG_JSON, 0, NULL, NULL, "Array of attachment ids to link" }, }; static const struct cmd_arg args_voucher_get[] = { { "id", ARG_INT, 1, NULL, NULL, "Voucher id" }, }; static const struct cmd_arg args_voucher_list[] = { { "fiscal_year", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, { "from", ARG_DATE, 0, NULL, NULL, "Earliest date" }, { "to", ARG_DATE, 0, NULL, NULL, "Latest date" }, { "series", ARG_STR, 0, NULL, NULL, "Number series filter" }, { "account", ARG_STR, 0, NULL, NULL, "Account number filter" }, { "text", ARG_STR, 0, NULL, NULL, "Description substring filter" }, { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, }; static const struct cmd_arg args_voucher_correct[] = { { "voucher", ARG_INT, 1, NULL, NULL, "Voucher id to correct" }, { "description", ARG_STR, 1, NULL, NULL, "Correction text" }, { "date", ARG_DATE, 0, NULL, NULL, "Defaults to the original date" }, { "client_ref", ARG_STR, 0, NULL, NULL, "Idempotency key" }, }; static const struct cmd_arg args_bokslut_post[] = { { "fiscal_year", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, { "entries", ARG_JSON, 0, NULL, NULL, "Array of {debit_account,credit_account,amount_ore,description?}" }, { "periodiseringsfond_ore", ARG_INT, 0, NULL, NULL, "Periodiseringsfond in öre" }, { "tax_rate", ARG_JSON, 0, "20.6", NULL, "Tax rate percent, 0-100" }, { "dispose", ARG_BOOL, 0, "true", NULL, "Post the result transfer to 8999/2099" }, { "date", ARG_DATE, 0, NULL, NULL, "Posting date; defaults to the fiscal year end" }, }; static const struct cmd_arg args_settings_set[] = { { "key", ARG_STR, 1, NULL, NULL, "default_series, attachment_dir, bank_account," " invoice_receivable_account, invoice_revenue_account, smtp_host," " smtp_port, smtp_user, smtp_from, smtp_reply_to, smtp_security or" " smtp_password" }, { "value", ARG_STR, 0, NULL, NULL, "Setting value; an empty value clears smtp_password" }, }; static const struct cmd_arg args_bank_import[] = { { "format", ARG_ENUM, 1, NULL, "seb", "Statement format" }, { "content_base64", ARG_STR, 0, NULL, NULL, "Statement content, base64; alternative to path" }, { "path", ARG_STR, 0, NULL, NULL, "Server-side path; alternative to content_base64" }, { "account", ARG_STR, 0, NULL, NULL, "Bank account number; defaults to settings.bank_account" }, }; static const struct cmd_arg args_bank_list[] = { { "status", ARG_ENUM, 0, "all", "all,unmatched,matched", "Match status filter" }, { "from", ARG_DATE, 0, NULL, NULL, "Earliest booked date" }, { "to", ARG_DATE, 0, NULL, NULL, "Latest booked date" }, { "account", ARG_STR, 0, NULL, NULL, "Bank account number filter" }, { "limit", ARG_INT, 0, "200", NULL, "Page size, 1-1000" }, }; static const struct cmd_arg args_bank_match[] = { { "transaction_id", ARG_INT, 1, NULL, NULL, "Bank transaction id" }, { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, }; static const struct cmd_arg args_report_rule_list[] = { { "report", ARG_ENUM, 0, NULL, "vat", "Report filter; omit for all reports" }, }; static const struct cmd_arg args_report_rule_create[] = { { "report", ARG_ENUM, 1, NULL, "vat", "Report the rule belongs to" }, { "box", ARG_STR, 1, NULL, NULL, "Blankett box, 1-3 digits" }, { "match_type", ARG_ENUM, 1, NULL, "account,range,type", "How pattern selects accounts" }, { "pattern", ARG_STR, 1, NULL, NULL, "Account, account range or account type" }, { "sign", ARG_INT, 0, "1", NULL, "1 or -1" }, { "sort_order", ARG_INT, 0, "0", NULL, "Evaluation order" }, }; static const struct cmd_arg args_report_rule_update[] = { { "id", ARG_INT, 1, NULL, NULL, "Rule id" }, { "box", ARG_STR, 0, NULL, NULL, "Blankett box, 1-3 digits" }, { "match_type", ARG_ENUM, 0, NULL, "account,range,type", "How pattern selects accounts" }, { "pattern", ARG_STR, 0, NULL, NULL, "Account, account range or account type" }, { "sign", ARG_INT, 0, NULL, NULL, "1 or -1" }, { "sort_order", ARG_INT, 0, NULL, NULL, "Evaluation order" }, }; static const struct cmd_arg args_report_rule_delete[] = { { "id", ARG_INT, 1, NULL, NULL, "Rule id" }, }; static const struct cmd_arg args_template_list[] = { { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active templates" }, }; static const struct cmd_arg args_template_get[] = { { "id", ARG_INT, 0, NULL, NULL, "Template id" }, { "name", ARG_STR, 0, NULL, NULL, "Template name" }, }; static const struct cmd_arg args_template_create[] = { { "name", ARG_STR, 1, NULL, NULL, "Template name" }, { "series", ARG_STR, 0, NULL, NULL, "Number series; defaults to settings.default_series" }, { "description", ARG_STR, 0, NULL, NULL, "Voucher text; {x} is replaced with the amount" }, { "rows", ARG_JSON, 1, NULL, NULL, "Array of {account,formula,description?}" }, }; static const struct cmd_arg args_template_update[] = { { "id", ARG_INT, 0, NULL, NULL, "Template id" }, { "name", ARG_STR, 0, NULL, NULL, "Template name, or the new name when id is given" }, { "series", ARG_STR, 0, NULL, NULL, "Number series" }, { "description", ARG_STR, 0, NULL, NULL, "Voucher text" }, { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, { "rows", ARG_JSON, 0, NULL, NULL, "Replacement rows, {account,formula,description?}" }, }; static const struct cmd_arg args_template_archive[] = { { "id", ARG_INT, 0, NULL, NULL, "Template id" }, { "name", ARG_STR, 0, NULL, NULL, "Template name" }, }; static const struct cmd_arg args_attachment_put[] = { { "filename", ARG_STR, 1, NULL, NULL, "File name" }, { "mime", ARG_STR, 0, "application/octet-stream", NULL, "MIME type" }, { "content_base64", ARG_STR, 1, NULL, NULL, "File content, base64" }, { "voucher_id", ARG_INT, 0, NULL, NULL, "Link to this voucher" }, }; static const struct cmd_arg args_attachment_link[] = { { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, }; static const struct cmd_arg args_attachment_unlink[] = { { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, }; static const struct cmd_arg args_attachment_get[] = { { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, }; static const struct cmd_arg args_attachment_list[] = { { "voucher_id", ARG_INT, 0, NULL, NULL, "Only attachments on this voucher" }, { "unlinked", ARG_BOOL, 0, NULL, NULL, "Only unlinked attachments (inbox)" }, { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, }; static const struct cmd_arg args_report_trial_balance[] = { { "fiscal_year", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, { "from", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, { "to", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, { "include_zero", ARG_BOOL, 0, "false", NULL, "Include accounts with no activity" }, }; static const struct cmd_arg args_report_income_statement[] = { { "fiscal_year", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, { "from", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, { "to", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, }; static const struct cmd_arg args_report_balance_sheet[] = { { "fiscal_year", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, { "to", ARG_DATE, 0, NULL, NULL, "Report through this date" }, }; static const struct cmd_arg args_report_vat[] = { { "from", ARG_DATE, 1, NULL, NULL, "Period start" }, { "to", ARG_DATE, 1, NULL, NULL, "Period end" }, }; static const struct cmd_arg args_report_general_ledger[] = { { "fiscal_year", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, { "accounts", ARG_JSON, 0, NULL, NULL, "Array of account numbers to include" }, { "from", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, { "to", ARG_DATE, 0, NULL, NULL, "Narrow the period" }, }; static const struct cmd_arg args_report_voucher_list[] = { { "fiscal_year", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, { "series", ARG_STR, 0, NULL, NULL, "Number series filter" }, }; static const struct cmd_arg args_report_vat_eskd[] = { { "from", ARG_DATE, 1, NULL, NULL, "Period start" }, { "to", ARG_DATE, 1, NULL, NULL, "Period end" }, { "upplysning", ARG_STR, 0, NULL, NULL, "Free-text message to Skatteverket" }, }; static const struct cmd_arg args_sru_export[] = { { "fiscal_year", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, { "adjustments", ARG_JSON, 0, NULL, NULL, "Array of {code,amount_ore} tax adjustments" }, { "submitter", ARG_JSON, 0, NULL, NULL, "Object overriding the INFO.SRU submitter" }, { "assisted", ARG_BOOL, 0, NULL, NULL, "Assisted declaration" }, { "audited", ARG_BOOL, 0, NULL, NULL, "Audited declaration" }, { "ignore_unmapped", ARG_BOOL, 0, "false", NULL, "Proceed despite unmapped accounts" }, }; static const struct cmd_arg args_sie_export[] = { { "fiscal_year", ARG_INT, 0, NULL, NULL, "Fiscal year id; defaults to the latest" }, { "inline", ARG_BOOL, 0, "false", NULL, "Also return content_base64" }, }; static const struct cmd_arg args_sie_import[] = { { "content_base64", ARG_STR, 0, NULL, NULL, "SIE file content, base64" }, { "path", ARG_STR, 0, NULL, NULL, "Server-side path; alternative to content_base64" }, }; static const struct cmd_arg args_customer_list[] = { { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active customers" }, }; static const struct cmd_arg args_customer_get[] = { { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, }; static const struct cmd_arg args_customer_create[] = { { "name", ARG_STR, 1, NULL, NULL, "Customer name, unique per org" }, { "address", ARG_STR, 0, NULL, NULL, "Street address; may contain newlines" }, { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, { "city", ARG_STR, 0, NULL, NULL, "City" }, { "country", ARG_STR, 0, "SE", NULL, "Country code" }, { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, { "email", ARG_STR, 0, NULL, NULL, "E-mail address" }, { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, { "payment_days", ARG_INT, 0, "30", NULL, "Payment terms in days" }, }; static const struct cmd_arg args_customer_update[] = { { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, { "name", ARG_STR, 0, NULL, NULL, "Customer name, unique per org" }, { "address", ARG_STR, 0, NULL, NULL, "Street address" }, { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, { "city", ARG_STR, 0, NULL, NULL, "City" }, { "country", ARG_STR, 0, NULL, NULL, "Country code" }, { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, { "email", ARG_STR, 0, NULL, NULL, "E-mail address" }, { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, { "payment_days", ARG_INT, 0, NULL, NULL, "Payment terms in days" }, { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, }; static const struct cmd_arg args_customer_archive[] = { { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, { "active", ARG_BOOL, 1, NULL, NULL, "false archives, true reactivates" }, }; static const struct cmd_arg args_invoice_sequence_set[] = { { "next_number", ARG_INT, 1, NULL, NULL, "Next invoice number" }, }; static const struct cmd_arg args_invoice_draft[] = { { "customer_id", ARG_INT, 1, NULL, NULL, "Customer id" }, { "invoice_date", ARG_DATE, 1, NULL, NULL, "Invoice date (YYYY-MM-DD)" }, { "due_date", ARG_DATE, 1, NULL, NULL, "Due date (YYYY-MM-DD)" }, { "delivery_date", ARG_STR, 0, NULL, NULL, "Delivery date or empty" }, { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, { "our_ref", ARG_STR, 0, NULL, NULL, "Our reference" }, { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, { "rows", ARG_JSON, 1, NULL, NULL, "Array of {article_no,description,quantity,unit,unit_price_ore,note," "vat_code,account}" }, }; static const struct cmd_arg args_invoice_get[] = { { "id", ARG_INT, 1, NULL, NULL, "Invoice id" }, }; static const struct cmd_arg args_invoice_list[] = { { "customer_id", ARG_INT, 0, NULL, NULL, "Customer filter" }, { "status", ARG_ENUM, 0, NULL, "issued,credited", "Status filter" }, { "limit", ARG_INT, 0, "200", NULL, "Page size, 1-1000" }, }; const struct command g_commands[] = { { "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health, NULL, 0 }, { "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta, NULL, 0 }, { "session.open", "Open a session (password or token)", PERM_PUBLIC, 0, 0, 0, h_session_open, CMD_ARGS(args_session_open) }, { "session.close", "Close the current session", PERM_READ, 0, 0, 0, h_session_close, NULL, 0 }, { "session.whoami", "Current user, org, role and scopes", PERM_READ, 0, 0, 0, h_session_whoami, NULL, 0 }, { "session.list_orgs", "Orgs the current user is a member of", PERM_READ, 0, 0, 0, h_session_list_orgs, NULL, 0 }, { "session.use_org", "Switch active org", PERM_READ, 0, 0, 0, h_session_use_org, CMD_ARGS(args_session_use_org) }, { "org.create", "Create an org and become its owner", PERM_READ, 0, 1, 1, h_org_create, CMD_ARGS(args_org_create) }, { "org.list", "List orgs for the current user", PERM_READ, 0, 0, 0, h_org_list, NULL, 0 }, { "org.get", "Get org details", PERM_READ, 1, 0, 0, h_org_get, NULL, 0 }, { "org.update", "Update org details (owner)", PERM_OWNER, 1, 1, 1, h_org_update, CMD_ARGS(args_org_update) }, { "org.member_list", "List org members", PERM_READ, 1, 0, 0, h_org_member_list, NULL, 0 }, { "org.member_add", "Add a member to the org", PERM_OWNER, 1, 1, 1, h_org_member_add, CMD_ARGS(args_member_role) }, { "org.member_set_role", "Change a member's role", PERM_OWNER, 1, 1, 1, h_org_member_set_role, CMD_ARGS(args_member_role) }, { "org.member_remove", "Remove a member from the org", PERM_OWNER, 1, 1, 1, h_org_member_remove, CMD_ARGS(args_member_username) }, { "board.list", "List board members", PERM_READ, 1, 0, 0, h_board_list, NULL, 0 }, { "board.add", "Add a board member", PERM_OWNER, 1, 1, 1, h_board_add, CMD_ARGS(args_board_add) }, { "board.update", "Update a board member", PERM_OWNER, 1, 1, 1, h_board_update, CMD_ARGS(args_board_update) }, { "board.remove", "Remove a board member", PERM_OWNER, 1, 1, 1, h_board_remove, CMD_ARGS(args_board_remove) }, { "user.create", "Create a user (system admin)", PERM_ADMIN, 0, 1, 1, h_user_create, CMD_ARGS(args_user_create) }, { "user.list", "List users (system admin)", PERM_ADMIN, 0, 0, 0, h_user_list, NULL, 0 }, { "token.create", "Create an API token for the active org", PERM_READ, 1, 1, 1, h_token_create, CMD_ARGS(args_token_create) }, { "token.list", "List your API tokens in the active org", PERM_READ, 1, 0, 0, h_token_list, NULL, 0 }, { "token.revoke", "Revoke an API token", PERM_READ, 1, 1, 1, h_token_revoke, CMD_ARGS(args_token_revoke) }, { "describe", "List implemented commands and permissions", PERM_READ, 0, 0, 0, h_describe, CMD_ARGS(args_describe) }, { "agent.instructions", "Workflow rules for agents", PERM_READ, 0, 0, 0, h_agent_instructions, NULL, 0 }, { "audit.list", "Read the audit log for the active org", PERM_READ, 1, 0, 0, h_audit_list, CMD_ARGS(args_audit_list) }, { "audit.verify", "Verify the voucher and audit hash chains", PERM_READ, 0, 0, 0, h_audit_verify, CMD_ARGS(args_audit_verify) }, { "backup.snapshot", "Create a consistent database snapshot", PERM_ADMIN, 0, 1, 0, h_backup_snapshot, CMD_ARGS(args_backup_snapshot) }, { "account.list", "List the chart of accounts", PERM_READ, 1, 0, 0, h_account_list, CMD_ARGS(args_account_list) }, { "account.get", "Get one account", PERM_READ, 1, 0, 0, h_account_get, CMD_ARGS(args_account_get) }, { "account.create", "Add an account", PERM_WRITE, 1, 1, 1, h_account_create, CMD_ARGS(args_account_create) }, { "account.update", "Rename or deactivate an account", PERM_WRITE, 1, 1, 1, h_account_update, CMD_ARGS(args_account_update) }, { "fiscal_year.list", "List fiscal years", PERM_READ, 1, 0, 0, h_fiscal_year_list, NULL, 0 }, { "fiscal_year.get", "Get a fiscal year", PERM_READ, 1, 0, 0, h_fiscal_year_get, CMD_ARGS(args_fiscal_year_get) }, { "fiscal_year.open", "Open a new fiscal year", PERM_OWNER, 1, 1, 1, h_fiscal_year_open, CMD_ARGS(args_fiscal_year_open) }, { "fiscal_year.close", "Close a fiscal year", PERM_OWNER, 1, 1, 0, h_fiscal_year_close, CMD_ARGS(args_fiscal_year_close) }, { "fiscal_year.reopen", "Reopen a closed fiscal year", PERM_OWNER, 1, 1, 1, h_fiscal_year_reopen, CMD_ARGS(args_fiscal_year_reopen) }, { "fiscal_year.update", "Update fiscal-year metadata (dividend)", PERM_WRITE, 1, 1, 1, h_fiscal_year_update, CMD_ARGS(args_fiscal_year_update) }, { "period.lock", "Lock a period through a date", PERM_OWNER, 1, 1, 1, h_period_lock, CMD_ARGS(args_period_lock) }, { "period.unlock", "Remove a period lock", PERM_OWNER, 1, 1, 1, h_period_unlock, CMD_ARGS(args_period_unlock) }, { "voucher.post", "Post an immutable voucher (rows or template+x)", PERM_WRITE, 1, 1, 1, h_voucher_post, CMD_ARGS(args_voucher_post) }, { "voucher.get", "Get a voucher with rows", PERM_READ, 1, 0, 0, h_voucher_get, CMD_ARGS(args_voucher_get) }, { "voucher.list", "List vouchers", PERM_READ, 1, 0, 0, h_voucher_list, CMD_ARGS(args_voucher_list) }, { "voucher.correct", "Post an ändringsverifikat", PERM_WRITE, 1, 1, 1, h_voucher_correct, CMD_ARGS(args_voucher_correct) }, { "bokslut.post", "Year-end: dispositions, tax and result transfer", PERM_WRITE, 1, 1, 1, h_bokslut_post, CMD_ARGS(args_bokslut_post) }, { "settings.get", "Read org settings", PERM_READ, 1, 0, 0, h_settings_get, NULL, 0 }, { "settings.set", "Change an org setting", PERM_WRITE, 1, 1, 1, h_settings_set, CMD_ARGS(args_settings_set) }, { "bank.import", "Import a bank statement (SEB CSV)", PERM_WRITE, 1, 1, 1, h_bank_import, CMD_ARGS(args_bank_import) }, { "bank.list", "List bank transactions, matches and suggestions", PERM_READ, 1, 0, 0, h_bank_list, CMD_ARGS(args_bank_list) }, { "bank.match", "Match a bank transaction to a voucher", PERM_WRITE, 1, 1, 1, h_bank_match, CMD_ARGS(args_bank_match) }, { "bank.unmatch", "Remove a transaction/voucher match", PERM_WRITE, 1, 1, 1, h_bank_unmatch, CMD_ARGS(args_bank_match) }, { "report.rule_list", "List per-org reporting rules", PERM_READ, 1, 0, 0, h_report_rule_list, CMD_ARGS(args_report_rule_list) }, { "report.rule_create", "Create a reporting rule (owner)", PERM_OWNER, 1, 1, 1, h_report_rule_create, CMD_ARGS(args_report_rule_create) }, { "report.rule_update", "Update a reporting rule (owner)", PERM_OWNER, 1, 1, 1, h_report_rule_update, CMD_ARGS(args_report_rule_update) }, { "report.rule_delete", "Delete a reporting rule (owner)", PERM_OWNER, 1, 1, 1, h_report_rule_delete, CMD_ARGS(args_report_rule_delete) }, { "template.list", "List voucher templates", PERM_READ, 1, 0, 0, h_template_list, CMD_ARGS(args_template_list) }, { "template.get", "Get a voucher template with its rows", PERM_READ, 1, 0, 0, h_template_get, CMD_ARGS(args_template_get) }, { "template.create", "Create a voucher template", PERM_WRITE, 1, 1, 1, h_template_create, CMD_ARGS(args_template_create) }, { "template.update", "Update a voucher template", PERM_WRITE, 1, 1, 1, h_template_update, CMD_ARGS(args_template_update) }, { "template.archive", "Archive a voucher template", PERM_WRITE, 1, 1, 1, h_template_archive, CMD_ARGS(args_template_archive) }, { "attachment.put", "Store an underlag (receipt, invoice)", PERM_WRITE, 1, 1, 1, h_attachment_put, CMD_ARGS(args_attachment_put) }, { "attachment.link", "Link an existing attachment to a voucher", PERM_WRITE, 1, 1, 1, h_attachment_link, CMD_ARGS(args_attachment_link) }, { "attachment.unlink", "Remove an attachment link", PERM_WRITE, 1, 1, 1, h_attachment_unlink, CMD_ARGS(args_attachment_unlink) }, { "attachment.get", "Fetch an attachment", PERM_READ, 1, 0, 0, h_attachment_get, CMD_ARGS(args_attachment_get) }, { "attachment.list", "List attachments / inbox", PERM_READ, 1, 0, 0, h_attachment_list, CMD_ARGS(args_attachment_list) }, { "report.trial_balance", "Saldobalans (IB, period, UB)", PERM_READ, 1, 0, 0, h_report_trial_balance, CMD_ARGS(args_report_trial_balance) }, { "report.income_statement", "Resultaträkning", PERM_READ, 1, 0, 0, h_report_income_statement, CMD_ARGS(args_report_income_statement) }, { "report.balance_sheet", "Balansräkning", PERM_READ, 1, 0, 0, h_report_balance_sheet, CMD_ARGS(args_report_balance_sheet) }, { "report.vat", "Momsdeklaration ruta för ruta", PERM_READ, 1, 0, 0, h_report_vat, CMD_ARGS(args_report_vat) }, { "report.general_ledger", "Huvudbok", PERM_READ, 1, 0, 0, h_report_general_ledger, CMD_ARGS(args_report_general_ledger) }, { "report.voucher_list", "Verifikationslista", PERM_READ, 1, 0, 0, h_report_voucher_list, CMD_ARGS(args_report_voucher_list) }, { "report.vat_eskd", "eSKD XML for the momsdeklaration (ISO-8859-1)", PERM_READ, 1, 0, 0, h_report_vat_eskd, CMD_ARGS(args_report_vat_eskd) }, { "sru.export", "INK2 SRU files (INFO.SRU + BLANKETTER.SRU)", PERM_READ, 1, 0, 0, h_sru_export, CMD_ARGS(args_sru_export) }, { "sie.export", "Export SIE 4 (PC8)", PERM_READ, 1, 0, 0, h_sie_export, CMD_ARGS(args_sie_export) }, { "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1, 1, h_sie_import, CMD_ARGS(args_sie_import) }, { "customer.list", "List customers ordered by name", PERM_READ, 1, 0, 0, h_customer_list, CMD_ARGS(args_customer_list) }, { "customer.get", "Get one customer", PERM_READ, 1, 0, 0, h_customer_get, CMD_ARGS(args_customer_get) }, { "customer.create", "Create a customer", PERM_WRITE, 1, 1, 1, h_customer_create, CMD_ARGS(args_customer_create) }, { "customer.update", "Update a customer (merged)", PERM_WRITE, 1, 1, 1, h_customer_update, CMD_ARGS(args_customer_update) }, { "customer.archive", "Archive or reactivate a customer", PERM_WRITE, 1, 1, 1, h_customer_archive, CMD_ARGS(args_customer_archive) }, { "invoice.sequence_get", "Read the next invoice number", PERM_READ, 1, 0, 0, h_invoice_sequence_get, NULL, 0 }, { "invoice.sequence_set", "Set the next invoice number (owner)", PERM_OWNER, 1, 1, 1, h_invoice_sequence_set, CMD_ARGS(args_invoice_sequence_set) }, { "invoice.preview", "Render an invoice draft without storing it", PERM_READ, 1, 0, 0, h_invoice_preview, CMD_ARGS(args_invoice_draft) }, { "invoice.issue", "Issue an invoice: number, PDF and voucher", PERM_WRITE, 1, 1, 1, h_invoice_issue, CMD_ARGS(args_invoice_draft) }, { "invoice.get", "Get an invoice with rows", PERM_READ, 1, 0, 0, h_invoice_get, CMD_ARGS(args_invoice_get) }, { "invoice.list", "List invoices, newest first", PERM_READ, 1, 0, 0, h_invoice_list, CMD_ARGS(args_invoice_list) }, { "invoice.pdf", "Fetch the stored invoice PDF", PERM_READ, 1, 0, 0, h_invoice_pdf, CMD_ARGS(args_invoice_get) }, }; 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; }