diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-17 19:55:36 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-17 19:55:36 +0200 |
| commit | 380195f7cd5e57acf2c1cf2bc41069e6b0b979ed (patch) | |
| tree | 32a88fb22a7fbe8f1fd5c105156d1f928c93950d /src | |
| download | bokf-0.1.0.tar.gz bokf-0.1.0.zip | |
Initial commit: daemon, clients, docs, Docker deploy pipelinev0.1.0
Diffstat (limited to 'src')
| -rw-r--r-- | src/audit.c | 214 | ||||
| -rw-r--r-- | src/audit.h | 19 | ||||
| -rw-r--r-- | src/auth.c | 92 | ||||
| -rw-r--r-- | src/auth.h | 19 | ||||
| -rw-r--r-- | src/bokfd.c | 494 | ||||
| -rw-r--r-- | src/commands.c | 3705 | ||||
| -rw-r--r-- | src/commands.h | 33 | ||||
| -rw-r--r-- | src/config.c | 171 | ||||
| -rw-r--r-- | src/config.h | 31 | ||||
| -rw-r--r-- | src/db.c | 519 | ||||
| -rw-r--r-- | src/db.h | 20 | ||||
| -rw-r--r-- | src/formula.c | 243 | ||||
| -rw-r--r-- | src/formula.h | 43 | ||||
| -rw-r--r-- | src/ledger.c | 615 | ||||
| -rw-r--r-- | src/ledger.h | 51 | ||||
| -rw-r--r-- | src/log.c | 45 | ||||
| -rw-r--r-- | src/log.h | 21 | ||||
| -rw-r--r-- | src/protocol.c | 204 | ||||
| -rw-r--r-- | src/protocol.h | 30 | ||||
| -rw-r--r-- | src/reports.c | 434 | ||||
| -rw-r--r-- | src/reports.h | 23 | ||||
| -rw-r--r-- | src/seed.c | 325 | ||||
| -rw-r--r-- | src/seed.h | 12 | ||||
| -rw-r--r-- | src/sessions.c | 86 | ||||
| -rw-r--r-- | src/sessions.h | 28 | ||||
| -rw-r--r-- | src/sie.c | 876 | ||||
| -rw-r--r-- | src/sie.h | 18 | ||||
| -rw-r--r-- | src/util.c | 564 | ||||
| -rw-r--r-- | src/util.h | 53 | ||||
| -rw-r--r-- | src/version.h | 10 |
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 |
