aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/audit.c214
-rw-r--r--src/audit.h19
-rw-r--r--src/auth.c92
-rw-r--r--src/auth.h19
-rw-r--r--src/bokfd.c494
-rw-r--r--src/commands.c3705
-rw-r--r--src/commands.h33
-rw-r--r--src/config.c171
-rw-r--r--src/config.h31
-rw-r--r--src/db.c519
-rw-r--r--src/db.h20
-rw-r--r--src/formula.c243
-rw-r--r--src/formula.h43
-rw-r--r--src/ledger.c615
-rw-r--r--src/ledger.h51
-rw-r--r--src/log.c45
-rw-r--r--src/log.h21
-rw-r--r--src/protocol.c204
-rw-r--r--src/protocol.h30
-rw-r--r--src/reports.c434
-rw-r--r--src/reports.h23
-rw-r--r--src/seed.c325
-rw-r--r--src/seed.h12
-rw-r--r--src/sessions.c86
-rw-r--r--src/sessions.h28
-rw-r--r--src/sie.c876
-rw-r--r--src/sie.h18
-rw-r--r--src/util.c564
-rw-r--r--src/util.h53
-rw-r--r--src/version.h10
30 files changed, 8998 insertions, 0 deletions
diff --git a/src/audit.c b/src/audit.c
new file mode 100644
index 0000000..2a3565d
--- /dev/null
+++ b/src/audit.c
@@ -0,0 +1,214 @@
+#include "audit.h"
+
+#include <stdio.h>
+#include <string.h>
+
+#include "util.h"
+
+static void set_err(char **err, const char *msg)
+{
+ if (err && !*err)
+ *err = xstrdup(msg);
+}
+
+static int64_t audit_last_seq(sqlite3 *db)
+{
+ sqlite3_stmt *st = NULL;
+ int64_t seq = 0;
+ if (sqlite3_prepare_v2(db, "SELECT seq FROM audit_log ORDER BY seq DESC LIMIT 1",
+ -1, &st, NULL) == SQLITE_OK &&
+ sqlite3_step(st) == SQLITE_ROW)
+ seq = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ return seq;
+}
+
+static void audit_last_hash(sqlite3 *db, unsigned char out[32])
+{
+ memset(out, 0, 32);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(db, "SELECT hash FROM audit_log ORDER BY seq DESC LIMIT 1",
+ -1, &st, NULL) == SQLITE_OK &&
+ sqlite3_step(st) == SQLITE_ROW) {
+ const void *b = sqlite3_column_blob(st, 0);
+ if (b && sqlite3_column_bytes(st, 0) == 32)
+ memcpy(out, b, 32);
+ }
+ sqlite3_finalize(st);
+}
+
+static void append_str16(struct buf *b, const char *s)
+{
+ size_t n = s ? strlen(s) : 0;
+ if (n > 0xffff)
+ n = 0xffff;
+ buf_append_u16be(b, (uint16_t)n);
+ buf_append(b, s ? s : "", n);
+}
+
+static void append_str32(struct buf *b, const char *s)
+{
+ size_t n = s ? strlen(s) : 0;
+ buf_append_u32be(b, (uint32_t)n);
+ buf_append(b, s ? s : "", n);
+}
+
+static void audit_hash(const unsigned char prev[32], int64_t seq, const char *at,
+ int64_t user_id, int64_t token_id, int64_t org_id,
+ const char *action, const char *request_json,
+ const char *result_code, unsigned char out[32])
+{
+ struct buf b;
+ buf_init(&b);
+ buf_append(&b, "bokf-v1-audit\0", 14);
+ buf_append(&b, prev, 32);
+ buf_append_u64be(&b, (uint64_t)seq);
+ append_str16(&b, at);
+ buf_append_u64be(&b, (uint64_t)user_id);
+ buf_append_u64be(&b, (uint64_t)token_id);
+ buf_append_u64be(&b, (uint64_t)org_id);
+ append_str16(&b, action);
+ append_str32(&b, request_json);
+ append_str16(&b, result_code);
+ util_sha256(b.p, b.len, out);
+ buf_free(&b);
+}
+
+int audit_append(sqlite3 *db, int64_t org_id, int64_t user_id, int64_t token_id,
+ const char *action, const char *request_json,
+ const char *result_code, char **err)
+{
+ unsigned char prev[32], hash[32];
+ audit_last_hash(db, prev);
+ int64_t seq = audit_last_seq(db) + 1;
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (!request_json)
+ request_json = "{}";
+ audit_hash(prev, seq, ts, user_id, token_id, org_id, action, request_json,
+ result_code, hash);
+
+ sqlite3_stmt *st = NULL;
+ int rc = sqlite3_prepare_v2(
+ db,
+ "INSERT INTO audit_log(seq,org_id,at,actor_user_id,actor_token_id,"
+ "action,request_json,result_code,hash_prev,hash)"
+ " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ set_err(err, sqlite3_errmsg(db));
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, seq);
+ if (org_id)
+ sqlite3_bind_int64(st, 2, org_id);
+ else
+ sqlite3_bind_null(st, 2);
+ sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT);
+ if (user_id)
+ sqlite3_bind_int64(st, 4, user_id);
+ else
+ sqlite3_bind_null(st, 4);
+ if (token_id)
+ sqlite3_bind_int64(st, 5, token_id);
+ else
+ sqlite3_bind_null(st, 5);
+ sqlite3_bind_text(st, 6, action, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 7, request_json, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 8, result_code, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_blob(st, 9, prev, 32, SQLITE_TRANSIENT);
+ sqlite3_bind_blob(st, 10, hash, 32, SQLITE_TRANSIENT);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ set_err(err, sqlite3_errmsg(db));
+ return -1;
+ }
+ return 0;
+}
+
+int audit_verify(sqlite3 *db, int64_t *checked, int64_t *bad_seq, char **err)
+{
+ sqlite3_stmt *st = NULL;
+ int rc = sqlite3_prepare_v2(
+ db,
+ "SELECT seq,org_id,at,actor_user_id,actor_token_id,action,request_json,"
+ "result_code,hash_prev,hash FROM audit_log ORDER BY seq",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ set_err(err, sqlite3_errmsg(db));
+ return -1;
+ }
+ unsigned char running[32];
+ memset(running, 0, 32);
+ int64_t n = 0;
+ int broken = 0;
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ n++;
+ int64_t seq = sqlite3_column_int64(st, 0);
+ int64_t org_id = sqlite3_column_type(st, 1) == SQLITE_NULL
+ ? 0
+ : sqlite3_column_int64(st, 1);
+ const char *at = (const char *)sqlite3_column_text(st, 2);
+ int64_t uid = sqlite3_column_type(st, 3) == SQLITE_NULL
+ ? 0
+ : sqlite3_column_int64(st, 3);
+ int64_t tid = sqlite3_column_type(st, 4) == SQLITE_NULL
+ ? 0
+ : sqlite3_column_int64(st, 4);
+ const char *action = (const char *)sqlite3_column_text(st, 5);
+ const char *req = (const char *)sqlite3_column_text(st, 6);
+ const char *code = (const char *)sqlite3_column_text(st, 7);
+ const void *hprev = sqlite3_column_blob(st, 8);
+ int hprev_n = sqlite3_column_bytes(st, 8);
+ const void *h = sqlite3_column_blob(st, 9);
+ int h_n = sqlite3_column_bytes(st, 9);
+
+ unsigned char expect[32];
+ audit_hash(running, seq, at ? at : "", uid, tid, org_id,
+ action ? action : "", req ? req : "", code ? code : "",
+ expect);
+ if (hprev_n != 32 || h_n != 32 ||
+ memcmp(hprev, running, 32) != 0 || memcmp(h, expect, 32) != 0) {
+ if (bad_seq)
+ *bad_seq = seq;
+ broken = 1;
+ break;
+ }
+ memcpy(running, expect, 32);
+ }
+ sqlite3_finalize(st);
+ if (checked)
+ *checked = n;
+ return broken ? 1 : 0;
+}
+
+char *audit_args_json(yyjson_val *args)
+{
+ if (!args || yyjson_is_null(args))
+ return xstrdup("{}");
+ if (!yyjson_is_obj(args)) {
+ char *s = yyjson_val_write(args, 0, NULL);
+ return s ? s : xstrdup("{}");
+ }
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *out = yyjson_mut_obj(doc);
+ yyjson_mut_doc_set_root(doc, out);
+ yyjson_obj_iter it = yyjson_obj_iter_with(args);
+ yyjson_val *key;
+ while ((key = yyjson_obj_iter_next(&it))) {
+ const char *k = yyjson_get_str(key);
+ yyjson_val *v = yyjson_obj_iter_get_val(key);
+ if (!k || !v)
+ continue;
+ if (strcmp(k, "password") == 0 || strcmp(k, "token") == 0 ||
+ strcmp(k, "content_base64") == 0 || strcmp(k, "secret") == 0)
+ continue;
+ yyjson_mut_val *copy = yyjson_val_mut_copy(doc, v);
+ if (copy)
+ yyjson_mut_obj_add(out, yyjson_mut_strcpy(doc, k), copy);
+ }
+ char *json = yyjson_mut_write(doc, 0, NULL);
+ yyjson_mut_doc_free(doc);
+ return json ? json : xstrdup("{}");
+}
diff --git a/src/audit.h b/src/audit.h
new file mode 100644
index 0000000..2031734
--- /dev/null
+++ b/src/audit.h
@@ -0,0 +1,19 @@
+#ifndef BOKF_AUDIT_H
+#define BOKF_AUDIT_H
+
+#include <sqlite3.h>
+#include <stdint.h>
+
+#include "yyjson.h"
+
+int audit_append(sqlite3 *db, int64_t org_id, int64_t user_id, int64_t token_id,
+ const char *action, const char *request_json,
+ const char *result_code, char **err);
+
+/* 0 = chain verified, 1 = chain broken, -1 = error */
+int audit_verify(sqlite3 *db, int64_t *checked, int64_t *bad_seq, char **err);
+
+/* Serialize command args for the audit log with secrets removed. */
+char *audit_args_json(yyjson_val *args);
+
+#endif
diff --git a/src/auth.c b/src/auth.c
new file mode 100644
index 0000000..62dcd04
--- /dev/null
+++ b/src/auth.c
@@ -0,0 +1,92 @@
+#include "auth.h"
+
+#include <argon2.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "util.h"
+
+#define ARGON2_T_COST 3
+#define ARGON2_M_COST (64 * 1024)
+#define ARGON2_PARALLELISM 1
+#define ARGON2_SALT_LEN 16
+#define ARGON2_HASH_LEN 32
+
+int auth_hash_password(const char *pw, char **out_phc)
+{
+ if (!pw)
+ return -1;
+ unsigned char salt[ARGON2_SALT_LEN];
+ if (util_random(salt, sizeof salt) != 0)
+ return -1;
+ char encoded[256];
+ int rc = argon2id_hash_encoded(ARGON2_T_COST, ARGON2_M_COST,
+ ARGON2_PARALLELISM, pw, strlen(pw), salt,
+ sizeof salt, ARGON2_HASH_LEN, encoded,
+ sizeof encoded);
+ if (rc != ARGON2_OK)
+ return -1;
+ *out_phc = xstrdup(encoded);
+ return 0;
+}
+
+int auth_verify_password(const char *phc, const char *pw)
+{
+ if (!phc || !pw)
+ return -1;
+ return argon2id_verify(phc, pw, strlen(pw)) == ARGON2_OK ? 0 : -1;
+}
+
+void auth_hash_token(const char *token, unsigned char out[32])
+{
+ util_sha256(token, strlen(token), out);
+}
+
+char *auth_generate_token(void)
+{
+ unsigned char raw[32];
+ if (util_random(raw, sizeof raw) != 0) {
+ fprintf(stderr, "fatal: no entropy source\n");
+ exit(1);
+ }
+ char *body = util_b64url(raw, sizeof raw);
+ size_t n = strlen(body) + sizeof AUTH_TOKEN_PREFIX + 1;
+ char *token = xmalloc(n);
+ snprintf(token, n, "%s%s", AUTH_TOKEN_PREFIX, body);
+ free(body);
+ return token;
+}
+
+int64_t auth_user_lookup(sqlite3 *db, const char *username, char **out_pwhash,
+ char **out_display, int *out_is_admin,
+ int *out_disabled)
+{
+ sqlite3_stmt *st = NULL;
+ int rc = sqlite3_prepare_v2(
+ db,
+ "SELECT id, pw_hash, display_name, is_admin, disabled_at FROM users"
+ " WHERE username = ?1",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK)
+ return -2;
+ sqlite3_bind_text(st, 1, username, -1, SQLITE_TRANSIENT);
+ int64_t id = -1;
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ id = sqlite3_column_int64(st, 0);
+ if (out_pwhash) {
+ const unsigned char *p = sqlite3_column_text(st, 1);
+ *out_pwhash = p ? xstrdup((const char *)p) : NULL;
+ }
+ if (out_display) {
+ const unsigned char *p = sqlite3_column_text(st, 2);
+ *out_display = p ? xstrdup((const char *)p) : NULL;
+ }
+ if (out_is_admin)
+ *out_is_admin = sqlite3_column_int(st, 3);
+ if (out_disabled)
+ *out_disabled = sqlite3_column_type(st, 4) != SQLITE_NULL;
+ }
+ sqlite3_finalize(st);
+ return id;
+}
diff --git a/src/auth.h b/src/auth.h
new file mode 100644
index 0000000..630d8dc
--- /dev/null
+++ b/src/auth.h
@@ -0,0 +1,19 @@
+#ifndef BOKF_AUTH_H
+#define BOKF_AUTH_H
+
+#include <sqlite3.h>
+#include <stdint.h>
+
+#define AUTH_TOKEN_PREFIX "bokf_"
+
+int auth_hash_password(const char *pw, char **out_phc);
+int auth_verify_password(const char *phc, const char *pw);
+void auth_hash_token(const char *token, unsigned char out[32]);
+char *auth_generate_token(void);
+
+/* Returns user id > 0, -1 if not found, -2 on database error. */
+int64_t auth_user_lookup(sqlite3 *db, const char *username, char **out_pwhash,
+ char **out_display, int *out_is_admin,
+ int *out_disabled);
+
+#endif
diff --git a/src/bokfd.c b/src/bokfd.c
new file mode 100644
index 0000000..70b654a
--- /dev/null
+++ b/src/bokfd.c
@@ -0,0 +1,494 @@
+#include <errno.h>
+#include <fcntl.h>
+#include <netdb.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/un.h>
+#include <termios.h>
+#include <unistd.h>
+
+#include "auth.h"
+#include "config.h"
+#include "db.h"
+#include "log.h"
+#include "protocol.h"
+#include "sessions.h"
+#include "util.h"
+#include "version.h"
+
+#define MAX_CONNS 64
+#define READ_CHUNK 65536
+
+struct conn {
+ int fd;
+ struct buf in;
+ struct buf out;
+ size_t out_sent;
+ int closing;
+};
+
+static volatile sig_atomic_t g_stop = 0;
+static size_t g_line_limit = 1024 * 1024;
+
+static void on_signal(int sig)
+{
+ (void)sig;
+ g_stop = 1;
+}
+
+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;
+}
+
+static void dirname_of(const char *path, char *out, size_t n)
+{
+ snprintf(out, n, "%s", path);
+ char *slash = strrchr(out, '/');
+ if (slash)
+ *slash = '\0';
+ else
+ snprintf(out, n, ".");
+}
+
+static int read_password(const char *prompt, char *buf, size_t n)
+{
+ if (!isatty(STDIN_FILENO)) {
+ const char *env = getenv("BOKFD_PASSWORD");
+ if (!env)
+ return -1;
+ snprintf(buf, n, "%s", env);
+ return 0;
+ }
+ struct termios old, noecho;
+ fprintf(stderr, "%s", prompt);
+ if (tcgetattr(STDIN_FILENO, &old) != 0)
+ return -1;
+ noecho = old;
+ noecho.c_lflag &= ~(tcflag_t)ECHO;
+ tcsetattr(STDIN_FILENO, TCSAFLUSH, &noecho);
+ char *r = fgets(buf, (int)n, stdin);
+ tcsetattr(STDIN_FILENO, TCSAFLUSH, &old);
+ fprintf(stderr, "\n");
+ if (!r)
+ return -1;
+ util_str_trim(buf);
+ return 0;
+}
+
+static int cmd_init(const char *db_path, const char *username)
+{
+ char dir[4096];
+ dirname_of(db_path, dir, sizeof dir);
+ if (mkdir_p(dir, 0700) != 0) {
+ fprintf(stderr, "bokfd: cannot create %s\n", dir);
+ return 1;
+ }
+ sqlite3 *db = NULL;
+ char *err = NULL;
+ if (db_open(db_path, &db, &err) != 0) {
+ fprintf(stderr, "bokfd: %s\n", err ? err : "cannot open database");
+ free(err);
+ return 1;
+ }
+ int64_t users = db_count(db, "SELECT count(*) FROM users");
+ if (users > 0) {
+ fprintf(stderr, "bokfd: database already initialized (%lld users)\n",
+ (long long)users);
+ sqlite3_close(db);
+ return 1;
+ }
+ char pw[256];
+ if (read_password("Password for admin: ", pw, sizeof pw) != 0 || !pw[0]) {
+ fprintf(stderr, "bokfd: no password provided\n");
+ sqlite3_close(db);
+ return 1;
+ }
+ if (isatty(STDIN_FILENO)) {
+ char pw2[256];
+ if (read_password("Repeat password: ", pw2, sizeof pw2) != 0 ||
+ strcmp(pw, pw2) != 0) {
+ fprintf(stderr, "bokfd: passwords do not match\n");
+ sqlite3_close(db);
+ return 1;
+ }
+ }
+ int64_t uid = 0;
+ if (db_create_user(db, username, username, pw, 1, &uid, &err) != 0) {
+ fprintf(stderr, "bokfd: %s\n", err ? err : "cannot create user");
+ free(err);
+ sqlite3_close(db);
+ return 1;
+ }
+ printf("initialized %s\nadmin user: %s (id %lld)\n", db_path, username,
+ (long long)uid);
+ sqlite3_close(db);
+ return 0;
+}
+
+static int unix_listen(const char *path)
+{
+ struct sockaddr_un sa;
+ memset(&sa, 0, sizeof sa);
+ sa.sun_family = AF_UNIX;
+ if (strlen(path) >= sizeof sa.sun_path)
+ return -1;
+ snprintf(sa.sun_path, sizeof sa.sun_path, "%s", path);
+ unlink(path);
+ int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
+ if (fd < 0)
+ return -1;
+ if (bind(fd, (struct sockaddr *)&sa, sizeof sa) != 0) {
+ close(fd);
+ return -1;
+ }
+ chmod(path, 0660);
+ if (listen(fd, 64) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+static int tcp_listen(const char *addrport)
+{
+ char host[256] = "127.0.0.1";
+ char port[16] = "8787";
+ const char *colon = strrchr(addrport, ':');
+ if (colon) {
+ size_t hl = (size_t)(colon - addrport);
+ if (hl < sizeof host) {
+ memcpy(host, addrport, hl);
+ host[hl] = '\0';
+ }
+ snprintf(port, sizeof port, "%s", colon + 1);
+ } else {
+ snprintf(port, sizeof port, "%s", addrport);
+ }
+ struct addrinfo hints, *res = NULL;
+ memset(&hints, 0, sizeof hints);
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = SOCK_STREAM;
+ hints.ai_flags = AI_PASSIVE;
+ if (getaddrinfo(host, port, &hints, &res) != 0)
+ return -1;
+ int fd = -1;
+ for (struct addrinfo *ai = res; ai; ai = ai->ai_next) {
+ fd = socket(ai->ai_family, ai->ai_socktype | SOCK_NONBLOCK | SOCK_CLOEXEC,
+ ai->ai_protocol);
+ if (fd < 0)
+ continue;
+ int one = 1;
+ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
+ if (bind(fd, ai->ai_addr, ai->ai_addrlen) == 0 && listen(fd, 64) == 0)
+ break;
+ close(fd);
+ fd = -1;
+ }
+ freeaddrinfo(res);
+ return fd;
+}
+
+static void accept_conns(int lfd, struct conn *conns, size_t *nconns)
+{
+ for (;;) {
+ int fd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
+ if (fd < 0)
+ return;
+ if (*nconns >= MAX_CONNS) {
+ close(fd);
+ continue;
+ }
+ struct conn *c = &conns[(*nconns)++];
+ memset(c, 0, sizeof *c);
+ c->fd = fd;
+ buf_init(&c->in);
+ buf_init(&c->out);
+ }
+}
+
+static void conn_read(struct conn *c, sqlite3 *db)
+{
+ char tmp[READ_CHUNK];
+ ssize_t r = read(c->fd, tmp, sizeof tmp);
+ if (r == 0) {
+ c->closing = 1;
+ return;
+ }
+ if (r < 0) {
+ if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)
+ return;
+ c->closing = 1;
+ return;
+ }
+ buf_append(&c->in, tmp, (size_t)r);
+ for (;;) {
+ unsigned char *nl = memchr(c->in.p, '\n', c->in.len);
+ if (!nl)
+ break;
+ size_t linelen = (size_t)(nl - c->in.p);
+ char *resp = protocol_handle_line(db, (const char *)c->in.p, linelen);
+ buf_append(&c->out, resp, strlen(resp));
+ buf_append(&c->out, "\n", 1);
+ free(resp);
+ size_t consumed = linelen + 1;
+ memmove(c->in.p, c->in.p + consumed, c->in.len - consumed);
+ c->in.len -= consumed;
+ }
+ if (c->in.len > g_line_limit) {
+ log_warn("dropping oversized request from fd %d", c->fd);
+ c->closing = 1;
+ }
+}
+
+static void conn_flush(struct conn *c)
+{
+ while (c->out_sent < c->out.len) {
+ ssize_t w = write(c->fd, c->out.p + c->out_sent, c->out.len - c->out_sent);
+ if (w > 0) {
+ c->out_sent += (size_t)w;
+ continue;
+ }
+ if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
+ return;
+ if (w < 0 && errno == EINTR)
+ continue;
+ c->closing = 1;
+ return;
+ }
+ c->out.len = 0;
+ c->out_sent = 0;
+}
+
+static void usage(FILE *f)
+{
+ fprintf(f,
+ "usage: bokfd [options]\n"
+ " bokfd init [--db PATH] [--user NAME]\n"
+ " bokfd --version\n"
+ "\n"
+ "options:\n"
+ " --config FILE config file (key = value)\n"
+ " --db PATH SQLite database (default /var/lib/bokfd/bokfd.db)\n"
+ " --socket PATH unix socket (default /run/bokfd/bokfd.sock)\n"
+ " --backup-dir DIR snapshot destination\n"
+ " --export-dir DIR SIE export destination\n"
+ " --log-level L error|warn|info|debug\n");
+}
+
+static const char *opt_value(const char *arg, const char *name, int *i,
+ int argc, char **argv)
+{
+ size_t n = strlen(name);
+ if (strncmp(arg, name, n) != 0)
+ return NULL;
+ if (arg[n] == '=')
+ return arg + n + 1;
+ if (arg[n] == '\0' && *i + 1 < argc)
+ return argv[++(*i)];
+ return NULL;
+}
+
+static void parse_args(int argc, char **argv, const char **config_file,
+ const char **init_user, int *init_mode)
+{
+ for (int i = 1; i < argc; i++) {
+ const char *a = argv[i];
+ const char *v;
+ if ((v = opt_value(a, "--config", &i, argc, argv)))
+ *config_file = v;
+ else if ((v = opt_value(a, "--db", &i, argc, argv)))
+ g_cfg.db_path = xstrdup(v);
+ else if ((v = opt_value(a, "--socket", &i, argc, argv)))
+ g_cfg.socket_path = xstrdup(v);
+ else if ((v = opt_value(a, "--backup-dir", &i, argc, argv)))
+ g_cfg.backup_dir = xstrdup(v);
+ else if ((v = opt_value(a, "--export-dir", &i, argc, argv)))
+ g_cfg.export_dir = xstrdup(v);
+ else if ((v = opt_value(a, "--log-level", &i, argc, argv)))
+ g_cfg.log_level = log_level_from_name(v);
+ else if ((v = opt_value(a, "--user", &i, argc, argv)))
+ *init_user = v;
+ else if (strcmp(a, "init") == 0)
+ *init_mode = 1;
+ else if (strcmp(a, "--version") == 0) {
+ printf("bokfd %s (protocol v%d)\n", BOKF_VERSION,
+ BOKF_PROTOCOL_VERSION);
+ exit(0);
+ } else {
+ fprintf(stderr, "bokfd: unknown argument %s\n", a);
+ usage(stderr);
+ exit(2);
+ }
+ }
+}
+
+int main(int argc, char **argv)
+{
+ config_defaults();
+ const char *config_file = NULL;
+ const char *init_user = "admin";
+ int init_mode = 0;
+ parse_args(argc, argv, &config_file, &init_user, &init_mode);
+
+ if (config_file) {
+ char *err = NULL;
+ if (config_load_file(config_file, &err) != 0) {
+ fprintf(stderr, "bokfd: %s\n", err ? err : "config error");
+ free(err);
+ return 1;
+ }
+ }
+ config_apply_env();
+ log_set_level(g_cfg.log_level);
+
+ if (init_mode)
+ return cmd_init(g_cfg.db_path, init_user);
+
+ sqlite3 *db = NULL;
+ char *err = NULL;
+ if (db_open(g_cfg.db_path, &db, &err) != 0) {
+ log_error("%s", err ? err : "cannot open database");
+ free(err);
+ return 1;
+ }
+ if (db_count(db, "SELECT count(*) FROM users") <= 0)
+ log_warn("no users in database; run 'bokfd init' first");
+
+ mkdir_p(g_cfg.backup_dir, 0700);
+ mkdir_p(g_cfg.export_dir, 0700);
+
+ char dir[4096];
+ dirname_of(g_cfg.socket_path, dir, sizeof dir);
+ if (mkdir_p(dir, 0755) != 0) {
+ log_error("cannot create socket directory %s", dir);
+ sqlite3_close(db);
+ return 1;
+ }
+ int lfd = unix_listen(g_cfg.socket_path);
+ if (lfd < 0) {
+ log_error("cannot listen on %s: %s", g_cfg.socket_path,
+ strerror(errno));
+ sqlite3_close(db);
+ return 1;
+ }
+ log_info("bokfd %s listening on %s", BOKF_VERSION, g_cfg.socket_path);
+
+ int tfd = -1;
+ if (g_cfg.tcp_enabled) {
+ tfd = tcp_listen(g_cfg.tcp_addr ? g_cfg.tcp_addr : "127.0.0.1:8787");
+ if (tfd < 0)
+ log_error("cannot listen on tcp %s",
+ g_cfg.tcp_addr ? g_cfg.tcp_addr : "127.0.0.1:8787");
+ else
+ log_info("bokfd listening on tcp %s",
+ g_cfg.tcp_addr ? g_cfg.tcp_addr : "127.0.0.1:8787");
+ }
+
+ signal(SIGINT, on_signal);
+ signal(SIGTERM, on_signal);
+ signal(SIGPIPE, SIG_IGN);
+ sessions_init(g_cfg.session_ttl);
+
+ /* base64 expands by 4/3; the JSON envelope needs some room too */
+ g_line_limit = (size_t)g_cfg.max_line_bytes;
+ size_t att_limit =
+ (size_t)g_cfg.max_attachment_bytes / 3 * 4 + 64 * 1024;
+ if (att_limit > g_line_limit)
+ g_line_limit = att_limit;
+
+ struct conn conns[MAX_CONNS];
+ size_t nconns = 0;
+ struct pollfd pfds[MAX_CONNS + 2];
+ struct conn *map[MAX_CONNS + 2];
+
+ while (!g_stop) {
+ int n = 0;
+ int nlisteners = 0;
+ pfds[n].fd = lfd;
+ pfds[n].events = POLLIN;
+ map[n] = NULL;
+ n++;
+ nlisteners++;
+ if (tfd >= 0) {
+ pfds[n].fd = tfd;
+ pfds[n].events = POLLIN;
+ map[n] = NULL;
+ n++;
+ nlisteners++;
+ }
+ for (size_t i = 0; i < nconns; i++) {
+ pfds[n].fd = conns[i].fd;
+ pfds[n].events = POLLIN | (conns[i].out.len > conns[i].out_sent
+ ? POLLOUT
+ : 0);
+ map[n] = &conns[i];
+ n++;
+ }
+ int pr = poll(pfds, (nfds_t)n, 1000);
+ if (pr < 0) {
+ if (errno == EINTR)
+ continue;
+ log_error("poll: %s", strerror(errno));
+ break;
+ }
+ for (int i = 0; i < nlisteners; i++)
+ if (pfds[i].revents & POLLIN)
+ accept_conns(pfds[i].fd, conns, &nconns);
+ for (int i = nlisteners; i < n; i++) {
+ struct conn *c = map[i];
+ if (c->fd < 0)
+ continue;
+ if (pfds[i].revents & (POLLIN | POLLHUP | POLLERR))
+ conn_read(c, db);
+ if (pfds[i].revents & POLLOUT)
+ conn_flush(c);
+ if (c->closing && c->out.len == c->out_sent) {
+ close(c->fd);
+ buf_free(&c->in);
+ buf_free(&c->out);
+ c->fd = -1;
+ }
+ }
+ size_t keep = 0;
+ for (size_t i = 0; i < nconns; i++) {
+ if (conns[i].fd >= 0)
+ conns[keep++] = conns[i];
+ }
+ nconns = keep;
+ }
+
+ log_info("shutting down");
+ for (size_t i = 0; i < nconns; i++) {
+ close(conns[i].fd);
+ buf_free(&conns[i].in);
+ buf_free(&conns[i].out);
+ }
+ close(lfd);
+ if (tfd >= 0)
+ close(tfd);
+ unlink(g_cfg.socket_path);
+ sessions_free_all();
+ sqlite3_close(db);
+ config_free();
+ return 0;
+}
diff --git a/src/commands.c b/src/commands.c
new file mode 100644
index 0000000..5ccadfc
--- /dev/null
+++ b/src/commands.c
@@ -0,0 +1,3705 @@
+#include "commands.h"
+
+#include <errno.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "audit.h"
+#include "sha256.h"
+#include "auth.h"
+#include "config.h"
+#include "db.h"
+#include "formula.h"
+#include "ledger.h"
+#include "log.h"
+#include "reports.h"
+#include "seed.h"
+#include "sie.h"
+#include "util.h"
+#include "version.h"
+
+/* ------------------------------------------------------------------ */
+/* small helpers */
+/* ------------------------------------------------------------------ */
+
+static const char *sq(const unsigned char *p)
+{
+ return p ? (const char *)p : "";
+}
+
+static const char *arg_str(yyjson_val *args, const char *key)
+{
+ if (!args || !yyjson_is_obj(args))
+ return NULL;
+ yyjson_val *v = yyjson_obj_get(args, key);
+ return v && yyjson_is_str(v) ? yyjson_get_str(v) : NULL;
+}
+
+static int arg_int(yyjson_val *args, const char *key, int64_t *out)
+{
+ if (!args || !yyjson_is_obj(args))
+ return 0;
+ yyjson_val *v = yyjson_obj_get(args, key);
+ if (!v || !yyjson_is_int(v))
+ return 0;
+ *out = yyjson_get_int(v);
+ return 1;
+}
+
+static int arg_bool(yyjson_val *args, const char *key, int *out)
+{
+ if (!args || !yyjson_is_obj(args))
+ return 0;
+ yyjson_val *v = yyjson_obj_get(args, key);
+ if (!v || !yyjson_is_bool(v))
+ return 0;
+ *out = yyjson_get_bool(v) ? 1 : 0;
+ return 1;
+}
+
+static yyjson_mut_val *fail(struct req *r, const char *code, const char *msg)
+{
+ r->err_code = code;
+ snprintf(r->err_msg, sizeof r->err_msg, "%s", msg);
+ return NULL;
+}
+
+static yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...)
+ __attribute__((format(printf, 3, 4)));
+
+static yyjson_mut_val *failf(struct req *r, const char *code, const char *fmt, ...)
+{
+ r->err_code = code;
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(r->err_msg, sizeof r->err_msg, fmt, ap);
+ va_end(ap);
+ return NULL;
+}
+
+static int mkdir_p(const char *path, mode_t mode)
+{
+ char tmp[4096];
+ if (!path || strlen(path) >= sizeof tmp)
+ return -1;
+ strcpy(tmp, path);
+ for (char *p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ if (mkdir(tmp, mode) != 0 && errno != EEXIST)
+ return -1;
+ *p = '/';
+ }
+ }
+ if (mkdir(tmp, mode) != 0 && errno != EEXIST)
+ return -1;
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* login rate limiting (in-memory, per key) */
+/* ------------------------------------------------------------------ */
+
+#define RL_MAX_KEYS 16
+#define RL_MAX_FAILS 5
+#define RL_WINDOW 900
+
+struct rl_entry {
+ char key[64];
+ int fails;
+ int64_t window_end;
+};
+
+static struct rl_entry g_rl[RL_MAX_KEYS];
+
+static struct rl_entry *rl_get(const char *key, int create)
+{
+ struct rl_entry *slot = NULL;
+ for (size_t i = 0; i < RL_MAX_KEYS; i++) {
+ if (g_rl[i].key[0] && strcmp(g_rl[i].key, key) == 0)
+ return &g_rl[i];
+ if (create && !g_rl[i].key[0] && !slot)
+ slot = &g_rl[i];
+ }
+ if (create && slot) {
+ snprintf(slot->key, sizeof slot->key, "%s", key);
+ slot->fails = 0;
+ slot->window_end = 0;
+ }
+ return slot;
+}
+
+static int rl_blocked(const char *key, int64_t *retry_after)
+{
+ struct rl_entry *e = rl_get(key, 0);
+ if (!e || e->fails < RL_MAX_FAILS)
+ return 0;
+ int64_t now = util_now();
+ if (e->window_end <= now)
+ return 0;
+ if (retry_after)
+ *retry_after = e->window_end - now;
+ return 1;
+}
+
+static void rl_fail(const char *key)
+{
+ struct rl_entry *e = rl_get(key, 1);
+ if (!e)
+ return;
+ int64_t now = util_now();
+ if (e->fails == 0 || e->window_end <= now)
+ e->window_end = now + RL_WINDOW;
+ e->fails++;
+}
+
+static void rl_ok(const char *key)
+{
+ struct rl_entry *e = rl_get(key, 0);
+ if (e) {
+ e->fails = 0;
+ e->window_end = 0;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* public commands */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *h_health(struct req *r)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "ok");
+ return o;
+}
+
+static yyjson_mut_val *h_meta(struct req *r)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "server", "bokfd");
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "version", BOKF_VERSION);
+ yyjson_mut_obj_add_int(r->rdoc, o, "protocol", BOKF_PROTOCOL_VERSION);
+ yyjson_mut_val *features = yyjson_mut_arr(r->rdoc);
+ yyjson_mut_arr_add_strcpy(r->rdoc, features, "describe");
+ yyjson_mut_arr_add_strcpy(r->rdoc, features, "agent.instructions");
+ yyjson_mut_arr_add_strcpy(r->rdoc, features, "orgs");
+ yyjson_mut_arr_add_strcpy(r->rdoc, features, "tokens");
+ yyjson_mut_arr_add_strcpy(r->rdoc, features, "backup");
+ yyjson_mut_obj_add_val(r->rdoc, o, "features", features);
+ yyjson_mut_val *limits = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, limits, "max_line_bytes",
+ g_cfg.max_line_bytes);
+ yyjson_mut_obj_add_int(r->rdoc, limits, "max_attachment_bytes",
+ g_cfg.max_attachment_bytes);
+ yyjson_mut_obj_add_int(r->rdoc, limits, "session_ttl_seconds",
+ g_cfg.session_ttl);
+ yyjson_mut_obj_add_val(r->rdoc, o, "limits", limits);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "tcp_enabled", g_cfg.tcp_enabled != 0);
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "time", ts);
+ return o;
+}
+
+static yyjson_mut_val *orgs_for_user(sqlite3 *db, yyjson_mut_doc *doc,
+ int64_t user_id, int64_t only_org,
+ int64_t *first_org)
+{
+ yyjson_mut_val *arr = yyjson_mut_arr(doc);
+ const char *sql = only_org
+ ? "SELECT o.id,o.name,m.role FROM memberships m"
+ " JOIN orgs o ON o.id=m.org_id"
+ " WHERE m.user_id=?1 AND o.id=?2 ORDER BY o.id"
+ : "SELECT o.id,o.name,m.role FROM memberships m"
+ " JOIN orgs o ON o.id=m.org_id"
+ " WHERE m.user_id=?1 ORDER BY o.id";
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK)
+ return arr;
+ sqlite3_bind_int64(st, 1, user_id);
+ if (only_org)
+ sqlite3_bind_int64(st, 2, only_org);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ int64_t id = sqlite3_column_int64(st, 0);
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr);
+ yyjson_mut_obj_add_int(doc, o, "id", id);
+ yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(doc, o, "role", sq(sqlite3_column_text(st, 2)));
+ if (first_org && *first_org == 0)
+ *first_org = id;
+ }
+ sqlite3_finalize(st);
+ return arr;
+}
+
+static yyjson_mut_val *session_payload(struct req *r, struct session *s,
+ int64_t user_id, const char *username,
+ const char *display,
+ yyjson_mut_val *orgs,
+ int64_t active_org)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "session", s->id);
+ yyjson_mut_val *u = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, u, "id", user_id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : "");
+ yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name",
+ display ? display : "");
+ yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", s->is_admin != 0);
+ yyjson_mut_obj_add_val(r->rdoc, o, "user", u);
+ yyjson_mut_obj_add_val(r->rdoc, o, "orgs", orgs);
+ if (active_org)
+ yyjson_mut_obj_add_int(r->rdoc, o, "active_org", active_org);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, o, "active_org");
+ return o;
+}
+
+static yyjson_mut_val *h_session_open(struct req *r)
+{
+ const char *method = arg_str(r->args, "method");
+ if (!method)
+ return fail(r, "INVALID_ARGS", "method is required");
+ char *reqjson = audit_args_json(r->args);
+
+ if (strcmp(method, "password") == 0) {
+ const char *username = arg_str(r->args, "username");
+ const char *password = arg_str(r->args, "password");
+ if (!username || !password) {
+ free(reqjson);
+ return fail(r, "INVALID_ARGS", "username and password are required");
+ }
+ int64_t retry = 0;
+ if (rl_blocked("local", &retry)) {
+ free(reqjson);
+ return failf(r, "RATE_LIMITED",
+ "too many failed logins, retry in %lld seconds",
+ (long long)retry);
+ }
+ char *pwhash = NULL, *display = NULL;
+ int is_admin = 0, disabled = 0;
+ int64_t uid = auth_user_lookup(r->db, username, &pwhash, &display,
+ &is_admin, &disabled);
+ int ok = uid > 0 && !disabled && pwhash &&
+ auth_verify_password(pwhash, password) == 0;
+ free(pwhash);
+ if (!ok) {
+ rl_fail("local");
+ audit_append(r->db, 0, uid > 0 ? uid : 0, 0, "auth.fail", reqjson,
+ "AUTH_FAILED", NULL);
+ free(display);
+ free(reqjson);
+ return fail(r, "AUTH_FAILED", "invalid credentials");
+ }
+ rl_ok("local");
+ int64_t active = 0;
+ yyjson_mut_val *orgs =
+ orgs_for_user(r->db, r->rdoc, uid, 0, &active);
+ struct session *s =
+ sessions_create(uid, is_admin, active, 0, "read,write,admin");
+ audit_append(r->db, active, uid, 0, "auth.open", reqjson, "OK", NULL);
+ yyjson_mut_val *out = session_payload(r, s, uid, username, display,
+ orgs, active);
+ free(display);
+ free(reqjson);
+ return out;
+ }
+
+ if (strcmp(method, "token") == 0) {
+ const char *token = arg_str(r->args, "token");
+ if (!token) {
+ free(reqjson);
+ return fail(r, "INVALID_ARGS", "token is required");
+ }
+ unsigned char th[32];
+ auth_hash_token(token, th);
+ sqlite3_stmt *st = NULL;
+ int rc = sqlite3_prepare_v2(
+ r->db,
+ "SELECT t.id,t.user_id,t.org_id,t.scopes,t.expires_at,t.revoked_at,"
+ " t.label,u.username,u.display_name,u.is_admin,u.disabled_at"
+ " FROM api_tokens t JOIN users u ON u.id=t.user_id"
+ " WHERE t.token_hash=?1",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ free(reqjson);
+ return fail(r, "INTERNAL", "database error");
+ }
+ sqlite3_bind_blob(st, 1, th, 32, SQLITE_TRANSIENT);
+ int found = sqlite3_step(st) == SQLITE_ROW;
+ int64_t token_id = 0, uid = 0, org_id = 0;
+ char *scopes = NULL, *expires = NULL, *revoked = NULL;
+ char *username = NULL, *display = NULL;
+ int is_admin = 0, disabled = 0;
+ if (found) {
+ token_id = sqlite3_column_int64(st, 0);
+ uid = sqlite3_column_int64(st, 1);
+ org_id = sqlite3_column_int64(st, 2);
+ scopes = xstrdup(sq(sqlite3_column_text(st, 3)));
+ if (sqlite3_column_type(st, 4) != SQLITE_NULL)
+ expires = xstrdup(sq(sqlite3_column_text(st, 4)));
+ if (sqlite3_column_type(st, 5) != SQLITE_NULL)
+ revoked = xstrdup(sq(sqlite3_column_text(st, 5)));
+ username = xstrdup(sq(sqlite3_column_text(st, 7)));
+ display = xstrdup(sq(sqlite3_column_text(st, 8)));
+ is_admin = sqlite3_column_int(st, 9);
+ disabled = sqlite3_column_type(st, 10) != SQLITE_NULL;
+ }
+ sqlite3_finalize(st);
+
+ int expired = 0;
+ if (expires) {
+ char today[16];
+ time_t t = (time_t)util_now();
+ struct tm tm;
+ gmtime_r(&t, &tm);
+ strftime(today, sizeof today, "%Y-%m-%d", &tm);
+ expired = strcmp(expires, today) < 0;
+ }
+ if (!found || revoked || disabled || expired) {
+ audit_append(r->db, 0, uid, 0, "auth.fail", reqjson, "AUTH_FAILED",
+ NULL);
+ free(scopes);
+ free(expires);
+ free(revoked);
+ free(username);
+ free(display);
+ free(reqjson);
+ return fail(r, "AUTH_FAILED", "invalid token");
+ }
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *up = NULL;
+ if (sqlite3_prepare_v2(
+ r->db, "UPDATE api_tokens SET last_used_at=?1 WHERE id=?2", -1,
+ &up, NULL) == SQLITE_OK) {
+ sqlite3_bind_text(up, 1, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(up, 2, token_id);
+ sqlite3_step(up);
+ sqlite3_finalize(up);
+ }
+ int64_t active = org_id;
+ yyjson_mut_val *orgs =
+ orgs_for_user(r->db, r->rdoc, uid, org_id, &active);
+ struct session *s = sessions_create(uid, is_admin, org_id, org_id,
+ scopes ? scopes : "read");
+ s->active_org = org_id;
+ s->token_id = token_id;
+ audit_append(r->db, org_id, uid, token_id, "auth.open", reqjson, "OK",
+ NULL);
+ yyjson_mut_val *out = session_payload(r, s, uid, username, display,
+ orgs, org_id);
+ free(scopes);
+ free(expires);
+ free(revoked);
+ free(username);
+ free(display);
+ free(reqjson);
+ return out;
+ }
+
+ free(reqjson);
+ return fail(r, "UNSUPPORTED", "unsupported auth method");
+}
+
+static yyjson_mut_val *h_session_close(struct req *r)
+{
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->sess->active_org, r->sess->user_id, 0,
+ "session.close", reqjson, "OK", NULL);
+ free(reqjson);
+ sessions_destroy(r->sess->id);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+static yyjson_mut_val *h_session_whoami(struct req *r)
+{
+ char *display = NULL, *username = NULL;
+ int is_admin = 0;
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db,
+ "SELECT username,display_name,is_admin FROM users"
+ " WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->sess->user_id);
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ username = xstrdup(sq(sqlite3_column_text(st, 0)));
+ display = xstrdup(sq(sqlite3_column_text(st, 1)));
+ is_admin = sqlite3_column_int(st, 2);
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_val *u = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, u, "id", r->sess->user_id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, u, "username", username ? username : "");
+ yyjson_mut_obj_add_strcpy(r->rdoc, u, "display_name", display ? display : "");
+ yyjson_mut_obj_add_bool(r->rdoc, u, "is_admin", is_admin != 0);
+ yyjson_mut_obj_add_val(r->rdoc, o, "user", u);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", r->sess->scopes);
+ if (r->sess->active_org) {
+ yyjson_mut_obj_add_int(r->rdoc, o, "active_org", r->sess->active_org);
+ char *role = db_membership_role(r->db, r->sess->active_org,
+ r->sess->user_id);
+ if (role)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, o, "role");
+ free(role);
+ } else {
+ yyjson_mut_obj_add_null(r->rdoc, o, "active_org");
+ yyjson_mut_obj_add_null(r->rdoc, o, "role");
+ }
+ free(username);
+ free(display);
+ return o;
+}
+
+static yyjson_mut_val *h_session_list_orgs(struct req *r)
+{
+ int64_t first = 0;
+ yyjson_mut_val *orgs = orgs_for_user(r->db, r->rdoc, r->sess->user_id, 0,
+ &first);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, o, "items", orgs);
+ return o;
+}
+
+static yyjson_mut_val *h_session_use_org(struct req *r)
+{
+ int64_t org = 0;
+ if (!arg_int(r->args, "org", &org) || org <= 0)
+ return fail(r, "INVALID_ARGS", "org is required");
+ if (r->sess->bound_org && org != r->sess->bound_org)
+ return fail(r, "ORG_FORBIDDEN", "token is bound to another org");
+ char *role = db_membership_role(r->db, org, r->sess->user_id);
+ if (!role)
+ return fail(r, "ORG_FORBIDDEN", "not a member of this org");
+ r->sess->active_org = org;
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "active_org", org);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "role", role);
+ free(role);
+ return o;
+}
+
+/* ------------------------------------------------------------------ */
+/* orgs */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *org_json(struct req *r, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", sq(sqlite3_column_text(st, 3)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "address",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code",
+ sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", sq(sqlite3_column_text(st, 6)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "country",
+ sq(sqlite3_column_text(st, 7)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", sq(sqlite3_column_text(st, 8)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "phone", sq(sqlite3_column_text(st, 9)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month",
+ sqlite3_column_int(st, 10));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period",
+ sq(sqlite3_column_text(st, 11)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework",
+ sq(sqlite3_column_text(st, 12)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 13)));
+ return o;
+}
+
+#define ORG_COLUMNS \
+ "id,name,org_nr,vat_nr,address,postal_code,city,country,email," \
+ "phone,fiscal_year_start_month,moms_period,framework,created_at"
+
+static yyjson_mut_val *h_org_create(struct req *r)
+{
+ if (!g_cfg.allow_org_create && !r->is_admin)
+ return fail(r, "FORBIDDEN", "org creation is disabled");
+ const char *name = arg_str(r->args, "name");
+ if (!name || !*name)
+ return fail(r, "INVALID_ARGS", "name is required");
+ const char *org_nr = arg_str(r->args, "org_nr");
+ int64_t fy_month = 1;
+ arg_int(r->args, "fiscal_year_start_month", &fy_month);
+ if (fy_month < 1 || fy_month > 12)
+ return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12");
+ const char *moms = arg_str(r->args, "moms_period");
+ if (!moms)
+ moms = "month";
+ if (strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 &&
+ strcmp(moms, "year") != 0)
+ return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year");
+ const char *framework = arg_str(r->args, "framework");
+ if (!framework)
+ framework = "K2";
+ if (strcmp(framework, "K2") != 0 && strcmp(framework, "K3") != 0)
+ return fail(r, "INVALID_ARGS", "framework must be K2 or K3");
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0)
+ return fail(r, "DB_BUSY", "could not start transaction");
+
+ sqlite3_stmt *st = NULL;
+ int rc = sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO orgs(name,org_nr,fiscal_year_start_month,moms_period,"
+ "framework,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT);
+ if (org_nr)
+ sqlite3_bind_text(st, 2, org_nr, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 2);
+ sqlite3_bind_int64(st, 3, fy_month);
+ sqlite3_bind_text(st, 4, moms, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, framework, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 7, r->sess->user_id);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ int64_t org_id = db_last_id(r->db);
+
+ rc = sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO memberships(org_id,user_id,role,created_at)"
+ " VALUES(?1,?2,'owner',?3)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, r->sess->user_id);
+ sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ int64_t fy_id = 0;
+ char *seed_err = NULL;
+ if (seed_org(r->db, org_id, framework, (int)fy_month, &fy_id,
+ &seed_err) != 0) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ yyjson_mut_val *res =
+ fail(r, "INTERNAL", seed_err ? seed_err : "seeding failed");
+ free(seed_err);
+ return res;
+ }
+ if (db_exec(r->db, "COMMIT", NULL) != 0)
+ return fail(r, "INTERNAL", "commit failed");
+
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, org_id, r->sess->user_id, r->sess->token_id,
+ "org.create", reqjson, "OK", NULL);
+ free(reqjson);
+ r->sess->active_org = org_id;
+
+ st = NULL;
+ if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, org_id);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = org_json(r, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "INTERNAL", "created org not found");
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy_id);
+ return o;
+}
+
+static yyjson_mut_val *h_org_list(struct req *r)
+{
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT o.id,o.name,o.org_nr,m.role FROM memberships m"
+ " JOIN orgs o ON o.id=m.org_id WHERE m.user_id=?1 ORDER BY o.id",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->sess->user_id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "role",
+ sq(sqlite3_column_text(st, 3)));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ return out;
+}
+
+static yyjson_mut_val *h_org_get(struct req *r)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = org_json(r, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "NOT_FOUND", "org not found");
+ return o;
+}
+
+static yyjson_mut_val *h_org_member_list(struct req *r)
+{
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT u.id,u.username,u.display_name,m.role FROM memberships m"
+ " JOIN users u ON u.id=m.user_id WHERE m.org_id=?1 ORDER BY u.id",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "user_id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "username",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "role",
+ sq(sqlite3_column_text(st, 3)));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ return out;
+}
+
+static int64_t username_to_id(sqlite3 *db, const char *username)
+{
+ sqlite3_stmt *st = NULL;
+ int64_t id = -1;
+ if (sqlite3_prepare_v2(db, "SELECT id FROM users WHERE username=?1", -1,
+ &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_text(st, 1, username, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) == SQLITE_ROW)
+ id = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ return id;
+}
+
+static int64_t owner_count(sqlite3 *db, int64_t org_id)
+{
+ sqlite3_stmt *st = NULL;
+ int64_t n = -1;
+ if (sqlite3_prepare_v2(
+ db, "SELECT count(*) FROM memberships WHERE org_id=?1 AND role='owner'",
+ -1, &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_int64(st, 1, org_id);
+ if (sqlite3_step(st) == SQLITE_ROW)
+ n = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ return n;
+}
+
+static int valid_role(const char *role)
+{
+ return role && (strcmp(role, "owner") == 0 ||
+ strcmp(role, "bookkeeper") == 0 ||
+ strcmp(role, "viewer") == 0);
+}
+
+static yyjson_mut_val *h_org_member_add(struct req *r)
+{
+ const char *username = arg_str(r->args, "username");
+ const char *role = arg_str(r->args, "role");
+ if (!username || !valid_role(role))
+ return fail(r, "INVALID_ARGS", "username and a valid role are required");
+ int64_t uid = username_to_id(r->db, username);
+ if (uid < 0)
+ return fail(r, "NOT_FOUND", "user not found");
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO memberships(org_id,user_id,role,created_at)"
+ " VALUES(?1,?2,?3,?4)",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, uid);
+ sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "CONFLICT", "user is already a member");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.add", reqjson,
+ "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+static yyjson_mut_val *h_org_member_set_role(struct req *r)
+{
+ const char *username = arg_str(r->args, "username");
+ const char *role = arg_str(r->args, "role");
+ if (!username || !valid_role(role))
+ return fail(r, "INVALID_ARGS", "username and a valid role are required");
+ int64_t uid = username_to_id(r->db, username);
+ if (uid < 0)
+ return fail(r, "NOT_FOUND", "user not found");
+ char *current = db_membership_role(r->db, r->org_id, uid);
+ if (!current)
+ return fail(r, "NOT_FOUND", "user is not a member");
+ if (strcmp(current, "owner") == 0 && strcmp(role, "owner") != 0 &&
+ owner_count(r->db, r->org_id) <= 1) {
+ free(current);
+ return fail(r, "CONFLICT", "cannot demote the last owner");
+ }
+ free(current);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE memberships SET role=?1 WHERE org_id=?2 AND user_id=?3",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_text(st, 1, role, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 2, r->org_id);
+ sqlite3_bind_int64(st, 3, uid);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.set_role",
+ reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+static yyjson_mut_val *h_org_member_remove(struct req *r)
+{
+ const char *username = arg_str(r->args, "username");
+ if (!username)
+ return fail(r, "INVALID_ARGS", "username is required");
+ int64_t uid = username_to_id(r->db, username);
+ if (uid < 0)
+ return fail(r, "NOT_FOUND", "user not found");
+ char *current = db_membership_role(r->db, r->org_id, uid);
+ if (!current)
+ return fail(r, "NOT_FOUND", "user is not a member");
+ if (strcmp(current, "owner") == 0 && owner_count(r->db, r->org_id) <= 1) {
+ free(current);
+ return fail(r, "CONFLICT", "cannot remove the last owner");
+ }
+ free(current);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db, "DELETE FROM memberships WHERE org_id=?1 AND user_id=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, uid);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.remove",
+ reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+/* ------------------------------------------------------------------ */
+/* users and tokens */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *h_user_create(struct req *r)
+{
+ const char *username = arg_str(r->args, "username");
+ const char *password = arg_str(r->args, "password");
+ const char *display = arg_str(r->args, "display_name");
+ if (!username || !password)
+ return fail(r, "INVALID_ARGS", "username and password are required");
+ int is_admin = 0;
+ arg_bool(r->args, "is_admin", &is_admin);
+ char *err = NULL;
+ int64_t uid = 0;
+ if (db_create_user(r->db, username, display, password, is_admin, &uid,
+ &err) != 0) {
+ const char *code = err && strstr(err, "already exists") ? "CONFLICT"
+ : "INTERNAL";
+ yyjson_mut_val *res = fail(r, code, err ? err : "could not create user");
+ free(err);
+ return res;
+ }
+ free(err);
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, 0, r->sess->user_id, 0, "user.create", reqjson, "OK",
+ NULL);
+ free(reqjson);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", uid);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "username", username);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name",
+ display ? display : username);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin", is_admin != 0);
+ return o;
+}
+
+static yyjson_mut_val *h_user_list(struct req *r)
+{
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT id,username,display_name,is_admin,created_at,disabled_at"
+ " FROM users ORDER BY id",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "username",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_bool(r->rdoc, o, "is_admin",
+ sqlite3_column_int(st, 3) != 0);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 4)));
+ if (sqlite3_column_type(st, 5) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "disabled_at");
+ else
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "disabled_at",
+ sq(sqlite3_column_text(st, 5)));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ return out;
+}
+
+static int scope_allowed(const char *role, const char *scope)
+{
+ int is_owner = strcmp(role, "owner") == 0;
+ int is_bookkeeper = is_owner || strcmp(role, "bookkeeper") == 0;
+ if (strcmp(scope, "read") == 0)
+ return 1;
+ if (strcmp(scope, "write") == 0)
+ return is_bookkeeper;
+ if (strcmp(scope, "admin") == 0)
+ return is_owner;
+ return 0;
+}
+
+static yyjson_mut_val *h_token_create(struct req *r)
+{
+ const char *label = arg_str(r->args, "label");
+ if (!label || !*label)
+ return fail(r, "INVALID_ARGS", "label is required");
+ char scopes[64] = "";
+ yyjson_val *sv = r->args && yyjson_is_obj(r->args)
+ ? yyjson_obj_get(r->args, "scopes")
+ : NULL;
+ if (sv) {
+ if (!yyjson_is_arr(sv))
+ return fail(r, "INVALID_ARGS", "scopes must be an array");
+ yyjson_val *item;
+ yyjson_arr_iter it = yyjson_arr_iter_with(sv);
+ while ((item = yyjson_arr_iter_next(&it))) {
+ const char *s = yyjson_get_str(item);
+ if (!s || !scope_allowed(r->role, s))
+ return fail(r, "FORBIDDEN", "scope not allowed for your role");
+ if (scopes[0])
+ strncat(scopes, ",", sizeof scopes - strlen(scopes) - 1);
+ strncat(scopes, s, sizeof scopes - strlen(scopes) - 1);
+ }
+ }
+ if (!scopes[0])
+ snprintf(scopes, sizeof scopes, "%s",
+ strcmp(r->role, "owner") == 0
+ ? "read,write,admin"
+ : (strcmp(r->role, "bookkeeper") == 0 ? "read,write"
+ : "read"));
+ const char *expires_at = arg_str(r->args, "expires_at");
+ if (expires_at && !util_parse_iso_date(expires_at))
+ return fail(r, "INVALID_ARGS", "expires_at must be YYYY-MM-DD");
+ char *token = auth_generate_token();
+ unsigned char th[32];
+ auth_hash_token(token, th);
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO api_tokens(org_id,user_id,label,token_hash,scopes,"
+ "created_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7)",
+ -1, &st, NULL) != SQLITE_OK) {
+ free(token);
+ return fail(r, "INTERNAL", "database error");
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, r->sess->user_id);
+ sqlite3_bind_text(st, 3, label, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_blob(st, 4, th, 32, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, scopes, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT);
+ if (expires_at)
+ sqlite3_bind_text(st, 7, expires_at, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 7);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ free(token);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ int64_t token_id = db_last_id(r->db);
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, token_id, "token.create",
+ reqjson, "OK", NULL);
+ free(reqjson);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", token_id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes", scopes);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "token", token);
+ if (expires_at)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at", expires_at);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, o, "expires_at");
+ free(token);
+ return o;
+}
+
+static yyjson_mut_val *h_token_list(struct req *r)
+{
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT id,label,scopes,created_at,expires_at,last_used_at,"
+ "revoked_at FROM api_tokens WHERE org_id=?1 AND user_id=?2"
+ " ORDER BY id",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, r->sess->user_id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "label",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "scopes",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 3)));
+ if (sqlite3_column_type(st, 4) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "expires_at");
+ else
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "expires_at",
+ sq(sqlite3_column_text(st, 4)));
+ if (sqlite3_column_type(st, 5) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "last_used_at");
+ else
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_used_at",
+ sq(sqlite3_column_text(st, 5)));
+ if (sqlite3_column_type(st, 6) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "revoked_at");
+ else
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "revoked_at",
+ sq(sqlite3_column_text(st, 6)));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ return out;
+}
+
+static yyjson_mut_val *h_token_revoke(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ int is_owner = strcmp(r->role, "owner") == 0;
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ is_owner
+ ? "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3"
+ : "UPDATE api_tokens SET revoked_at=?1 WHERE id=?2 AND org_id=?3"
+ " AND user_id=?4",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 2, id);
+ sqlite3_bind_int64(st, 3, r->org_id);
+ if (!is_owner)
+ sqlite3_bind_int64(st, 4, r->sess->user_id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "token not found");
+ audit_append(r->db, r->org_id, r->sess->user_id, id, "token.revoke", "{}",
+ "OK", NULL);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+/* ------------------------------------------------------------------ */
+/* discovery */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *command_json(struct req *r, const struct command *c)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", c->name);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "summary", c->summary);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "mutating", c->mutating != 0);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", c->dry_run != 0);
+ yyjson_mut_val *perm = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, perm, "role", perm_name(c->perm));
+ const char *scope = perm_scope(c->perm);
+ if (scope)
+ yyjson_mut_obj_add_strcpy(r->rdoc, perm, "scope", scope);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, perm, "scope");
+ yyjson_mut_obj_add_bool(r->rdoc, perm, "require_org", c->need_org != 0);
+ yyjson_mut_obj_add_val(r->rdoc, o, "permission", perm);
+ return o;
+}
+
+static yyjson_mut_val *h_describe(struct req *r)
+{
+ const char *name = arg_str(r->args, "cmd");
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "protocol", BOKF_PROTOCOL_VERSION);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "server_version", BOKF_VERSION);
+ if (name) {
+ const struct command *c = command_find(name);
+ if (!c)
+ return fail(r, "NOT_FOUND", "unknown command");
+ yyjson_mut_obj_add_val(r->rdoc, o, "command", command_json(r, c));
+ return o;
+ }
+ yyjson_mut_val *arr = yyjson_mut_arr(r->rdoc);
+ for (size_t i = 0; i < g_commands_count; i++)
+ yyjson_mut_arr_add_val(arr, command_json(r, &g_commands[i]));
+ yyjson_mut_obj_add_val(r->rdoc, o, "commands", arr);
+ return o;
+}
+
+static const char AGENT_INSTRUCTIONS[] =
+ "# bokf agent instructions (protocol v1)\n"
+ "\n"
+ "You operate a Swedish bookkeeping system through its JSON API.\n"
+ "\n"
+ "## Authentication\n"
+ "- Open one session with `session.open` using an API token:\n"
+ " {\"method\":\"token\",\"token\":\"bokf_...\"}.\n"
+ "- Send the returned session id as `session` on every request.\n"
+ "- Never handle a password unless the human explicitly logs you in that way.\n"
+ "\n"
+ "## Ground rules\n"
+ "- Amounts are integer öre. Dates are YYYY-MM-DD. Account numbers are strings.\n"
+ "- For every mutating command: call it once with `dry_run:true`, inspect the\n"
+ " result, then call again with identical args and a `client_ref` to post.\n"
+ "- Posted data cannot be edited or deleted. Corrections are new vouchers via\n"
+ " `voucher.correct`; the original always remains visible.\n"
+ "- Postings must balance: at least two rows, sum debit == sum credit.\n"
+ "- Locked periods (`PERIOD_LOCKED`) and closed fiscal years\n"
+ " (`FISCAL_YEAR_CLOSED`) are hard stops. Ask the human.\n"
+ "- `fiscal_year.close`, `period.lock`, `sie.import` and `backup.snapshot`\n"
+ " are irreversible or sensitive: confirm with the human first.\n"
+ "- Underlag: upload with `attachment.put`, link with `attachment_ids` when\n"
+ " posting.\n"
+ "- On `CONFLICT` or `replayed:true`, fetch the existing object instead of\n"
+ " retrying. On `RATE_LIMITED`, back off and report.\n"
+ "- Konteringsmallar: list with `template.list`, apply with `voucher.post`\n"
+ " `{\"template\":\"name\",\"x\":1250}` where x is kronor; formulas use x,\n"
+ " numbers and + - * /. Positive results debit, negative credit.\n"
+ "\n"
+ "## Discovery\n"
+ "- `describe` lists every implemented command with permissions.\n"
+ "- Reports are read-only: `report.trial_balance`, `report.balance_sheet`,\n"
+ " `report.income_statement`, `report.general_ledger`, `report.vat`.\n";
+
+static yyjson_mut_val *h_agent_instructions(struct req *r)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "format", "markdown");
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "content", AGENT_INSTRUCTIONS);
+ return o;
+}
+
+/* ------------------------------------------------------------------ */
+/* audit and backup */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *h_audit_list(struct req *r)
+{
+ int64_t cursor = 0, limit = 100;
+ arg_int(r->args, "cursor", &cursor);
+ arg_int(r->args, "limit", &limit);
+ if (limit < 1)
+ limit = 100;
+ if (limit > 1000)
+ limit = 1000;
+ const char *action = arg_str(r->args, "action");
+
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ const char *sql = action
+ ? "SELECT seq,at,actor_user_id,actor_token_id,action,"
+ "request_json,result_code FROM audit_log"
+ " WHERE org_id=?1 AND seq>?2 AND action=?3"
+ " ORDER BY seq LIMIT ?4"
+ : "SELECT seq,at,actor_user_id,actor_token_id,action,"
+ "request_json,result_code FROM audit_log"
+ " WHERE org_id=?1 AND seq>?2 ORDER BY seq LIMIT ?4";
+ if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, cursor);
+ if (action)
+ sqlite3_bind_text(st, 3, action, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, limit);
+ int64_t last_seq = cursor;
+ int64_t n = 0;
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ n++;
+ last_seq = sqlite3_column_int64(st, 0);
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "seq", last_seq);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "at",
+ sq(sqlite3_column_text(st, 1)));
+ if (sqlite3_column_type(st, 2) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "actor_user_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "actor_user_id",
+ sqlite3_column_int64(st, 2));
+ if (sqlite3_column_type(st, 3) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "actor_token_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "actor_token_id",
+ sqlite3_column_int64(st, 3));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "action",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "result_code",
+ sq(sqlite3_column_text(st, 6)));
+ const char *req = (const char *)sqlite3_column_text(st, 5);
+ yyjson_doc *parsed = req ? yyjson_read(req, strlen(req), 0) : NULL;
+ if (parsed && yyjson_is_obj(yyjson_doc_get_root(parsed)))
+ yyjson_mut_obj_add_val(r->rdoc, o, "request",
+ yyjson_val_mut_copy(r->rdoc,
+ yyjson_doc_get_root(parsed)));
+ if (parsed)
+ yyjson_doc_free(parsed);
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ if (n == limit)
+ yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last_seq);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor");
+ return out;
+}
+
+static yyjson_mut_val *h_audit_verify(struct req *r)
+{
+ int64_t checked = 0, bad_seq = 0;
+ char *err = NULL;
+ int rc = audit_verify(r->db, &checked, &bad_seq, &err);
+ if (rc < 0) {
+ yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed");
+ free(err);
+ return res;
+ }
+ free(err);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "ok", rc == 0);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "kind", "audit");
+ yyjson_mut_obj_add_int(r->rdoc, o, "checked", checked);
+ if (rc == 1)
+ yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", bad_seq);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_seq");
+ return o;
+}
+
+static yyjson_mut_val *h_backup_snapshot(struct req *r)
+{
+ const char *dest = arg_str(r->args, "dest");
+ if (mkdir_p(g_cfg.backup_dir, 0700) != 0)
+ return fail(r, "INTERNAL", "cannot create backup directory");
+ char path[4096];
+ if (dest && *dest) {
+ snprintf(path, sizeof path, "%s", dest);
+ } else {
+ char stamp[32];
+ time_t t = (time_t)util_now();
+ struct tm tm;
+ gmtime_r(&t, &tm);
+ strftime(stamp, sizeof stamp, "%Y%m%dT%H%M%SZ", &tm);
+ snprintf(path, sizeof path, "%s/bokfd-%s.db", g_cfg.backup_dir, stamp);
+ }
+ if (access(path, F_OK) == 0)
+ return fail(r, "CONFLICT", "destination already exists");
+ char *sql = sqlite3_mprintf("VACUUM INTO %Q", path);
+ int rc = db_exec(r->db, sql, NULL);
+ sqlite3_free(sql);
+ if (rc != 0)
+ return fail(r, "INTERNAL", "VACUUM INTO failed");
+
+ struct stat sb;
+ if (stat(path, &sb) != 0)
+ return fail(r, "INTERNAL", "backup file not found after snapshot");
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ return fail(r, "INTERNAL", "cannot read backup file");
+ SHA256_CTX ctx;
+ sha256_init(&ctx);
+ unsigned char chunk[65536];
+ size_t rn;
+ while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0)
+ sha256_update(&ctx, (const BYTE *)chunk, rn);
+ fclose(f);
+ unsigned char digest[32];
+ sha256_final(&ctx, digest);
+ char hex[65];
+ util_hex(digest, 32, hex);
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+
+ audit_append(r->db, r->org_id, r->sess->user_id, 0, "backup.snapshot",
+ "{}", "OK", NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex);
+ yyjson_mut_obj_add_int(r->rdoc, o, "size", (int64_t)sb.st_size);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts);
+ return o;
+}
+
+/* ------------------------------------------------------------------ */
+/* ------------------------------------------------------------------ */
+/* shared helpers for M2 commands */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *json_to_mut(yyjson_mut_doc *doc, const char *json)
+{
+ if (!json)
+ return NULL;
+ yyjson_doc *d = yyjson_read(json, strlen(json), 0);
+ if (!d)
+ return NULL;
+ yyjson_mut_val *v = yyjson_val_mut_copy(doc, yyjson_doc_get_root(d));
+ yyjson_doc_free(d);
+ return v;
+}
+
+static int hash_file(const char *path, char hex[65], int64_t *size)
+{
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ return -1;
+ SHA256_CTX ctx;
+ sha256_init(&ctx);
+ unsigned char chunk[65536];
+ size_t rn;
+ int64_t total = 0;
+ while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) {
+ sha256_update(&ctx, (const BYTE *)chunk, rn);
+ total += (int64_t)rn;
+ }
+ int bad = ferror(f);
+ fclose(f);
+ if (bad)
+ return -1;
+ unsigned char digest[32];
+ sha256_final(&ctx, digest);
+ util_hex(digest, 32, hex);
+ if (size)
+ *size = total;
+ return 0;
+}
+
+static int is_digits(const char *s)
+{
+ if (!s || !*s)
+ return 0;
+ for (; *s; s++)
+ if (*s < '0' || *s > '9')
+ return 0;
+ return 1;
+}
+
+/* ------------------------------------------------------------------ */
+/* kontoplan */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *account_json(yyjson_mut_doc *doc, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_int(doc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(doc, o, "number",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(doc, o, "name", sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(doc, o, "type", sq(sqlite3_column_text(st, 3)));
+ if (sqlite3_column_type(st, 4) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(doc, o, "sru_code");
+ else
+ yyjson_mut_obj_add_strcpy(doc, o, "sru_code",
+ sq(sqlite3_column_text(st, 4)));
+ if (sqlite3_column_type(st, 5) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(doc, o, "vat_code");
+ else
+ yyjson_mut_obj_add_strcpy(doc, o, "vat_code",
+ sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_bool(doc, o, "active", sqlite3_column_int(st, 6) != 0);
+ return o;
+}
+
+#define ACCOUNT_COLUMNS "id,number,name,type,sru_code,vat_code,active"
+
+static yyjson_mut_val *h_account_list(struct req *r)
+{
+ int active_only = 0;
+ arg_bool(r->args, "active_only", &active_only);
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ const char *sql = active_only
+ ? "SELECT " ACCOUNT_COLUMNS " FROM accounts"
+ " WHERE org_id=?1 AND active=1 ORDER BY number"
+ : "SELECT " ACCOUNT_COLUMNS " FROM accounts"
+ " WHERE org_id=?1 ORDER BY number";
+ if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ while (sqlite3_step(st) == SQLITE_ROW)
+ yyjson_mut_arr_add_val(items, account_json(r->rdoc, st));
+ sqlite3_finalize(st);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, o, "items", items);
+ return o;
+}
+
+static yyjson_mut_val *h_account_get(struct req *r)
+{
+ int64_t id = 0;
+ const char *number = arg_str(r->args, "number");
+ arg_int(r->args, "id", &id);
+ if (!id && !number)
+ return fail(r, "INVALID_ARGS", "id or number is required");
+ sqlite3_stmt *st = NULL;
+ const char *sql = id ? "SELECT " ACCOUNT_COLUMNS " FROM accounts"
+ " WHERE org_id=?1 AND id=?2"
+ : "SELECT " ACCOUNT_COLUMNS " FROM accounts"
+ " WHERE org_id=?1 AND number=?2";
+ if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ if (id)
+ sqlite3_bind_int64(st, 2, id);
+ else
+ sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = account_json(r->rdoc, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "NOT_FOUND", "account not found");
+ return o;
+}
+
+static int valid_account_type(const char *t)
+{
+ return t && (!strcmp(t, "asset") || !strcmp(t, "liability") ||
+ !strcmp(t, "equity") || !strcmp(t, "revenue") ||
+ !strcmp(t, "expense"));
+}
+
+static yyjson_mut_val *h_account_create(struct req *r)
+{
+ const char *number = arg_str(r->args, "number");
+ const char *name = arg_str(r->args, "name");
+ const char *type = arg_str(r->args, "type");
+ const char *sru = arg_str(r->args, "sru_code");
+ const char *vat = arg_str(r->args, "vat_code");
+ if (!is_digits(number) || strlen(number) > 10)
+ return fail(r, "INVALID_ARGS", "number must be 1-10 digits");
+ if (!name || !*name)
+ return fail(r, "INVALID_ARGS", "name is required");
+ if (!valid_account_type(type))
+ return fail(r, "INVALID_ARGS",
+ "type must be asset, liability, equity, revenue or expense");
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO accounts(org_id,number,name,type,sru_code,vat_code,"
+ "created_at) VALUES(?1,?2,?3,?4,?5,?6,?7)",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, type, -1, SQLITE_TRANSIENT);
+ if (sru)
+ sqlite3_bind_text(st, 5, sru, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 5);
+ if (vat)
+ sqlite3_bind_text(st, 6, vat, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 6);
+ sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ if ((rc & 0xff) == SQLITE_CONSTRAINT)
+ return fail(r, "CONFLICT", "account number already exists");
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ int64_t id = db_last_id(r->db);
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "account.create", reqjson, "OK", NULL);
+ free(reqjson);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "number", number);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "type", type);
+ return o;
+}
+
+static yyjson_mut_val *h_account_update(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ const char *name = arg_str(r->args, "name");
+ const char *sru = arg_str(r->args, "sru_code");
+ const char *vat = arg_str(r->args, "vat_code");
+ int active = -1;
+ arg_bool(r->args, "active", &active);
+ if (!name && !sru && !vat && active < 0)
+ return fail(r, "INVALID_ARGS", "nothing to update");
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE accounts SET"
+ " name=COALESCE(?2,name), sru_code=COALESCE(?3,sru_code),"
+ " vat_code=COALESCE(?4,vat_code),"
+ " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6"
+ " WHERE org_id=?1 AND id=?7",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ if (name)
+ sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 2);
+ if (sru)
+ sqlite3_bind_text(st, 3, sru, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 3);
+ if (vat)
+ sqlite3_bind_text(st, 4, vat, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 4);
+ sqlite3_bind_int(st, 5, active);
+ sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 7, id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "account not found");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "account.update", reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+/* ------------------------------------------------------------------ */
+/* fiscal years and period locks */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *fy_json(struct req *r, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "label",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date",
+ sq(sqlite3_column_text(st, 3)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "status",
+ sq(sqlite3_column_text(st, 4)));
+ if (sqlite3_column_type(st, 5) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "locked_until");
+ else
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until",
+ sq(sqlite3_column_text(st, 5)));
+ return o;
+}
+
+#define FY_COLUMNS "id,label,start_date,end_date,status,locked_until"
+
+static yyjson_mut_val *h_fiscal_year_list(struct req *r)
+{
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT " FY_COLUMNS " FROM fiscal_years WHERE org_id=?1"
+ " ORDER BY start_date",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ while (sqlite3_step(st) == SQLITE_ROW)
+ yyjson_mut_arr_add_val(items, fy_json(r, st));
+ sqlite3_finalize(st);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, o, "items", items);
+ return o;
+}
+
+static yyjson_mut_val *h_fiscal_year_get(struct req *r)
+{
+ int64_t id = 0;
+ arg_int(r->args, "id", &id);
+ sqlite3_stmt *st = NULL;
+ const char *sql = id
+ ? "SELECT " FY_COLUMNS " FROM fiscal_years"
+ " WHERE org_id=?1 AND id=?2"
+ : "SELECT " FY_COLUMNS " FROM fiscal_years"
+ " WHERE org_id=?1 ORDER BY start_date DESC LIMIT 1";
+ if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ if (id)
+ sqlite3_bind_int64(st, 2, id);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = fy_json(r, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "NOT_FOUND", "fiscal year not found");
+ return o;
+}
+
+static int fy_exists_in_range(sqlite3 *db, int64_t org_id, const char *start,
+ const char *end)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT count(*) FROM fiscal_years WHERE org_id=?1"
+ " AND NOT (end_date < ?2 OR start_date > ?3)",
+ -1, &st, NULL) != SQLITE_OK)
+ return 1;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, end, -1, SQLITE_TRANSIENT);
+ int64_t n = 0;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ n = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ return n > 0;
+}
+
+static yyjson_mut_val *h_fiscal_year_open(struct req *r)
+{
+ const char *label = arg_str(r->args, "label");
+ const char *start = arg_str(r->args, "start_date");
+ const char *end = arg_str(r->args, "end_date");
+ if (!label || !*label)
+ return fail(r, "INVALID_ARGS", "label is required");
+ if (!util_parse_iso_date(start) || !util_parse_iso_date(end))
+ return fail(r, "INVALID_ARGS", "start_date and end_date must be YYYY-MM-DD");
+ if (strcmp(start, end) >= 0)
+ return fail(r, "INVALID_ARGS", "start_date must be before end_date");
+ if (fy_exists_in_range(r->db, r->org_id, start, end))
+ return fail(r, "CONFLICT", "fiscal year overlaps an existing one");
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO fiscal_years(org_id,label,start_date,end_date,"
+ "created_at) VALUES(?1,?2,?3,?4,?5)",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, label, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, start, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, end, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "CONFLICT", "could not create fiscal year");
+ int64_t id = db_last_id(r->db);
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "fiscal_year.open", reqjson, "OK", NULL);
+ free(reqjson);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "label", label);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "start_date", start);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "end_date", end);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "status", "open");
+ return o;
+}
+
+static yyjson_mut_val *h_fiscal_year_close(struct req *r)
+{
+ int64_t id = 0;
+ int confirm = 0;
+ arg_int(r->args, "id", &id);
+ arg_bool(r->args, "confirm", &confirm);
+ if (id <= 0 || !confirm)
+ return fail(r, "INVALID_ARGS", "id and confirm:true are required");
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE fiscal_years SET status='closed', closed_at=?3,"
+ " closed_by=?4 WHERE org_id=?1 AND id=?2 AND status='open'",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, r->sess->user_id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "open fiscal year not found");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "fiscal_year.close", reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+static yyjson_mut_val *h_period_lock(struct req *r)
+{
+ int64_t fy = 0;
+ arg_int(r->args, "fiscal_year", &fy);
+ const char *until = arg_str(r->args, "until");
+ if (fy <= 0 || !util_parse_iso_date(until))
+ return fail(r, "INVALID_ARGS",
+ "fiscal_year and until (YYYY-MM-DD) are required");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE fiscal_years SET locked_until=?3 WHERE org_id=?1 AND id=?2"
+ " AND ?3 BETWEEN start_date AND end_date",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, fy);
+ sqlite3_bind_text(st, 3, until, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND",
+ "fiscal year not found or until is outside it");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "period.lock", reqjson, "OK", NULL);
+ free(reqjson);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year", fy);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until", until);
+ return o;
+}
+
+static yyjson_mut_val *h_period_unlock(struct req *r)
+{
+ int64_t fy = 0;
+ arg_int(r->args, "fiscal_year", &fy);
+ if (fy <= 0)
+ return fail(r, "INVALID_ARGS", "fiscal_year is required");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE fiscal_years SET locked_until=NULL WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, fy);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "fiscal year not found");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "period.unlock", reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+/* ------------------------------------------------------------------ */
+/* vouchers */
+/* ------------------------------------------------------------------ */
+
+static int parse_rows(struct req *r, yyjson_val *rowsv, struct ledger_row **out,
+ size_t *out_n)
+{
+ if (!rowsv || !yyjson_is_arr(rowsv))
+ return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1;
+ size_t n = yyjson_arr_size(rowsv);
+ struct ledger_row *rows = xcalloc(n ? n : 1, sizeof *rows);
+ size_t k = 0;
+ yyjson_arr_iter it = yyjson_arr_iter_with(rowsv);
+ yyjson_val *item;
+ while ((item = yyjson_arr_iter_next(&it))) {
+ if (!yyjson_is_obj(item)) {
+ free(rows);
+ fail(r, "INVALID_ARGS", "each row must be an object");
+ return -1;
+ }
+ yyjson_val *av = yyjson_obj_get(item, "account");
+ if (!av || !yyjson_is_str(av)) {
+ free(rows);
+ failf(r, "INVALID_ARGS", "row %zu: account is required", k + 1);
+ return -1;
+ }
+ yyjson_val *dv = yyjson_obj_get(item, "debit_ore");
+ yyjson_val *cv = yyjson_obj_get(item, "credit_ore");
+ if ((dv && !yyjson_is_int(dv)) || (cv && !yyjson_is_int(cv))) {
+ free(rows);
+ failf(r, "INVALID_ARGS", "row %zu: amounts must be integer öre",
+ k + 1);
+ return -1;
+ }
+ rows[k].account = yyjson_get_str(av);
+ rows[k].debit_ore = dv ? yyjson_get_int(dv) : 0;
+ rows[k].credit_ore = cv ? yyjson_get_int(cv) : 0;
+ yyjson_val *d = yyjson_obj_get(item, "description");
+ rows[k].description = d && yyjson_is_str(d) ? yyjson_get_str(d) : NULL;
+ k++;
+ }
+ *out = rows;
+ *out_n = k;
+ return 0;
+}
+
+static int parse_attachment_ids(struct req *r, int64_t **out, size_t *out_n)
+{
+ yyjson_val *av = r->args ? yyjson_obj_get(r->args, "attachment_ids") : NULL;
+ *out = NULL;
+ *out_n = 0;
+ if (!av)
+ return 0;
+ if (!yyjson_is_arr(av))
+ return fail(r, "INVALID_ARGS", "attachment_ids must be an array") ? -1
+ : -1;
+ size_t n = yyjson_arr_size(av);
+ int64_t *ids = xcalloc(n ? n : 1, sizeof *ids);
+ size_t k = 0;
+ yyjson_arr_iter it = yyjson_arr_iter_with(av);
+ yyjson_val *item;
+ while ((item = yyjson_arr_iter_next(&it))) {
+ if (!yyjson_is_int(item)) {
+ free(ids);
+ fail(r, "INVALID_ARGS", "attachment_ids must be integers");
+ return -1;
+ }
+ ids[k++] = yyjson_get_int(item);
+ }
+ *out = ids;
+ *out_n = k;
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* settings */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *h_settings_get(struct req *r)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ int have_default = 0;
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st,
+ NULL) == SQLITE_OK) {
+ sqlite3_bind_int64(st, 1, r->org_id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ const char *k = (const char *)sqlite3_column_text(st, 0);
+ const char *v = (const char *)sqlite3_column_text(st, 1);
+ if (k) {
+ yyjson_mut_obj_add(o, yyjson_mut_strcpy(r->rdoc, k),
+ yyjson_mut_strcpy(r->rdoc, v ? v : ""));
+ if (strcmp(k, "default_series") == 0)
+ have_default = 1;
+ }
+ }
+ sqlite3_finalize(st);
+ }
+ if (!have_default)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A");
+ return o;
+}
+
+static yyjson_mut_val *h_settings_set(struct req *r)
+{
+ const char *key = arg_str(r->args, "key");
+ const char *value = arg_str(r->args, "value");
+ if (!key || !value)
+ return fail(r, "INVALID_ARGS", "key and value are required");
+ size_t maxlen;
+ if (strcmp(key, "default_series") == 0)
+ maxlen = 8;
+ else if (strcmp(key, "attachment_dir") == 0)
+ maxlen = 255;
+ else
+ return fail(r, "UNSUPPORTED", "unknown setting");
+ size_t len = strlen(value);
+ if (len == 0 || len > maxlen)
+ return failf(r, "INVALID_ARGS", "%s must be 1-%zu characters", key,
+ maxlen);
+ for (const char *p = value; *p; p++)
+ if ((unsigned char)*p < 32)
+ return failf(r, "INVALID_ARGS",
+ "%s must not contain control characters", key);
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+ }
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO settings(org_id,key,value) VALUES(?1,?2,?3)"
+ " ON CONFLICT(org_id,key) DO UPDATE SET value=excluded.value",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, value, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "settings.set", reqjson, "OK", NULL);
+ free(reqjson);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value);
+ return o;
+}
+
+/* ------------------------------------------------------------------ */
+/* voucher templates (konteringsmallar) */
+/* ------------------------------------------------------------------ */
+
+struct tpl_head {
+ int64_t id;
+ char name[128];
+ char series[16];
+ char description[256];
+};
+
+struct tpl_loaded {
+ char account[16];
+ char formula[256];
+ char desc[128];
+};
+
+struct tpl_row_in {
+ int64_t account_id;
+ char account[16];
+ char formula[256];
+ char desc[128];
+};
+
+static double parse_kr_double(const char *s, int *ok)
+{
+ *ok = 0;
+ if (!s)
+ return 0;
+ char buf[64];
+ size_t j = 0;
+ for (const char *p = s; *p && j < sizeof buf - 1; p++) {
+ if (*p == ' ' || *p == '\t')
+ continue;
+ buf[j++] = (*p == ',') ? '.' : *p;
+ }
+ buf[j] = '\0';
+ if (!j || (j == 1 && buf[0] == '-'))
+ return 0;
+ char *end = NULL;
+ double v = strtod(buf, &end);
+ if (!end || *end)
+ return 0;
+ *ok = 1;
+ return v;
+}
+
+static char *replace_x(const char *text, double x)
+{
+ struct buf b;
+ buf_init(&b);
+ for (const char *p = text; *p;) {
+ if (p[0] == '{' && p[1] == 'x' && p[2] == '}') {
+ char tmp[64];
+ snprintf(tmp, sizeof tmp, "%.2f", x);
+ buf_append(&b, tmp, strlen(tmp));
+ p += 3;
+ } else {
+ buf_append(&b, p, 1);
+ p++;
+ }
+ }
+ buf_append(&b, "", 1);
+ return (char *)b.p;
+}
+
+static int load_template(sqlite3 *db, int64_t org_id, int64_t id,
+ const char *name, struct tpl_head *head, char **err)
+{
+ sqlite3_stmt *st = NULL;
+ const char *sql =
+ id ? "SELECT id,name,series,description FROM voucher_templates"
+ " WHERE org_id=?1 AND id=?2 AND active=1"
+ : "SELECT id,name,series,description FROM voucher_templates"
+ " WHERE org_id=?1 AND name=?2 AND active=1";
+ if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
+ if (err)
+ *err = xstrdup("database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ if (id)
+ sqlite3_bind_int64(st, 2, id);
+ else
+ sqlite3_bind_text(st, 2, name ? name : "", -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ if (err)
+ *err = xstrdup("template not found");
+ return -1;
+ }
+ head->id = sqlite3_column_int64(st, 0);
+ snprintf(head->name, sizeof head->name, "%s",
+ sqlite3_column_text(st, 1));
+ snprintf(head->series, sizeof head->series, "%s",
+ sqlite3_column_text(st, 2));
+ snprintf(head->description, sizeof head->description, "%s",
+ sqlite3_column_text(st, 3));
+ sqlite3_finalize(st);
+ return 0;
+}
+
+static int load_template_rows(sqlite3 *db, int64_t org_id, int64_t tpl,
+ struct tpl_loaded **out, size_t *out_n,
+ char **err)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT a.number,tr.formula,COALESCE(tr.description,'')"
+ " FROM voucher_template_rows tr JOIN accounts a"
+ " ON a.org_id=tr.org_id AND a.id=tr.account_id"
+ " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no",
+ -1, &st, NULL) != SQLITE_OK) {
+ if (err)
+ *err = xstrdup("database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, tpl);
+ size_t cap = 0, n = 0;
+ struct tpl_loaded *rows = NULL;
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ if (n == cap) {
+ cap = cap ? cap * 2 : 8;
+ rows = xrealloc(rows, cap * sizeof *rows);
+ }
+ memset(&rows[n], 0, sizeof rows[n]);
+ snprintf(rows[n].account, sizeof rows[n].account, "%s",
+ sqlite3_column_text(st, 0));
+ snprintf(rows[n].formula, sizeof rows[n].formula, "%s",
+ sqlite3_column_text(st, 1));
+ snprintf(rows[n].desc, sizeof rows[n].desc, "%s",
+ sqlite3_column_text(st, 2));
+ n++;
+ }
+ sqlite3_finalize(st);
+ *out = rows;
+ *out_n = n;
+ return 0;
+}
+
+static int parse_template_rows(struct req *r, yyjson_val *rowsv,
+ struct tpl_row_in **out, size_t *out_n)
+{
+ *out = NULL;
+ *out_n = 0;
+ if (!rowsv || !yyjson_is_arr(rowsv))
+ return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1;
+ size_t n = yyjson_arr_size(rowsv);
+ if (n == 0 || n > 100)
+ return fail(r, "INVALID_ARGS",
+ "a template needs between 1 and 100 rows")
+ ? -1
+ : -1;
+ struct tpl_row_in *rows = xcalloc(n, sizeof *rows);
+ size_t k = 0;
+ yyjson_arr_iter it = yyjson_arr_iter_with(rowsv);
+ yyjson_val *item;
+ while ((item = yyjson_arr_iter_next(&it))) {
+ if (!yyjson_is_obj(item)) {
+ free(rows);
+ fail(r, "INVALID_ARGS", "each row must be an object");
+ return -1;
+ }
+ yyjson_val *av = yyjson_obj_get(item, "account");
+ yyjson_val *fv = yyjson_obj_get(item, "formula");
+ if (!av || !yyjson_is_str(av) || !fv || !yyjson_is_str(fv)) {
+ free(rows);
+ failf(r, "INVALID_ARGS",
+ "row %zu: account and formula are required", k + 1);
+ return -1;
+ }
+ const char *account = yyjson_get_str(av);
+ const char *formula = yyjson_get_str(fv);
+ if (!formula_valid(formula)) {
+ free(rows);
+ failf(r, "INVALID_ARGS", "row %zu: ogiltig formel '%s'", k + 1,
+ formula);
+ return -1;
+ }
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db,
+ "SELECT id FROM accounts WHERE org_id=?1"
+ " AND number=?2",
+ -1, &st, NULL) != SQLITE_OK) {
+ free(rows);
+ return fail(r, "INTERNAL", "database error") ? -1 : -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ free(rows);
+ failf(r, "ACCOUNT_NOT_FOUND", "row %zu: account %s does not exist",
+ k + 1, account);
+ return -1;
+ }
+ rows[k].account_id = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ snprintf(rows[k].account, sizeof rows[k].account, "%s", account);
+ snprintf(rows[k].formula, sizeof rows[k].formula, "%s", formula);
+ yyjson_val *dv = yyjson_obj_get(item, "description");
+ if (dv && yyjson_is_str(dv))
+ snprintf(rows[k].desc, sizeof rows[k].desc, "%s",
+ yyjson_get_str(dv));
+ k++;
+ }
+ *out = rows;
+ *out_n = k;
+ return 0;
+}
+
+static yyjson_mut_val *tpl_rows_json(struct req *r, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "line_no", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "account",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "formula",
+ sq(sqlite3_column_text(st, 3)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description",
+ sq(sqlite3_column_text(st, 4)));
+ return o;
+}
+
+static yyjson_mut_val *h_template_list(struct req *r)
+{
+ int active_only = 0;
+ arg_bool(r->args, "active_only", &active_only);
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ const char *sql =
+ active_only
+ ? "SELECT t.id,t.name,t.series,t.description,t.active,"
+ "(SELECT count(*) FROM voucher_template_rows tr"
+ " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)"
+ " FROM voucher_templates t WHERE t.org_id=?1 AND t.active=1"
+ " ORDER BY t.name"
+ : "SELECT t.id,t.name,t.series,t.description,t.active,"
+ "(SELECT count(*) FROM voucher_template_rows tr"
+ " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)"
+ " FROM voucher_templates t WHERE t.org_id=?1 ORDER BY t.name";
+ if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description",
+ sq(sqlite3_column_text(st, 3)));
+ yyjson_mut_obj_add_bool(r->rdoc, o, "active",
+ sqlite3_column_int(st, 4) != 0);
+ yyjson_mut_obj_add_int(r->rdoc, o, "row_count",
+ sqlite3_column_int64(st, 5));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ return out;
+}
+
+static yyjson_mut_val *h_template_get(struct req *r)
+{
+ int64_t id = 0;
+ arg_int(r->args, "id", &id);
+ const char *name = arg_str(r->args, "name");
+ if (id <= 0 && !name)
+ return fail(r, "INVALID_ARGS", "id or name is required");
+ sqlite3_stmt *st = NULL;
+ const char *sql =
+ id > 0 ? "SELECT id,name,series,description,active"
+ " FROM voucher_templates WHERE org_id=?1 AND id=?2"
+ : "SELECT id,name,series,description,active"
+ " FROM voucher_templates WHERE org_id=?1 AND name=?2";
+ if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ if (id > 0)
+ sqlite3_bind_int64(st, 2, id);
+ else
+ sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "template not found");
+ }
+ int64_t tpl = sqlite3_column_int64(st, 0);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description",
+ sq(sqlite3_column_text(st, 3)));
+ yyjson_mut_obj_add_bool(r->rdoc, o, "active",
+ sqlite3_column_int(st, 4) != 0);
+ sqlite3_finalize(st);
+
+ yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc);
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT tr.line_no,a.number,a.name,tr.formula,"
+ "COALESCE(tr.description,'') FROM voucher_template_rows tr"
+ " JOIN accounts a ON a.org_id=tr.org_id AND a.id=tr.account_id"
+ " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no",
+ -1, &st, NULL) == SQLITE_OK) {
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, tpl);
+ while (sqlite3_step(st) == SQLITE_ROW)
+ yyjson_mut_arr_add_val(rows, tpl_rows_json(r, st));
+ sqlite3_finalize(st);
+ }
+ yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows);
+ return o;
+}
+
+static yyjson_mut_val *h_template_create(struct req *r)
+{
+ const char *name = arg_str(r->args, "name");
+ const char *series = arg_str(r->args, "series");
+ const char *description = arg_str(r->args, "description");
+ if (!name || !*name)
+ return fail(r, "INVALID_ARGS", "name is required");
+ char *series_owned = NULL;
+ if (!series || !*series) {
+ series_owned = db_setting(r->db, r->org_id, "default_series");
+ series = series_owned && *series_owned ? series_owned : "A";
+ }
+ if (!description)
+ description = "";
+ struct tpl_row_in *rows = NULL;
+ size_t nrows = 0;
+ if (parse_template_rows(
+ r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, &rows,
+ &nrows) != 0)
+ return NULL;
+
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description);
+ yyjson_mut_val *arr = yyjson_mut_arr(r->rdoc);
+ for (size_t i = 0; i < nrows; i++) {
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, arr);
+ yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", rows[i].account);
+ yyjson_mut_obj_add_strcpy(r->rdoc, ro, "formula", rows[i].formula);
+ yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", rows[i].desc);
+ }
+ yyjson_mut_obj_add_val(r->rdoc, o, "rows", arr);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ free(rows);
+ free(series_owned);
+ return o;
+ }
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) {
+ free(rows);
+ free(series_owned);
+ return fail(r, "DB_BUSY", "could not start transaction");
+ }
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db,
+ "INSERT INTO voucher_templates(org_id,name,series,"
+ "description,created_at) VALUES(?1,?2,?3,?4,?5)",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ return fail(r, "INTERNAL", "database error");
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ if ((rc & 0xff) == SQLITE_CONSTRAINT)
+ return fail(r, "CONFLICT", "a template with that name exists");
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ int64_t tpl = db_last_id(r->db);
+ for (size_t i = 0; i < nrows; i++) {
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO voucher_template_rows(org_id,template_id,line_no,"
+ "account_id,formula,description) VALUES(?1,?2,?3,?4,?5,?6)",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ return fail(r, "INTERNAL", "database error");
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, tpl);
+ sqlite3_bind_int64(st, 3, (int64_t)i + 1);
+ sqlite3_bind_int64(st, 4, rows[i].account_id);
+ sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT);
+ if (rows[i].desc[0])
+ sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 6);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ }
+ free(rows);
+ if (db_exec(r->db, "COMMIT", NULL) != 0)
+ return fail(r, "INTERNAL", "commit failed");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "template.create", reqjson, "OK", NULL);
+ free(reqjson);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description);
+ free(series_owned);
+ return o;
+}
+
+static yyjson_mut_val *h_template_update(struct req *r)
+{
+ int64_t id = 0;
+ arg_int(r->args, "id", &id);
+ const char *name_arg = arg_str(r->args, "name");
+ if (id <= 0 && !name_arg)
+ return fail(r, "INVALID_ARGS", "id or name is required");
+ struct tpl_head head;
+ char *err = NULL;
+ if (load_template(r->db, r->org_id, id, name_arg, &head, &err) != 0) {
+ yyjson_mut_val *res =
+ fail(r, "NOT_FOUND", err ? err : "template not found");
+ free(err);
+ return res;
+ }
+ const char *new_name = arg_str(r->args, "name");
+ const char *series = arg_str(r->args, "series");
+ const char *description = arg_str(r->args, "description");
+ int active = -1;
+ arg_bool(r->args, "active", &active);
+ yyjson_val *rowsv = r->args ? yyjson_obj_get(r->args, "rows") : NULL;
+ struct tpl_row_in *rows = NULL;
+ size_t nrows = 0;
+ if (rowsv) {
+ if (parse_template_rows(r, rowsv, &rows, &nrows) != 0)
+ return NULL;
+ }
+ if (!new_name && !series && !description && active < 0 && !rowsv) {
+ free(rows);
+ return fail(r, "INVALID_ARGS", "nothing to update");
+ }
+
+ if (r->dry_run) {
+ free(rows);
+ return h_template_get(r);
+ }
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) {
+ free(rows);
+ return fail(r, "DB_BUSY", "could not start transaction");
+ }
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE voucher_templates SET"
+ " name=COALESCE(?2,name), series=COALESCE(?3,series),"
+ " description=COALESCE(?4,description),"
+ " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6"
+ " WHERE org_id=?1 AND id=?7",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ return fail(r, "INTERNAL", "database error");
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ if (new_name)
+ sqlite3_bind_text(st, 2, new_name, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 2);
+ if (series)
+ sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 3);
+ if (description)
+ sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 4);
+ sqlite3_bind_int(st, 5, active);
+ sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 7, head.id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ if (rowsv) {
+ if (sqlite3_prepare_v2(
+ r->db,
+ "DELETE FROM voucher_template_rows WHERE org_id=?1"
+ " AND template_id=?2",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ return fail(r, "INTERNAL", "database error");
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, head.id);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ for (size_t i = 0; i < nrows; i++) {
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO voucher_template_rows(org_id,template_id,"
+ "line_no,account_id,formula,description)"
+ " VALUES(?1,?2,?3,?4,?5,?6)",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ return fail(r, "INTERNAL", "database error");
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, head.id);
+ sqlite3_bind_int64(st, 3, (int64_t)i + 1);
+ sqlite3_bind_int64(st, 4, rows[i].account_id);
+ sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT);
+ if (rows[i].desc[0])
+ sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 6);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ free(rows);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ }
+ }
+ free(rows);
+ if (db_exec(r->db, "COMMIT", NULL) != 0)
+ return fail(r, "INTERNAL", "commit failed");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "template.update", reqjson, "OK", NULL);
+ free(reqjson);
+ return h_template_get(r);
+}
+
+static yyjson_mut_val *h_template_archive(struct req *r)
+{
+ int64_t id = 0;
+ arg_int(r->args, "id", &id);
+ const char *name = arg_str(r->args, "name");
+ if (id <= 0 && !name)
+ return fail(r, "INVALID_ARGS", "id or name is required");
+ sqlite3_stmt *st = NULL;
+ const char *sql =
+ id > 0 ? "UPDATE voucher_templates SET active=0 WHERE org_id=?1"
+ " AND id=?2"
+ : "UPDATE voucher_templates SET active=0 WHERE org_id=?1"
+ " AND name=?2";
+ if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ if (id > 0)
+ sqlite3_bind_int64(st, 2, id);
+ else
+ sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "template not found");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "template.archive", reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+static yyjson_mut_val *h_voucher_post(struct req *r)
+{
+ const char *date = arg_str(r->args, "date");
+ const char *description = arg_str(r->args, "description");
+ const char *series = arg_str(r->args, "series");
+ const char *client_ref = arg_str(r->args, "client_ref");
+ int64_t corrects = 0;
+ arg_int(r->args, "corrects_voucher", &corrects);
+ if (!date)
+ return fail(r, "INVALID_ARGS", "date is required");
+
+ /* template application: {template, x} instead of {rows} */
+ int64_t tpl_id = 0;
+ const char *tpl_name = NULL;
+ yyjson_val *tv = r->args ? yyjson_obj_get(r->args, "template") : NULL;
+ if (tv) {
+ if (yyjson_is_int(tv))
+ tpl_id = yyjson_get_int(tv);
+ else if (yyjson_is_str(tv))
+ tpl_name = yyjson_get_str(tv);
+ else
+ return fail(r, "INVALID_ARGS", "template must be an id or a name");
+ if (r->args && yyjson_obj_get(r->args, "rows"))
+ return fail(r, "INVALID_ARGS",
+ "use either rows or template, not both");
+ }
+ double x = 0;
+ yyjson_val *xv = r->args ? yyjson_obj_get(r->args, "x") : NULL;
+ if (xv) {
+ if (yyjson_is_str(xv)) {
+ int ok = 0;
+ x = parse_kr_double(yyjson_get_str(xv), &ok);
+ if (!ok)
+ return fail(r, "INVALID_ARGS", "x must be a number in kronor");
+ } else if (yyjson_is_real(xv)) {
+ x = yyjson_get_real(xv);
+ } else if (yyjson_is_int(xv)) {
+ x = (double)yyjson_get_int(xv);
+ } else {
+ return fail(r, "INVALID_ARGS", "x must be a number in kronor");
+ }
+ }
+
+ struct ledger_row *rows = NULL;
+ size_t nrows = 0;
+ char *owned_desc = NULL;
+ char owned_series[16] = "";
+ int rows_owned = 0;
+
+ if (tv) {
+ struct tpl_head head;
+ char *err = NULL;
+ if (load_template(r->db, r->org_id, tpl_id, tpl_name, &head, &err) !=
+ 0) {
+ yyjson_mut_val *res =
+ fail(r, "NOT_FOUND", err ? err : "template not found");
+ free(err);
+ return res;
+ }
+ struct tpl_loaded *trows = NULL;
+ size_t ntrows = 0;
+ if (load_template_rows(r->db, r->org_id, head.id, &trows, &ntrows,
+ &err) != 0) {
+ yyjson_mut_val *res =
+ fail(r, "INTERNAL", err ? err : "could not load template");
+ free(err);
+ return res;
+ }
+ struct template_row *in =
+ xcalloc(ntrows ? ntrows : 1, sizeof *in);
+ for (size_t i = 0; i < ntrows; i++) {
+ in[i].account = trows[i].account;
+ in[i].formula = trows[i].formula;
+ in[i].description = trows[i].desc[0] ? trows[i].desc : NULL;
+ }
+ struct resolved_row *resolved =
+ xcalloc(ntrows ? ntrows : 1, sizeof *resolved);
+ char ferr[256] = "";
+ if (formula_resolve_rows(in, ntrows, x, resolved, &nrows, ferr,
+ sizeof ferr) != 0) {
+ free(in);
+ free(resolved);
+ free(trows);
+ return fail(r, "INVALID_ARGS",
+ ferr[0] ? ferr : "template could not be resolved");
+ }
+ free(in);
+ free(trows);
+ rows = xcalloc(nrows, sizeof *rows);
+ for (size_t i = 0; i < nrows; i++) {
+ rows[i].account = xstrdup(resolved[i].account);
+ rows[i].debit_ore = resolved[i].debit_ore;
+ rows[i].credit_ore = resolved[i].credit_ore;
+ rows[i].description =
+ resolved[i].description[0] ? xstrdup(resolved[i].description)
+ : NULL;
+ }
+ free(resolved);
+ rows_owned = 1;
+ if (!series && head.series[0])
+ snprintf(owned_series, sizeof owned_series, "%s", head.series);
+ if (!description && head.description[0])
+ owned_desc = replace_x(head.description, x);
+ if (!description && !owned_desc)
+ owned_desc = xstrdup(head.name);
+ } else {
+ if (!description)
+ return fail(r, "INVALID_ARGS",
+ "date and description are required");
+ if (parse_rows(r, r->args ? yyjson_obj_get(r->args, "rows") : NULL,
+ &rows, &nrows) != 0)
+ return NULL;
+ }
+
+ int64_t *att = NULL;
+ size_t natt = 0;
+ if (parse_attachment_ids(r, &att, &natt) != 0) {
+ if (rows_owned)
+ for (size_t i = 0; i < nrows; i++) {
+ free((char *)rows[i].account);
+ free((char *)rows[i].description);
+ }
+ free(rows);
+ free(owned_desc);
+ return NULL;
+ }
+
+ struct ledger_post_opts o;
+ memset(&o, 0, sizeof o);
+ o.org_id = r->org_id;
+ o.user_id = r->sess->user_id;
+ o.token_id = r->sess->token_id;
+ o.date = date;
+ o.description = description ? description : owned_desc;
+ o.series = series ? series
+ : (owned_series[0] ? owned_series : NULL);
+ o.rows = rows;
+ o.nrows = nrows;
+ o.corrects_voucher_id = corrects;
+ o.attachment_ids = att;
+ o.n_attachments = natt;
+ o.client_ref = client_ref;
+ o.dry_run = r->dry_run;
+
+ struct ledger_error e;
+ char *json = NULL;
+ int rc = ledger_post(r->db, &o, &e, &json);
+ if (rows_owned)
+ for (size_t i = 0; i < nrows; i++) {
+ free((char *)rows[i].account);
+ free((char *)rows[i].description);
+ }
+ free(rows);
+ free(att);
+ free(owned_desc);
+ if (rc != 0) {
+ if (e.has_details) {
+ yyjson_mut_val *details = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, details, "difference_ore",
+ e.difference_ore);
+ r->err_details = details;
+ }
+ return fail(r, e.code ? e.code : "INTERNAL", e.msg);
+ }
+
+ if (!r->dry_run) {
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "voucher.post", reqjson, "OK", NULL);
+ free(reqjson);
+ }
+ yyjson_mut_val *res = json_to_mut(r->rdoc, json);
+ free(json);
+ if (!res)
+ return fail(r, "INTERNAL", "could not serialize result");
+ return res;
+}
+
+static yyjson_mut_val *voucher_json(struct req *r, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id",
+ sqlite3_column_int64(st, 1));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "date",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description",
+ sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "source",
+ sq(sqlite3_column_text(st, 6)));
+ if (sqlite3_column_type(st, 7) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id",
+ sqlite3_column_int64(st, 7));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 8)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "created_by_user",
+ sqlite3_column_int64(st, 9));
+ char hex[65];
+ const void *hb = sqlite3_column_blob(st, 10);
+ if (hb && sqlite3_column_bytes(st, 10) == 32) {
+ util_hex((const unsigned char *)hb, 32, hex);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash_prev", hex);
+ }
+ const void *vb = sqlite3_column_blob(st, 11);
+ if (vb && sqlite3_column_bytes(st, 11) == 32) {
+ util_hex((const unsigned char *)vb, 32, hex);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "hash", hex);
+ }
+ return o;
+}
+
+#define VOUCHER_COLUMNS \
+ "id,fiscal_year_id,series,number,date,description,source," \
+ "corrects_voucher_id,created_at,created_by_user,hash_prev,hash"
+
+static yyjson_mut_val *h_voucher_get(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT " VOUCHER_COLUMNS " FROM vouchers WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "voucher not found");
+ }
+ yyjson_mut_val *o = voucher_json(r, st);
+ sqlite3_finalize(st);
+
+ yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc);
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT a.number,a.name,r.debit_ore,r.credit_ore,"
+ "COALESCE(r.description,'') FROM voucher_rows r"
+ " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
+ " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no",
+ -1, &st, NULL) == SQLITE_OK) {
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, rows);
+ yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account",
+ sq(sqlite3_column_text(st, 0)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, ro, "name",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_int(r->rdoc, ro, "debit_ore",
+ sqlite3_column_int64(st, 2));
+ yyjson_mut_obj_add_int(r->rdoc, ro, "credit_ore",
+ sqlite3_column_int64(st, 3));
+ yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description",
+ sq(sqlite3_column_text(st, 4)));
+ }
+ sqlite3_finalize(st);
+ }
+ yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows);
+
+ yyjson_mut_val *atts = yyjson_mut_arr(r->rdoc);
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT a.id,a.filename FROM voucher_attachments va"
+ " JOIN attachments a ON a.org_id=va.org_id AND a.id=va.attachment_id"
+ " WHERE va.org_id=?1 AND va.voucher_id=?2 ORDER BY a.id",
+ -1, &st, NULL) == SQLITE_OK) {
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *ao = yyjson_mut_arr_add_obj(r->rdoc, atts);
+ yyjson_mut_obj_add_int(r->rdoc, ao, "id",
+ sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, ao, "filename",
+ sq(sqlite3_column_text(st, 1)));
+ }
+ sqlite3_finalize(st);
+ }
+ yyjson_mut_obj_add_val(r->rdoc, o, "attachments", atts);
+ return o;
+}
+
+static yyjson_mut_val *h_voucher_list(struct req *r)
+{
+ int64_t fy = 0, cursor = 0, limit = 100, account_id = 0;
+ arg_int(r->args, "fiscal_year", &fy);
+ arg_int(r->args, "cursor", &cursor);
+ arg_int(r->args, "limit", &limit);
+ if (limit < 1)
+ limit = 100;
+ if (limit > 1000)
+ limit = 1000;
+ const char *series = arg_str(r->args, "series");
+ const char *from = arg_str(r->args, "from");
+ const char *to = arg_str(r->args, "to");
+ const char *text = arg_str(r->args, "text");
+ const char *account = arg_str(r->args, "account");
+ if (account) {
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT id FROM accounts WHERE org_id=?1 AND number=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "account not found");
+ }
+ account_id = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ }
+
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT v.id,v.series,v.number,v.date,v.description,v.source,"
+ "v.corrects_voucher_id,"
+ "(SELECT count(*) FROM voucher_rows r WHERE r.org_id=v.org_id"
+ " AND r.voucher_id=v.id)"
+ " FROM vouchers v WHERE v.org_id=?1"
+ " AND (?2=0 OR v.fiscal_year_id=?2)"
+ " AND (?3='' OR v.series=?3)"
+ " AND (?4='' OR v.date>=?4)"
+ " AND (?5='' OR v.date<=?5)"
+ " AND (?6=0 OR EXISTS(SELECT 1 FROM voucher_rows r2"
+ " WHERE r2.org_id=v.org_id AND r2.voucher_id=v.id"
+ " AND r2.account_id=?6))"
+ " AND (?7='' OR v.description LIKE '%'||?7||'%')"
+ " AND v.id>?8 ORDER BY v.id LIMIT ?9",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, fy);
+ sqlite3_bind_text(st, 3, series ? series : "", -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, from ? from : "", -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, to ? to : "", -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 6, account_id);
+ sqlite3_bind_text(st, 7, text ? text : "", -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 8, cursor);
+ sqlite3_bind_int64(st, 9, limit);
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ int64_t last = cursor, n = 0;
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ n++;
+ last = sqlite3_column_int64(st, 0);
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", last);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "series",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "number",
+ sqlite3_column_int64(st, 2));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "date",
+ sq(sqlite3_column_text(st, 3)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "source",
+ sq(sqlite3_column_text(st, 5)));
+ if (sqlite3_column_type(st, 6) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "corrects_voucher_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "corrects_voucher_id",
+ sqlite3_column_int64(st, 6));
+ yyjson_mut_obj_add_int(r->rdoc, o, "row_count",
+ sqlite3_column_int64(st, 7));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ if (n == limit)
+ yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor");
+ return out;
+}
+
+static yyjson_mut_val *h_voucher_correct(struct req *r)
+{
+ int64_t id = 0;
+ const char *description = arg_str(r->args, "description");
+ const char *date = arg_str(r->args, "date");
+ const char *client_ref = arg_str(r->args, "client_ref");
+ if (!arg_int(r->args, "voucher", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "voucher is required");
+ if (!description || !*description)
+ return fail(r, "INVALID_ARGS", "description is required");
+
+ sqlite3_stmt *st = NULL;
+ char series[16] = "", orig_date[16] = "";
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT series,date FROM vouchers WHERE org_id=?1 AND id=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "voucher not found");
+ }
+ snprintf(series, sizeof series, "%s", sqlite3_column_text(st, 0));
+ snprintf(orig_date, sizeof orig_date, "%s", sqlite3_column_text(st, 1));
+ sqlite3_finalize(st);
+
+ struct ledger_row *rows = NULL;
+ size_t nrows = 0, cap = 0;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT a.number,r.debit_ore,r.credit_ore,"
+ "COALESCE(r.description,'') FROM voucher_rows r"
+ " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
+ " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ if (nrows == cap) {
+ cap = cap ? cap * 2 : 8;
+ rows = xrealloc(rows, cap * sizeof *rows);
+ }
+ memset(&rows[nrows], 0, sizeof rows[nrows]);
+ rows[nrows].account = xstrdup(sq(sqlite3_column_text(st, 0)));
+ rows[nrows].debit_ore = sqlite3_column_int64(st, 2);
+ rows[nrows].credit_ore = sqlite3_column_int64(st, 1);
+ const unsigned char *d = sqlite3_column_text(st, 3);
+ rows[nrows].description = d && *d ? xstrdup((const char *)d) : NULL;
+ nrows++;
+ }
+ sqlite3_finalize(st);
+ if (nrows == 0)
+ return fail(r, "INTERNAL", "voucher has no rows");
+
+ struct ledger_post_opts o;
+ memset(&o, 0, sizeof o);
+ o.org_id = r->org_id;
+ o.user_id = r->sess->user_id;
+ o.token_id = r->sess->token_id;
+ o.date = date ? date : orig_date;
+ o.description = description;
+ o.series = series;
+ o.rows = rows;
+ o.nrows = nrows;
+ o.corrects_voucher_id = id;
+ o.client_ref = client_ref;
+ o.dry_run = r->dry_run;
+
+ struct ledger_error e;
+ char *json = NULL;
+ int rc = ledger_post(r->db, &o, &e, &json);
+ for (size_t i = 0; i < nrows; i++) {
+ free((char *)rows[i].account);
+ free((char *)rows[i].description);
+ }
+ free(rows);
+ if (rc != 0)
+ return fail(r, e.code ? e.code : "INTERNAL", e.msg);
+ if (!r->dry_run) {
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "voucher.correct", reqjson, "OK", NULL);
+ free(reqjson);
+ }
+ yyjson_mut_val *res = json_to_mut(r->rdoc, json);
+ free(json);
+ if (!res)
+ return fail(r, "INTERNAL", "could not serialize result");
+ return res;
+}
+
+/* ------------------------------------------------------------------ */
+/* attachments */
+/* ------------------------------------------------------------------ */
+
+static int link_attachment(struct req *r, int64_t aid, int64_t vid,
+ char **errmsg)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT (SELECT count(*) FROM vouchers WHERE org_id=?1 AND id=?2),"
+ "(SELECT count(*) FROM voucher_attachments WHERE org_id=?1"
+ " AND attachment_id=?3),"
+ "(SELECT count(*) FROM attachments WHERE org_id=?1 AND id=?3)",
+ -1, &st, NULL) != SQLITE_OK) {
+ *errmsg = xstrdup("database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, vid);
+ sqlite3_bind_int64(st, 3, aid);
+ int64_t vexists = 0, linked = 0, aexists = 0;
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ vexists = sqlite3_column_int64(st, 0);
+ linked = sqlite3_column_int64(st, 1);
+ aexists = sqlite3_column_int64(st, 2);
+ }
+ sqlite3_finalize(st);
+ if (!aexists) {
+ *errmsg = xstrdup("attachment not found");
+ return -1;
+ }
+ if (!vexists) {
+ *errmsg = xstrdup("voucher not found");
+ return -1;
+ }
+ if (linked) {
+ *errmsg = xstrdup("attachment is already linked");
+ return -1;
+ }
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO voucher_attachments(org_id,voucher_id,attachment_id,"
+ "created_at) VALUES(?1,?2,?3,?4)",
+ -1, &st, NULL) != SQLITE_OK) {
+ *errmsg = xstrdup("database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, vid);
+ sqlite3_bind_int64(st, 3, aid);
+ sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ *errmsg = xstrdup("could not link attachment");
+ return -1;
+ }
+ return 0;
+}
+
+static yyjson_mut_val *h_attachment_put(struct req *r)
+{
+ const char *filename = arg_str(r->args, "filename");
+ const char *mime = arg_str(r->args, "mime");
+ const char *b64 = arg_str(r->args, "content_base64");
+ int64_t voucher_id = 0;
+ arg_int(r->args, "voucher_id", &voucher_id);
+ if (!filename || !*filename || !b64)
+ return fail(r, "INVALID_ARGS",
+ "filename and content_base64 are required");
+ if (!mime || !*mime)
+ mime = "application/octet-stream";
+ unsigned char *content = NULL;
+ size_t len = 0;
+ if (util_b64_decode(b64, strlen(b64), &content, &len) != 0)
+ return fail(r, "INVALID_ARGS", "content_base64 is not valid base64");
+ if ((long)len > g_cfg.max_attachment_bytes) {
+ free(content);
+ return failf(r, "TOO_LARGE", "attachment exceeds %ld bytes",
+ g_cfg.max_attachment_bytes);
+ }
+ unsigned char hash[32];
+ util_sha256(content, len, hash);
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT id FROM attachments WHERE org_id=?1 AND sha256=?2"
+ " AND filename=?3",
+ -1, &st, NULL) != SQLITE_OK) {
+ free(content);
+ return fail(r, "INTERNAL", "database error");
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT);
+ int64_t existing = 0;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ existing = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+
+ if (existing) {
+ free(content);
+ if (voucher_id) {
+ char *emsg = NULL;
+ if (link_attachment(r, existing, voucher_id, &emsg) != 0) {
+ yyjson_mut_val *res = fail(r, "CONFLICT", emsg);
+ free(emsg);
+ return res;
+ }
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "attachment.link", reqjson, "OK", NULL);
+ free(reqjson);
+ }
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", existing);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "replayed", true);
+ return o;
+ }
+
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes,"
+ "content,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
+ -1, &st, NULL) != SQLITE_OK) {
+ free(content);
+ return fail(r, "INTERNAL", "database error");
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, mime, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 5, (int64_t)len);
+ sqlite3_bind_blob(st, 6, content, (int)len, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 8, r->sess->user_id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ free(content);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ int64_t id = db_last_id(r->db);
+
+ if (voucher_id) {
+ char *emsg = NULL;
+ if (link_attachment(r, id, voucher_id, &emsg) != 0) {
+ yyjson_mut_val *res = fail(r, "NOT_FOUND", emsg);
+ free(emsg);
+ return res;
+ }
+ }
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "attachment.put", reqjson, "OK", NULL);
+ free(reqjson);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", (int64_t)len);
+ return o;
+}
+
+static yyjson_mut_val *h_attachment_get(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT filename,mime,size_bytes,content,sha256,created_at"
+ " FROM attachments WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "attachment not found");
+ }
+ const void *content = sqlite3_column_blob(st, 3);
+ size_t len = (size_t)sqlite3_column_bytes(st, 3);
+ char *b64 = util_b64(content ? content : (const unsigned char *)"", len);
+ char hex[65];
+ const void *hb = sqlite3_column_blob(st, 4);
+ if (hb && sqlite3_column_bytes(st, 4) == 32)
+ util_hex((const unsigned char *)hb, 32, hex);
+ else
+ hex[0] = '\0';
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename",
+ sq(sqlite3_column_text(st, 0)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes",
+ sqlite3_column_int64(st, 2));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64);
+ free(b64);
+ sqlite3_finalize(st);
+ return o;
+}
+
+static yyjson_mut_val *h_attachment_list(struct req *r)
+{
+ int64_t voucher_id = 0, cursor = 0, limit = 100;
+ int unlinked = 0;
+ arg_int(r->args, "voucher_id", &voucher_id);
+ arg_int(r->args, "cursor", &cursor);
+ arg_int(r->args, "limit", &limit);
+ arg_bool(r->args, "unlinked", &unlinked);
+ if (limit < 1)
+ limit = 100;
+ if (limit > 1000)
+ limit = 1000;
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT a.id,a.filename,a.mime,a.size_bytes,a.created_at,a.sha256,"
+ "(SELECT va.voucher_id FROM voucher_attachments va"
+ " WHERE va.org_id=a.org_id AND va.attachment_id=a.id LIMIT 1)"
+ " FROM attachments a WHERE a.org_id=?1"
+ " AND (?2=0 OR (SELECT va.voucher_id FROM voucher_attachments va"
+ " WHERE va.org_id=a.org_id AND va.attachment_id=a.id LIMIT 1)=?2)"
+ " AND (?3=0 OR NOT EXISTS(SELECT 1 FROM voucher_attachments va2"
+ " WHERE va2.org_id=a.org_id AND va2.attachment_id=a.id))"
+ " AND a.id>?4 ORDER BY a.id LIMIT ?5",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, voucher_id);
+ sqlite3_bind_int(st, 3, unlinked);
+ sqlite3_bind_int64(st, 4, cursor);
+ sqlite3_bind_int64(st, 5, limit);
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ int64_t n = 0, last = cursor;
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ n++;
+ last = sqlite3_column_int64(st, 0);
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", last);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes",
+ sqlite3_column_int64(st, 3));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 4)));
+ char hex[65];
+ const void *hb = sqlite3_column_blob(st, 5);
+ if (hb && sqlite3_column_bytes(st, 5) == 32)
+ util_hex((const unsigned char *)hb, 32, hex);
+ else
+ hex[0] = '\0';
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex);
+ if (sqlite3_column_type(st, 6) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id",
+ sqlite3_column_int64(st, 6));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ if (n == limit)
+ yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor");
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* reports and SIE */
+/* ------------------------------------------------------------------ */
+
+static int64_t default_fy_id(sqlite3 *db, int64_t org_id)
+{
+ sqlite3_stmt *st = NULL;
+ int64_t id = 0;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT id FROM fiscal_years WHERE org_id=?1"
+ " ORDER BY start_date DESC LIMIT 1",
+ -1, &st, NULL) == SQLITE_OK) {
+ sqlite3_bind_int64(st, 1, org_id);
+ if (sqlite3_step(st) == SQLITE_ROW)
+ id = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ }
+ return id;
+}
+
+static int req_fy(struct req *r, int64_t *out)
+{
+ int64_t fy = 0;
+ arg_int(r->args, "fiscal_year", &fy);
+ if (!fy)
+ fy = default_fy_id(r->db, r->org_id);
+ if (!fy) {
+ fail(r, "NOT_FOUND", "no fiscal year; open one first");
+ return -1;
+ }
+ *out = fy;
+ return 0;
+}
+
+static yyjson_mut_val *h_report_trial_balance(struct req *r)
+{
+ int64_t fy = 0;
+ if (req_fy(r, &fy) != 0)
+ return NULL;
+ int include_zero = 0;
+ arg_bool(r->args, "include_zero", &include_zero);
+ char *err = NULL;
+ yyjson_mut_val *res = report_trial_balance(
+ r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"),
+ arg_str(r->args, "to"), include_zero, &err);
+ if (!res) {
+ yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed");
+ free(err);
+ return e;
+ }
+ return res;
+}
+
+static yyjson_mut_val *h_report_income_statement(struct req *r)
+{
+ int64_t fy = 0;
+ if (req_fy(r, &fy) != 0)
+ return NULL;
+ char *err = NULL;
+ yyjson_mut_val *res = report_income_statement(
+ r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"),
+ arg_str(r->args, "to"), &err);
+ if (!res) {
+ yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed");
+ free(err);
+ return e;
+ }
+ return res;
+}
+
+static yyjson_mut_val *h_report_balance_sheet(struct req *r)
+{
+ int64_t fy = 0;
+ if (req_fy(r, &fy) != 0)
+ return NULL;
+ char *err = NULL;
+ yyjson_mut_val *res = report_balance_sheet(
+ r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "to"), &err);
+ if (!res) {
+ yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed");
+ free(err);
+ return e;
+ }
+ return res;
+}
+
+static yyjson_mut_val *h_report_vat(struct req *r)
+{
+ const char *from = arg_str(r->args, "from");
+ const char *to = arg_str(r->args, "to");
+ if (!from || !to)
+ return fail(r, "INVALID_ARGS", "from and to are required");
+ char *err = NULL;
+ yyjson_mut_val *res =
+ report_vat(r->rdoc, r->db, r->org_id, from, to, &err);
+ if (!res) {
+ yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed");
+ free(err);
+ return e;
+ }
+ return res;
+}
+
+static unsigned char *read_file(const char *path, size_t *out_len)
+{
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ return NULL;
+ struct buf b;
+ buf_init(&b);
+ unsigned char chunk[65536];
+ size_t rn;
+ while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0)
+ buf_append(&b, chunk, rn);
+ int bad = ferror(f);
+ fclose(f);
+ if (bad) {
+ buf_free(&b);
+ return NULL;
+ }
+ *out_len = b.len;
+ return b.p;
+}
+
+static yyjson_mut_val *h_sie_export(struct req *r)
+{
+ int64_t fy = 0;
+ if (req_fy(r, &fy) != 0)
+ return NULL;
+ int inline_out = 0;
+ arg_bool(r->args, "inline", &inline_out);
+
+ if (mkdir_p(g_cfg.export_dir, 0700) != 0)
+ return fail(r, "INTERNAL", "cannot create export directory");
+ char label[64] = "";
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT label FROM fiscal_years WHERE org_id=?1 AND id=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, fy);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "fiscal year not found");
+ }
+ snprintf(label, sizeof label, "%s", sqlite3_column_text(st, 0));
+ sqlite3_finalize(st);
+ for (char *p = label; *p; p++)
+ if (*p == '/')
+ *p = '-';
+ char path[4096];
+ snprintf(path, sizeof path, "%s/%lld_%s.se", g_cfg.export_dir,
+ (long long)r->org_id, label);
+ char *err = NULL;
+ if (sie_export_file(r->db, r->org_id, fy, path, &err) != 0) {
+ yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "export failed");
+ free(err);
+ return e;
+ }
+ char hex[65];
+ int64_t size = 0;
+ if (hash_file(path, hex, &size) != 0)
+ return fail(r, "INTERNAL", "could not read exported file");
+
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "sie.export", "{}", "OK", NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "path", path);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex);
+ yyjson_mut_obj_add_int(r->rdoc, o, "size", size);
+ if (inline_out) {
+ size_t flen = 0;
+ unsigned char *content = read_file(path, &flen);
+ if (!content)
+ return fail(r, "INTERNAL", "could not read exported file");
+ char *b64 = util_b64(content, flen);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64);
+ free(b64);
+ free(content);
+ }
+ return o;
+}
+
+static yyjson_mut_val *h_sie_import(struct req *r)
+{
+ const char *b64 = arg_str(r->args, "content_base64");
+ const char *path = arg_str(r->args, "path");
+ unsigned char *content = NULL;
+ size_t len = 0;
+ if (b64) {
+ if (util_b64_decode(b64, strlen(b64), &content, &len) != 0)
+ return fail(r, "INVALID_ARGS",
+ "content_base64 is not valid base64");
+ } else if (path) {
+ content = read_file(path, &len);
+ if (!content)
+ return fail(r, "NOT_FOUND", "could not read file");
+ } else {
+ return fail(r, "INVALID_ARGS", "content_base64 or path is required");
+ }
+ if (len > 64u * 1024u * 1024u) {
+ free(content);
+ return fail(r, "TOO_LARGE", "SIE file is too large");
+ }
+ int64_t fy = 0, vouchers = 0, accounts = 0;
+ char *err = NULL;
+ int rc = sie_import_content(r->db, r->org_id, r->sess->user_id, content,
+ len, r->dry_run, &fy, &vouchers, &accounts,
+ &err);
+ free(content);
+ if (rc != 0) {
+ yyjson_mut_val *e = fail(r, "INVALID_ARGS", err ? err : "import failed");
+ free(err);
+ return e;
+ }
+ if (!r->dry_run) {
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "sie.import", reqjson, "OK", NULL);
+ free(reqjson);
+ }
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy);
+ yyjson_mut_obj_add_int(r->rdoc, o, "vouchers", vouchers);
+ yyjson_mut_obj_add_int(r->rdoc, o, "accounts_created", accounts);
+ if (r->dry_run)
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+}
+
+/* command table */
+/* ------------------------------------------------------------------ */
+
+const struct command g_commands[] = {
+ { "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health },
+ { "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta },
+ { "session.open", "Open a session (password or token)", PERM_PUBLIC, 0, 0,
+ 0, h_session_open },
+ { "session.close", "Close the current session", PERM_READ, 0, 0, 0,
+ h_session_close },
+ { "session.whoami", "Current user, org, role and scopes", PERM_READ, 0, 0,
+ 0, h_session_whoami },
+ { "session.list_orgs", "Orgs the current user is a member of", PERM_READ,
+ 0, 0, 0, h_session_list_orgs },
+ { "session.use_org", "Switch active org", PERM_READ, 0, 0, 0,
+ h_session_use_org },
+ { "org.create", "Create an org and become its owner", PERM_READ, 0, 1, 1,
+ h_org_create },
+ { "org.list", "List orgs for the current user", PERM_READ, 0, 0, 0,
+ h_org_list },
+ { "org.get", "Get org details", PERM_READ, 1, 0, 0, h_org_get },
+ { "org.member_list", "List org members", PERM_READ, 1, 0, 0,
+ h_org_member_list },
+ { "org.member_add", "Add a member to the org", PERM_OWNER, 1, 1, 1,
+ h_org_member_add },
+ { "org.member_set_role", "Change a member's role", PERM_OWNER, 1, 1, 1,
+ h_org_member_set_role },
+ { "org.member_remove", "Remove a member from the org", PERM_OWNER, 1, 1, 1,
+ h_org_member_remove },
+ { "user.create", "Create a user (system admin)", PERM_ADMIN, 0, 1, 1,
+ h_user_create },
+ { "user.list", "List users (system admin)", PERM_ADMIN, 0, 0, 0,
+ h_user_list },
+ { "token.create", "Create an API token for the active org", PERM_READ, 1,
+ 1, 1, h_token_create },
+ { "token.list", "List your API tokens in the active org", PERM_READ, 1, 0,
+ 0, h_token_list },
+ { "token.revoke", "Revoke an API token", PERM_READ, 1, 1, 1,
+ h_token_revoke },
+ { "describe", "List implemented commands and permissions", PERM_READ, 0, 0,
+ 0, h_describe },
+ { "agent.instructions", "Workflow rules for agents", PERM_READ, 0, 0, 0,
+ h_agent_instructions },
+ { "audit.list", "Read the audit log for the active org", PERM_READ, 1, 0,
+ 0, h_audit_list },
+ { "audit.verify", "Verify the audit hash chain", PERM_READ, 0, 0, 0,
+ h_audit_verify },
+ { "backup.snapshot", "Create a consistent database snapshot", PERM_ADMIN,
+ 0, 1, 0, h_backup_snapshot },
+ { "account.list", "List the chart of accounts", PERM_READ, 1, 0, 0,
+ h_account_list },
+ { "account.get", "Get one account", PERM_READ, 1, 0, 0, h_account_get },
+ { "account.create", "Add an account", PERM_WRITE, 1, 1, 1,
+ h_account_create },
+ { "account.update", "Rename or deactivate an account", PERM_WRITE, 1, 1, 1,
+ h_account_update },
+ { "fiscal_year.list", "List fiscal years", PERM_READ, 1, 0, 0,
+ h_fiscal_year_list },
+ { "fiscal_year.get", "Get a fiscal year", PERM_READ, 1, 0, 0,
+ h_fiscal_year_get },
+ { "fiscal_year.open", "Open a new fiscal year", PERM_OWNER, 1, 1, 1,
+ h_fiscal_year_open },
+ { "fiscal_year.close", "Close a fiscal year (irreversible)", PERM_OWNER, 1,
+ 1, 0, h_fiscal_year_close },
+ { "period.lock", "Lock a period through a date", PERM_OWNER, 1, 1, 1,
+ h_period_lock },
+ { "period.unlock", "Remove a period lock", PERM_OWNER, 1, 1, 1,
+ h_period_unlock },
+ { "voucher.post", "Post an immutable voucher (rows or template+x)",
+ PERM_WRITE, 1, 1, 1, h_voucher_post },
+ { "voucher.get", "Get a voucher with rows", PERM_READ, 1, 0, 0,
+ h_voucher_get },
+ { "voucher.list", "List vouchers", PERM_READ, 1, 0, 0, h_voucher_list },
+ { "voucher.correct", "Post an Àndringsverifikat", PERM_WRITE, 1, 1, 1,
+ h_voucher_correct },
+ { "settings.get", "Read org settings", PERM_READ, 1, 0, 0,
+ h_settings_get },
+ { "settings.set", "Change an org setting", PERM_WRITE, 1, 1, 1,
+ h_settings_set },
+ { "template.list", "List voucher templates", PERM_READ, 1, 0, 0,
+ h_template_list },
+ { "template.get", "Get a voucher template with its rows", PERM_READ, 1, 0,
+ 0, h_template_get },
+ { "template.create", "Create a voucher template", PERM_WRITE, 1, 1, 1,
+ h_template_create },
+ { "template.update", "Update a voucher template", PERM_WRITE, 1, 1, 1,
+ h_template_update },
+ { "template.archive", "Archive a voucher template", PERM_WRITE, 1, 1, 1,
+ h_template_archive },
+ { "attachment.put", "Store an underlag (receipt, invoice)", PERM_WRITE, 1,
+ 1, 1, h_attachment_put },
+ { "attachment.get", "Fetch an attachment", PERM_READ, 1, 0, 0,
+ h_attachment_get },
+ { "attachment.list", "List attachments / inbox", PERM_READ, 1, 0, 0,
+ h_attachment_list },
+ { "report.trial_balance", "Saldobalans (IB, period, UB)", PERM_READ, 1, 0,
+ 0, h_report_trial_balance },
+ { "report.income_statement", "ResultatrÀkning", PERM_READ, 1, 0, 0,
+ h_report_income_statement },
+ { "report.balance_sheet", "BalansrÀkning", PERM_READ, 1, 0, 0,
+ h_report_balance_sheet },
+ { "report.vat", "Momsdeklaration ruta för ruta", PERM_READ, 1, 0, 0,
+ h_report_vat },
+ { "sie.export", "Export SIE 4 (PC8)", PERM_READ, 1, 0, 0, h_sie_export },
+ { "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1,
+ 1, h_sie_import },
+};
+
+const size_t g_commands_count = sizeof g_commands / sizeof g_commands[0];
+
+const struct command *command_find(const char *name)
+{
+ if (!name)
+ return NULL;
+ for (size_t i = 0; i < g_commands_count; i++)
+ if (strcmp(g_commands[i].name, name) == 0)
+ return &g_commands[i];
+ return NULL;
+}
+
+const char *perm_name(enum cmd_perm p)
+{
+ switch (p) {
+ case PERM_PUBLIC:
+ return "public";
+ case PERM_READ:
+ return "viewer";
+ case PERM_WRITE:
+ return "bookkeeper";
+ case PERM_OWNER:
+ return "owner";
+ case PERM_ADMIN:
+ return "admin";
+ }
+ return "unknown";
+}
+
+const char *perm_scope(enum cmd_perm p)
+{
+ switch (p) {
+ case PERM_PUBLIC:
+ return NULL;
+ case PERM_READ:
+ return "read";
+ case PERM_WRITE:
+ return "write";
+ case PERM_OWNER:
+ case PERM_ADMIN:
+ return "admin";
+ }
+ return NULL;
+}
diff --git a/src/commands.h b/src/commands.h
new file mode 100644
index 0000000..d04affc
--- /dev/null
+++ b/src/commands.h
@@ -0,0 +1,33 @@
+#ifndef BOKF_COMMANDS_H
+#define BOKF_COMMANDS_H
+
+#include <stddef.h>
+
+#include "protocol.h"
+
+enum cmd_perm {
+ PERM_PUBLIC = 0,
+ PERM_READ,
+ PERM_WRITE,
+ PERM_OWNER,
+ PERM_ADMIN
+};
+
+struct command {
+ const char *name;
+ const char *summary;
+ enum cmd_perm perm;
+ int need_org;
+ int mutating;
+ int dry_run;
+ yyjson_mut_val *(*fn)(struct req *r);
+};
+
+extern const struct command g_commands[];
+extern const size_t g_commands_count;
+
+const struct command *command_find(const char *name);
+const char *perm_name(enum cmd_perm p);
+const char *perm_scope(enum cmd_perm p);
+
+#endif
diff --git a/src/config.c b/src/config.c
new file mode 100644
index 0000000..3bce6e9
--- /dev/null
+++ b/src/config.c
@@ -0,0 +1,171 @@
+#include "config.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "log.h"
+#include "util.h"
+
+struct config g_cfg;
+
+static void set_str(char **dst, const char *val)
+{
+ free(*dst);
+ *dst = xstrdup(val);
+}
+
+void config_defaults(void)
+{
+ memset(&g_cfg, 0, sizeof g_cfg);
+ g_cfg.socket_path = xstrdup("/run/bokfd/bokfd.sock");
+ g_cfg.db_path = xstrdup("/var/lib/bokfd/bokfd.db");
+ g_cfg.backup_dir = xstrdup("/var/lib/bokfd/backup");
+ g_cfg.export_dir = xstrdup("/var/lib/bokfd/export");
+ g_cfg.tcp_addr = NULL;
+ g_cfg.tcp_enabled = 0;
+ g_cfg.log_level = LOG_INFO;
+ g_cfg.allow_org_create = 1;
+ g_cfg.audit_reads = 0;
+ g_cfg.synchronous = xstrdup("FULL");
+ g_cfg.chart_k2_file = xstrdup("/usr/local/share/bokf/bas_k2.csv");
+ g_cfg.chart_k3_file = xstrdup("/usr/local/share/bokf/bas_k3.csv");
+ g_cfg.session_ttl = 8 * 3600;
+ g_cfg.max_line_bytes = 1024 * 1024;
+ g_cfg.max_attachment_bytes = 10 * 1024 * 1024;
+}
+
+static int apply_kv(const char *key, const char *val, char **err)
+{
+ if (strcmp(key, "socket") == 0)
+ set_str(&g_cfg.socket_path, val);
+ else if (strcmp(key, "db") == 0)
+ set_str(&g_cfg.db_path, val);
+ else if (strcmp(key, "backup_dir") == 0)
+ set_str(&g_cfg.backup_dir, val);
+ else if (strcmp(key, "export_dir") == 0)
+ set_str(&g_cfg.export_dir, val);
+ else if (strcmp(key, "tcp") == 0) {
+ if (strcmp(val, "off") == 0 || strcmp(val, "") == 0) {
+ g_cfg.tcp_enabled = 0;
+ } else {
+ set_str(&g_cfg.tcp_addr, val);
+ g_cfg.tcp_enabled = 1;
+ }
+ } else if (strcmp(key, "log_level") == 0) {
+ g_cfg.log_level = log_level_from_name(val);
+ } else if (strcmp(key, "allow_org_create") == 0) {
+ g_cfg.allow_org_create = strcmp(val, "false") != 0;
+ } else if (strcmp(key, "audit_reads") == 0) {
+ g_cfg.audit_reads = strcmp(val, "true") == 0;
+ } else if (strcmp(key, "synchronous") == 0) {
+ set_str(&g_cfg.synchronous, val);
+ } else if (strcmp(key, "chart_k2") == 0) {
+ set_str(&g_cfg.chart_k2_file, val);
+ } else if (strcmp(key, "chart_k3") == 0) {
+ set_str(&g_cfg.chart_k3_file, val);
+ } else if (strcmp(key, "session_ttl") == 0) {
+ g_cfg.session_ttl = strtol(val, NULL, 10);
+ } else if (strcmp(key, "max_line_bytes") == 0) {
+ g_cfg.max_line_bytes = strtol(val, NULL, 10);
+ } else if (strcmp(key, "max_attachment_bytes") == 0) {
+ g_cfg.max_attachment_bytes = strtol(val, NULL, 10);
+ } else {
+ char buf[256];
+ snprintf(buf, sizeof buf, "unknown config key: %s", key);
+ if (err)
+ *err = xstrdup(buf);
+ return -1;
+ }
+ return 0;
+}
+
+int config_load_file(const char *path, char **err)
+{
+ FILE *f = fopen(path, "r");
+ if (!f) {
+ char buf[512];
+ snprintf(buf, sizeof buf, "cannot open config file %s", path);
+ if (err)
+ *err = xstrdup(buf);
+ return -1;
+ }
+ char line[1024];
+ int lineno = 0;
+ int rc = 0;
+ while (fgets(line, sizeof line, f)) {
+ lineno++;
+ char *s = line;
+ char *hash = strchr(s, '#');
+ if (hash)
+ *hash = '\0';
+ s = util_str_trim(s);
+ if (!*s)
+ continue;
+ char *eq = strchr(s, '=');
+ if (!eq) {
+ char buf[512];
+ snprintf(buf, sizeof buf, "%s:%d: expected key = value", path,
+ lineno);
+ if (err)
+ *err = xstrdup(buf);
+ rc = -1;
+ break;
+ }
+ *eq = '\0';
+ char *key = util_str_trim(s);
+ char *val = util_str_trim(eq + 1);
+ if (apply_kv(key, val, err) != 0) {
+ rc = -1;
+ break;
+ }
+ }
+ fclose(f);
+ return rc;
+}
+
+void config_apply_env(void)
+{
+ const char *v;
+ if ((v = getenv("BOKFD_SOCKET")))
+ set_str(&g_cfg.socket_path, v);
+ if ((v = getenv("BOKFD_DB")))
+ set_str(&g_cfg.db_path, v);
+ if ((v = getenv("BOKFD_BACKUP_DIR")))
+ set_str(&g_cfg.backup_dir, v);
+ if ((v = getenv("BOKFD_EXPORT_DIR")))
+ set_str(&g_cfg.export_dir, v);
+ if ((v = getenv("BOKFD_TCP")))
+ apply_kv("tcp", v, NULL);
+ if ((v = getenv("BOKFD_LOG_LEVEL")))
+ g_cfg.log_level = log_level_from_name(v);
+ if ((v = getenv("BOKFD_ALLOW_ORG_CREATE")))
+ g_cfg.allow_org_create = strcmp(v, "false") != 0;
+ if ((v = getenv("BOKFD_AUDIT_READS")))
+ g_cfg.audit_reads = strcmp(v, "true") == 0;
+ if ((v = getenv("BOKFD_SYNCHRONOUS")))
+ set_str(&g_cfg.synchronous, v);
+ if ((v = getenv("BOKFD_CHART_K2")))
+ set_str(&g_cfg.chart_k2_file, v);
+ if ((v = getenv("BOKFD_CHART_K3")))
+ set_str(&g_cfg.chart_k3_file, v);
+ if ((v = getenv("BOKFD_SESSION_TTL")))
+ g_cfg.session_ttl = strtol(v, NULL, 10);
+ if ((v = getenv("BOKFD_MAX_LINE_BYTES")))
+ g_cfg.max_line_bytes = strtol(v, NULL, 10);
+ if ((v = getenv("BOKFD_MAX_ATTACHMENT_BYTES")))
+ g_cfg.max_attachment_bytes = strtol(v, NULL, 10);
+}
+
+void config_free(void)
+{
+ free(g_cfg.socket_path);
+ free(g_cfg.db_path);
+ free(g_cfg.backup_dir);
+ free(g_cfg.export_dir);
+ free(g_cfg.tcp_addr);
+ free(g_cfg.synchronous);
+ free(g_cfg.chart_k2_file);
+ free(g_cfg.chart_k3_file);
+ memset(&g_cfg, 0, sizeof g_cfg);
+}
diff --git a/src/config.h b/src/config.h
new file mode 100644
index 0000000..dad9737
--- /dev/null
+++ b/src/config.h
@@ -0,0 +1,31 @@
+#ifndef BOKF_CONFIG_H
+#define BOKF_CONFIG_H
+
+#include <stddef.h>
+
+struct config {
+ char *socket_path;
+ char *db_path;
+ char *backup_dir;
+ char *export_dir;
+ char *tcp_addr;
+ int tcp_enabled;
+ int log_level;
+ int allow_org_create;
+ int audit_reads;
+ char *synchronous;
+ char *chart_k2_file;
+ char *chart_k3_file;
+ long session_ttl;
+ long max_line_bytes;
+ long max_attachment_bytes;
+};
+
+extern struct config g_cfg;
+
+void config_defaults(void);
+int config_load_file(const char *path, char **err);
+void config_apply_env(void);
+void config_free(void);
+
+#endif
diff --git a/src/db.c b/src/db.c
new file mode 100644
index 0000000..c52d763
--- /dev/null
+++ b/src/db.c
@@ -0,0 +1,519 @@
+#include "db.h"
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "auth.h"
+#include "config.h"
+#include "log.h"
+#include "util.h"
+
+static const char SCHEMA_V1[] =
+ "CREATE TABLE meta ("
+ " key TEXT PRIMARY KEY,"
+ " value TEXT NOT NULL"
+ ") STRICT;\n"
+
+ "CREATE TABLE users ("
+ " id INTEGER PRIMARY KEY,"
+ " username TEXT NOT NULL COLLATE NOCASE UNIQUE,"
+ " display_name TEXT NOT NULL,"
+ " pw_hash TEXT NOT NULL,"
+ " is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0,1)),"
+ " created_at TEXT NOT NULL,"
+ " disabled_at TEXT"
+ ") STRICT;\n"
+
+ "CREATE TABLE orgs ("
+ " id INTEGER PRIMARY KEY,"
+ " name TEXT NOT NULL,"
+ " org_nr TEXT,"
+ " vat_nr TEXT,"
+ " address TEXT,"
+ " postal_code TEXT,"
+ " city TEXT,"
+ " country TEXT NOT NULL DEFAULT 'SE',"
+ " email TEXT,"
+ " phone TEXT,"
+ " fiscal_year_start_month INTEGER NOT NULL DEFAULT 1"
+ " CHECK (fiscal_year_start_month BETWEEN 1 AND 12),"
+ " moms_period TEXT NOT NULL DEFAULT 'month'"
+ " CHECK (moms_period IN ('month','quarter','year')),"
+ " framework TEXT NOT NULL DEFAULT 'K2'"
+ " CHECK (framework IN ('K2','K3')),"
+ " created_at TEXT NOT NULL,"
+ " created_by INTEGER NOT NULL REFERENCES users(id),"
+ " archived_at TEXT"
+ ") STRICT;\n"
+
+ "CREATE TABLE memberships ("
+ " org_id INTEGER NOT NULL REFERENCES orgs(id),"
+ " user_id INTEGER NOT NULL REFERENCES users(id),"
+ " role TEXT NOT NULL CHECK (role IN ('owner','bookkeeper','viewer')),"
+ " created_at TEXT NOT NULL,"
+ " PRIMARY KEY (org_id, user_id)"
+ ") STRICT;\n"
+
+ "CREATE TABLE api_tokens ("
+ " id INTEGER PRIMARY KEY,"
+ " org_id INTEGER NOT NULL REFERENCES orgs(id),"
+ " user_id INTEGER NOT NULL REFERENCES users(id),"
+ " label TEXT NOT NULL,"
+ " token_hash BLOB NOT NULL UNIQUE CHECK (length(token_hash) = 32),"
+ " scopes TEXT NOT NULL,"
+ " created_at TEXT NOT NULL,"
+ " expires_at TEXT,"
+ " last_used_at TEXT,"
+ " revoked_at TEXT"
+ ") STRICT;\n"
+
+ "CREATE TABLE accounts ("
+ " org_id INTEGER NOT NULL REFERENCES orgs(id),"
+ " id INTEGER PRIMARY KEY,"
+ " number TEXT NOT NULL"
+ " CHECK (number GLOB '[0-9]*' AND length(number) BETWEEN 1 AND 10),"
+ " name TEXT NOT NULL,"
+ " type TEXT NOT NULL"
+ " CHECK (type IN ('asset','liability','equity','revenue','expense')),"
+ " sru_code TEXT,"
+ " vat_code TEXT,"
+ " active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)),"
+ " created_at TEXT NOT NULL,"
+ " updated_at TEXT,"
+ " UNIQUE (org_id, id),"
+ " UNIQUE (org_id, number)"
+ ") STRICT;\n"
+ "CREATE INDEX idx_accounts_type ON accounts(org_id, type);\n"
+
+ "CREATE TABLE fiscal_years ("
+ " org_id INTEGER NOT NULL REFERENCES orgs(id),"
+ " id INTEGER PRIMARY KEY,"
+ " label TEXT NOT NULL,"
+ " start_date TEXT NOT NULL,"
+ " end_date TEXT NOT NULL,"
+ " status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')),"
+ " locked_until TEXT,"
+ " created_at TEXT NOT NULL,"
+ " closed_at TEXT,"
+ " closed_by INTEGER REFERENCES users(id),"
+ " UNIQUE (org_id, id),"
+ " UNIQUE (org_id, label),"
+ " CHECK (start_date < end_date),"
+ " CHECK (locked_until IS NULL OR locked_until <= end_date)"
+ ") STRICT;\n"
+
+ "CREATE TABLE sequences ("
+ " org_id INTEGER NOT NULL,"
+ " fiscal_year_id INTEGER NOT NULL,"
+ " series TEXT NOT NULL,"
+ " next_number INTEGER NOT NULL DEFAULT 1,"
+ " PRIMARY KEY (org_id, fiscal_year_id, series),"
+ " FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_years(org_id, id)"
+ ") STRICT;\n"
+
+ "CREATE TABLE vouchers ("
+ " org_id INTEGER NOT NULL REFERENCES orgs(id),"
+ " id INTEGER PRIMARY KEY,"
+ " fiscal_year_id INTEGER NOT NULL,"
+ " series TEXT NOT NULL,"
+ " number INTEGER NOT NULL CHECK (number > 0),"
+ " date TEXT NOT NULL CHECK (date LIKE '____-__-__'),"
+ " description TEXT NOT NULL CHECK (length(description) > 0),"
+ " source TEXT NOT NULL DEFAULT 'manual'"
+ " CHECK (source IN ('manual','agent','sie_import','system','ib')),"
+ " client_ref TEXT,"
+ " corrects_voucher_id INTEGER,"
+ " created_at TEXT NOT NULL,"
+ " created_by_user INTEGER NOT NULL REFERENCES users(id),"
+ " created_by_token INTEGER REFERENCES api_tokens(id),"
+ " hash_prev BLOB NOT NULL CHECK (length(hash_prev) = 32),"
+ " hash BLOB NOT NULL CHECK (length(hash) = 32),"
+ " UNIQUE (org_id, id),"
+ " UNIQUE (org_id, fiscal_year_id, series, number),"
+ " UNIQUE (org_id, client_ref),"
+ " FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_years(org_id, id),"
+ " FOREIGN KEY (org_id, corrects_voucher_id) REFERENCES vouchers(org_id, id)"
+ ") STRICT;\n"
+
+ "CREATE TABLE voucher_rows ("
+ " org_id INTEGER NOT NULL,"
+ " id INTEGER PRIMARY KEY,"
+ " voucher_id INTEGER NOT NULL,"
+ " line_no INTEGER NOT NULL,"
+ " account_id INTEGER NOT NULL,"
+ " debit_ore INTEGER NOT NULL DEFAULT 0 CHECK (debit_ore >= 0),"
+ " credit_ore INTEGER NOT NULL DEFAULT 0 CHECK (credit_ore >= 0),"
+ " description TEXT,"
+ " CHECK ((debit_ore = 0) <> (credit_ore = 0)),"
+ " CHECK (debit_ore > 0 OR credit_ore > 0),"
+ " UNIQUE (org_id, id),"
+ " UNIQUE (org_id, voucher_id, line_no),"
+ " FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id),"
+ " FOREIGN KEY (org_id, account_id) REFERENCES accounts(org_id, id)"
+ ") STRICT;\n"
+ "CREATE INDEX idx_vouchers_date ON vouchers(org_id, date);\n"
+ "CREATE INDEX idx_vouchers_fy ON vouchers(org_id, fiscal_year_id, series, number);\n"
+ "CREATE INDEX idx_rows_voucher ON voucher_rows(org_id, voucher_id, line_no);\n"
+ "CREATE INDEX idx_rows_account ON voucher_rows(org_id, account_id);\n"
+
+ "CREATE TRIGGER vouchers_no_update BEFORE UPDATE ON vouchers BEGIN"
+ " SELECT RAISE(ABORT, 'vouchers are append-only');"
+ "END;\n"
+ "CREATE TRIGGER vouchers_no_delete BEFORE DELETE ON vouchers BEGIN"
+ " SELECT RAISE(ABORT, 'vouchers are append-only');"
+ "END;\n"
+ "CREATE TRIGGER voucher_rows_no_update BEFORE UPDATE ON voucher_rows BEGIN"
+ " SELECT RAISE(ABORT, 'voucher rows are append-only');"
+ "END;\n"
+ "CREATE TRIGGER voucher_rows_no_delete BEFORE DELETE ON voucher_rows BEGIN"
+ " SELECT RAISE(ABORT, 'voucher rows are append-only');"
+ "END;\n"
+
+ "CREATE TABLE attachments ("
+ " org_id INTEGER NOT NULL REFERENCES orgs(id),"
+ " id INTEGER PRIMARY KEY,"
+ " sha256 BLOB NOT NULL CHECK (length(sha256) = 32),"
+ " filename TEXT NOT NULL,"
+ " mime TEXT NOT NULL,"
+ " size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),"
+ " content BLOB NOT NULL,"
+ " created_at TEXT NOT NULL,"
+ " created_by INTEGER NOT NULL REFERENCES users(id),"
+ " UNIQUE (org_id, id),"
+ " UNIQUE (org_id, sha256, filename)"
+ ") STRICT;\n"
+
+ "CREATE TABLE voucher_attachments ("
+ " org_id INTEGER NOT NULL,"
+ " voucher_id INTEGER NOT NULL,"
+ " attachment_id INTEGER NOT NULL,"
+ " created_at TEXT NOT NULL,"
+ " PRIMARY KEY (org_id, voucher_id, attachment_id),"
+ " FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id),"
+ " FOREIGN KEY (org_id, attachment_id) REFERENCES attachments(org_id, id)"
+ ") STRICT;\n"
+
+ "CREATE TABLE audit_log ("
+ " seq INTEGER PRIMARY KEY,"
+ " org_id INTEGER REFERENCES orgs(id),"
+ " at TEXT NOT NULL,"
+ " actor_user_id INTEGER REFERENCES users(id),"
+ " actor_token_id INTEGER REFERENCES api_tokens(id),"
+ " action TEXT NOT NULL,"
+ " request_json TEXT NOT NULL DEFAULT '{}',"
+ " result_code TEXT NOT NULL,"
+ " hash_prev BLOB NOT NULL CHECK (length(hash_prev) = 32),"
+ " hash BLOB NOT NULL CHECK (length(hash) = 32)"
+ ") STRICT;\n"
+ "CREATE INDEX idx_audit_org ON audit_log(org_id, seq);\n"
+
+ "CREATE TRIGGER audit_no_update BEFORE UPDATE ON audit_log BEGIN"
+ " SELECT RAISE(ABORT, 'audit log is append-only');"
+ "END;\n"
+ "CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_log BEGIN"
+ " SELECT RAISE(ABORT, 'audit log is append-only');"
+ "END;\n"
+
+ "CREATE TABLE idempotency ("
+ " org_id INTEGER NOT NULL,"
+ " client_ref TEXT NOT NULL,"
+ " cmd TEXT NOT NULL,"
+ " response_json TEXT NOT NULL,"
+ " created_at TEXT NOT NULL,"
+ " PRIMARY KEY (org_id, client_ref),"
+ " FOREIGN KEY (org_id) REFERENCES orgs(id)"
+ ") STRICT;\n"
+
+ "CREATE TABLE report_rules ("
+ " org_id INTEGER NOT NULL REFERENCES orgs(id),"
+ " id INTEGER PRIMARY KEY,"
+ " report TEXT NOT NULL,"
+ " box TEXT NOT NULL,"
+ " match_type TEXT NOT NULL CHECK (match_type IN ('account','range','type')),"
+ " pattern TEXT NOT NULL,"
+ " sign INTEGER NOT NULL DEFAULT 1 CHECK (sign IN (1,-1)),"
+ " sort_order INTEGER NOT NULL DEFAULT 0,"
+ " UNIQUE (org_id, id)"
+ ") STRICT;\n"
+
+ "CREATE TABLE settings ("
+ " org_id INTEGER NOT NULL REFERENCES orgs(id),"
+ " key TEXT NOT NULL,"
+ " value TEXT NOT NULL,"
+ " PRIMARY KEY (org_id, key)"
+ ") STRICT;\n";
+
+static const char SCHEMA_V2[] =
+ "CREATE TABLE voucher_templates ("
+ " org_id INTEGER NOT NULL REFERENCES orgs(id),"
+ " id INTEGER PRIMARY KEY,"
+ " name TEXT NOT NULL,"
+ " series TEXT NOT NULL DEFAULT 'A',"
+ " description TEXT NOT NULL DEFAULT '',"
+ " active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)),"
+ " created_at TEXT NOT NULL,"
+ " updated_at TEXT,"
+ " UNIQUE (org_id, id),"
+ " UNIQUE (org_id, name)"
+ ") STRICT;\n"
+
+ "CREATE TABLE voucher_template_rows ("
+ " org_id INTEGER NOT NULL,"
+ " id INTEGER PRIMARY KEY,"
+ " template_id INTEGER NOT NULL,"
+ " line_no INTEGER NOT NULL,"
+ " account_id INTEGER NOT NULL,"
+ " formula TEXT NOT NULL CHECK (length(formula) > 0),"
+ " description TEXT,"
+ " UNIQUE (org_id, id),"
+ " UNIQUE (org_id, template_id, line_no),"
+ " FOREIGN KEY (org_id, template_id)"
+ " REFERENCES voucher_templates(org_id, id),"
+ " FOREIGN KEY (org_id, account_id) REFERENCES accounts(org_id, id)"
+ ") STRICT;\n"
+ "CREATE INDEX idx_template_rows ON voucher_template_rows(org_id,"
+ " template_id, line_no);\n";
+
+static void set_err(char **err, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+
+static void set_err(char **err, const char *fmt, ...)
+{
+ if (!err)
+ return;
+ char buf[1024];
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(buf, sizeof buf, fmt, ap);
+ va_end(ap);
+ free(*err);
+ *err = xstrdup(buf);
+}
+
+int db_exec(sqlite3 *db, const char *sql, char **err)
+{
+ char *msg = NULL;
+ int rc = sqlite3_exec(db, sql, NULL, NULL, &msg);
+ if (rc != SQLITE_OK) {
+ set_err(err, "%s", msg ? msg : sqlite3_errmsg(db));
+ sqlite3_free(msg);
+ return -1;
+ }
+ return 0;
+}
+
+int db_migrate(sqlite3 *db, char **err)
+{
+ if (db_exec(db, "BEGIN IMMEDIATE", err) != 0)
+ return -1;
+ if (db_exec(db, SCHEMA_V1, err) != 0) {
+ db_exec(db, "ROLLBACK", NULL);
+ return -1;
+ }
+ if (db_exec(db, SCHEMA_V2, err) != 0) {
+ db_exec(db, "ROLLBACK", NULL);
+ return -1;
+ }
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ char *sql = sqlite3_mprintf(
+ "INSERT INTO meta(key,value) VALUES('schema_version','%d')"
+ " ON CONFLICT(key) DO UPDATE SET value=excluded.value;"
+ "INSERT INTO meta(key,value) VALUES('created_at','%q');",
+ BOKF_SCHEMA_VERSION, ts);
+ int rc = db_exec(db, sql, err);
+ sqlite3_free(sql);
+ if (rc != 0) {
+ db_exec(db, "ROLLBACK", NULL);
+ return -1;
+ }
+ if (db_exec(db, "COMMIT", err) != 0)
+ return -1;
+ log_info("database schema created at version %d", BOKF_SCHEMA_VERSION);
+ return 0;
+}
+
+/* Forward-only upgrades for existing databases. */
+static int db_upgrade(sqlite3 *db, int from, char **err)
+{
+ if (db_exec(db, "BEGIN IMMEDIATE", err) != 0)
+ return -1;
+ if (from < 2 && db_exec(db, SCHEMA_V2, err) != 0) {
+ db_exec(db, "ROLLBACK", NULL);
+ return -1;
+ }
+ char *sql = sqlite3_mprintf(
+ "UPDATE meta SET value='%d' WHERE key='schema_version'",
+ BOKF_SCHEMA_VERSION);
+ int rc = db_exec(db, sql, err);
+ sqlite3_free(sql);
+ if (rc != 0) {
+ db_exec(db, "ROLLBACK", NULL);
+ return -1;
+ }
+ if (db_exec(db, "COMMIT", err) != 0)
+ return -1;
+ log_info("database schema upgraded from version %d to %d", from,
+ BOKF_SCHEMA_VERSION);
+ return 0;
+}
+
+int db_open(const char *path, sqlite3 **out, char **err)
+{
+ sqlite3 *db = NULL;
+ int rc = sqlite3_open_v2(path, &db,
+ SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL);
+ if (rc != SQLITE_OK) {
+ set_err(err, "cannot open database %s: %s", path,
+ db ? sqlite3_errmsg(db) : "unknown error");
+ sqlite3_close(db);
+ return -1;
+ }
+ sqlite3_extended_result_codes(db, 1);
+
+ char pragmas[512];
+ snprintf(pragmas, sizeof pragmas,
+ "PRAGMA journal_mode=WAL;"
+ "PRAGMA foreign_keys=ON;"
+ "PRAGMA busy_timeout=5000;"
+ "PRAGMA synchronous=%s;"
+ "PRAGMA wal_autocheckpoint=1000;",
+ g_cfg.synchronous ? g_cfg.synchronous : "FULL");
+ if (db_exec(db, pragmas, err) != 0) {
+ sqlite3_close(db);
+ return -1;
+ }
+
+ sqlite3_stmt *st = NULL;
+ rc = sqlite3_prepare_v2(db, "SELECT value FROM meta WHERE key='schema_version'",
+ -1, &st, NULL);
+ if (rc == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW) {
+ int version = atoi((const char *)sqlite3_column_text(st, 0));
+ sqlite3_finalize(st);
+ if (version > BOKF_SCHEMA_VERSION) {
+ set_err(err, "database schema version %d is newer than this binary (%d)",
+ version, BOKF_SCHEMA_VERSION);
+ sqlite3_close(db);
+ return -1;
+ }
+ if (version < BOKF_SCHEMA_VERSION) {
+ if (db_upgrade(db, version, err) != 0) {
+ sqlite3_close(db);
+ return -1;
+ }
+ }
+ } else {
+ sqlite3_finalize(st);
+ if (db_migrate(db, err) != 0) {
+ sqlite3_close(db);
+ return -1;
+ }
+ }
+
+ *out = db;
+ return 0;
+}
+
+int64_t db_last_id(sqlite3 *db)
+{
+ return sqlite3_last_insert_rowid(db);
+}
+
+int64_t db_count(sqlite3 *db, const char *sql)
+{
+ sqlite3_stmt *st = NULL;
+ int64_t n = -1;
+ if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK &&
+ sqlite3_step(st) == SQLITE_ROW)
+ n = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ return n;
+}
+
+int db_create_user(sqlite3 *db, const char *username, const char *display_name,
+ const char *password, int is_admin, int64_t *out_id,
+ char **err)
+{
+ if (!username || !*username) {
+ set_err(err, "username is required");
+ return -1;
+ }
+ char *phc = NULL;
+ if (auth_hash_password(password, &phc) != 0) {
+ set_err(err, "password hashing failed");
+ return -1;
+ }
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ int rc = sqlite3_prepare_v2(
+ db,
+ "INSERT INTO users(username,display_name,pw_hash,is_admin,created_at)"
+ " VALUES(?1,?2,?3,?4,?5)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ set_err(err, "prepare failed: %s", sqlite3_errmsg(db));
+ free(phc);
+ return -1;
+ }
+ sqlite3_bind_text(st, 1, username, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 2, display_name && *display_name ? display_name : username,
+ -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, phc, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int(st, 4, is_admin ? 1 : 0);
+ sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ free(phc);
+ if (rc != SQLITE_DONE) {
+ if ((rc & 0xff) == SQLITE_CONSTRAINT)
+ set_err(err, "username already exists");
+ else
+ set_err(err, "insert failed: %s", sqlite3_errmsg(db));
+ return -1;
+ }
+ if (out_id)
+ *out_id = db_last_id(db);
+ return 0;
+}
+
+char *db_setting(sqlite3 *db, int64_t org_id, const char *key)
+{
+ sqlite3_stmt *st = NULL;
+ char *value = NULL;
+ if (sqlite3_prepare_v2(
+ db, "SELECT value FROM settings WHERE org_id=?1 AND key=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return NULL;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ const unsigned char *v = sqlite3_column_text(st, 0);
+ if (v)
+ value = xstrdup((const char *)v);
+ }
+ sqlite3_finalize(st);
+ return value;
+}
+
+char *db_membership_role(sqlite3 *db, int64_t org_id, int64_t user_id)
+{
+ sqlite3_stmt *st = NULL;
+ char *role = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT role FROM memberships WHERE org_id=?1 AND user_id=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return NULL;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, user_id);
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ const unsigned char *r = sqlite3_column_text(st, 0);
+ if (r)
+ role = xstrdup((const char *)r);
+ }
+ sqlite3_finalize(st);
+ return role;
+}
diff --git a/src/db.h b/src/db.h
new file mode 100644
index 0000000..2473d93
--- /dev/null
+++ b/src/db.h
@@ -0,0 +1,20 @@
+#ifndef BOKF_DB_H
+#define BOKF_DB_H
+
+#include <sqlite3.h>
+#include <stdint.h>
+
+#define BOKF_SCHEMA_VERSION 2
+
+int db_open(const char *path, sqlite3 **out, char **err);
+int db_migrate(sqlite3 *db, char **err);
+int db_exec(sqlite3 *db, const char *sql, char **err);
+int db_create_user(sqlite3 *db, const char *username, const char *display_name,
+ const char *password, int is_admin, int64_t *out_id,
+ char **err);
+char *db_membership_role(sqlite3 *db, int64_t org_id, int64_t user_id);
+char *db_setting(sqlite3 *db, int64_t org_id, const char *key);
+int64_t db_count(sqlite3 *db, const char *sql);
+int64_t db_last_id(sqlite3 *db);
+
+#endif
diff --git a/src/formula.c b/src/formula.c
new file mode 100644
index 0000000..1642515
--- /dev/null
+++ b/src/formula.c
@@ -0,0 +1,243 @@
+#include "formula.h"
+
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+struct parser {
+ const char *p;
+ double x;
+ char err[128];
+};
+
+static void skip_ws(struct parser *ps)
+{
+ while (*ps->p == ' ' || *ps->p == '\t')
+ ps->p++;
+}
+
+static int parse_expr(struct parser *ps, double *out);
+
+static int parse_primary(struct parser *ps, double *out)
+{
+ skip_ws(ps);
+ if (*ps->p == '(') {
+ ps->p++;
+ if (parse_expr(ps, out) != 0)
+ return -1;
+ skip_ws(ps);
+ if (*ps->p != ')') {
+ snprintf(ps->err, sizeof ps->err, "saknar ')'");
+ return -1;
+ }
+ ps->p++;
+ return 0;
+ }
+ if (*ps->p == 'x' || *ps->p == 'X') {
+ ps->p++;
+ *out = ps->x;
+ return 0;
+ }
+ if ((*ps->p >= '0' && *ps->p <= '9') || *ps->p == '.') {
+ char *end = NULL;
+ double v = strtod(ps->p, &end);
+ if (!end || end == ps->p) {
+ snprintf(ps->err, sizeof ps->err, "ogiltigt tal");
+ return -1;
+ }
+ ps->p = end;
+ *out = v;
+ return 0;
+ }
+ snprintf(ps->err, sizeof ps->err, "förvÀntade tal, x eller '('");
+ return -1;
+}
+
+static int parse_factor(struct parser *ps, double *out)
+{
+ skip_ws(ps);
+ if (*ps->p == '-') {
+ ps->p++;
+ if (parse_factor(ps, out) != 0)
+ return -1;
+ *out = -*out;
+ return 0;
+ }
+ if (*ps->p == '+') {
+ ps->p++;
+ return parse_factor(ps, out);
+ }
+ return parse_primary(ps, out);
+}
+
+static int parse_term(struct parser *ps, double *out)
+{
+ double left;
+ if (parse_factor(ps, &left) != 0)
+ return -1;
+ for (;;) {
+ skip_ws(ps);
+ char op = *ps->p;
+ if (op != '*' && op != '/')
+ break;
+ ps->p++;
+ double right;
+ if (parse_factor(ps, &right) != 0)
+ return -1;
+ if (op == '*') {
+ left *= right;
+ } else {
+ if (right == 0.0) {
+ snprintf(ps->err, sizeof ps->err, "division med noll");
+ return -1;
+ }
+ left /= right;
+ }
+ }
+ *out = left;
+ return 0;
+}
+
+static int parse_expr(struct parser *ps, double *out)
+{
+ double left;
+ if (parse_term(ps, &left) != 0)
+ return -1;
+ for (;;) {
+ skip_ws(ps);
+ char op = *ps->p;
+ if (op != '+' && op != '-')
+ break;
+ ps->p++;
+ double right;
+ if (parse_term(ps, &right) != 0)
+ return -1;
+ if (op == '+')
+ left += right;
+ else
+ left -= right;
+ }
+ *out = left;
+ return 0;
+}
+
+int formula_eval(const char *formula, double x, double *out_kr, char *errbuf,
+ size_t errlen)
+{
+ if (errbuf && errlen)
+ errbuf[0] = '\0';
+ if (!formula || !*formula) {
+ if (errbuf)
+ snprintf(errbuf, errlen, "tom formel");
+ return -1;
+ }
+ struct parser ps;
+ ps.p = formula;
+ ps.x = x;
+ ps.err[0] = '\0';
+ double out = 0;
+ if (parse_expr(&ps, &out) != 0) {
+ if (errbuf)
+ snprintf(errbuf, errlen, "%s", ps.err);
+ return -1;
+ }
+ skip_ws(&ps);
+ if (*ps.p) {
+ if (errbuf)
+ snprintf(errbuf, errlen, "ovÀntat tecken '%c'", *ps.p);
+ return -1;
+ }
+ if (!isfinite(out)) {
+ if (errbuf)
+ snprintf(errbuf, errlen, "ogiltigt resultat");
+ return -1;
+ }
+ *out_kr = out;
+ return 0;
+}
+
+int formula_valid(const char *formula)
+{
+ double v;
+ return formula_eval(formula, 1.0, &v, NULL, 0) == 0;
+}
+
+int formula_resolve_rows(const struct template_row *rows, size_t nrows,
+ double x, struct resolved_row *out, size_t *out_n,
+ char *errbuf, size_t errlen)
+{
+ if (errbuf && errlen)
+ errbuf[0] = '\0';
+ size_t n = 0;
+ for (size_t i = 0; i < nrows; i++) {
+ double kr = 0;
+ char err[128] = "";
+ if (formula_eval(rows[i].formula, x, &kr, err, sizeof err) != 0) {
+ if (errbuf)
+ snprintf(errbuf, errlen, "rad %zu: %s", i + 1, err);
+ return -1;
+ }
+ int64_t ore = (int64_t)llround(kr * 100.0);
+ if (ore == 0)
+ continue;
+ struct resolved_row *r = &out[n++];
+ memset(r, 0, sizeof *r);
+ snprintf(r->account, sizeof r->account, "%s", rows[i].account);
+ if (ore > 0)
+ r->debit_ore = ore;
+ else
+ r->credit_ore = -ore;
+ if (rows[i].description)
+ snprintf(r->description, sizeof r->description, "%s",
+ rows[i].description);
+ }
+ if (n < 2) {
+ if (errbuf)
+ snprintf(errbuf, errlen,
+ "mallen gav fÀrre Àn tvÄ rader med belopp");
+ return -1;
+ }
+
+ int64_t sum_d = 0, sum_c = 0;
+ for (size_t i = 0; i < n; i++) {
+ sum_d += out[i].debit_ore;
+ sum_c += out[i].credit_ore;
+ }
+ int64_t diff = sum_d - sum_c;
+ if (diff != 0) {
+ /* the rounding remainder goes to the largest row */
+ size_t biggest = 0;
+ int64_t best = -1;
+ for (size_t i = 0; i < n; i++) {
+ int64_t amount = out[i].debit_ore ? out[i].debit_ore
+ : out[i].credit_ore;
+ if (amount > best) {
+ best = amount;
+ biggest = i;
+ }
+ }
+ struct resolved_row *r = &out[biggest];
+ if (r->debit_ore) {
+ r->debit_ore -= diff;
+ if (r->debit_ore < 0) {
+ r->credit_ore = -r->debit_ore;
+ r->debit_ore = 0;
+ }
+ } else {
+ r->credit_ore += diff;
+ if (r->credit_ore < 0) {
+ r->debit_ore = -r->credit_ore;
+ r->credit_ore = 0;
+ }
+ }
+ if (r->debit_ore == 0 && r->credit_ore == 0) {
+ if (errbuf)
+ snprintf(errbuf, errlen,
+ "kunde inte balansera avrundningen");
+ return -1;
+ }
+ }
+ *out_n = n;
+ return 0;
+}
diff --git a/src/formula.h b/src/formula.h
new file mode 100644
index 0000000..f8476d0
--- /dev/null
+++ b/src/formula.h
@@ -0,0 +1,43 @@
+#ifndef BOKF_FORMULA_H
+#define BOKF_FORMULA_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+/* Tiny, deterministic expression evaluator for voucher templates.
+ Grammar: expr := term (('+'|'-') term)*
+ term := factor (('*'|'/') factor)*
+ factor := ('+'|'-') factor | primary
+ primary := number | 'x' | '(' expr ')'
+ Values are kronor as double; x is the template variable in kronor. */
+
+struct template_row {
+ const char *account; /* account number */
+ const char *formula;
+ const char *description; /* optional */
+};
+
+struct resolved_row {
+ char account[16];
+ int64_t debit_ore;
+ int64_t credit_ore;
+ char description[128];
+};
+
+/* Returns 0 on success and stores the value in *out_kr. On error returns
+ -1 and writes a message to errbuf. */
+int formula_eval(const char *formula, double x, double *out_kr, char *errbuf,
+ size_t errlen);
+
+/* Evaluates all rows with x, rounds to whole öre, drops zero rows, and
+ assigns the rounding remainder to the largest row so debit equals credit.
+ Returns 0 on success and sets *out_n. Errors (bad formula, too few
+ rows) are reported in errbuf. */
+int formula_resolve_rows(const struct template_row *rows, size_t nrows,
+ double x, struct resolved_row *out, size_t *out_n,
+ char *errbuf, size_t errlen);
+
+/* True when the formula parses (evaluated with x = 1). */
+int formula_valid(const char *formula);
+
+#endif
diff --git a/src/ledger.c b/src/ledger.c
new file mode 100644
index 0000000..72402f0
--- /dev/null
+++ b/src/ledger.c
@@ -0,0 +1,615 @@
+#include "ledger.h"
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "db.h"
+#include "util.h"
+#include "yyjson.h"
+
+static int fail(struct ledger_error *e, const char *code, const char *fmt, ...)
+ __attribute__((format(printf, 3, 4)));
+
+static int fail(struct ledger_error *e, const char *code, const char *fmt, ...)
+{
+ e->code = code;
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(e->msg, sizeof e->msg, fmt, ap);
+ va_end(ap);
+ return -1;
+}
+
+static void append_str16(struct buf *b, const char *s)
+{
+ size_t n = s ? strlen(s) : 0;
+ if (n > 0xffff)
+ n = 0xffff;
+ buf_append_u16be(b, (uint16_t)n);
+ buf_append(b, s ? s : "", n);
+}
+
+static void append_str32(struct buf *b, const char *s)
+{
+ size_t n = s ? strlen(s) : 0;
+ buf_append_u32be(b, (uint32_t)n);
+ buf_append(b, s ? s : "", n);
+}
+
+void ledger_voucher_hash(const unsigned char prev[32], int64_t org_id,
+ const char *fy_label, const char *series,
+ int64_t number, const char *date,
+ const char *description,
+ const struct ledger_row *rows, size_t nrows,
+ unsigned char out[32])
+{
+ struct buf b;
+ buf_init(&b);
+ buf_append(&b, "bokf-v1-voucher\0", 16);
+ buf_append(&b, prev, 32);
+ buf_append_u64be(&b, (uint64_t)org_id);
+ append_str16(&b, fy_label);
+ append_str16(&b, series);
+ buf_append_u64be(&b, (uint64_t)number);
+ buf_append(&b, date, strlen(date));
+ append_str32(&b, description);
+ buf_append_u32be(&b, (uint32_t)nrows);
+ for (size_t i = 0; i < nrows; i++) {
+ append_str16(&b, rows[i].account);
+ buf_append_u64be(&b, (uint64_t)rows[i].debit_ore);
+ buf_append_u64be(&b, (uint64_t)rows[i].credit_ore);
+ append_str32(&b, rows[i].description);
+ }
+ util_sha256(b.p, b.len, out);
+ buf_free(&b);
+}
+
+static int idempotent_lookup(sqlite3 *db, int64_t org_id, const char *client_ref,
+ char **out_json)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT response_json FROM idempotency WHERE org_id=?1"
+ " AND client_ref=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return 0;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, client_ref, -1, SQLITE_TRANSIENT);
+ int found = 0;
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ const unsigned char *p = sqlite3_column_text(st, 0);
+ if (p) {
+ yyjson_doc *d = yyjson_read((const char *)p, strlen((const char *)p), 0);
+ if (d && yyjson_is_obj(yyjson_doc_get_root(d))) {
+ yyjson_mut_doc *m = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *root =
+ yyjson_val_mut_copy(m, yyjson_doc_get_root(d));
+ yyjson_mut_doc_set_root(m, root);
+ yyjson_mut_obj_add_bool(m, root, "replayed", true);
+ *out_json = yyjson_mut_write(m, 0, NULL);
+ yyjson_mut_doc_free(m);
+ found = 1;
+ }
+ if (d)
+ yyjson_doc_free(d);
+ }
+ }
+ sqlite3_finalize(st);
+ return found;
+}
+
+static yyjson_mut_val *build_result(yyjson_mut_doc *doc, int64_t id,
+ int64_t org_id, int64_t fy_id,
+ const char *series, int64_t number,
+ const char *date, const char *description,
+ const char *source,
+ int64_t corrects_voucher_id,
+ const struct ledger_row *rows,
+ size_t nrows,
+ const int64_t *attachment_ids,
+ size_t n_attachments,
+ const unsigned char hash_prev[32],
+ const unsigned char hash[32], int dry_run,
+ const char *created_at)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_int(doc, o, "id", id);
+ yyjson_mut_obj_add_int(doc, o, "org_id", org_id);
+ yyjson_mut_obj_add_int(doc, o, "fiscal_year_id", fy_id);
+ yyjson_mut_obj_add_strcpy(doc, o, "series", series);
+ yyjson_mut_obj_add_int(doc, o, "number", number);
+ yyjson_mut_obj_add_strcpy(doc, o, "date", date);
+ yyjson_mut_obj_add_strcpy(doc, o, "description", description);
+ yyjson_mut_obj_add_strcpy(doc, o, "source", source);
+ yyjson_mut_obj_add_strcpy(doc, o, "created_at", created_at);
+ if (corrects_voucher_id)
+ yyjson_mut_obj_add_int(doc, o, "corrects_voucher_id",
+ corrects_voucher_id);
+ else
+ yyjson_mut_obj_add_null(doc, o, "corrects_voucher_id");
+ yyjson_mut_val *rarr = yyjson_mut_arr(doc);
+ for (size_t i = 0; i < nrows; i++) {
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(doc, rarr);
+ yyjson_mut_obj_add_int(doc, ro, "line_no", (int64_t)i + 1);
+ yyjson_mut_obj_add_strcpy(doc, ro, "account", rows[i].account);
+ yyjson_mut_obj_add_int(doc, ro, "debit_ore", rows[i].debit_ore);
+ yyjson_mut_obj_add_int(doc, ro, "credit_ore", rows[i].credit_ore);
+ if (rows[i].description)
+ yyjson_mut_obj_add_strcpy(doc, ro, "description",
+ rows[i].description);
+ else
+ yyjson_mut_obj_add_null(doc, ro, "description");
+ }
+ yyjson_mut_obj_add_val(doc, o, "rows", rarr);
+ yyjson_mut_val *aarr = yyjson_mut_arr(doc);
+ for (size_t i = 0; i < n_attachments; i++)
+ yyjson_mut_arr_add_int(doc, aarr, attachment_ids[i]);
+ yyjson_mut_obj_add_val(doc, o, "attachment_ids", aarr);
+ char hex[65];
+ util_hex(hash_prev, 32, hex);
+ yyjson_mut_obj_add_strcpy(doc, o, "hash_prev", hex);
+ util_hex(hash, 32, hex);
+ yyjson_mut_obj_add_strcpy(doc, o, "hash", hex);
+ if (dry_run)
+ yyjson_mut_obj_add_bool(doc, o, "dry_run", true);
+ return o;
+}
+
+static const char *source_for(const struct ledger_post_opts *o)
+{
+ if (o->source)
+ return o->source;
+ return o->token_id ? "agent" : "manual";
+}
+
+/* sqlite3_step wrapper that captures the message before finalize clears it */
+static int db_step(sqlite3 *db, sqlite3_stmt *st, char *errbuf, size_t n)
+{
+ int rc = sqlite3_step(st);
+ if (rc != SQLITE_DONE && errbuf && !errbuf[0])
+ snprintf(errbuf, n, "%s", sqlite3_errmsg(db));
+ return rc;
+}
+
+int ledger_post(sqlite3 *db, const struct ledger_post_opts *o,
+ struct ledger_error *e, char **out_result_json)
+{
+ memset(e, 0, sizeof *e);
+ *out_result_json = NULL;
+
+ char *series_owned = NULL;
+ const char *series = o->series;
+ if (!series || !*series) {
+ series_owned = db_setting(db, o->org_id, "default_series");
+ series = series_owned && *series_owned ? series_owned : "A";
+ }
+ if (!util_parse_iso_date(o->date)) {
+ free(series_owned);
+ return fail(e, "INVALID_ARGS", "date must be YYYY-MM-DD");
+ }
+ if (!o->description || !*o->description) {
+ free(series_owned);
+ return fail(e, "INVALID_ARGS", "description is required");
+ }
+ if (!o->rows || o->nrows < 2) {
+ free(series_owned);
+ return fail(e, "INVALID_ARGS", "at least two rows are required");
+ }
+ if (strlen(series) > 8) {
+ free(series_owned);
+ return fail(e, "INVALID_ARGS", "series is too long");
+ }
+ if (o->client_ref && strlen(o->client_ref) > 64) {
+ free(series_owned);
+ return fail(e, "INVALID_ARGS", "client_ref is too long");
+ }
+
+ if (o->client_ref && *o->client_ref) {
+ char *replay = NULL;
+ if (idempotent_lookup(db, o->org_id, o->client_ref, &replay)) {
+ *out_result_json = replay;
+ free(series_owned);
+ return 0;
+ }
+ }
+
+ int64_t sum_debit = 0, sum_credit = 0;
+ for (size_t i = 0; i < o->nrows; i++) {
+ const struct ledger_row *r = &o->rows[i];
+ if (!r->account || !*r->account)
+ return fail(e, "INVALID_ARGS", "row %zu: account is required",
+ i + 1);
+ if (r->debit_ore < 0 || r->credit_ore < 0)
+ return fail(e, "INVALID_ARGS",
+ "row %zu: amounts must be positive öre", i + 1);
+ if ((r->debit_ore == 0) == (r->credit_ore == 0))
+ return fail(e, "INVALID_ARGS",
+ "row %zu: exactly one of debit/credit must be set",
+ i + 1);
+ sum_debit += r->debit_ore;
+ sum_credit += r->credit_ore;
+ }
+ if (sum_debit != sum_credit) {
+ e->has_details = 1;
+ e->difference_ore = sum_debit - sum_credit;
+ return fail(e, "UNBALANCED", "debit and credit differ by %lld öre",
+ (long long)(sum_debit - sum_credit));
+ }
+
+ if (sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK)
+ return fail(e, "DB_BUSY", "could not start transaction");
+
+ int rc = -1;
+ sqlite3_stmt *st = NULL;
+
+ /* fiscal year */
+ int64_t fy_id = 0;
+ char fy_label[64] = "";
+ char fy_start[16] = "", fy_end[16] = "";
+ char fy_status[16] = "";
+ char locked_until[16] = "";
+ rc = sqlite3_prepare_v2(
+ db,
+ "SELECT id,label,start_date,end_date,status,"
+ "COALESCE(locked_until,'') FROM fiscal_years"
+ " WHERE org_id=?1 AND start_date<=?2 AND end_date>=?2",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK)
+ goto busy;
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_text(st, 2, o->date, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ return fail(e, "DATE_OUT_OF_RANGE",
+ "no fiscal year contains %s; open one first", o->date);
+ }
+ fy_id = sqlite3_column_int64(st, 0);
+ snprintf(fy_label, sizeof fy_label, "%s",
+ (const char *)sqlite3_column_text(st, 1));
+ snprintf(fy_start, sizeof fy_start, "%s",
+ (const char *)sqlite3_column_text(st, 2));
+ snprintf(fy_end, sizeof fy_end, "%s",
+ (const char *)sqlite3_column_text(st, 3));
+ snprintf(fy_status, sizeof fy_status, "%s",
+ (const char *)sqlite3_column_text(st, 4));
+ snprintf(locked_until, sizeof locked_until, "%s",
+ (const char *)sqlite3_column_text(st, 5));
+ sqlite3_finalize(st);
+
+ if (strcmp(fy_status, "closed") == 0) {
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ return fail(e, "FISCAL_YEAR_CLOSED", "fiscal year %s is closed",
+ fy_label);
+ }
+ if (locked_until[0] && strcmp(o->date, locked_until) <= 0) {
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ return fail(e, "PERIOD_LOCKED", "period is locked through %s",
+ locked_until);
+ }
+
+ /* resolve accounts */
+ int64_t *acct_ids = xcalloc(o->nrows, sizeof(int64_t));
+ for (size_t i = 0; i < o->nrows; i++) {
+ rc = sqlite3_prepare_v2(
+ db,
+ "SELECT id,active FROM accounts WHERE org_id=?1 AND number=?2", -1,
+ &st, NULL);
+ if (rc != SQLITE_OK) {
+ free(acct_ids);
+ goto busy;
+ }
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_text(st, 2, o->rows[i].account, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ free(acct_ids);
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ return fail(e, "ACCOUNT_NOT_FOUND", "account %s does not exist",
+ o->rows[i].account);
+ }
+ int active = sqlite3_column_int(st, 1);
+ acct_ids[i] = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ if (!active) {
+ free(acct_ids);
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ return fail(e, "ACCOUNT_INACTIVE", "account %s is inactive",
+ o->rows[i].account);
+ }
+ }
+
+ if (o->corrects_voucher_id) {
+ rc = sqlite3_prepare_v2(
+ db,
+ "SELECT count(*) FROM vouchers WHERE org_id=?1 AND id=?2", -1, &st,
+ NULL);
+ if (rc != SQLITE_OK) {
+ free(acct_ids);
+ goto busy;
+ }
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_int64(st, 2, o->corrects_voucher_id);
+ int64_t exists = sqlite3_step(st) == SQLITE_ROW
+ ? sqlite3_column_int64(st, 0)
+ : 0;
+ sqlite3_finalize(st);
+ if (!exists) {
+ free(acct_ids);
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ return fail(e, "NOT_FOUND", "voucher %lld does not exist",
+ (long long)o->corrects_voucher_id);
+ }
+ }
+
+ /* number sequence */
+ rc = sqlite3_prepare_v2(
+ db,
+ "SELECT next_number FROM sequences WHERE org_id=?1"
+ " AND fiscal_year_id=?2 AND series=?3",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ free(acct_ids);
+ goto busy;
+ }
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT);
+ int64_t number = 1;
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ number = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ rc = sqlite3_prepare_v2(
+ db,
+ "UPDATE sequences SET next_number=next_number+1"
+ " WHERE org_id=?1 AND fiscal_year_id=?2 AND series=?3",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ free(acct_ids);
+ goto busy;
+ }
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT);
+ rc = db_step(db, st, e->msg, sizeof e->msg);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ free(acct_ids);
+ goto busy;
+ }
+ } else {
+ sqlite3_finalize(st);
+ rc = sqlite3_prepare_v2(
+ db,
+ "INSERT INTO sequences(org_id,fiscal_year_id,series,next_number)"
+ " VALUES(?1,?2,?3,2)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ free(acct_ids);
+ goto busy;
+ }
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT);
+ rc = db_step(db, st, e->msg, sizeof e->msg);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ free(acct_ids);
+ goto busy;
+ }
+ }
+
+ /* vouchers are chained per org in insertion order */
+ unsigned char prev[32];
+ memset(prev, 0, sizeof prev);
+ rc = sqlite3_prepare_v2(
+ db, "SELECT hash FROM vouchers WHERE org_id=?1 ORDER BY id DESC LIMIT 1",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ free(acct_ids);
+ goto busy;
+ }
+ sqlite3_bind_int64(st, 1, o->org_id);
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ const void *b = sqlite3_column_blob(st, 0);
+ if (b && sqlite3_column_bytes(st, 0) == 32)
+ memcpy(prev, b, 32);
+ }
+ sqlite3_finalize(st);
+
+ unsigned char hash[32];
+ ledger_voucher_hash(prev, o->org_id, fy_label, series, number, o->date,
+ o->description, o->rows, o->nrows, hash);
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ const char *source = source_for(o);
+
+ if (o->dry_run) {
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *res = build_result(
+ doc, 0, o->org_id, fy_id, series, number, o->date, o->description,
+ source, o->corrects_voucher_id, o->rows, o->nrows,
+ o->attachment_ids, o->n_attachments, prev, hash, 1, ts);
+ yyjson_mut_doc_set_root(doc, res);
+ *out_result_json = yyjson_mut_write(doc, 0, NULL);
+ yyjson_mut_doc_free(doc);
+ free(acct_ids);
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ free(series_owned);
+ return 0;
+ }
+
+ rc = sqlite3_prepare_v2(
+ db,
+ "INSERT INTO vouchers(org_id,fiscal_year_id,series,number,date,"
+ "description,source,client_ref,corrects_voucher_id,created_at,"
+ "created_by_user,created_by_token,hash_prev,hash)"
+ " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ free(acct_ids);
+ goto busy;
+ }
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, number);
+ sqlite3_bind_text(st, 5, o->date, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 6, o->description, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 7, source, -1, SQLITE_TRANSIENT);
+ if (o->client_ref && *o->client_ref)
+ sqlite3_bind_text(st, 8, o->client_ref, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 8);
+ if (o->corrects_voucher_id)
+ sqlite3_bind_int64(st, 9, o->corrects_voucher_id);
+ else
+ sqlite3_bind_null(st, 9);
+ sqlite3_bind_text(st, 10, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 11, o->user_id);
+ if (o->token_id)
+ sqlite3_bind_int64(st, 12, o->token_id);
+ else
+ sqlite3_bind_null(st, 12);
+ sqlite3_bind_blob(st, 13, prev, 32, SQLITE_TRANSIENT);
+ sqlite3_bind_blob(st, 14, hash, 32, SQLITE_TRANSIENT);
+ rc = db_step(db, st, e->msg, sizeof e->msg);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ free(acct_ids);
+ goto busy;
+ }
+ int64_t voucher_id = sqlite3_last_insert_rowid(db);
+
+ for (size_t i = 0; i < o->nrows; i++) {
+ rc = sqlite3_prepare_v2(
+ db,
+ "INSERT INTO voucher_rows(org_id,voucher_id,line_no,account_id,"
+ "debit_ore,credit_ore,description)"
+ " VALUES(?1,?2,?3,?4,?5,?6,?7)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ free(acct_ids);
+ goto busy;
+ }
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_int64(st, 2, voucher_id);
+ sqlite3_bind_int64(st, 3, (int64_t)i + 1);
+ sqlite3_bind_int64(st, 4, acct_ids[i]);
+ sqlite3_bind_int64(st, 5, o->rows[i].debit_ore);
+ sqlite3_bind_int64(st, 6, o->rows[i].credit_ore);
+ if (o->rows[i].description)
+ sqlite3_bind_text(st, 7, o->rows[i].description, -1,
+ SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 7);
+ rc = db_step(db, st, e->msg, sizeof e->msg);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ free(acct_ids);
+ goto busy;
+ }
+ }
+ free(acct_ids);
+ acct_ids = NULL;
+
+ for (size_t i = 0; i < o->n_attachments; i++) {
+ int64_t aid = o->attachment_ids[i];
+ rc = sqlite3_prepare_v2(
+ db,
+ "SELECT (SELECT count(*) FROM attachments WHERE org_id=?1 AND id=?2),"
+ "(SELECT count(*) FROM voucher_attachments WHERE org_id=?1 AND"
+ " attachment_id=?2)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK)
+ goto busy;
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_int64(st, 2, aid);
+ int linked = 0, exists = 0;
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ exists = (int)sqlite3_column_int64(st, 0);
+ linked = (int)sqlite3_column_int64(st, 1);
+ }
+ sqlite3_finalize(st);
+ if (!exists) {
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ return fail(e, "NOT_FOUND", "attachment %lld does not exist",
+ (long long)aid);
+ }
+ if (linked) {
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ return fail(e, "CONFLICT", "attachment %lld is already linked",
+ (long long)aid);
+ }
+ rc = sqlite3_prepare_v2(
+ db,
+ "INSERT INTO voucher_attachments(org_id,voucher_id,attachment_id,"
+ "created_at) VALUES(?1,?2,?3,?4)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK)
+ goto busy;
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_int64(st, 2, voucher_id);
+ sqlite3_bind_int64(st, 3, aid);
+ sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT);
+ rc = db_step(db, st, e->msg, sizeof e->msg);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ goto busy;
+ }
+
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *res = build_result(
+ doc, voucher_id, o->org_id, fy_id, series, number, o->date,
+ o->description, source, o->corrects_voucher_id, o->rows, o->nrows,
+ o->attachment_ids, o->n_attachments, prev, hash, 0, ts);
+ yyjson_mut_doc_set_root(doc, res);
+ char *result_json = yyjson_mut_write(doc, 0, NULL);
+ yyjson_mut_doc_free(doc);
+ if (!result_json)
+ goto busy;
+
+ if (o->client_ref && *o->client_ref) {
+ rc = sqlite3_prepare_v2(
+ db,
+ "INSERT INTO idempotency(org_id,client_ref,cmd,response_json,"
+ "created_at) VALUES(?1,?2,'voucher.post',?3,?4)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ free(result_json);
+ goto busy;
+ }
+ sqlite3_bind_int64(st, 1, o->org_id);
+ sqlite3_bind_text(st, 2, o->client_ref, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, result_json, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT);
+ rc = db_step(db, st, e->msg, sizeof e->msg);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ free(result_json);
+ goto busy;
+ }
+ }
+
+ if (sqlite3_exec(db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
+ free(result_json);
+ free(series_owned);
+ return fail(e, "DB_BUSY", "commit failed: %s", sqlite3_errmsg(db));
+ }
+ free(series_owned);
+ *out_result_json = result_json;
+ return 0;
+
+busy:
+ free(series_owned);
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ e->code = "DB_BUSY";
+ if (!e->msg[0])
+ snprintf(e->msg, sizeof e->msg, "database error: %s",
+ sqlite3_errmsg(db));
+ return -1;
+}
diff --git a/src/ledger.h b/src/ledger.h
new file mode 100644
index 0000000..30271d0
--- /dev/null
+++ b/src/ledger.h
@@ -0,0 +1,51 @@
+#ifndef BOKF_LEDGER_H
+#define BOKF_LEDGER_H
+
+#include <sqlite3.h>
+#include <stddef.h>
+#include <stdint.h>
+
+struct ledger_row {
+ const char *account;
+ int64_t debit_ore;
+ int64_t credit_ore;
+ const char *description;
+};
+
+struct ledger_error {
+ const char *code;
+ char msg[512];
+ int has_details;
+ int64_t difference_ore;
+};
+
+struct ledger_post_opts {
+ int64_t org_id;
+ int64_t user_id;
+ int64_t token_id;
+ const char *date;
+ const char *description;
+ const char *series; /* NULL -> "A" */
+ const struct ledger_row *rows;
+ size_t nrows;
+ int64_t corrects_voucher_id; /* 0 = none */
+ const int64_t *attachment_ids;
+ size_t n_attachments;
+ const char *client_ref; /* NULL = none */
+ int dry_run;
+ const char *source; /* NULL -> "manual" or "agent" */
+};
+
+int ledger_post(sqlite3 *db, const struct ledger_post_opts *o,
+ struct ledger_error *e, char **out_result_json);
+
+/* Appends the canonical bytes for a voucher hash (SCHEMA.md 7.1) and
+ returns the 32-byte hash. */
+void ledger_voucher_hash(const unsigned char prev[32], int64_t org_id,
+ const char *fy_label, const char *series,
+ int64_t number, const char *date,
+ const char *description,
+ const struct ledger_row *rows, size_t nrows,
+ unsigned char out[32]);
+
+#endif
diff --git a/src/log.c b/src/log.c
new file mode 100644
index 0000000..616803f
--- /dev/null
+++ b/src/log.c
@@ -0,0 +1,45 @@
+#include "log.h"
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+
+#include "util.h"
+
+static int g_level = LOG_INFO;
+
+void log_set_level(int level)
+{
+ g_level = level;
+}
+
+int log_level_from_name(const char *name)
+{
+ if (!name)
+ return LOG_INFO;
+ if (strcmp(name, "error") == 0)
+ return LOG_ERROR;
+ if (strcmp(name, "warn") == 0)
+ return LOG_WARN;
+ if (strcmp(name, "info") == 0)
+ return LOG_INFO;
+ if (strcmp(name, "debug") == 0)
+ return LOG_DEBUG;
+ return LOG_INFO;
+}
+
+void log_msg(int level, const char *fmt, ...)
+{
+ if (level > g_level)
+ return;
+ static const char *names[] = { "error", "warn", "info", "debug" };
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ fprintf(stderr, "%s [%s] ", ts, names[level]);
+ va_list ap;
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ va_end(ap);
+ fputc('\n', stderr);
+}
diff --git a/src/log.h b/src/log.h
new file mode 100644
index 0000000..b3683ea
--- /dev/null
+++ b/src/log.h
@@ -0,0 +1,21 @@
+#ifndef BOKF_LOG_H
+#define BOKF_LOG_H
+
+enum {
+ LOG_ERROR = 0,
+ LOG_WARN = 1,
+ LOG_INFO = 2,
+ LOG_DEBUG = 3
+};
+
+void log_set_level(int level);
+int log_level_from_name(const char *name);
+void log_msg(int level, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+
+#define log_error(...) log_msg(LOG_ERROR, __VA_ARGS__)
+#define log_warn(...) log_msg(LOG_WARN, __VA_ARGS__)
+#define log_info(...) log_msg(LOG_INFO, __VA_ARGS__)
+#define log_debug(...) log_msg(LOG_DEBUG, __VA_ARGS__)
+
+#endif
diff --git a/src/protocol.c b/src/protocol.c
new file mode 100644
index 0000000..7c00d6b
--- /dev/null
+++ b/src/protocol.c
@@ -0,0 +1,204 @@
+#include "protocol.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+#include "commands.h"
+#include "db.h"
+#include "log.h"
+#include "util.h"
+#include "version.h"
+
+static void add_error(yyjson_mut_doc *doc, yyjson_mut_val *resp,
+ const char *code, const char *msg,
+ yyjson_mut_val *details)
+{
+ yyjson_mut_obj_add_bool(doc, resp, "ok", false);
+ yyjson_mut_val *e = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_strcpy(doc, e, "code", code);
+ yyjson_mut_obj_add_strcpy(doc, e, "message", msg);
+ if (details)
+ yyjson_mut_obj_add_val(doc, e, "details", details);
+ yyjson_mut_obj_add_val(doc, resp, "error", e);
+}
+
+static int scope_has(const char *list, const char *scope)
+{
+ if (!list || !scope)
+ return 0;
+ size_t n = strlen(scope);
+ const char *p = list;
+ while (*p) {
+ const char *comma = strchr(p, ',');
+ size_t len = comma ? (size_t)(comma - p) : strlen(p);
+ if (len == n && strncmp(p, scope, n) == 0)
+ return 1;
+ if (!comma)
+ break;
+ p = comma + 1;
+ }
+ return 0;
+}
+
+static int role_rank(const char *role)
+{
+ if (!role)
+ return 0;
+ if (strcmp(role, "viewer") == 0)
+ return 1;
+ if (strcmp(role, "bookkeeper") == 0)
+ return 2;
+ if (strcmp(role, "owner") == 0)
+ return 3;
+ return 0;
+}
+
+char *protocol_handle_line(sqlite3 *db, const char *line, size_t len)
+{
+ yyjson_doc *doc = yyjson_read(line, len, 0);
+ yyjson_mut_doc *rdoc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *resp = yyjson_mut_obj(rdoc);
+ yyjson_mut_doc_set_root(rdoc, resp);
+
+ const char *id = "";
+ struct req r;
+ memset(&r, 0, sizeof r);
+ char *role = NULL;
+
+ if (!doc || !yyjson_is_obj(yyjson_doc_get_root(doc))) {
+ yyjson_mut_obj_add_strcpy(rdoc, resp, "id", id);
+ add_error(rdoc, resp, "PARSE_ERROR", "request must be a JSON object",
+ NULL);
+ goto done;
+ }
+
+ yyjson_val *root = yyjson_doc_get_root(doc);
+ yyjson_val *idv = yyjson_obj_get(root, "id");
+ if (idv && yyjson_is_str(idv))
+ id = yyjson_get_str(idv);
+ yyjson_mut_obj_add_strcpy(rdoc, resp, "id", id);
+
+ yyjson_val *vv = yyjson_obj_get(root, "v");
+ int v = vv && yyjson_is_int(vv) ? yyjson_get_int(vv) : -1;
+ if (v != BOKF_PROTOCOL_VERSION) {
+ add_error(rdoc, resp, "UNSUPPORTED_VERSION",
+ "unsupported protocol version; this server speaks v1", NULL);
+ goto done;
+ }
+
+ yyjson_val *cmdv = yyjson_obj_get(root, "cmd");
+ const char *cmdname =
+ cmdv && yyjson_is_str(cmdv) ? yyjson_get_str(cmdv) : NULL;
+ if (!cmdname) {
+ add_error(rdoc, resp, "INVALID_ARGS", "cmd is required", NULL);
+ goto done;
+ }
+ const struct command *cmd = command_find(cmdname);
+ if (!cmd) {
+ add_error(rdoc, resp, "UNKNOWN_COMMAND", "unknown command", NULL);
+ goto done;
+ }
+
+ r.db = db;
+ r.rdoc = rdoc;
+ r.id = id;
+ r.cmd = cmdname;
+ r.args = yyjson_obj_get(root, "args");
+ yyjson_val *drv = yyjson_obj_get(root, "dry_run");
+ r.dry_run = drv && yyjson_is_bool(drv) && yyjson_get_bool(drv);
+
+ if (cmd->perm != PERM_PUBLIC) {
+ yyjson_val *sv = yyjson_obj_get(root, "session");
+ const char *sid = sv && yyjson_is_str(sv) ? yyjson_get_str(sv) : NULL;
+ r.sess = sessions_get(sid);
+ if (!r.sess) {
+ add_error(rdoc, resp, sid ? "SESSION_EXPIRED" : "AUTH_REQUIRED",
+ sid ? "session is invalid or expired"
+ : "session is required",
+ NULL);
+ goto done;
+ }
+ r.is_admin = r.sess->is_admin;
+
+ int64_t org = r.sess->active_org;
+ yyjson_val *ov = yyjson_obj_get(root, "org");
+ if (ov && yyjson_is_int(ov))
+ org = yyjson_get_int(ov);
+
+ if (cmd->need_org) {
+ if (r.sess->bound_org) {
+ if (org != r.sess->bound_org) {
+ add_error(rdoc, resp, "ORG_FORBIDDEN",
+ "token is bound to another org", NULL);
+ goto done;
+ }
+ org = r.sess->bound_org;
+ }
+ if (!org) {
+ add_error(rdoc, resp, "ORG_REQUIRED",
+ "no active org; pass org or use session.use_org",
+ NULL);
+ goto done;
+ }
+ role = db_membership_role(db, org, r.sess->user_id);
+ if (!role) {
+ add_error(rdoc, resp, "ORG_FORBIDDEN",
+ "not a member of this org", NULL);
+ goto done;
+ }
+ r.org_id = org;
+ r.has_org = 1;
+ r.role = role;
+ } else {
+ r.org_id = org;
+ }
+
+ int rank = role_rank(r.role);
+ const char *scope = perm_scope(cmd->perm);
+ if (cmd->perm == PERM_ADMIN && !r.is_admin) {
+ add_error(rdoc, resp, "FORBIDDEN", "system admin required", NULL);
+ goto done;
+ }
+ if (cmd->perm == PERM_WRITE && rank < 2) {
+ add_error(rdoc, resp, "FORBIDDEN", "bookkeeper role required",
+ NULL);
+ goto done;
+ }
+ if (cmd->perm == PERM_OWNER && rank < 3) {
+ add_error(rdoc, resp, "FORBIDDEN", "owner role required", NULL);
+ goto done;
+ }
+ if (cmd->perm != PERM_ADMIN && scope &&
+ !scope_has(r.sess->scopes, scope)) {
+ add_error(rdoc, resp, "FORBIDDEN",
+ "token scope does not allow this command", NULL);
+ goto done;
+ }
+ }
+
+ {
+ yyjson_mut_val *result = cmd->fn(&r);
+ if (!result) {
+ add_error(rdoc, resp, r.err_code ? r.err_code : "INTERNAL",
+ r.err_msg[0] ? r.err_msg : "internal error",
+ r.err_details);
+ } else {
+ yyjson_mut_obj_add_bool(rdoc, resp, "ok", true);
+ yyjson_mut_obj_add_val(rdoc, resp, "result", result);
+ }
+ }
+
+done:
+ free(role);
+ if (doc)
+ yyjson_doc_free(doc);
+ yyjson_write_err werr;
+ werr.code = 0;
+ werr.msg = NULL;
+ char *out = yyjson_mut_write_opts(rdoc, 0, NULL, NULL, &werr);
+ if (!out)
+ log_error("response write failed: code=%d %s", (int)werr.code,
+ werr.msg ? werr.msg : "");
+ yyjson_mut_doc_free(rdoc);
+ return out ? out : xstrdup("{\"id\":\"\",\"ok\":false}");
+}
diff --git a/src/protocol.h b/src/protocol.h
new file mode 100644
index 0000000..de7cb3a
--- /dev/null
+++ b/src/protocol.h
@@ -0,0 +1,30 @@
+#ifndef BOKF_PROTOCOL_H
+#define BOKF_PROTOCOL_H
+
+#include <sqlite3.h>
+#include <stdint.h>
+
+#include "sessions.h"
+#include "yyjson.h"
+
+struct req {
+ sqlite3 *db;
+ yyjson_mut_doc *rdoc;
+ const char *id;
+ const char *cmd;
+ struct session *sess;
+ int64_t org_id;
+ int has_org;
+ int is_admin;
+ const char *role;
+ int dry_run;
+ yyjson_val *args;
+ const char *err_code;
+ char err_msg[512];
+ yyjson_mut_val *err_details;
+};
+
+/* Returns a malloc'd response line (no trailing newline). */
+char *protocol_handle_line(sqlite3 *db, const char *line, size_t len);
+
+#endif
diff --git a/src/reports.c b/src/reports.c
new file mode 100644
index 0000000..1bfdcfb
--- /dev/null
+++ b/src/reports.c
@@ -0,0 +1,434 @@
+#include "reports.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "util.h"
+
+struct fy_info {
+ int64_t id;
+ char label[64];
+ char start[16];
+ char end[16];
+};
+
+static void set_err(char **err, const char *msg)
+{
+ if (err && !*err)
+ *err = xstrdup(msg);
+}
+
+static int load_fy(sqlite3 *db, int64_t org_id, int64_t fy_id,
+ struct fy_info *fy, char **err)
+{
+ sqlite3_stmt *st = NULL;
+ int rc = sqlite3_prepare_v2(
+ db,
+ "SELECT id,label,start_date,end_date FROM fiscal_years"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ set_err(err, "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ set_err(err, "fiscal year not found");
+ return -1;
+ }
+ fy->id = sqlite3_column_int64(st, 0);
+ snprintf(fy->label, sizeof fy->label, "%s", sqlite3_column_text(st, 1));
+ snprintf(fy->start, sizeof fy->start, "%s", sqlite3_column_text(st, 2));
+ snprintf(fy->end, sizeof fy->end, "%s", sqlite3_column_text(st, 3));
+ sqlite3_finalize(st);
+ return 0;
+}
+
+static yyjson_mut_val *fy_json(yyjson_mut_doc *doc, const struct fy_info *fy)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_int(doc, o, "id", fy->id);
+ yyjson_mut_obj_add_strcpy(doc, o, "label", fy->label);
+ yyjson_mut_obj_add_strcpy(doc, o, "start_date", fy->start);
+ yyjson_mut_obj_add_strcpy(doc, o, "end_date", fy->end);
+ return o;
+}
+
+/* One row per account with IB and period movements. IB is the series "IB"
+ voucher(s) of this fiscal year plus all non-IB history before `from`.
+ Amounts signed: debit positive. */
+static yyjson_mut_val *balance_query(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *from, const char *to,
+ const char *types_filter, char **err)
+{
+ char sql[1280];
+ snprintf(sql, sizeof sql,
+ "SELECT a.number,a.name,a.type,"
+ " COALESCE(SUM(CASE WHEN v.series <> 'IB'"
+ " AND v.date BETWEEN ?2 AND ?3 THEN r.debit_ore END),0),"
+ " COALESCE(SUM(CASE WHEN v.series <> 'IB'"
+ " AND v.date BETWEEN ?2 AND ?3 THEN r.credit_ore END),0),"
+ " COALESCE(SUM(CASE WHEN (v.series = 'IB'"
+ " AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'"
+ " AND v.date < ?2) THEN r.debit_ore END),0),"
+ " COALESCE(SUM(CASE WHEN (v.series = 'IB'"
+ " AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'"
+ " AND v.date < ?2) THEN r.credit_ore END),0)"
+ " FROM accounts a"
+ " LEFT JOIN (voucher_rows r JOIN vouchers v"
+ " ON v.org_id=r.org_id AND v.id=r.voucher_id)"
+ " ON r.org_id=a.org_id AND r.account_id=a.id"
+ " WHERE a.org_id=?1 AND a.active=1%s"
+ " GROUP BY a.id ORDER BY a.number",
+ types_filter ? types_filter : "");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
+ set_err(err, "database error");
+ return NULL;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, from, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, to, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, fy_id);
+ yyjson_mut_val *arr = yyjson_mut_arr(doc);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ const char *number = (const char *)sqlite3_column_text(st, 0);
+ const char *name = (const char *)sqlite3_column_text(st, 1);
+ const char *type = (const char *)sqlite3_column_text(st, 2);
+ int64_t debit = sqlite3_column_int64(st, 3);
+ int64_t credit = sqlite3_column_int64(st, 4);
+ int64_t ib = sqlite3_column_int64(st, 5) - sqlite3_column_int64(st, 6);
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr);
+ yyjson_mut_obj_add_strcpy(doc, o, "account", number ? number : "");
+ yyjson_mut_obj_add_strcpy(doc, o, "name", name ? name : "");
+ yyjson_mut_obj_add_strcpy(doc, o, "type", type ? type : "");
+ yyjson_mut_obj_add_int(doc, o, "ib_ore", ib);
+ yyjson_mut_obj_add_int(doc, o, "debit_ore", debit);
+ yyjson_mut_obj_add_int(doc, o, "credit_ore", credit);
+ yyjson_mut_obj_add_int(doc, o, "ub_ore", ib + debit - credit);
+ }
+ sqlite3_finalize(st);
+ return arr;
+}
+
+yyjson_mut_val *report_trial_balance(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *from, const char *to,
+ int include_zero, char **err)
+{
+ struct fy_info fy;
+ if (load_fy(db, org_id, fy_id, &fy, err) != 0)
+ return NULL;
+ if (!from)
+ from = fy.start;
+ if (!to)
+ to = fy.end;
+
+ yyjson_mut_val *rows = balance_query(doc, db, org_id, fy.id, from, to, NULL, err);
+ if (!rows)
+ return NULL;
+
+ yyjson_mut_val *filtered = include_zero ? rows : yyjson_mut_arr(doc);
+ int64_t t_debit = 0, t_credit = 0, t_ib = 0, t_ub = 0;
+ if (!include_zero) {
+ size_t n = yyjson_mut_arr_size(rows);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_mut_val *row = yyjson_mut_arr_get(rows, i);
+ yyjson_mut_val *ibv = yyjson_mut_obj_get(row, "ib_ore");
+ yyjson_mut_val *dv = yyjson_mut_obj_get(row, "debit_ore");
+ yyjson_mut_val *cv = yyjson_mut_obj_get(row, "credit_ore");
+ int64_t ib = ibv ? yyjson_mut_get_int(ibv) : 0;
+ int64_t d = dv ? yyjson_mut_get_int(dv) : 0;
+ int64_t c = cv ? yyjson_mut_get_int(cv) : 0;
+ if (ib == 0 && d == 0 && c == 0)
+ continue;
+ yyjson_mut_arr_add_val(filtered, yyjson_mut_val_mut_copy(doc, row));
+ }
+ }
+ size_t n = yyjson_mut_arr_size(filtered);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_mut_val *row = yyjson_mut_arr_get(filtered, i);
+ t_ib += yyjson_mut_get_int(yyjson_mut_obj_get(row, "ib_ore"));
+ t_debit += yyjson_mut_get_int(yyjson_mut_obj_get(row, "debit_ore"));
+ t_credit += yyjson_mut_get_int(yyjson_mut_obj_get(row, "credit_ore"));
+ t_ub += yyjson_mut_get_int(yyjson_mut_obj_get(row, "ub_ore"));
+ }
+
+ yyjson_mut_val *o = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, o, "fiscal_year", fy_json(doc, &fy));
+ yyjson_mut_obj_add_strcpy(doc, o, "from", from);
+ yyjson_mut_obj_add_strcpy(doc, o, "to", to);
+ yyjson_mut_obj_add_val(doc, o, "accounts", filtered);
+ yyjson_mut_val *tot = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_int(doc, tot, "ib_ore", t_ib);
+ yyjson_mut_obj_add_int(doc, tot, "debit_ore", t_debit);
+ yyjson_mut_obj_add_int(doc, tot, "credit_ore", t_credit);
+ yyjson_mut_obj_add_int(doc, tot, "ub_ore", t_ub);
+ yyjson_mut_obj_add_val(doc, o, "totals", tot);
+ return o;
+}
+
+yyjson_mut_val *report_income_statement(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *from, const char *to,
+ char **err)
+{
+ struct fy_info fy;
+ if (load_fy(db, org_id, fy_id, &fy, err) != 0)
+ return NULL;
+ if (!from)
+ from = fy.start;
+ if (!to)
+ to = fy.end;
+
+ yyjson_mut_val *rows = balance_query(
+ doc, db, org_id, fy.id, from, to,
+ " AND a.type IN ('revenue','expense')", err);
+ if (!rows)
+ return NULL;
+
+ yyjson_mut_val *rev = yyjson_mut_arr(doc);
+ yyjson_mut_val *exp = yyjson_mut_arr(doc);
+ int64_t rev_total = 0, exp_total = 0;
+ size_t n = yyjson_mut_arr_size(rows);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_mut_val *row = yyjson_mut_arr_get(rows, i);
+ const char *type =
+ yyjson_mut_get_str(yyjson_mut_obj_get(row, "type"));
+ int64_t d = yyjson_mut_get_int(yyjson_mut_obj_get(row, "debit_ore"));
+ int64_t c = yyjson_mut_get_int(yyjson_mut_obj_get(row, "credit_ore"));
+ if (d == 0 && c == 0)
+ continue;
+ int is_rev = type && strcmp(type, "revenue") == 0;
+ int64_t amount = is_rev ? c - d : d - c;
+ yyjson_mut_val *copy = yyjson_mut_val_mut_copy(doc, row);
+ yyjson_mut_obj_add_int(doc, copy, "amount_ore", amount);
+ if (is_rev) {
+ yyjson_mut_arr_add_val(rev, copy);
+ rev_total += amount;
+ } else {
+ yyjson_mut_arr_add_val(exp, copy);
+ exp_total += amount;
+ }
+ }
+
+ yyjson_mut_val *o = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, o, "fiscal_year", fy_json(doc, &fy));
+ yyjson_mut_obj_add_strcpy(doc, o, "from", from);
+ yyjson_mut_obj_add_strcpy(doc, o, "to", to);
+ yyjson_mut_val *rsec = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, rsec, "accounts", rev);
+ yyjson_mut_obj_add_int(doc, rsec, "total_ore", rev_total);
+ yyjson_mut_obj_add_val(doc, o, "revenue", rsec);
+ yyjson_mut_val *esec = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, esec, "accounts", exp);
+ yyjson_mut_obj_add_int(doc, esec, "total_ore", exp_total);
+ yyjson_mut_obj_add_val(doc, o, "expenses", esec);
+ yyjson_mut_obj_add_int(doc, o, "result_ore", rev_total - exp_total);
+ return o;
+}
+
+yyjson_mut_val *report_balance_sheet(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *to, char **err)
+{
+ struct fy_info fy;
+ if (load_fy(db, org_id, fy_id, &fy, err) != 0)
+ return NULL;
+ if (!to)
+ to = fy.end;
+
+ yyjson_mut_val *rows = balance_query(
+ doc, db, org_id, fy.id, fy.start, to,
+ " AND a.type IN ('asset','liability','equity')", err);
+ if (!rows)
+ return NULL;
+
+ yyjson_mut_val *assets = yyjson_mut_arr(doc);
+ yyjson_mut_val *liab = yyjson_mut_arr(doc);
+ yyjson_mut_val *equity = yyjson_mut_arr(doc);
+ int64_t a_total = 0, l_total = 0, e_total = 0;
+ size_t n = yyjson_mut_arr_size(rows);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_mut_val *row = yyjson_mut_arr_get(rows, i);
+ const char *type =
+ yyjson_mut_get_str(yyjson_mut_obj_get(row, "type"));
+ int64_t ub = yyjson_mut_get_int(yyjson_mut_obj_get(row, "ub_ore"));
+ if (ub == 0)
+ continue;
+ int64_t amount;
+ yyjson_mut_val **target;
+ if (type && strcmp(type, "asset") == 0) {
+ amount = ub;
+ target = &assets;
+ a_total += amount;
+ } else if (type && strcmp(type, "liability") == 0) {
+ amount = -ub;
+ target = &liab;
+ l_total += amount;
+ } else {
+ amount = -ub;
+ target = &equity;
+ e_total += amount;
+ }
+ yyjson_mut_val *copy = yyjson_mut_val_mut_copy(doc, row);
+ yyjson_mut_obj_add_int(doc, copy, "amount_ore", amount);
+ yyjson_mut_arr_add_val(*target, copy);
+ }
+
+ /* current year result belongs to equity */
+ yyjson_mut_val *inc = balance_query(
+ doc, db, org_id, fy.id, fy.start, to,
+ " AND a.type IN ('revenue','expense')", err);
+ int64_t result = 0;
+ if (inc) {
+ size_t m = yyjson_mut_arr_size(inc);
+ for (size_t i = 0; i < m; i++) {
+ yyjson_mut_val *row = yyjson_mut_arr_get(inc, i);
+ int64_t d =
+ yyjson_mut_get_int(yyjson_mut_obj_get(row, "debit_ore"));
+ int64_t c =
+ yyjson_mut_get_int(yyjson_mut_obj_get(row, "credit_ore"));
+ const char *type =
+ yyjson_mut_get_str(yyjson_mut_obj_get(row, "type"));
+ if (type && strcmp(type, "revenue") == 0)
+ result += c - d;
+ else
+ result -= d - c;
+ }
+ }
+ e_total += result;
+
+ yyjson_mut_val *o = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, o, "fiscal_year", fy_json(doc, &fy));
+ yyjson_mut_obj_add_strcpy(doc, o, "to", to);
+ yyjson_mut_val *asec = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, asec, "accounts", assets);
+ yyjson_mut_obj_add_int(doc, asec, "total_ore", a_total);
+ yyjson_mut_obj_add_val(doc, o, "assets", asec);
+ yyjson_mut_val *lsec = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, lsec, "accounts", liab);
+ yyjson_mut_obj_add_int(doc, lsec, "total_ore", l_total);
+ yyjson_mut_obj_add_val(doc, o, "liabilities", lsec);
+ yyjson_mut_val *esec = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, esec, "accounts", equity);
+ yyjson_mut_obj_add_int(doc, esec, "total_ore", e_total);
+ yyjson_mut_obj_add_int(doc, esec, "result_ore", result);
+ yyjson_mut_obj_add_val(doc, o, "equity", esec);
+ return o;
+}
+
+struct box_label {
+ const char *box;
+ const char *label;
+};
+
+static const struct box_label BOX_LABELS[] = {
+ { "05", "Momspliktig försÀljning 25%" },
+ { "06", "Momspliktig försÀljning 12%" },
+ { "07", "Momspliktig försÀljning 6%" },
+ { "10", "UtgÄende moms 25%" },
+ { "11", "UtgÄende moms 12%" },
+ { "12", "UtgÄende moms 6%" },
+ { "48", "IngÄende moms" },
+};
+
+static const char *box_label(const char *box)
+{
+ for (size_t i = 0; i < sizeof BOX_LABELS / sizeof BOX_LABELS[0]; i++)
+ if (strcmp(BOX_LABELS[i].box, box) == 0)
+ return BOX_LABELS[i].label;
+ return "Ruta";
+}
+
+yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
+ const char *from, const char *to, char **err)
+{
+ if (!util_parse_iso_date(from) || !util_parse_iso_date(to)) {
+ set_err(err, "from and to must be YYYY-MM-DD");
+ return NULL;
+ }
+ yyjson_mut_val *boxes = yyjson_mut_arr(doc);
+ int64_t out_total = 0, in_total = 0;
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT box,match_type,pattern,sign FROM report_rules"
+ " WHERE org_id=?1 AND report='vat' ORDER BY sort_order",
+ -1, &st, NULL) != SQLITE_OK) {
+ set_err(err, "database error");
+ return NULL;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ const char *box = (const char *)sqlite3_column_text(st, 0);
+ const char *mt = (const char *)sqlite3_column_text(st, 1);
+ const char *pattern = (const char *)sqlite3_column_text(st, 2);
+ int sign = sqlite3_column_int(st, 3);
+ int64_t amount = 0;
+ sqlite3_stmt *qs = NULL;
+ const char *sql =
+ strcmp(mt, "account") == 0
+ ? "SELECT COALESCE(SUM(r.debit_ore),0),"
+ "COALESCE(SUM(r.credit_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 a.number=?2 AND v.series <> 'IB'"
+ " AND v.date BETWEEN ?3 AND ?4"
+ : "SELECT COALESCE(SUM(r.debit_ore),0),"
+ "COALESCE(SUM(r.credit_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 CAST(a.number AS INTEGER) BETWEEN ?2"
+ " AND ?3 AND v.series <> 'IB' AND v.date BETWEEN ?4 AND ?5";
+ if (sqlite3_prepare_v2(db, sql, -1, &qs, NULL) != SQLITE_OK)
+ break;
+ sqlite3_bind_int64(qs, 1, org_id);
+ if (strcmp(mt, "account") == 0) {
+ sqlite3_bind_text(qs, 2, pattern, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(qs, 3, from, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(qs, 4, to, -1, SQLITE_TRANSIENT);
+ } else {
+ long lo = 0, hi = 0;
+ if (sscanf(pattern, "%ld-%ld", &lo, &hi) != 2) {
+ sqlite3_finalize(qs);
+ continue;
+ }
+ sqlite3_bind_int64(qs, 2, lo);
+ sqlite3_bind_int64(qs, 3, hi);
+ sqlite3_bind_text(qs, 4, from, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(qs, 5, to, -1, SQLITE_TRANSIENT);
+ }
+ if (sqlite3_step(qs) == SQLITE_ROW)
+ amount = (sqlite3_column_int64(qs, 0) -
+ sqlite3_column_int64(qs, 1)) *
+ sign;
+ sqlite3_finalize(qs);
+ if (strcmp(box, "48") == 0)
+ in_total += amount;
+ else
+ out_total += amount;
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, boxes);
+ yyjson_mut_obj_add_strcpy(doc, o, "box", box);
+ yyjson_mut_obj_add_strcpy(doc, o, "label", box_label(box));
+ yyjson_mut_obj_add_int(doc, o, "amount_ore", amount);
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, boxes);
+ yyjson_mut_obj_add_strcpy(doc, o, "box", "49");
+ yyjson_mut_obj_add_strcpy(doc, o, "label", "Moms att betala eller fÄ tillbaka");
+ yyjson_mut_obj_add_int(doc, o, "amount_ore", out_total - in_total);
+
+ yyjson_mut_val *res = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_strcpy(doc, res, "from", from);
+ yyjson_mut_obj_add_strcpy(doc, res, "to", to);
+ yyjson_mut_obj_add_val(doc, res, "boxes", boxes);
+ yyjson_mut_obj_add_strcpy(doc, res, "note",
+ "starter mapping; review against Skatteverket"
+ " before filing");
+ return res;
+}
diff --git a/src/reports.h b/src/reports.h
new file mode 100644
index 0000000..37d5f67
--- /dev/null
+++ b/src/reports.h
@@ -0,0 +1,23 @@
+#ifndef BOKF_REPORTS_H
+#define BOKF_REPORTS_H
+
+#include <sqlite3.h>
+#include <stdint.h>
+
+#include "yyjson.h"
+
+yyjson_mut_val *report_trial_balance(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *from, const char *to,
+ int include_zero, char **err);
+yyjson_mut_val *report_income_statement(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *from, const char *to,
+ char **err);
+yyjson_mut_val *report_balance_sheet(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *to, char **err);
+yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
+ const char *from, const char *to, char **err);
+
+#endif
diff --git a/src/seed.c b/src/seed.c
new file mode 100644
index 0000000..cd1bf71
--- /dev/null
+++ b/src/seed.c
@@ -0,0 +1,325 @@
+#include "seed.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#include "config.h"
+#include "log.h"
+#include "util.h"
+
+static void set_err(char **err, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+
+static void set_err(char **err, const char *fmt, ...)
+{
+ if (!err || *err)
+ return;
+ char buf[512];
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(buf, sizeof buf, fmt, ap);
+ va_end(ap);
+ *err = xstrdup(buf);
+}
+
+struct chart_entry {
+ const char *number;
+ const char *name;
+ const char *type;
+};
+
+/* Minimal fallback used only when no chart file can be read. The full
+ BAS chart ships in data/bas_k2.csv and data/bas_k3.csv. */
+static const struct chart_entry FALLBACK[] = {
+ { "1220", "Inventarier och verktyg", "asset" },
+ { "1240", "Bilar och andra transportmedel", "asset" },
+ { "1250", "Datorer", "asset" },
+ { "1510", "Kundfordringar", "asset" },
+ { "1650", "Momsfordran", "asset" },
+ { "1710", "Förutbetalda kostnader och upplupna intÀkter", "asset" },
+ { "1930", "Företagskonto/checkkonto", "asset" },
+ { "1940", "Övriga bankkonton", "asset" },
+ { "2010", "Eget kapital", "equity" },
+ { "2013", "Egna uttag", "equity" },
+ { "2091", "Balanserad vinst eller förlust", "equity" },
+ { "2099", "Årets resultat", "equity" },
+ { "2440", "Leverantörsskulder", "liability" },
+ { "2510", "Skatteskulder", "liability" },
+ { "2610", "UtgÄende moms 25%", "liability" },
+ { "2620", "UtgÄende moms 12%", "liability" },
+ { "2630", "UtgÄende moms 6%", "liability" },
+ { "2640", "IngÄende moms", "liability" },
+ { "2650", "Redovisningskonto för moms", "liability" },
+ { "2710", "Personalskatt", "liability" },
+ { "3001", "FörsÀljning varor 25%", "revenue" },
+ { "3002", "FörsÀljning tjÀnster 25%", "revenue" },
+ { "3021", "FörsÀljning varor 12%", "revenue" },
+ { "3022", "FörsÀljning tjÀnster 12%", "revenue" },
+ { "3031", "FörsÀljning varor 6%", "revenue" },
+ { "3032", "FörsÀljning tjÀnster 6%", "revenue" },
+ { "4010", "Inköp varor", "expense" },
+ { "5010", "Lokalhyra", "expense" },
+ { "5410", "Förbrukningsinventarier", "expense" },
+ { "5460", "Förbrukningsmaterial", "expense" },
+ { "6110", "Kontorsmaterial", "expense" },
+ { "6310", "FöretagsförsÀkringar", "expense" },
+ { "6530", "RedovisningstjÀnster", "expense" },
+ { "6540", "IT-tjÀnster", "expense" },
+ { "6570", "Bankkostnader", "expense" },
+ { "7010", "Löner till kollektivanstÀllda", "expense" },
+ { "7510", "Arbetsgivaravgifter", "expense" },
+ { "8999", "Årets resultat", "expense" },
+};
+
+/* Splits one CSV line in place. Handles quoted fields and doubled quotes. */
+static int csv_split(char *line, char **fields, int max, int *out_n)
+{
+ int n = 0;
+ char *p = line;
+ while (*p && n < max) {
+ char *start = p;
+ char *w = p;
+ if (*p == '"') {
+ p++;
+ while (*p) {
+ if (*p == '"') {
+ if (p[1] == '"') {
+ *w++ = '"';
+ p += 2;
+ continue;
+ }
+ p++;
+ break;
+ }
+ *w++ = *p++;
+ }
+ *w = '\0';
+ if (*p == ',')
+ p++;
+ } else {
+ while (*p && *p != ',')
+ *w++ = *p++;
+ int had_comma = *p == ',';
+ if (had_comma)
+ p++;
+ *w = '\0';
+ }
+ fields[n++] = start;
+ }
+ *out_n = n;
+ return 0;
+}
+
+static int insert_account(sqlite3 *db, int64_t org_id, const char *number,
+ const char *name, const char *type, const char *ts)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "INSERT OR IGNORE INTO accounts(org_id,number,name,type,created_at)"
+ " VALUES(?1,?2,?3,?4,?5)",
+ -1, &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_int64(st, 1, 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);
+ sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ return rc == SQLITE_DONE ? 0 : -1;
+}
+
+static int valid_type(const char *t)
+{
+ return t && (!strcmp(t, "asset") || !strcmp(t, "liability") ||
+ !strcmp(t, "equity") || !strcmp(t, "revenue") ||
+ !strcmp(t, "expense"));
+}
+
+static FILE *open_chart(const char *configured, const char *fallback_name)
+{
+ if (configured && *configured) {
+ FILE *f = fopen(configured, "r");
+ if (f)
+ return f;
+ char alt[512];
+ snprintf(alt, sizeof alt, "data/%s", fallback_name);
+ f = fopen(alt, "r");
+ if (f)
+ return f;
+ }
+ return NULL;
+}
+
+static int seed_accounts(sqlite3 *db, int64_t org_id, const char *framework,
+ const char *ts, char **err)
+{
+ int is_k3 = framework && strcmp(framework, "K3") == 0;
+ const char *path = is_k3 ? g_cfg.chart_k3_file : g_cfg.chart_k2_file;
+ const char *name = is_k3 ? "bas_k3.csv" : "bas_k2.csv";
+ FILE *f = open_chart(path, name);
+ if (!f) {
+ log_warn("chart file not found; using built-in starter chart");
+ for (size_t i = 0; i < sizeof FALLBACK / sizeof FALLBACK[0]; i++)
+ if (insert_account(db, org_id, FALLBACK[i].number, FALLBACK[i].name,
+ FALLBACK[i].type, ts) != 0) {
+ set_err(err, "could not seed account %s", FALLBACK[i].number);
+ return -1;
+ }
+ return 0;
+ }
+ char *line = NULL;
+ size_t cap = 0;
+ ssize_t len;
+ int line_no = 0;
+ int count = 0;
+ while ((len = getline(&line, &cap, f)) > 0) {
+ line_no++;
+ if (len > 0 && line[len - 1] == '\n')
+ line[--len] = '\0';
+ if (len > 0 && line[len - 1] == '\r')
+ line[--len] = '\0';
+ char *fields[8];
+ int n = 0;
+ csv_split(line, fields, 8, &n);
+ if (n < 3)
+ continue;
+ if (line_no == 1 && strcmp(fields[0], "number") == 0)
+ continue;
+ if (!valid_type(fields[2]))
+ continue;
+ if (insert_account(db, org_id, fields[0], fields[1], fields[2], ts) !=
+ 0) {
+ set_err(err, "could not seed account %s (line %d)", fields[0],
+ line_no);
+ free(line);
+ fclose(f);
+ return -1;
+ }
+ count++;
+ }
+ free(line);
+ fclose(f);
+ log_info("seeded %d accounts from %s", count, path);
+ return 0;
+}
+
+struct vat_rule {
+ const char *box;
+ const char *match_type;
+ const char *pattern;
+ int sign;
+ int sort_order;
+};
+
+/* Starter mapping from BAS account ranges to momsdeklaration boxes. It
+ covers the common domestic cases only and must be reviewed against the
+ current Skatteverket blankett before filing. */
+static const struct vat_rule VAT_RULES[] = {
+ { "05", "range", "3001-3019", -1, 10 },
+ { "06", "range", "3021-3029", -1, 20 },
+ { "07", "range", "3031-3039", -1, 30 },
+ { "10", "range", "2610-2619", -1, 40 },
+ { "11", "range", "2620-2629", -1, 50 },
+ { "12", "range", "2630-2639", -1, 60 },
+ { "48", "range", "2640-2649", 1, 70 },
+};
+
+static int seed_rules(sqlite3 *db, int64_t org_id, char **err)
+{
+ for (size_t i = 0; i < sizeof VAT_RULES / sizeof VAT_RULES[0]; i++) {
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "INSERT INTO report_rules(org_id,report,box,match_type,pattern,"
+ "sign,sort_order) VALUES(?1,'vat',?2,?3,?4,?5,?6)",
+ -1, &st, NULL) != SQLITE_OK) {
+ set_err(err, "could not seed moms rules: %s", sqlite3_errmsg(db));
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, VAT_RULES[i].box, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, VAT_RULES[i].match_type, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, VAT_RULES[i].pattern, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int(st, 5, VAT_RULES[i].sign);
+ sqlite3_bind_int(st, 6, VAT_RULES[i].sort_order);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ set_err(err, "could not seed moms rules: %s", sqlite3_errmsg(db));
+ return -1;
+ }
+ }
+ return 0;
+}
+
+static int seed_default_fiscal_year(sqlite3 *db, int64_t org_id,
+ int fy_start_month, int64_t *out_fy,
+ char **err)
+{
+ time_t t = time(NULL);
+ struct tm tm;
+ gmtime_r(&t, &tm);
+ int y = tm.tm_year + 1900;
+ int m = tm.tm_mon + 1;
+ int start_year = m >= fy_start_month ? y : y - 1;
+
+ char start[16], end[16];
+ util_date_fmt(start_year, fy_start_month, 1, start, sizeof start);
+ util_date_add_months(start, 12, end, sizeof end);
+ util_date_add_days(end, -1, end, sizeof end);
+ int ey, em, ed;
+ util_date_parse(end, &ey, &em, &ed);
+
+ char label[32];
+ if (fy_start_month == 1)
+ snprintf(label, sizeof label, "%d", ey);
+ else
+ snprintf(label, sizeof label, "%d/%d", start_year, ey);
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "INSERT INTO fiscal_years(org_id,label,start_date,end_date,"
+ "created_at) VALUES(?1,?2,?3,?4,?5)",
+ -1, &st, NULL) != SQLITE_OK) {
+ set_err(err, "could not create fiscal year: %s", sqlite3_errmsg(db));
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, label, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, start, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, end, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ set_err(err, "could not create fiscal year: %s", sqlite3_errmsg(db));
+ return -1;
+ }
+ if (out_fy)
+ *out_fy = sqlite3_last_insert_rowid(db);
+ log_info("org %lld: fiscal year %s (%s .. %s)", (long long)org_id, label,
+ start, end);
+ return 0;
+}
+
+int seed_org(sqlite3 *db, int64_t org_id, const char *framework,
+ int fy_start_month, int64_t *out_fy_id, char **err)
+{
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (seed_accounts(db, org_id, framework, ts, err) != 0)
+ return -1;
+ if (seed_rules(db, org_id, err) != 0)
+ return -1;
+ if (seed_default_fiscal_year(db, org_id, fy_start_month, out_fy_id, err) !=
+ 0)
+ return -1;
+ return 0;
+}
diff --git a/src/seed.h b/src/seed.h
new file mode 100644
index 0000000..826391b
--- /dev/null
+++ b/src/seed.h
@@ -0,0 +1,12 @@
+#ifndef BOKF_SEED_H
+#define BOKF_SEED_H
+
+#include <sqlite3.h>
+#include <stdint.h>
+
+/* Seeds a fresh org inside an existing transaction: chart of accounts,
+ moms report rules and the current fiscal year. Returns 0 on success. */
+int seed_org(sqlite3 *db, int64_t org_id, const char *framework,
+ int fy_start_month, int64_t *out_fy_id, char **err);
+
+#endif
diff --git a/src/sessions.c b/src/sessions.c
new file mode 100644
index 0000000..66ec375
--- /dev/null
+++ b/src/sessions.c
@@ -0,0 +1,86 @@
+#include "sessions.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "util.h"
+
+static struct session *g_sessions;
+static long g_ttl = 8 * 3600;
+
+void sessions_init(long ttl_seconds)
+{
+ if (ttl_seconds > 0)
+ g_ttl = ttl_seconds;
+}
+
+struct session *sessions_create(int64_t user_id, int is_admin,
+ int64_t active_org, int64_t bound_org,
+ const char *scopes)
+{
+ struct session *s = xcalloc(1, sizeof *s);
+ char *id = util_random_id("s_", 18);
+ snprintf(s->id, sizeof s->id, "%s", id);
+ free(id);
+ s->user_id = user_id;
+ s->is_admin = is_admin;
+ s->active_org = active_org;
+ s->bound_org = bound_org;
+ snprintf(s->scopes, sizeof s->scopes, "%s", scopes ? scopes : "read");
+ s->created = s->last_seen = s->expires = util_now();
+ s->expires = s->created + g_ttl;
+ s->next = g_sessions;
+ g_sessions = s;
+ return s;
+}
+
+struct session *sessions_get(const char *id)
+{
+ if (!id)
+ return NULL;
+ int64_t now = util_now();
+ struct session **pp = &g_sessions;
+ while (*pp) {
+ struct session *s = *pp;
+ if (s->expires <= now) {
+ *pp = s->next;
+ free(s);
+ continue;
+ }
+ if (strcmp(s->id, id) == 0) {
+ s->last_seen = now;
+ s->expires = now + g_ttl;
+ return s;
+ }
+ pp = &s->next;
+ }
+ return NULL;
+}
+
+void sessions_destroy(const char *id)
+{
+ if (!id)
+ return;
+ struct session **pp = &g_sessions;
+ while (*pp) {
+ struct session *s = *pp;
+ if (strcmp(s->id, id) == 0) {
+ *pp = s->next;
+ free(s);
+ return;
+ }
+ pp = &s->next;
+ }
+}
+
+void sessions_free_all(void)
+{
+ struct session *s = g_sessions;
+ while (s) {
+ struct session *next = s->next;
+ free(s);
+ s = next;
+ }
+ g_sessions = NULL;
+}
diff --git a/src/sessions.h b/src/sessions.h
new file mode 100644
index 0000000..d573b23
--- /dev/null
+++ b/src/sessions.h
@@ -0,0 +1,28 @@
+#ifndef BOKF_SESSIONS_H
+#define BOKF_SESSIONS_H
+
+#include <stdint.h>
+
+struct session {
+ char id[64];
+ int64_t user_id;
+ int is_admin;
+ int64_t active_org;
+ int64_t bound_org; /* token sessions are bound to one org; 0 = none */
+ int64_t token_id; /* api token used to open the session, 0 for password */
+ char scopes[32];
+ int64_t created;
+ int64_t last_seen;
+ int64_t expires;
+ struct session *next;
+};
+
+void sessions_init(long ttl_seconds);
+struct session *sessions_create(int64_t user_id, int is_admin,
+ int64_t active_org, int64_t bound_org,
+ const char *scopes);
+struct session *sessions_get(const char *id);
+void sessions_destroy(const char *id);
+void sessions_free_all(void);
+
+#endif
diff --git a/src/sie.c b/src/sie.c
new file mode 100644
index 0000000..05604d2
--- /dev/null
+++ b/src/sie.c
@@ -0,0 +1,876 @@
+#include "sie.h"
+
+#include <ctype.h>
+#include <math.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#include "ledger.h"
+#include "util.h"
+#include "version.h"
+
+static void set_err(char **err, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+
+static void set_err(char **err, const char *fmt, ...)
+{
+ if (!err || *err)
+ return;
+ char buf[512];
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(buf, sizeof buf, fmt, ap);
+ va_end(ap);
+ *err = xstrdup(buf);
+}
+
+/* ------------------------------------------------------------------ */
+/* export */
+/* ------------------------------------------------------------------ */
+
+static void put(FILE *f, const char *s)
+{
+ unsigned char buf[4096];
+ size_t n = util_utf8_to_cp437(s, buf, sizeof buf);
+ fwrite(buf, 1, n, f);
+}
+
+static void put_quoted(FILE *f, const char *s)
+{
+ fputc('"', f);
+ for (const char *p = s; *p; p++)
+ fputc(*p == '"' ? '\'' : *p, f);
+ fputc('"', f);
+}
+
+static void put_amount(FILE *f, int64_t ore)
+{
+ int64_t v = ore;
+ if (v < 0) {
+ fputc('-', f);
+ v = -v;
+ }
+ fprintf(f, "%lld.%02lld", (long long)(v / 100), (long long)(v % 100));
+}
+
+static void put_date(FILE *f, const char *iso)
+{
+ if (!iso || strlen(iso) != 10)
+ return;
+ fwrite(iso, 4, 1, f);
+ fwrite(iso + 5, 2, 1, f);
+ fwrite(iso + 8, 2, 1, f);
+}
+
+int sie_export_file(sqlite3 *db, int64_t org_id, int64_t fy_id,
+ const char *path, char **err)
+{
+ char label[64] = "", start[16] = "", end[16] = "";
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT label,start_date,end_date FROM fiscal_years"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK) {
+ set_err(err, "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ set_err(err, "fiscal year not found");
+ return -1;
+ }
+ snprintf(label, sizeof label, "%s", sqlite3_column_text(st, 0));
+ snprintf(start, sizeof start, "%s", sqlite3_column_text(st, 1));
+ snprintf(end, sizeof end, "%s", sqlite3_column_text(st, 2));
+ sqlite3_finalize(st);
+
+ char org_name[256] = "", org_nr[64] = "";
+ if (sqlite3_prepare_v2(db, "SELECT name,COALESCE(org_nr,'') FROM orgs WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK) {
+ set_err(err, "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ snprintf(org_name, sizeof org_name, "%s", sqlite3_column_text(st, 0));
+ snprintf(org_nr, sizeof org_nr, "%s", sqlite3_column_text(st, 1));
+ }
+ sqlite3_finalize(st);
+
+ FILE *f = fopen(path, "wb");
+ if (!f) {
+ set_err(err, "cannot create %s", path);
+ return -1;
+ }
+ time_t now = time(NULL);
+ struct tm tm;
+ gmtime_r(&now, &tm);
+ char gendate[16];
+ strftime(gendate, sizeof gendate, "%Y%m%d", &tm);
+
+ put(f, "#FLAGGA 0\n");
+ put(f, "#PROGRAM \"bokf\" \"" BOKF_VERSION "\"\n");
+ put(f, "#FORMAT PC8\n");
+ fprintf(f, "#GEN %s\n", gendate);
+ put(f, "#SIETYP 4\n");
+ put(f, "#FNR ");
+ put(f, label);
+ put(f, "\n");
+ put(f, "#ORGNR ");
+ put(f, org_nr);
+ put(f, "\n");
+ put(f, "#FNAMN ");
+ put_quoted(f, org_name);
+ put(f, "\n");
+ put(f, "#RAR 0 ");
+ put_date(f, start);
+ put(f, " ");
+ put_date(f, end);
+ put(f, "\n");
+
+ /* accounts and balances: IB = this year's series "IB" voucher(s) plus
+ all non-IB history before the year; UB = everything through the end;
+ RES = the year's non-IB movements. */
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT a.number,a.name,"
+ " COALESCE(SUM(CASE WHEN (v.series = 'IB'"
+ " AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'"
+ " AND v.date < ?2) THEN r.debit_ore - r.credit_ore END),0),"
+ " COALESCE(SUM(CASE WHEN v.date <= ?3"
+ " THEN r.debit_ore - r.credit_ore END),0),"
+ " COALESCE(SUM(CASE WHEN v.series <> 'IB'"
+ " AND v.date >= ?2 AND v.date <= ?3"
+ " THEN r.debit_ore - r.credit_ore END),0)"
+ " FROM accounts a LEFT JOIN (voucher_rows r JOIN vouchers v"
+ " ON v.org_id=r.org_id AND v.id=r.voucher_id)"
+ " ON r.org_id=a.org_id AND r.account_id=a.id"
+ " WHERE a.org_id=?1 AND a.active=1"
+ " GROUP BY a.id ORDER BY a.number",
+ -1, &st, NULL) != SQLITE_OK) {
+ fclose(f);
+ set_err(err, "database error");
+ 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);
+ sqlite3_bind_int64(st, 4, fy_id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ const char *number = (const char *)sqlite3_column_text(st, 0);
+ const char *name = (const char *)sqlite3_column_text(st, 1);
+ int64_t ib = sqlite3_column_int64(st, 2);
+ int64_t ub = sqlite3_column_int64(st, 3);
+ int64_t res = sqlite3_column_int64(st, 4);
+ put(f, "#KONTO ");
+ put(f, number);
+ put(f, " ");
+ put_quoted(f, name ? name : "");
+ put(f, "\n");
+ if (ib != 0) {
+ put(f, "#IB 0 ");
+ put(f, number);
+ put(f, " ");
+ put_amount(f, ib);
+ put(f, "\n");
+ }
+ if (ub != 0) {
+ put(f, "#UB 0 ");
+ put(f, number);
+ put(f, " ");
+ put_amount(f, ub);
+ put(f, "\n");
+ }
+ if (res != 0) {
+ put(f, "#RES 0 ");
+ put(f, number);
+ put(f, " ");
+ put_amount(f, res);
+ put(f, "\n");
+ }
+ }
+ sqlite3_finalize(st);
+
+ /* vouchers */
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT id,series,number,date,description FROM vouchers"
+ " WHERE org_id=?1 AND fiscal_year_id=?2 ORDER BY series,number",
+ -1, &st, NULL) != SQLITE_OK) {
+ fclose(f);
+ set_err(err, "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ int64_t current_id = 0;
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ current_id = sqlite3_column_int64(st, 0);
+ put(f, "#VER ");
+ put_quoted(f, (const char *)sqlite3_column_text(st, 1));
+ fprintf(f, " %lld ", (long long)sqlite3_column_int64(st, 2));
+ put_date(f, (const char *)sqlite3_column_text(st, 3));
+ put(f, " ");
+ put_quoted(f, (const char *)sqlite3_column_text(st, 4));
+ put(f, "\n{\n");
+ sqlite3_stmt *rs = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT a.number,r.debit_ore,r.credit_ore,"
+ "COALESCE(r.description,''),v.date FROM voucher_rows r"
+ " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
+ " JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id"
+ " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no",
+ -1, &rs, NULL) == SQLITE_OK) {
+ sqlite3_bind_int64(rs, 1, org_id);
+ sqlite3_bind_int64(rs, 2, current_id);
+ while (sqlite3_step(rs) == SQLITE_ROW) {
+ put(f, "#TRANS ");
+ put(f, (const char *)sqlite3_column_text(rs, 0));
+ put(f, " {} ");
+ put_amount(f, sqlite3_column_int64(rs, 1) -
+ sqlite3_column_int64(rs, 2));
+ put(f, " ");
+ put_date(f, (const char *)sqlite3_column_text(rs, 4));
+ put(f, " ");
+ put_quoted(f, (const char *)sqlite3_column_text(rs, 3));
+ put(f, "\n");
+ }
+ sqlite3_finalize(rs);
+ }
+ put(f, "}\n");
+ }
+ sqlite3_finalize(st);
+
+ if (fclose(f) != 0) {
+ set_err(err, "cannot write %s", path);
+ return -1;
+ }
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* import */
+/* ------------------------------------------------------------------ */
+
+#define MAX_TOK 24
+
+static int tokenize(char *line, char *toks[], int max, int *out_n)
+{
+ int n = 0;
+ char *p = line;
+ while (*p) {
+ while (*p == ' ' || *p == '\t')
+ p++;
+ if (!*p)
+ break;
+ if (n >= max)
+ return -1;
+ if (*p == '"') {
+ p++;
+ char *w = p;
+ toks[n++] = w;
+ while (*p) {
+ if (*p == '"') {
+ if (p[1] == '"') {
+ *w++ = '"';
+ p += 2;
+ continue;
+ }
+ p++;
+ break;
+ }
+ *w++ = *p++;
+ }
+ *w = '\0';
+ } else if (*p == '{' || *p == '}') {
+ toks[n++] = p;
+ p++;
+ if (n < max && *p == '}') {
+ toks[n++] = p;
+ p++;
+ }
+ } else {
+ toks[n++] = p;
+ while (*p && *p != ' ' && *p != '\t')
+ p++;
+ if (*p)
+ *p++ = '\0';
+ }
+ }
+ *out_n = n;
+ return 0;
+}
+
+static int parse_amount(const char *s, int64_t *out)
+{
+ char *end = NULL;
+ double v = strtod(s, &end);
+ if (!end || *end || !isfinite(v))
+ return -1;
+ *out = (int64_t)llround(v * 100.0);
+ return 0;
+}
+
+static int sie_date(const char *s, char *out)
+{
+ if (!s || strlen(s) != 8)
+ return -1;
+ for (int i = 0; i < 8; i++)
+ if (!isdigit((unsigned char)s[i]))
+ return -1;
+ snprintf(out, 16, "%.4s-%.2s-%.2s", s, s + 4, s + 6);
+ return 0;
+}
+
+static const char *type_for_number(const char *number)
+{
+ int n = atoi(number);
+ int cls = n / 1000;
+ if (cls == 1)
+ return "asset";
+ if (cls == 2)
+ return (n >= 2000 && n <= 2099) ? "equity" : "liability";
+ if (cls == 3)
+ return "revenue";
+ if (cls >= 4 && cls <= 8)
+ return "expense";
+ return "asset";
+}
+
+struct import_row {
+ char account[16];
+ int64_t debit, credit;
+ char desc[256];
+};
+
+struct import_ver {
+ char series[16];
+ int64_t number;
+ char date[16];
+ char desc[512];
+ struct import_row *rows;
+ size_t nrows, cap;
+ int line_no;
+};
+
+static int ver_add_row(struct import_ver *v, const char *account, int64_t debit,
+ int64_t credit, const char *desc)
+{
+ if (v->nrows == v->cap) {
+ v->cap = v->cap ? v->cap * 2 : 8;
+ v->rows = xrealloc(v->rows, v->cap * sizeof *v->rows);
+ }
+ struct import_row *r = &v->rows[v->nrows++];
+ snprintf(r->account, sizeof r->account, "%s", account);
+ r->debit = debit;
+ r->credit = credit;
+ snprintf(r->desc, sizeof r->desc, "%s", desc ? desc : "");
+ return 0;
+}
+
+static int account_id(sqlite3 *db, int64_t org_id, const char *number,
+ int64_t *out)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db, "SELECT id FROM accounts WHERE org_id=?1 AND number=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT);
+ int found = 0;
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ *out = sqlite3_column_int64(st, 0);
+ found = 1;
+ }
+ sqlite3_finalize(st);
+ return found ? 0 : -1;
+}
+
+static int ensure_account(sqlite3 *db, int64_t org_id, const char *number,
+ const char *name, int64_t *out, int *created)
+{
+ if (account_id(db, org_id, number, out) == 0)
+ return 0;
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "INSERT INTO accounts(org_id,number,name,type,created_at)"
+ " VALUES(?1,?2,?3,?4,?5)",
+ -1, &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, number, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, name && *name ? name : number, -1,
+ SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, type_for_number(number), -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return -1;
+ *out = sqlite3_last_insert_rowid(db);
+ if (created)
+ (*created)++;
+ return 0;
+}
+
+static int insert_import_voucher(sqlite3 *db, int64_t org_id, int64_t user_id,
+ int64_t fy_id, const char *fy_label,
+ struct import_ver *v, char **err)
+{
+ int64_t sum_d = 0, sum_c = 0;
+ for (size_t i = 0; i < v->nrows; i++) {
+ sum_d += v->rows[i].debit;
+ sum_c += v->rows[i].credit;
+ }
+ if (v->nrows == 0)
+ return 0;
+ if (sum_d != sum_c) {
+ set_err(err, "SIE line %d: voucher %s %lld does not balance",
+ v->line_no, v->series, (long long)v->number);
+ return -1;
+ }
+ if (!util_parse_iso_date(v->date)) {
+ set_err(err, "SIE line %d: bad voucher date %s", v->line_no, v->date);
+ return -1;
+ }
+ if (!v->desc[0])
+ snprintf(v->desc, sizeof v->desc, "Imported voucher %s %lld",
+ v->series, (long long)v->number);
+
+ unsigned char prev[32];
+ memset(prev, 0, sizeof prev);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db, "SELECT hash FROM vouchers WHERE org_id=?1 ORDER BY id DESC LIMIT 1",
+ -1, &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_int64(st, 1, org_id);
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ const void *b = sqlite3_column_blob(st, 0);
+ if (b && sqlite3_column_bytes(st, 0) == 32)
+ memcpy(prev, b, 32);
+ }
+ sqlite3_finalize(st);
+
+ struct ledger_row *lrows = xcalloc(v->nrows, sizeof *lrows);
+ for (size_t i = 0; i < v->nrows; i++) {
+ lrows[i].account = v->rows[i].account;
+ lrows[i].debit_ore = v->rows[i].debit;
+ lrows[i].credit_ore = v->rows[i].credit;
+ lrows[i].description = v->rows[i].desc[0] ? v->rows[i].desc : NULL;
+ }
+ unsigned char hash[32];
+ ledger_voucher_hash(prev, org_id, fy_label, v->series, v->number, v->date,
+ v->desc, lrows, v->nrows, hash);
+ free(lrows);
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (sqlite3_prepare_v2(
+ db,
+ "INSERT INTO vouchers(org_id,fiscal_year_id,series,number,date,"
+ "description,source,created_at,created_by_user,hash_prev,hash)"
+ " VALUES(?1,?2,?3,?4,?5,?6,'sie_import',?7,?8,?9,?10)",
+ -1, &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ sqlite3_bind_text(st, 3, v->series, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, v->number);
+ sqlite3_bind_text(st, 5, v->date, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 6, v->desc, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 8, user_id);
+ sqlite3_bind_blob(st, 9, prev, 32, SQLITE_TRANSIENT);
+ sqlite3_bind_blob(st, 10, hash, 32, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ set_err(err, "SIE line %d: %s", v->line_no, sqlite3_errmsg(db));
+ return -1;
+ }
+ int64_t vid = sqlite3_last_insert_rowid(db);
+
+ for (size_t i = 0; i < v->nrows; i++) {
+ int64_t aid = 0;
+ if (account_id(db, org_id, v->rows[i].account, &aid) != 0) {
+ set_err(err, "SIE line %d: account %s does not exist", v->line_no,
+ v->rows[i].account);
+ return -1;
+ }
+ if (sqlite3_prepare_v2(
+ db,
+ "INSERT INTO voucher_rows(org_id,voucher_id,line_no,account_id,"
+ "debit_ore,credit_ore,description) VALUES(?1,?2,?3,?4,?5,?6,?7)",
+ -1, &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, vid);
+ sqlite3_bind_int64(st, 3, (int64_t)i + 1);
+ sqlite3_bind_int64(st, 4, aid);
+ sqlite3_bind_int64(st, 5, v->rows[i].debit);
+ sqlite3_bind_int64(st, 6, v->rows[i].credit);
+ if (v->rows[i].desc[0])
+ sqlite3_bind_text(st, 7, v->rows[i].desc, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 7);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ set_err(err, "SIE line %d: %s", v->line_no, sqlite3_errmsg(db));
+ return -1;
+ }
+ }
+ return 0;
+}
+
+static int update_sequence(sqlite3 *db, int64_t org_id, int64_t fy_id,
+ const char *series, int64_t number)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "INSERT INTO sequences(org_id,fiscal_year_id,series,next_number)"
+ " VALUES(?1,?2,?3,?4)"
+ " ON CONFLICT(org_id,fiscal_year_id,series) DO UPDATE SET"
+ " next_number=MAX(next_number, excluded.next_number)",
+ -1, &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, number + 1);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ return rc == SQLITE_DONE ? 0 : -1;
+}
+
+int sie_import_content(sqlite3 *db, int64_t org_id, int64_t user_id,
+ const unsigned char *data, size_t len, int dry_run,
+ int64_t *out_fy_id, int64_t *out_vouchers,
+ int64_t *out_accounts, char **err)
+{
+ char *text = util_cp437_to_utf8(data, len);
+ char *save = NULL;
+ char *line = strtok_r(text, "\n", &save);
+ int line_no = 0;
+
+ char fy_start[16] = "", fy_end[16] = "";
+ struct import_ver ver;
+ memset(&ver, 0, sizeof ver);
+ int in_ver = 0;
+ int has_ib_ver = 0;
+ struct import_row *ib_rows = NULL;
+ size_t ib_n = 0, ib_cap = 0;
+ char **konto_numbers = NULL, **konto_names = NULL;
+ size_t konto_n = 0, konto_cap = 0;
+ int64_t max_number_by_series_seen = 0;
+ char last_series[16] = "";
+ int64_t vouchers = 0;
+ int rc = -1;
+ char *local_err = NULL;
+
+#define FAIL(...) \
+ do { \
+ set_err(&local_err, __VA_ARGS__); \
+ goto out; \
+ } while (0)
+
+ while (line) {
+ line_no++;
+ if (line[0] == '\0') {
+ line = strtok_r(NULL, "\n", &save);
+ continue;
+ }
+ char *lbuf = line;
+ char *toks[MAX_TOK];
+ int n = 0;
+ if (tokenize(lbuf, toks, MAX_TOK, &n) != 0)
+ FAIL("SIE line %d: too many tokens", line_no);
+ if (n == 0) {
+ line = strtok_r(NULL, "\n", &save);
+ continue;
+ }
+ if (toks[0][0] != '#') {
+ if (in_ver && strcmp(toks[0], "}") == 0)
+ in_ver = 0;
+ line = strtok_r(NULL, "\n", &save);
+ continue;
+ }
+ if (strcmp(toks[0], "#RAR") == 0 && n >= 4) {
+ if (sie_date(toks[2], fy_start) != 0 ||
+ sie_date(toks[3], fy_end) != 0)
+ FAIL("SIE line %d: bad #RAR dates", line_no);
+ } else if (strcmp(toks[0], "#KONTO") == 0 && n >= 2) {
+ const char *name = n >= 3 ? toks[2] : "";
+ if (konto_n == konto_cap) {
+ konto_cap = konto_cap ? konto_cap * 2 : 32;
+ konto_numbers = xrealloc(konto_numbers,
+ konto_cap * sizeof(char *));
+ konto_names =
+ xrealloc(konto_names, konto_cap * sizeof(char *));
+ }
+ konto_numbers[konto_n] = xstrdup(toks[1]);
+ konto_names[konto_n] = xstrdup(name);
+ konto_n++;
+ } else if (strcmp(toks[0], "#IB") == 0 && n >= 4) {
+ if (strcmp(toks[1], "0") == 0 && strcmp(toks[2], "0") != 0) {
+ int64_t amt = 0;
+ if (parse_amount(toks[3], &amt) != 0)
+ FAIL("SIE line %d: bad #IB amount", line_no);
+ if (amt != 0) {
+ if (ib_n == ib_cap) {
+ ib_cap = ib_cap ? ib_cap * 2 : 16;
+ ib_rows =
+ xrealloc(ib_rows, ib_cap * sizeof *ib_rows);
+ }
+ struct import_row *r = &ib_rows[ib_n++];
+ memset(r, 0, sizeof *r);
+ snprintf(r->account, sizeof r->account, "%s", toks[2]);
+ if (amt > 0)
+ r->debit = amt;
+ else
+ r->credit = -amt;
+ }
+ }
+ } else if (strcmp(toks[0], "#VER") == 0 && n >= 4) {
+ if (in_ver)
+ FAIL("SIE line %d: #VER without closing }", line_no);
+ memset(&ver, 0, sizeof ver); /* previous rows already freed */
+ snprintf(ver.series, sizeof ver.series, "%s", toks[1]);
+ if (strcmp(ver.series, "IB") == 0)
+ has_ib_ver = 1;
+ ver.number = strtoll(toks[2], NULL, 10);
+ ver.line_no = line_no;
+ if (sie_date(toks[3], ver.date) != 0)
+ FAIL("SIE line %d: bad #VER date", line_no);
+ if (n >= 5)
+ snprintf(ver.desc, sizeof ver.desc, "%s", toks[4]);
+ in_ver = 1;
+ } else if (strcmp(toks[0], "#TRANS") == 0 && n >= 3) {
+ /* rows are collected in the second pass */
+ }
+ line = strtok_r(NULL, "\n", &save);
+ }
+ if (in_ver)
+ FAIL("SIE: unterminated #VER block");
+
+ if (!fy_start[0]) {
+ FAIL("SIE file has no #RAR; cannot determine fiscal year");
+ }
+
+ if (sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK)
+ FAIL("database busy");
+
+ int64_t fy_id = 0;
+ char fy_label[64] = "";
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT id,label FROM fiscal_years WHERE org_id=?1 AND"
+ " start_date=?2 AND end_date=?3",
+ -1, &st, NULL) != SQLITE_OK)
+ FAIL("database error");
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, fy_start, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, fy_end, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ fy_id = sqlite3_column_int64(st, 0);
+ snprintf(fy_label, sizeof fy_label, "%s", sqlite3_column_text(st, 1));
+ }
+ sqlite3_finalize(st);
+ if (!fy_id) {
+ int sy, sm, sd, ey, em, ed2;
+ util_date_parse(fy_start, &sy, &sm, &sd);
+ util_date_parse(fy_end, &ey, &em, &ed2);
+ if (sm == 1 && sd == 1)
+ snprintf(fy_label, sizeof fy_label, "%d", ey);
+ else
+ snprintf(fy_label, sizeof fy_label, "%d/%d", sy, ey);
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (sqlite3_prepare_v2(
+ db,
+ "INSERT INTO fiscal_years(org_id,label,start_date,end_date,"
+ "created_at) VALUES(?1,?2,?3,?4,?5)",
+ -1, &st, NULL) != SQLITE_OK)
+ FAIL("database error");
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_text(st, 2, fy_label, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, fy_start, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, fy_end, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) != SQLITE_DONE)
+ FAIL("could not create fiscal year");
+ sqlite3_finalize(st);
+ fy_id = sqlite3_last_insert_rowid(db);
+ if (!fy_label[0])
+ snprintf(fy_label, sizeof fy_label, "%s", fy_start);
+ }
+
+ int64_t existing = 0;
+ if (sqlite3_prepare_v2(
+ db, "SELECT count(*) FROM vouchers WHERE org_id=?1 AND fiscal_year_id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ FAIL("database error");
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ if (sqlite3_step(st) == SQLITE_ROW)
+ existing = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ if (existing > 0)
+ FAIL("target fiscal year already has %lld vouchers; import refused",
+ (long long)existing);
+
+ int created_accounts = 0;
+ for (size_t i = 0; i < konto_n; i++) {
+ int64_t aid = 0;
+ if (ensure_account(db, org_id, konto_numbers[i], konto_names[i], &aid,
+ &created_accounts) != 0)
+ FAIL("could not create account %s", konto_numbers[i]);
+ }
+ for (size_t i = 0; i < ib_n; i++) {
+ int64_t aid = 0;
+ if (ensure_account(db, org_id, ib_rows[i].account, NULL, &aid,
+ &created_accounts) != 0)
+ FAIL("could not create account %s", ib_rows[i].account);
+ }
+
+ if (ib_n > 0 && !has_ib_ver) {
+ struct import_ver ibver;
+ memset(&ibver, 0, sizeof ibver);
+ snprintf(ibver.series, sizeof ibver.series, "IB");
+ ibver.number = 1;
+ snprintf(ibver.date, sizeof ibver.date, "%s", fy_start);
+ snprintf(ibver.desc, sizeof ibver.desc, "IngÄende balanser (SIE)");
+ for (size_t i = 0; i < ib_n; i++) {
+ int64_t aid = 0;
+ (void)aid;
+ ver_add_row(&ibver, ib_rows[i].account, ib_rows[i].debit,
+ ib_rows[i].credit, NULL);
+ }
+ if (insert_import_voucher(db, org_id, user_id, fy_id, fy_label, &ibver,
+ &local_err) != 0)
+ goto out;
+ update_sequence(db, org_id, fy_id, "IB", 1);
+ free(ibver.rows);
+ vouchers++;
+ }
+
+ /* re-walk the text to post vouchers (rows were kept only per block) */
+ {
+ char *text2 = util_cp437_to_utf8(data, len);
+ char *save2 = NULL;
+ char *l2 = strtok_r(text2, "\n", &save2);
+ int recno = 0;
+ memset(&ver, 0, sizeof ver);
+ in_ver = 0;
+ while (l2) {
+ recno++;
+ if (l2[0] && l2[0] != ' ' && l2[0] != '\t') {
+ char *t2[MAX_TOK];
+ int m = 0;
+ if (tokenize(l2, t2, MAX_TOK, &m) == 0 && m > 0 &&
+ strcmp(t2[0], "#VER") == 0 && m >= 4) {
+ if (in_ver) {
+ if (insert_import_voucher(db, org_id, user_id, fy_id,
+ fy_label, &ver,
+ &local_err) != 0) {
+ free(text2);
+ goto out;
+ }
+ if (strlen(ver.series) > 0) {
+ snprintf(last_series, sizeof last_series, "%s",
+ ver.series);
+ max_number_by_series_seen = ver.number;
+ }
+ update_sequence(db, org_id, fy_id, ver.series,
+ ver.number);
+ vouchers++;
+ free(ver.rows);
+ memset(&ver, 0, sizeof ver);
+ }
+ snprintf(ver.series, sizeof ver.series, "%s", t2[1]);
+ ver.number = strtoll(t2[2], NULL, 10);
+ ver.line_no = recno;
+ sie_date(t2[3], ver.date);
+ if (m >= 5)
+ snprintf(ver.desc, sizeof ver.desc, "%s", t2[4]);
+ in_ver = 1;
+ } else if (m > 0 && strcmp(t2[0], "#TRANS") == 0 && in_ver &&
+ m >= 3) {
+ int64_t amt = 0;
+ for (int i = 2; i < m; i++) {
+ if (parse_amount(t2[i], &amt) == 0)
+ break;
+ }
+ ver_add_row(&ver, t2[1], amt > 0 ? amt : 0,
+ amt < 0 ? -amt : 0, "");
+ }
+ }
+ l2 = strtok_r(NULL, "\n", &save2);
+ }
+ if (in_ver) {
+ if (insert_import_voucher(db, org_id, user_id, fy_id, fy_label,
+ &ver, &local_err) != 0) {
+ free(text2);
+ goto out;
+ }
+ update_sequence(db, org_id, fy_id, ver.series, ver.number);
+ vouchers++;
+ free(ver.rows);
+ memset(&ver, 0, sizeof ver);
+ }
+ (void)last_series;
+ (void)max_number_by_series_seen;
+ free(text2);
+ }
+
+ if (dry_run) {
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ } else if (sqlite3_exec(db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
+ FAIL("commit failed");
+ }
+ if (out_fy_id)
+ *out_fy_id = fy_id;
+ if (out_vouchers)
+ *out_vouchers = vouchers;
+ if (out_accounts)
+ *out_accounts = created_accounts;
+ rc = 0;
+
+out:
+ if (rc != 0) {
+ sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+ if (err && local_err)
+ *err = local_err;
+ else
+ free(local_err);
+ } else {
+ free(local_err);
+ }
+ for (size_t i = 0; i < ib_n; i++)
+ (void)0;
+ free(ib_rows);
+ for (size_t i = 0; i < konto_n; i++) {
+ free(konto_numbers[i]);
+ free(konto_names[i]);
+ }
+ free(konto_numbers);
+ free(konto_names);
+ free(ver.rows);
+ free(text);
+ return rc;
+#undef FAIL
+}
diff --git a/src/sie.h b/src/sie.h
new file mode 100644
index 0000000..16a9cb5
--- /dev/null
+++ b/src/sie.h
@@ -0,0 +1,18 @@
+#ifndef BOKF_SIE_H
+#define BOKF_SIE_H
+
+#include <sqlite3.h>
+#include <stddef.h>
+#include <stdint.h>
+
+int sie_export_file(sqlite3 *db, int64_t org_id, int64_t fy_id,
+ const char *path, char **err);
+
+/* Imports SIE 4 content (CP437 or ASCII bytes). On dry_run everything is
+ executed and rolled back. */
+int sie_import_content(sqlite3 *db, int64_t org_id, int64_t user_id,
+ const unsigned char *data, size_t len, int dry_run,
+ int64_t *out_fy_id, int64_t *out_vouchers,
+ int64_t *out_accounts, char **err);
+
+#endif
diff --git a/src/util.c b/src/util.c
new file mode 100644
index 0000000..9543875
--- /dev/null
+++ b/src/util.c
@@ -0,0 +1,564 @@
+#include "util.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/random.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "sha256.h"
+
+void *xmalloc(size_t n)
+{
+ void *p = malloc(n ? n : 1);
+ if (!p) {
+ perror("malloc");
+ exit(1);
+ }
+ return p;
+}
+
+void *xcalloc(size_t n, size_t sz)
+{
+ void *p = calloc(n ? n : 1, sz ? sz : 1);
+ if (!p) {
+ perror("calloc");
+ exit(1);
+ }
+ return p;
+}
+
+void *xrealloc(void *p, size_t n)
+{
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ perror("realloc");
+ exit(1);
+ }
+ return q;
+}
+
+char *xstrdup(const char *s)
+{
+ char *p = strdup(s);
+ if (!p) {
+ perror("strdup");
+ exit(1);
+ }
+ return p;
+}
+
+void buf_init(struct buf *b)
+{
+ b->p = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+void buf_free(struct buf *b)
+{
+ free(b->p);
+ b->p = NULL;
+ b->len = b->cap = 0;
+}
+
+static void buf_reserve(struct buf *b, size_t extra)
+{
+ if (b->len + extra <= b->cap)
+ return;
+ size_t nc = b->cap ? b->cap : 64;
+ while (nc < b->len + extra)
+ nc *= 2;
+ b->p = xrealloc(b->p, nc);
+ b->cap = nc;
+}
+
+void buf_append(struct buf *b, const void *data, size_t n)
+{
+ if (!n)
+ return;
+ buf_reserve(b, n);
+ memcpy(b->p + b->len, data, n);
+ b->len += n;
+}
+
+void buf_append_u16be(struct buf *b, uint16_t v)
+{
+ unsigned char t[2] = { (unsigned char)(v >> 8), (unsigned char)v };
+ buf_append(b, t, sizeof t);
+}
+
+void buf_append_u32be(struct buf *b, uint32_t v)
+{
+ unsigned char t[4] = { (unsigned char)(v >> 24), (unsigned char)(v >> 16),
+ (unsigned char)(v >> 8), (unsigned char)v };
+ buf_append(b, t, sizeof t);
+}
+
+void buf_append_u64be(struct buf *b, uint64_t v)
+{
+ unsigned char t[8];
+ for (int i = 0; i < 8; i++)
+ t[i] = (unsigned char)(v >> (56 - 8 * i));
+ buf_append(b, t, sizeof t);
+}
+
+void util_sha256(const void *data, size_t n, unsigned char out[32])
+{
+ SHA256_CTX ctx;
+ sha256_init(&ctx);
+ sha256_update(&ctx, (const BYTE *)data, n);
+ sha256_final(&ctx, out);
+}
+
+void util_hex(const unsigned char *in, size_t n, char *out)
+{
+ static const char hex[] = "0123456789abcdef";
+ for (size_t i = 0; i < n; i++) {
+ out[2 * i] = hex[in[i] >> 4];
+ out[2 * i + 1] = hex[in[i] & 15];
+ }
+ out[2 * n] = '\0';
+}
+
+static const char B64URL[] =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+char *util_b64url(const unsigned char *in, size_t n)
+{
+ size_t olen = ((n + 2) / 3) * 4 + 1;
+ char *out = xmalloc(olen);
+ size_t i = 0, o = 0;
+ while (i + 3 <= n) {
+ uint32_t v = ((uint32_t)in[i] << 16) | ((uint32_t)in[i + 1] << 8) |
+ (uint32_t)in[i + 2];
+ out[o++] = B64URL[(v >> 18) & 63];
+ out[o++] = B64URL[(v >> 12) & 63];
+ out[o++] = B64URL[(v >> 6) & 63];
+ out[o++] = B64URL[v & 63];
+ i += 3;
+ }
+ if (i + 1 == n) {
+ uint32_t v = (uint32_t)in[i] << 16;
+ out[o++] = B64URL[(v >> 18) & 63];
+ out[o++] = B64URL[(v >> 12) & 63];
+ } else if (i + 2 == n) {
+ uint32_t v = ((uint32_t)in[i] << 16) | ((uint32_t)in[i + 1] << 8);
+ out[o++] = B64URL[(v >> 18) & 63];
+ out[o++] = B64URL[(v >> 12) & 63];
+ out[o++] = B64URL[(v >> 6) & 63];
+ }
+ out[o] = '\0';
+ return out;
+}
+
+char *util_b64(const unsigned char *in, size_t n)
+{
+ static const char B64[] =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ size_t olen = ((n + 2) / 3) * 4 + 1;
+ char *out = xmalloc(olen);
+ size_t i = 0, o = 0;
+ while (i + 3 <= n) {
+ uint32_t v = ((uint32_t)in[i] << 16) | ((uint32_t)in[i + 1] << 8) |
+ (uint32_t)in[i + 2];
+ out[o++] = B64[(v >> 18) & 63];
+ out[o++] = B64[(v >> 12) & 63];
+ out[o++] = B64[(v >> 6) & 63];
+ out[o++] = B64[v & 63];
+ i += 3;
+ }
+ if (i + 1 == n) {
+ uint32_t v = (uint32_t)in[i] << 16;
+ out[o++] = B64[(v >> 18) & 63];
+ out[o++] = B64[(v >> 12) & 63];
+ out[o++] = '=';
+ out[o++] = '=';
+ } else if (i + 2 == n) {
+ uint32_t v = ((uint32_t)in[i] << 16) | ((uint32_t)in[i + 1] << 8);
+ out[o++] = B64[(v >> 18) & 63];
+ out[o++] = B64[(v >> 12) & 63];
+ out[o++] = B64[(v >> 6) & 63];
+ out[o++] = '=';
+ }
+ out[o] = '\0';
+ return out;
+}
+
+int util_random(void *out, size_t n)
+{
+ unsigned char *p = out;
+ size_t got = 0;
+ while (got < n) {
+ ssize_t r = getrandom(p + got, n - got, 0);
+ if (r < 0) {
+ if (errno == EINTR)
+ continue;
+ break;
+ }
+ got += (size_t)r;
+ }
+ if (got == n)
+ return 0;
+ int fd = open("/dev/urandom", O_RDONLY);
+ if (fd < 0)
+ return -1;
+ got = 0;
+ while (got < n) {
+ ssize_t r = read(fd, p + got, n - got);
+ if (r <= 0) {
+ if (r < 0 && errno == EINTR)
+ continue;
+ close(fd);
+ return -1;
+ }
+ got += (size_t)r;
+ }
+ close(fd);
+ return 0;
+}
+
+char *util_random_id(const char *prefix, size_t nbytes)
+{
+ unsigned char raw[64];
+ if (nbytes > sizeof raw)
+ nbytes = sizeof raw;
+ if (util_random(raw, nbytes) != 0) {
+ fprintf(stderr, "fatal: no entropy source\n");
+ exit(1);
+ }
+ char *b = util_b64url(raw, nbytes);
+ size_t plen = prefix ? strlen(prefix) : 0;
+ char *out = xmalloc(plen + strlen(b) + 1);
+ memcpy(out, prefix ? prefix : "", plen);
+ strcpy(out + plen, b);
+ free(b);
+ return out;
+}
+
+int util_const_eq(const void *a, const void *b, size_t n)
+{
+ const volatile unsigned char *x = a;
+ const volatile unsigned char *y = b;
+ unsigned char d = 0;
+ for (size_t i = 0; i < n; i++)
+ d |= (unsigned char)(x[i] ^ y[i]);
+ return d == 0;
+}
+
+int64_t util_now(void)
+{
+ return (int64_t)time(NULL);
+}
+
+void util_iso8601(int64_t t, char *buf, size_t n)
+{
+ time_t tt = (time_t)t;
+ struct tm tm;
+ gmtime_r(&tt, &tm);
+ strftime(buf, n, "%Y-%m-%dT%H:%M:%SZ", &tm);
+}
+
+char *util_str_trim(char *s)
+{
+ while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n')
+ s++;
+ size_t len = strlen(s);
+ while (len > 0) {
+ char c = s[len - 1];
+ if (c != ' ' && c != '\t' && c != '\r' && c != '\n')
+ break;
+ s[--len] = '\0';
+ }
+ return s;
+}
+
+static int is_leap(int y)
+{
+ return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
+}
+
+int util_parse_iso_date(const char *s)
+{
+ if (!s || strlen(s) != 10)
+ return 0;
+ if (s[4] != '-' || s[7] != '-')
+ return 0;
+ static const int mdays[] = { 31, 28, 31, 30, 31, 30,
+ 31, 31, 30, 31, 30, 31 };
+ int y = 0, m = 0, d = 0;
+ for (int i = 0; i < 10; i++) {
+ if (i == 4 || i == 7)
+ continue;
+ if (s[i] < '0' || s[i] > '9')
+ return 0;
+ }
+ y = (s[0] - '0') * 1000 + (s[1] - '0') * 100 + (s[2] - '0') * 10 +
+ (s[3] - '0');
+ m = (s[5] - '0') * 10 + (s[6] - '0');
+ d = (s[8] - '0') * 10 + (s[9] - '0');
+ if (y < 1900 || y > 2200 || m < 1 || m > 12 || d < 1)
+ return 0;
+ int max = mdays[m - 1] + (m == 2 && is_leap(y) ? 1 : 0);
+ return d <= max;
+}
+
+/* ------------------------------------------------------------------ */
+/* base64 */
+/* ------------------------------------------------------------------ */
+
+static int b64_val(char c)
+{
+ if (c >= 'A' && c <= 'Z')
+ return c - 'A';
+ if (c >= 'a' && c <= 'z')
+ return c - 'a' + 26;
+ if (c >= '0' && c <= '9')
+ return c - '0' + 52;
+ if (c == '+')
+ return 62;
+ if (c == '/')
+ return 63;
+ return -1;
+}
+
+int util_b64_decode(const char *in, size_t in_len, unsigned char **out,
+ size_t *out_len)
+{
+ while (in_len > 0 && (in[in_len - 1] == '\n' || in[in_len - 1] == '\r' ||
+ in[in_len - 1] == ' ' || in[in_len - 1] == '\t'))
+ in_len--;
+ size_t i = 0;
+ while (i < in_len && (in[i] == '\n' || in[i] == '\r' || in[i] == ' ' ||
+ in[i] == '\t'))
+ i++;
+ if (i >= in_len)
+ return -1;
+ size_t cap = (in_len - i) / 4 * 3 + 3;
+ unsigned char *buf = xmalloc(cap);
+ size_t o = 0;
+ int pad = 0;
+ for (; i < in_len; i += 4) {
+ if (i + 1 > in_len - 1)
+ break;
+ int v[4];
+ for (int k = 0; k < 4 && i + (size_t)k < in_len; k++) {
+ char c = in[i + (size_t)k];
+ if (c == '=') {
+ pad++;
+ v[k] = 0;
+ } else {
+ v[k] = b64_val(c);
+ if (v[k] < 0) {
+ free(buf);
+ return -1;
+ }
+ if (pad) {
+ free(buf);
+ return -1;
+ }
+ }
+ }
+ if (pad > 2) {
+ free(buf);
+ return -1;
+ }
+ buf[o++] = (unsigned char)((v[0] << 2) | (v[1] >> 4));
+ if (pad < 2)
+ buf[o++] = (unsigned char)(((v[1] & 15) << 4) | (v[2] >> 2));
+ if (pad < 1)
+ buf[o++] = (unsigned char)(((v[2] & 3) << 6) | v[3]);
+ }
+ *out = buf;
+ *out_len = o;
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* dates */
+/* ------------------------------------------------------------------ */
+
+int util_date_parse(const char *s, int *y, int *m, int *d)
+{
+ if (!util_parse_iso_date(s))
+ return -1;
+ *y = (s[0] - '0') * 1000 + (s[1] - '0') * 100 + (s[2] - '0') * 10 +
+ (s[3] - '0');
+ *m = (s[5] - '0') * 10 + (s[6] - '0');
+ *d = (s[8] - '0') * 10 + (s[9] - '0');
+ return 0;
+}
+
+void util_date_fmt(int y, int m, int d, char *buf, size_t n)
+{
+ snprintf(buf, n, "%04d-%02d-%02d", y, m, d);
+}
+
+int util_days_in_month(int y, int m)
+{
+ static const int mdays[] = { 31, 28, 31, 30, 31, 30,
+ 31, 31, 30, 31, 30, 31 };
+ if (m < 1 || m > 12)
+ return 0;
+ return mdays[m - 1] + (m == 2 && is_leap(y) ? 1 : 0);
+}
+
+void util_date_add_months(const char *date, int months, char *out, size_t n)
+{
+ int y, m, d;
+ if (util_date_parse(date, &y, &m, &d) != 0) {
+ snprintf(out, n, "%s", date);
+ return;
+ }
+ int total = y * 12 + (m - 1) + months;
+ int ny = total / 12;
+ int nm = total % 12 + 1;
+ int max = util_days_in_month(ny, nm);
+ if (d > max)
+ d = max;
+ util_date_fmt(ny, nm, d, out, n);
+}
+
+static int64_t days_from_civil(int y, int m, int d)
+{
+ y -= m <= 2;
+ int64_t era = (y >= 0 ? y : y - 399) / 400;
+ unsigned yoe = (unsigned)(y - era * 400);
+ unsigned doy = (unsigned)((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1);
+ unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ return era * 146097 + (int64_t)doe - 719468;
+}
+
+static void civil_from_days(int64_t z, int *y, int *m, int *d)
+{
+ z += 719468;
+ int64_t era = (z >= 0 ? z : z - 146096) / 146097;
+ unsigned doe = (unsigned)(z - era * 146097);
+ unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
+ int yy = (int)(yoe + era * 400);
+ unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ unsigned mp = (5 * doy + 2) / 153;
+ *d = (int)(doy - (153 * mp + 2) / 5 + 1);
+ *m = (int)(mp + (mp < 10 ? 3 : -9));
+ *y = yy + (*m <= 2);
+}
+
+void util_date_add_days(const char *date, int days, char *out, size_t n)
+{
+ int y, m, d;
+ if (util_date_parse(date, &y, &m, &d) != 0) {
+ snprintf(out, n, "%s", date);
+ return;
+ }
+ int64_t z = days_from_civil(y, m, d) + days;
+ civil_from_days(z, &y, &m, &d);
+ util_date_fmt(y, m, d, out, n);
+}
+
+int util_date_valid(const char *s)
+{
+ return util_parse_iso_date(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* CP437 conversion (subset: Latin-1 letters used in SIE files) */
+/* ------------------------------------------------------------------ */
+
+/* Unicode codepoints for CP437 bytes 0x80..0xDF. Bytes 0xE0..0xFF are
+ Greek/math symbols that do not occur in Swedish bookkeeping text and
+ convert to '?'. */
+static const uint16_t CP437_HIGH[0x60] = {
+ 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7,
+ 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5,
+ 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9,
+ 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192,
+ 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA,
+ 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB,
+ 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
+ 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510,
+ 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
+ 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567,
+ 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B,
+ 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580,
+};
+
+static size_t cp437_put_utf8(unsigned char *out, size_t o, uint16_t cp)
+{
+ if (cp < 0x80) {
+ out[o++] = (unsigned char)cp;
+ } else if (cp < 0x800) {
+ out[o++] = (unsigned char)(0xC0 | (cp >> 6));
+ out[o++] = (unsigned char)(0x80 | (cp & 0x3F));
+ } else {
+ out[o++] = (unsigned char)(0xE0 | (cp >> 12));
+ out[o++] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
+ out[o++] = (unsigned char)(0x80 | (cp & 0x3F));
+ }
+ return o;
+}
+
+char *util_cp437_to_utf8(const unsigned char *in, size_t n)
+{
+ char *out = xmalloc(n * 2 + 1);
+ size_t o = 0;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char b = in[i];
+ if (b < 0x80) {
+ out[o++] = (char)b;
+ } else if (b <= 0xDF) {
+ o = cp437_put_utf8((unsigned char *)out, o, CP437_HIGH[b - 0x80]);
+ } else {
+ out[o++] = '?';
+ }
+ }
+ out[o] = '\0';
+ return out;
+}
+
+size_t util_utf8_to_cp437(const char *in, unsigned char *out, size_t out_sz)
+{
+ size_t o = 0;
+ const unsigned char *p = (const unsigned char *)in;
+ while (*p && o < out_sz) {
+ uint32_t cp;
+ size_t len;
+ if (*p < 0x80) {
+ cp = *p;
+ len = 1;
+ } else if ((*p & 0xE0) == 0xC0) {
+ cp = (uint32_t)(*p & 0x1F) << 6;
+ if ((p[1] & 0xC0) != 0x80)
+ break;
+ cp |= p[1] & 0x3F;
+ len = 2;
+ } else if ((*p & 0xF0) == 0xE0) {
+ cp = (uint32_t)(*p & 0x0F) << 12;
+ if ((p[1] & 0xC0) != 0x80 || (p[2] & 0xC0) != 0x80)
+ break;
+ cp |= (uint32_t)(p[1] & 0x3F) << 6;
+ cp |= p[2] & 0x3F;
+ len = 3;
+ } else {
+ p++;
+ out[o++] = '?';
+ continue;
+ }
+ if (cp < 0x80) {
+ out[o++] = (unsigned char)cp;
+ } else {
+ unsigned char best = '?';
+ for (unsigned b = 0x80; b <= 0xDF; b++) {
+ if (CP437_HIGH[b - 0x80] == cp) {
+ best = (unsigned char)b;
+ break;
+ }
+ }
+ out[o++] = best;
+ }
+ p += len;
+ }
+ return o;
+}
diff --git a/src/util.h b/src/util.h
new file mode 100644
index 0000000..d854f76
--- /dev/null
+++ b/src/util.h
@@ -0,0 +1,53 @@
+#ifndef BOKF_UTIL_H
+#define BOKF_UTIL_H
+
+#include <stddef.h>
+#include <stdint.h>
+#include <time.h>
+
+void *xmalloc(size_t n);
+void *xcalloc(size_t n, size_t sz);
+void *xrealloc(void *p, size_t n);
+char *xstrdup(const char *s);
+
+struct buf {
+ unsigned char *p;
+ size_t len, cap;
+};
+
+void buf_init(struct buf *b);
+void buf_free(struct buf *b);
+void buf_append(struct buf *b, const void *data, size_t n);
+void buf_append_u16be(struct buf *b, uint16_t v);
+void buf_append_u32be(struct buf *b, uint32_t v);
+void buf_append_u64be(struct buf *b, uint64_t v);
+
+void util_sha256(const void *data, size_t n, unsigned char out[32]);
+void util_hex(const unsigned char *in, size_t n, char *out);
+char *util_b64url(const unsigned char *in, size_t n);
+char *util_b64(const unsigned char *in, size_t n);
+int util_random(void *out, size_t n);
+char *util_random_id(const char *prefix, size_t nbytes);
+int util_const_eq(const void *a, const void *b, size_t n);
+int64_t util_now(void);
+void util_iso8601(int64_t t, char *buf, size_t n);
+char *util_str_trim(char *s);
+int util_parse_iso_date(const char *s);
+
+/* base64 (standard alphabet, padding required) */
+int util_b64_decode(const char *in, size_t in_len, unsigned char **out,
+ size_t *out_len);
+
+/* date helpers, all dates "YYYY-MM-DD" */
+int util_date_parse(const char *s, int *y, int *m, int *d);
+void util_date_fmt(int y, int m, int d, char *buf, size_t n);
+int util_days_in_month(int y, int m);
+void util_date_add_months(const char *date, int months, char *out, size_t n);
+void util_date_add_days(const char *date, int days, char *out, size_t n);
+int util_date_valid(const char *s);
+
+/* encoding conversion for SIE files (IBM PC8 / CP437) */
+size_t util_utf8_to_cp437(const char *in, unsigned char *out, size_t out_sz);
+char *util_cp437_to_utf8(const unsigned char *in, size_t n);
+
+#endif
diff --git a/src/version.h b/src/version.h
new file mode 100644
index 0000000..bffeb52
--- /dev/null
+++ b/src/version.h
@@ -0,0 +1,10 @@
+#ifndef BOKF_VERSION_H
+#define BOKF_VERSION_H
+
+#ifndef BOKF_VERSION
+#define BOKF_VERSION "0.1.0-dev"
+#endif
+
+#define BOKF_PROTOCOL_VERSION 1
+
+#endif