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