#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "client.h" #include "config.h" #include "db.h" #include "protocol.h" #include "sessions.h" #include "util.h" #include "yyjson.h" static sqlite3 *g_db; static char g_session[128]; static int g_failures; #define CHECK(cond) \ do { \ if (!(cond)) { \ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \ #cond); \ g_failures++; \ } \ } while (0) static char *reqf(const char *fmt, ...) __attribute__((format(printf, 1, 2))); static char *reqf(const char *fmt, ...) { static char buf[4096]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof buf, fmt, ap); va_end(ap); return buf; } static yyjson_doc *call(const char *req) { char *resp = protocol_handle_line(g_db, req, strlen(req)); if (!resp) { CHECK(0 && "null response"); return NULL; } yyjson_doc *d = yyjson_read(resp, strlen(resp), 0); free(resp); return d; } static yyjson_val *jget(yyjson_doc *d, const char *path) { if (!d) return NULL; char tmp[128]; snprintf(tmp, sizeof tmp, "%s", path); yyjson_val *v = yyjson_doc_get_root(d); char *save = NULL; for (char *tok = strtok_r(tmp, ".", &save); tok; tok = strtok_r(NULL, ".", &save)) { if (!v) return NULL; if (yyjson_is_obj(v)) { v = yyjson_obj_get(v, tok); } else if (yyjson_is_arr(v)) { char *end = NULL; long idx = strtol(tok, &end, 10); if (!end || *end) return NULL; v = yyjson_arr_get(v, (size_t)idx); } else { return NULL; } } return v; } static const char *jstr(yyjson_doc *d, const char *path) { yyjson_val *v = jget(d, path); return v && yyjson_is_str(v) ? yyjson_get_str(v) : NULL; } static int64_t jint(yyjson_doc *d, const char *path) { yyjson_val *v = jget(d, path); return v && yyjson_is_int(v) ? yyjson_get_int(v) : -1; } static int jbool(yyjson_doc *d, const char *path) { yyjson_val *v = jget(d, path); return v && yyjson_is_bool(v) ? yyjson_get_bool(v) : 0; } static void check_str(yyjson_doc *d, const char *path, const char *expect, int line) { const char *got = jstr(d, path); if (!got || strcmp(got, expect) != 0) { fprintf(stderr, "FAIL %s:%d: %s = %s, expected %s\n", __FILE__, line, path, got ? got : "(null)", expect); g_failures++; } } #define CHECK_STR(doc, path, expect) check_str(doc, path, expect, __LINE__) static void check_ok(yyjson_doc *d, int line) { if (!jbool(d, "ok")) { const char *code = jstr(d, "error.code"); fprintf(stderr, "FAIL %s:%d: not ok (%s)\n", __FILE__, line, code ? code : "no code"); g_failures++; } } #define CHECK_OK(doc) check_ok(doc, __LINE__) static int64_t find_amount(yyjson_doc *d, const char *array_path, const char *key, const char *value, const char *field) { yyjson_val *arr = jget(d, array_path); size_t n = arr && yyjson_is_arr(arr) ? yyjson_arr_size(arr) : 0; for (size_t i = 0; i < n; i++) { yyjson_val *row = yyjson_arr_get(arr, i); yyjson_val *kv = yyjson_obj_get(row, key); if (kv && yyjson_is_str(kv) && strcmp(yyjson_get_str(kv), value) == 0) { yyjson_val *v = yyjson_obj_get(row, field); return v && yyjson_is_int(v) ? yyjson_get_int(v) : -2; } } return -1; } static int64_t vat_box(yyjson_doc *d, const char *box) { return find_amount(d, "result.boxes", "box", box, "amount_ore"); } static yyjson_val *find_arg(yyjson_doc *d, const char *name) { yyjson_val *arr = jget(d, "result.command.args"); size_t n = arr && yyjson_is_arr(arr) ? yyjson_arr_size(arr) : 0; for (size_t i = 0; i < n; i++) { yyjson_val *o = yyjson_arr_get(arr, i); yyjson_val *nv = o ? yyjson_obj_get(o, "name") : NULL; if (nv && yyjson_is_str(nv) && strcmp(yyjson_get_str(nv), name) == 0) return o; } return NULL; } static int arg_field_is(yyjson_val *arg, const char *field, const char *expect) { yyjson_val *v = arg ? yyjson_obj_get(arg, field) : NULL; return v && yyjson_is_str(v) && strcmp(yyjson_get_str(v), expect) == 0; } static int arg_required(yyjson_val *arg) { yyjson_val *v = arg ? yyjson_obj_get(arg, "required") : NULL; return v && yyjson_is_bool(v) && yyjson_get_bool(v); } static yyjson_doc *call_sie_import(const char *session, int64_t org, const char *b64, int dry) { size_t len = strlen(b64) + 1024; char *req = xmalloc(len); snprintf(req, len, "{\"v\":1,\"id\":\"imp\",\"cmd\":\"sie.import\",\"session\":\"%s\"," "\"org\":%lld%s,\"args\":{\"content_base64\":\"%s\"}}", session, (long long)org, dry ? ",\"dry_run\":true" : "", b64); yyjson_doc *d = call(req); free(req); return d; } static int login(const char *user, const char *pass) { yyjson_doc *d = call(reqf( "{\"v\":1,\"id\":\"l\",\"cmd\":\"session.open\",\"args\":" "{\"method\":\"password\",\"username\":\"%s\",\"password\":\"%s\"}}", user, pass)); int ok = jbool(d, "ok"); if (ok) snprintf(g_session, sizeof g_session, "%s", jstr(d, "result.session")); yyjson_doc_free(d); return ok; } static void echo_child(int wfd, int tls) { int sfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); if (sfd < 0) _exit(1); struct sockaddr_in sa; memset(&sa, 0, sizeof sa); sa.sin_family = AF_INET; sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); sa.sin_port = 0; if (bind(sfd, (struct sockaddr *)&sa, sizeof sa) != 0 || listen(sfd, 1) != 0) _exit(1); socklen_t slen = sizeof sa; if (getsockname(sfd, (struct sockaddr *)&sa, &slen) != 0) _exit(1); int port = ntohs(sa.sin_port); if (write(wfd, &port, sizeof port) != (ssize_t)sizeof port) _exit(1); close(wfd); int cfd = accept(sfd, NULL, NULL); if (cfd < 0) _exit(1); char buf[256]; if (tls) { SSL_CTX *ctx = SSL_CTX_new(TLS_server_method()); if (!ctx || SSL_CTX_use_certificate_chain_file(ctx, "tests/tls_test_cert.pem") != 1 || SSL_CTX_use_PrivateKey_file(ctx, "tests/tls_test_key.pem", SSL_FILETYPE_PEM) != 1) _exit(1); SSL *ssl = SSL_new(ctx); SSL_set_fd(ssl, cfd); if (SSL_accept(ssl) != 1) _exit(1); size_t got = 0; while (got < sizeof buf) { int n = SSL_read(ssl, buf + got, (int)(sizeof buf - got)); if (n <= 0) break; got += (size_t)n; if (memchr(buf, '\n', got)) break; } (void)SSL_write(ssl, "tls-ok", 6); SSL_shutdown(ssl); SSL_free(ssl); SSL_CTX_free(ctx); } else { size_t got = 0; while (got < sizeof buf) { ssize_t n = read(cfd, buf + got, sizeof buf - got); if (n <= 0) break; got += (size_t)n; if (memchr(buf, '\n', got)) break; } (void)write(cfd, "tcp-ok", 6); } close(cfd); close(sfd); _exit(0); } /* variant 0: plaintext tcp; 1: TLS trusting the test CA; 2: TLS without the CA, which must fail certificate verification. */ static void test_transport(void) { signal(SIGPIPE, SIG_IGN); for (int variant = 0; variant < 3; variant++) { int tls = variant > 0; int pfd[2]; if (pipe(pfd) != 0) { CHECK(0 && "pipe"); return; } pid_t pid = fork(); if (pid == 0) { close(pfd[0]); echo_child(pfd[1], tls); } close(pfd[1]); int port = 0; if (read(pfd[0], &port, sizeof port) != (ssize_t)sizeof port) port = 0; close(pfd[0]); if (port <= 0) { CHECK(0 && "no test server port"); waitpid(pid, NULL, 0); continue; } if (variant == 1) setenv("BOKFD_TLS_CA", "tests/tls_test_cert.pem", 1); else unsetenv("BOKFD_TLS_CA"); char target[64]; snprintf(target, sizeof target, "%s:localhost:%d", tls ? "tls" : "tcp", port); struct client_conn c; if (variant == 2) { CHECK(client_connect(target, &c) != 0); waitpid(pid, NULL, 0); continue; } CHECK(client_connect(target, &c) == 0); CHECK(client_send_line(&c, "{\"v\":1}") == 0); char *line = client_read_line(&c); CHECK(line && strncmp(line, tls ? "tls-ok" : "tcp-ok", 6) == 0); if (!line) fprintf(stderr, "transport error: %s\n", client_last_error()); free(line); client_close(&c); waitpid(pid, NULL, 0); } } static int count_snapshots(const char *dir) { DIR *d = opendir(dir); if (!d) return -1; int n = 0; struct dirent *e; while ((e = readdir(d)) != NULL) { if (strncmp(e->d_name, "pre-migration-v3-", 17) == 0) n++; } closedir(d); return n; } static char *db_text(sqlite3 *db, const char *sql) { sqlite3_stmt *st = NULL; char *value = NULL; if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW) { const unsigned char *v = sqlite3_column_text(st, 0); if (v) value = xstrdup((const char *)v); } sqlite3_finalize(st); return value; } static int find_snapshot(const char *dir, char *out, size_t n) { DIR *d = opendir(dir); if (!d) return -1; int found = -1; struct dirent *e; while ((e = readdir(d)) != NULL) { if (strncmp(e->d_name, "pre-migration-v3-", 17) != 0) continue; char p[1500]; snprintf(p, sizeof p, "%s/%s", dir, e->d_name); sqlite3 *sdb = NULL; if (sqlite3_open_v2(p, &sdb, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { sqlite3_close(sdb); continue; } char *v = db_text(sdb, "SELECT value FROM meta WHERE key='schema_version'"); if (v && strcmp(v, "3") == 0) { snprintf(out, n, "%s", p); found = 0; } free(v); sqlite3_close(sdb); if (found == 0) break; } closedir(d); return found; } static void test_pre_migration_snapshot(const char *tmpdir, const char *backupdir) { char dbpath[600]; snprintf(dbpath, sizeof dbpath, "%s/migrate.db", tmpdir); char *err = NULL; sqlite3 *db = NULL; CHECK(db_open(dbpath, &db, &err) == 0); free(err); err = NULL; CHECK(count_snapshots(backupdir) == 0); if (!db) return; const char *downgrade[] = { "ALTER TABLE fiscal_years DROP COLUMN dividend_ore", "ALTER TABLE fiscal_years DROP COLUMN events", "ALTER TABLE fiscal_years DROP COLUMN agm_date", "ALTER TABLE fiscal_years DROP COLUMN dividend_date", "ALTER TABLE fiscal_years DROP COLUMN employees", "ALTER TABLE fiscal_years DROP COLUMN notes", "ALTER TABLE orgs DROP COLUMN description", "ALTER TABLE orgs DROP COLUMN shares", "DROP TABLE board_members", "UPDATE meta SET value='3' WHERE key='schema_version'", }; for (size_t i = 0; i < sizeof downgrade / sizeof downgrade[0]; i++) CHECK(db_exec(db, downgrade[i], &err) == 0); free(err); err = NULL; sqlite3_close(db); db = NULL; /* a failed snapshot must abort before the first migration statement */ char blocker[600], baddir[700]; snprintf(blocker, sizeof blocker, "%s/blocker", tmpdir); snprintf(baddir, sizeof baddir, "%s/backup", blocker); FILE *f = fopen(blocker, "wb"); if (f) fclose(f); CHECK(f != NULL); free(g_cfg.backup_dir); g_cfg.backup_dir = xstrdup(baddir); CHECK(db_open(dbpath, &db, &err) != 0); CHECK(db == NULL); CHECK(err && strstr(err, "pre-migration snapshot") != NULL); free(err); err = NULL; free(g_cfg.backup_dir); g_cfg.backup_dir = xstrdup(backupdir); sqlite3 *raw = NULL; CHECK(sqlite3_open_v2(dbpath, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); char *v = db_text(raw, "SELECT value FROM meta WHERE key='schema_version'"); CHECK(v && strcmp(v, "3") == 0); free(v); sqlite3_close(raw); /* a timestamp collision must not clobber the existing file */ time_t t = time(NULL); while (time(NULL) == t) ; t = (time_t)util_now(); struct tm tm; gmtime_r(&t, &tm); char stamp[32]; strftime(stamp, sizeof stamp, "%Y%m%dT%H%M%SZ", &tm); char occupied[1400]; snprintf(occupied, sizeof occupied, "%s/pre-migration-v3-%s.db", backupdir, stamp); f = fopen(occupied, "wb"); if (f) { fputs("occupied", f); fclose(f); } CHECK(f != NULL); CHECK(db_open(dbpath, &db, &err) == 0); if (!db) { fprintf(stderr, "migration db_open: %s\n", err ? err : "?"); free(err); unlink(occupied); unlink(dbpath); return; } CHECK(count_snapshots(backupdir) == 2); time_t t2 = (time_t)util_now(); gmtime_r(&t2, &tm); char stamp2[32]; strftime(stamp2, sizeof stamp2, "%Y%m%dT%H%M%SZ", &tm); if (strcmp(stamp, stamp2) == 0) { char expect[1500]; snprintf(expect, sizeof expect, "%s/pre-migration-v3-%s-1.db", backupdir, stamp); CHECK(access(expect, F_OK) == 0); } char buf[32] = ""; f = fopen(occupied, "rb"); if (f) { size_t got = fread(buf, 1, sizeof buf - 1, f); buf[got] = '\0'; fclose(f); } CHECK(strcmp(buf, "occupied") == 0); /* the snapshot is a valid database holding the pre-migration state */ char snap[1500] = ""; CHECK(find_snapshot(backupdir, snap, sizeof snap) == 0); sqlite3 *sdb = NULL; CHECK(sqlite3_open_v2(snap, &sdb, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); v = db_text(sdb, "SELECT value FROM meta WHERE key='schema_version'"); CHECK(v && strcmp(v, "3") == 0); free(v); v = db_text(sdb, "PRAGMA quick_check"); CHECK(v && strcmp(v, "ok") == 0); free(v); sqlite3_close(sdb); /* the reopened database is at the current version and usable */ char ver[16]; snprintf(ver, sizeof ver, "%d", BOKF_SCHEMA_VERSION); v = db_text(db, "SELECT value FROM meta WHERE key='schema_version'"); CHECK(v && strcmp(v, ver) == 0); free(v); v = db_text(db, "SELECT name FROM sqlite_master WHERE type='trigger'" " AND name='attachments_no_update'"); CHECK(v && strcmp(v, "attachments_no_update") == 0); free(v); v = db_text(db, "SELECT name FROM sqlite_master WHERE type='trigger'" " AND name='attachments_no_delete'"); CHECK(v && strcmp(v, "attachments_no_delete") == 0); free(v); int64_t user_id = 0; CHECK(db_create_user(db, "migrated", "Migrated", "secret123", 0, &user_id, &err) == 0); free(err); err = NULL; CHECK(user_id > 0); sqlite3_close(db); db = NULL; unlink(occupied); if (snap[0]) unlink(snap); unlink(dbpath); snprintf(dbpath, sizeof dbpath, "%s/migrate.db-wal", tmpdir); unlink(dbpath); snprintf(dbpath, sizeof dbpath, "%s/migrate.db-shm", tmpdir); unlink(dbpath); unlink(blocker); } int main(void) { char tmpdir[] = "/tmp/bokf-test-XXXXXX"; if (!mkdtemp(tmpdir)) { perror("mkdtemp"); return 1; } char dbpath[512], backupdir[512]; snprintf(dbpath, sizeof dbpath, "%s/bokfd.db", tmpdir); snprintf(backupdir, sizeof backupdir, "%s/backup", tmpdir); mkdir(backupdir, 0700); config_defaults(); g_cfg.log_level = 0; free(g_cfg.backup_dir); g_cfg.backup_dir = xstrdup(backupdir); free(g_cfg.export_dir); g_cfg.export_dir = xstrdup(tmpdir); free(g_cfg.chart_k2_file); g_cfg.chart_k2_file = xstrdup("data/bas_k2.csv"); free(g_cfg.chart_k3_file); g_cfg.chart_k3_file = xstrdup("data/bas_k3.csv"); char *err = NULL; if (db_open(dbpath, &g_db, &err) != 0) { fprintf(stderr, "db_open: %s\n", err ? err : "?"); return 1; } int64_t admin_id = 0; if (db_create_user(g_db, "admin", "Admin", "secret123", 1, &admin_id, &err) != 0) { fprintf(stderr, "create admin: %s\n", err ? err : "?"); return 1; } sessions_init(3600); yyjson_doc *d; d = call("{\"v\":1,\"id\":\"1\",\"cmd\":\"health\"}"); CHECK_OK(d); CHECK_STR(d, "result.status", "ok"); yyjson_doc_free(d); d = call("{\"v\":1,\"id\":\"2\",\"cmd\":\"meta\"}"); CHECK_OK(d); CHECK(jint(d, "result.protocol") == 1); CHECK_STR(d, "result.server", "bokfd"); yyjson_doc_free(d); d = call("{\"v\":1,\"id\":\"3\",\"cmd\":\"nope\"}"); CHECK(!jbool(d, "ok")); CHECK_STR(d, "error.code", "UNKNOWN_COMMAND"); yyjson_doc_free(d); d = call("{\"v\":9,\"id\":\"4\",\"cmd\":\"health\"}"); CHECK_STR(d, "error.code", "UNSUPPORTED_VERSION"); yyjson_doc_free(d); d = call("not json"); CHECK_STR(d, "error.code", "PARSE_ERROR"); yyjson_doc_free(d); d = call("{\"v\":1,\"id\":\"5\",\"cmd\":\"describe\"}"); CHECK_STR(d, "error.code", "AUTH_REQUIRED"); yyjson_doc_free(d); CHECK(!login("admin", "wrong")); CHECK(!login("nobody", "wrong")); CHECK(login("admin", "secret123")); d = call(reqf("{\"v\":1,\"id\":\"6\",\"cmd\":\"session.whoami\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK_STR(d, "result.user.username", "admin"); CHECK(jbool(d, "result.user.is_admin")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"7\",\"cmd\":\"org.create\"," "\"session\":\"%s\",\"args\":" "{\"name\":\"AB Ett\",\"org_nr\":\"5560123456\"}}", g_session)); CHECK_OK(d); int64_t org_id = jint(d, "result.id"); CHECK(org_id > 0); CHECK_STR(d, "result.name", "AB Ett"); CHECK_STR(d, "result.org_nr", "5560123456"); CHECK(jint(d, "result.fiscal_year_id") > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8\",\"cmd\":\"org.list\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == 1); CHECK_STR(d, "result.items.0.role", "owner"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8b\",\"cmd\":\"org.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"org_nr\":\"559331-2126\",\"vat_nr\":\"SE559331212601\"," "\"address\":\"Lingonvägen 9\",\"postal_code\":\"192 48\"," "\"city\":\"Sollentuna\",\"email\":\"a@b.se\"," "\"phone\":\"+46700000000\"," "\"description\":\"Konsultverksamhet inom it.\"," "\"shares\":25000}}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.vat_nr", "SE559331212601"); CHECK_STR(d, "result.org_nr", "559331-2126"); CHECK_STR(d, "result.description", "Konsultverksamhet inom it."); CHECK(jint(d, "result.shares") == 25000); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8c\",\"cmd\":\"org.get\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.address", "Lingonvägen 9"); CHECK_STR(d, "result.postal_code", "192 48"); CHECK_STR(d, "result.city", "Sollentuna"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8d\",\"cmd\":\"org.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"moms_period\":\"week\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8e\",\"cmd\":\"org.update\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":" "{\"city\":\"Nyköping\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jbool(d, "result.dry_run")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8f\",\"cmd\":\"org.get\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.city", "Sollentuna"); CHECK_STR(d, "result.description", "Konsultverksamhet inom it."); CHECK(jint(d, "result.shares") == 25000); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8g\",\"cmd\":\"board.add\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"name\":\"Anders Bergsten\"}}", g_session, (int)org_id)); CHECK_OK(d); int64_t bm1 = jint(d, "result.id"); CHECK(bm1 > 0); CHECK_STR(d, "result.title", "Styrelseledamot"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8h\",\"cmd\":\"board.add\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"name\":\"Saphira Bergsten\"," "\"title\":\"Styrelseledamot\"}}", g_session, (int)org_id)); CHECK_OK(d); int64_t bm2 = jint(d, "result.id"); CHECK(bm2 > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8i\",\"cmd\":\"board.list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == 2); CHECK_STR(d, "result.items.0.name", "Anders Bergsten"); CHECK_STR(d, "result.items.1.name", "Saphira Bergsten"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8j\",\"cmd\":\"board.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"title\":\"Suppleant\"}}", g_session, (int)org_id, (long long)bm2)); CHECK_OK(d); CHECK_STR(d, "result.title", "Suppleant"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8k\",\"cmd\":\"board.add\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8l\",\"cmd\":\"board.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":999999," "\"name\":\"Ingen\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "NOT_FOUND"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8m\",\"cmd\":\"board.remove\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)org_id, (long long)bm2)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8n\",\"cmd\":\"board.list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == 1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"9\",\"cmd\":\"session.use_org\"," "\"session\":\"%s\",\"args\":{\"org\":%d}}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.role", "owner"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"10\",\"cmd\":\"org.get\"," "\"session\":\"%s\",\"org\":10}", g_session)); CHECK_STR(d, "error.code", "ORG_FORBIDDEN"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"11\",\"cmd\":\"org.get\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.moms_period", "month"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"12\",\"cmd\":\"token.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"label\":\"test\",\"scopes\":[\"read\",\"write\"]}}", g_session, (int)org_id)); CHECK_OK(d); char token_copy[128] = ""; snprintf(token_copy, sizeof token_copy, "%s", jstr(d, "result.token")); CHECK(strncmp(token_copy, "bokf_", 5) == 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"13\",\"cmd\":\"token.list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK(yyjson_arr_size(jget(d, "result.items")) == 1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"14\",\"cmd\":\"token.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"label\":\"ro\",\"scopes\":[\"read\"]}}", g_session, (int)org_id)); CHECK_OK(d); char ro_token[128] = ""; snprintf(ro_token, sizeof ro_token, "%s", jstr(d, "result.token")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"15\",\"cmd\":\"session.open\",\"args\":" "{\"method\":\"token\",\"token\":\"%s\"}}", token_copy)); CHECK_OK(d); CHECK(jint(d, "result.active_org") == org_id); snprintf(g_session, sizeof g_session, "%s", jstr(d, "result.session")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"16\",\"cmd\":\"session.whoami\"," "\"session\":\"%s\"}", g_session)); CHECK_STR(d, "result.scopes", "read,write"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"17\",\"cmd\":\"session.open\",\"args\":" "{\"method\":\"token\",\"token\":\"%s\"}}", ro_token)); CHECK_OK(d); char ro_session[128] = ""; snprintf(ro_session, sizeof ro_session, "%s", jstr(d, "result.session")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"18\",\"cmd\":\"org.member_add\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"username\":\"bob\",\"role\":\"viewer\"}}", ro_session, (int)org_id)); CHECK_STR(d, "error.code", "FORBIDDEN"); yyjson_doc_free(d); CHECK(login("admin", "secret123")); d = call(reqf("{\"v\":1,\"id\":\"19\",\"cmd\":\"user.create\"," "\"session\":\"%s\",\"args\":" "{\"username\":\"bob\",\"password\":\"hunter22\"," "\"display_name\":\"Bob\"}}", g_session)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"20\",\"cmd\":\"user.create\"," "\"session\":\"%s\",\"args\":" "{\"username\":\"bob\",\"password\":\"hunter22\"}}", g_session)); CHECK_STR(d, "error.code", "CONFLICT"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"21\",\"cmd\":\"org.member_add\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"username\":\"bob\",\"role\":\"viewer\"}}", g_session, (int)org_id)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"22\",\"cmd\":\"org.member_list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK(yyjson_arr_size(jget(d, "result.items")) == 2); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"23\",\"cmd\":\"org.member_set_role\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"username\":\"bob\",\"role\":\"bookkeeper\"}}", g_session, (int)org_id)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"24\",\"cmd\":\"org.member_remove\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"username\":\"admin\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "CONFLICT"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"25\",\"cmd\":\"org.member_remove\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"username\":\"bob\"}}", g_session, (int)org_id)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"26\",\"cmd\":\"audit.verify\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(jbool(d, "result.ok")); CHECK(jint(d, "result.checked") > 5); CHECK(jint(d, "result.vouchers_checked") == 0); CHECK(jint(d, "result.unbalanced_vouchers") == 0); CHECK(jint(d, "result.first_bad_voucher_id") == -1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"27\",\"cmd\":\"audit.list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) > 0); yyjson_doc_free(d); err = NULL; CHECK(db_exec(g_db, "UPDATE audit_log SET action='x'", &err) != 0); free(err); err = NULL; CHECK(db_exec(g_db, "DELETE FROM audit_log", &err) != 0); free(err); d = call(reqf("{\"v\":1,\"id\":\"27\",\"cmd\":\"backup.snapshot\"," "\"session\":\"%s\"}", ro_session)); CHECK_STR(d, "error.code", "FORBIDDEN"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"28\",\"cmd\":\"backup.snapshot\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(jint(d, "result.size") > 0); CHECK(jstr(d, "result.sha256") && strlen(jstr(d, "result.sha256")) == 64); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"28b\",\"cmd\":\"token.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"label\":\"backup\",\"scopes\":[\"admin\"]}}", g_session, (int)org_id)); CHECK_OK(d); char backup_token[128] = ""; snprintf(backup_token, sizeof backup_token, "%s", jstr(d, "result.token")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"28c\",\"cmd\":\"session.open\",\"args\":" "{\"method\":\"token\",\"token\":\"%s\"}}", backup_token)); CHECK_OK(d); char backup_session[128] = ""; snprintf(backup_session, sizeof backup_session, "%s", jstr(d, "result.session")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"28d\",\"cmd\":\"backup.snapshot\"," "\"session\":\"%s\",\"args\":{\"dest\":\"%s/snap-token.db\"}}", backup_session, tmpdir)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"29\",\"cmd\":\"describe\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.commands")) > 10); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"30\",\"cmd\":\"describe\"," "\"session\":\"%s\",\"args\":{\"cmd\":\"session.open\"}}", g_session)); CHECK_STR(d, "result.command.name", "session.open"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"30a\",\"cmd\":\"describe\"," "\"session\":\"%s\",\"args\":{\"cmd\":\"voucher.post\"}}", g_session)); CHECK_OK(d); CHECK_STR(d, "result.command.name", "voucher.post"); CHECK(arg_required(find_arg(d, "date"))); CHECK(arg_field_is(find_arg(d, "date"), "type", "date")); CHECK(arg_field_is(find_arg(d, "rows"), "type", "json")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"30b\",\"cmd\":\"describe\"," "\"session\":\"%s\",\"args\":{\"cmd\":\"account.create\"}}", g_session)); CHECK_OK(d); CHECK_STR(d, "result.command.name", "account.create"); CHECK(arg_required(find_arg(d, "number"))); CHECK(arg_field_is(find_arg(d, "number"), "type", "string")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"30f\",\"cmd\":\"describe\"," "\"session\":\"%s\",\"args\":{\"cmd\":\"report.rule_create\"}}", g_session)); CHECK_OK(d); CHECK_STR(d, "result.command.name", "report.rule_create"); CHECK(arg_required(find_arg(d, "report"))); CHECK(arg_field_is(find_arg(d, "match_type"), "type", "enum")); CHECK(!arg_required(find_arg(d, "sign"))); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"30c\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"description\":\"Saknar datum\",\"rows\":[" "{\"account\":\"1930\",\"debit_ore\":100}," "{\"account\":\"3001\",\"credit_ore\":100}]}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"30d\",\"cmd\":\"account.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"number\":1930," "\"name\":\"Fel typ\",\"type\":\"expense\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"30e\",\"cmd\":\"org.member_set_role\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"username\":\"admin\",\"role\":\"king\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"31\",\"cmd\":\"agent.instructions\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(jstr(d, "result.content") && strstr(jstr(d, "result.content"), "dry_run") != NULL); yyjson_doc_free(d); /* ---------------- M2: kontoplan, vouchers, reports, SIE ---------- */ d = call(reqf("{\"v\":1,\"id\":\"40\",\"cmd\":\"account.list\"," "\"session\":\"%s\",\"org\":%d," "\"args\":{\"active_only\":true}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) > 1000); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"41\",\"cmd\":\"account.get\"," "\"session\":\"%s\",\"org\":%d," "\"args\":{\"number\":\"1930\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.type", "asset"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"42\",\"cmd\":\"account.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"number\":\"0099\",\"name\":\"Testkonto\"," "\"type\":\"expense\"}}", g_session, (int)org_id)); CHECK_OK(d); int64_t acc2999 = jint(d, "result.id"); CHECK(acc2999 > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"43\",\"cmd\":\"account.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"number\":\"0099\",\"name\":\"Dup\"," "\"type\":\"expense\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "CONFLICT"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"44\",\"cmd\":\"account.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"id\":%lld,\"name\":\"Testkonto 2\"}}", g_session, (int)org_id, (long long)acc2999)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"45\",\"cmd\":\"account.get\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)org_id, (long long)acc2999)); CHECK_STR(d, "result.name", "Testkonto 2"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46\",\"cmd\":\"fiscal_year.list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == 1); int64_t fy1 = jint(d, "result.items.0.id"); CHECK(fy1 > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46a\",\"cmd\":\"fiscal_year.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"dividend_ore\":123456}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK(jint(d, "result.dividend_ore") == 123456); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46b\",\"cmd\":\"fiscal_year.get\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK(jint(d, "result.dividend_ore") == 123456); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46c\",\"cmd\":\"fiscal_year.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"dividend_ore\":-1}}", g_session, (int)org_id, (long long)fy1)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46d\",\"cmd\":\"fiscal_year.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"events\":\"Inga väsentliga händelser.\"," "\"agm_date\":\"2026-06-04\"," "\"dividend_date\":\"2026-06-05\"," "\"employees\":\"1\",\"notes\":\"Revisor saknas.\"}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK_STR(d, "result.events", "Inga väsentliga händelser."); CHECK_STR(d, "result.agm_date", "2026-06-04"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46e\",\"cmd\":\"fiscal_year.get\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK(jint(d, "result.dividend_ore") == 123456); CHECK_STR(d, "result.events", "Inga väsentliga händelser."); CHECK_STR(d, "result.agm_date", "2026-06-04"); CHECK_STR(d, "result.dividend_date", "2026-06-05"); CHECK_STR(d, "result.employees", "1"); CHECK_STR(d, "result.notes", "Revisor saknas."); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46f\",\"cmd\":\"fiscal_year.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"agm_date\":\"2026-6-4\"}}", g_session, (int)org_id, (long long)fy1)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); /* a new year inherits the stable "Information om året" fields */ { d = call(reqf("{\"v\":1,\"id\":\"46g\",\"cmd\":\"org.create\"," "\"session\":\"%s\",\"args\":{\"name\":\"Årskopia AB\"}}", g_session)); CHECK_OK(d); int64_t ocopy = jint(d, "result.id"); CHECK(ocopy > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46h\",\"cmd\":\"fiscal_year.list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)ocopy)); CHECK_OK(d); int64_t fysrc = jint(d, "result.items.0.id"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46i\",\"cmd\":\"fiscal_year.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"events\":\"E\",\"employees\":\"2\"," "\"notes\":\"N\",\"agm_date\":\"2026-06-04\"," "\"dividend_date\":\"2026-06-05\"}}", g_session, (int)ocopy, (long long)fysrc)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46j\",\"cmd\":\"fiscal_year.open\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"label\":" "\"2027\",\"start_date\":\"2027-01-01\"," "\"end_date\":\"2027-12-31\"}}", g_session, (int)ocopy)); CHECK_OK(d); int64_t fynext = jint(d, "result.id"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"46k\",\"cmd\":\"fiscal_year.get\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)ocopy, (long long)fynext)); CHECK_OK(d); CHECK_STR(d, "result.events", "E"); CHECK_STR(d, "result.employees", "2"); CHECK_STR(d, "result.notes", "N"); CHECK_STR(d, "result.agm_date", ""); CHECK_STR(d, "result.dividend_date", ""); CHECK(jint(d, "result.dividend_ore") == 0); yyjson_doc_free(d); } /* sale: 1250.00 kr incl 25% moms */ d = call(reqf("{\"v\":1,\"id\":\"47\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-01-15\",\"description\":\"Försäljning\",\"rows\":[" "{\"account\":\"1930\",\"debit_ore\":125000}," "{\"account\":\"3001\",\"credit_ore\":100000}," "{\"account\":\"2611\",\"credit_ore\":25000}]}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jint(d, "result.number") == 1); int64_t sale_id = jint(d, "result.id"); CHECK(sale_id > 0); CHECK(jstr(d, "result.hash") && strlen(jstr(d, "result.hash")) == 64); CHECK(yyjson_arr_size(jget(d, "result.rows")) == 3); yyjson_doc_free(d); /* dry run must not persist */ d = call(reqf("{\"v\":1,\"id\":\"48\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," "\"args\":{\"date\":\"2026-01-16\",\"description\":" "\"Torrkörning\",\"rows\":[" "{\"account\":\"1930\",\"debit_ore\":100}," "{\"account\":\"3001\",\"credit_ore\":100}]}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jbool(d, "result.dry_run")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"49\",\"cmd\":\"voucher.list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == 1); yyjson_doc_free(d); /* invalid vouchers */ d = call(reqf("{\"v\":1,\"id\":\"50\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-01-17\",\"description\":\"Obalans\",\"rows\":[" "{\"account\":\"1930\",\"debit_ore\":100}," "{\"account\":\"3001\",\"credit_ore\":90}]}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "UNBALANCED"); CHECK(jint(d, "error.details.difference_ore") == 10); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"51\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-01-17\",\"description\":\"Fel konto\",\"rows\":[" "{\"account\":\"9999\",\"debit_ore\":100}," "{\"account\":\"3001\",\"credit_ore\":100}]}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "ACCOUNT_NOT_FOUND"); yyjson_doc_free(d); /* reports based on the single sale */ d = call(reqf("{\"v\":1,\"id\":\"52\",\"cmd\":\"report.income_statement\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jint(d, "result.result_ore") == 100000); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"53\",\"cmd\":\"report.vat\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"from\":\"2026-01-01\",\"to\":\"2026-01-31\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(vat_box(d, "05") == 100000); CHECK(vat_box(d, "10") == 25000); yyjson_doc_free(d); /* huvudbok: one account block per account with activity or IB */ d = call(reqf("{\"v\":1,\"id\":\"54a\",\"cmd\":\"report.general_ledger\"" ",\"session\":\"%s\",\"org\":%d,\"args\":" "{\"fiscal_year\":%lld}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.accounts")) == 3); CHECK(find_amount(d, "result.accounts", "account", "1930", "debit_ore") == 125000); CHECK(find_amount(d, "result.accounts", "account", "1930", "ub_ore") == 125000); CHECK(find_amount(d, "result.accounts", "account", "3001", "ub_ore") == -100000); CHECK(yyjson_arr_size(jget(d, "result.accounts.0.rows")) == 1); CHECK(jint(d, "result.accounts.0.rows.0.saldo_ore") == 125000); CHECK_STR(d, "result.last_voucher.series", "A"); CHECK(jint(d, "result.last_voucher.number") == 1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"54b\",\"cmd\":\"report.general_ledger\"" ",\"session\":\"%s\",\"org\":%d,\"args\":" "{\"fiscal_year\":%lld,\"accounts\":[\"1930\"]}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.accounts")) == 1); yyjson_doc_free(d); /* verifikationslista: vouchers with their rows and totals */ d = call(reqf("{\"v\":1,\"id\":\"54c\",\"cmd\":\"report.voucher_list\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"fiscal_year\":%lld}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.vouchers")) == 1); CHECK_STR(d, "result.vouchers.0.series", "A"); CHECK(jint(d, "result.vouchers.0.number") == 1); CHECK(yyjson_arr_size(jget(d, "result.vouchers.0.rows")) == 3); CHECK(jint(d, "result.totals.debit_ore") == 125000); CHECK(jint(d, "result.totals.credit_ore") == 125000); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"54d\",\"cmd\":\"report.voucher_list\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"fiscal_year\":%lld,\"series\":\"ZZ\"}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.vouchers")) == 0); yyjson_doc_free(d); /* eSKD: ISO-8859-1 XML for the momsdeklaration */ d = call(reqf("{\"v\":1,\"id\":\"54e\",\"cmd\":\"report.vat_eskd\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"from\":\"2026-01-01\",\"to\":\"2026-01-31\"," "\"upplysning\":\"Blå\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.org_nr", "559331-2126"); CHECK_STR(d, "result.period", "202601"); char *b64 = jstr(d, "result.content_base64"); unsigned char *xmlb = NULL; size_t xmll = 0; CHECK(b64 && util_b64_decode(b64, strlen(b64), &xmlb, &xmll) == 0); char *xml = xmalloc(xmll + 1); memcpy(xml, xmlb, xmll); xml[xmll] = '\0'; free(xmlb); CHECK(strstr(xml, "") != NULL); CHECK(strstr(xml, "559331-2126") != NULL); CHECK(strstr(xml, "202601") != NULL); CHECK(strstr(xml, "1000") != NULL); CHECK(strstr(xml, "250") != NULL); CHECK(strstr(xml, "250") != NULL); CHECK(strstr(xml, "Bl\xe5") != NULL); free(xml); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"54\",\"cmd\":\"report.trial_balance\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(find_amount(d, "result.accounts", "account", "1930", "ub_ore") == 125000); CHECK(jint(d, "result.totals.debit_ore") == jint(d, "result.totals.credit_ore")); yyjson_doc_free(d); /* idempotency */ d = call(reqf("{\"v\":1,\"id\":\"55\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-01-20\",\"description\":\"Faktura 2\"," "\"client_ref\":\"ref-1\",\"rows\":[" "{\"account\":\"1930\",\"debit_ore\":50000}," "{\"account\":\"3001\",\"credit_ore\":50000}]}}", g_session, (int)org_id)); CHECK_OK(d); int64_t v2 = jint(d, "result.id"); CHECK(!jbool(d, "result.replayed")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"56\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-01-20\",\"description\":\"Faktura 2\"," "\"client_ref\":\"ref-1\",\"rows\":[" "{\"account\":\"1930\",\"debit_ore\":50000}," "{\"account\":\"3001\",\"credit_ore\":50000}]}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jbool(d, "result.replayed")); CHECK(jint(d, "result.id") == v2); yyjson_doc_free(d); /* attachments */ d = call(reqf("{\"v\":1,\"id\":\"57\",\"cmd\":\"attachment.put\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"filename\":" "\"kvitto.txt\",\"mime\":\"text/plain\"," "\"content_base64\":\"aGVsbG8gd29ybGQ=\"}}", g_session, (int)org_id)); CHECK_OK(d); int64_t att_id = jint(d, "result.id"); CHECK(att_id > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"58\",\"cmd\":\"attachment.list\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"unlinked\":true}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == 1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"59\",\"cmd\":\"attachment.get\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)org_id, (long long)att_id)); CHECK_OK(d); CHECK_STR(d, "result.content_base64", "aGVsbG8gd29ybGQ="); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"60\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-01-25\",\"description\":\"Lokalhyra\"," "\"attachment_ids\":[%lld],\"rows\":[" "{\"account\":\"5010\",\"debit_ore\":10000}," "{\"account\":\"1930\",\"credit_ore\":10000}]}}", g_session, (int)org_id, (long long)att_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.attachment_ids")) == 1); int64_t rent_id = jint(d, "result.id"); CHECK(rent_id > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"61\",\"cmd\":\"attachment.list\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"unlinked\":true}}", g_session, (int)org_id)); CHECK(yyjson_arr_size(jget(d, "result.items")) == 0); yyjson_doc_free(d); /* the same content may link to several vouchers, but not twice to one */ d = call(reqf("{\"v\":1,\"id\":\"61a\",\"cmd\":\"attachment.put\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"filename\":" "\"kvitto.txt\",\"mime\":\"text/plain\"," "\"content_base64\":\"aGVsbG8gd29ybGQ=\",\"voucher_id\":%lld}}", g_session, (int)org_id, (long long)sale_id)); CHECK_OK(d); CHECK(jint(d, "result.id") == att_id); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"61b\",\"cmd\":\"attachment.put\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"filename\":" "\"kvitto.txt\",\"mime\":\"text/plain\"," "\"content_base64\":\"aGVsbG8gd29ybGQ=\",\"voucher_id\":%lld}}", g_session, (int)org_id, (long long)sale_id)); CHECK_STR(d, "error.code", "CONFLICT"); yyjson_doc_free(d); /* link/unlink an existing attachment */ d = call(reqf("{\"v\":1,\"id\":\"61c\",\"cmd\":\"attachment.put\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"filename\":" "\"extra.txt\",\"content_base64\":\"ZXh0cmE=\"}}", g_session, (int)org_id)); CHECK_OK(d); int64_t orphan = jint(d, "result.id"); CHECK(orphan > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"61d\",\"cmd\":\"attachment.link\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"voucher_id\":%lld}}", g_session, (int)org_id, (long long)orphan, (long long)sale_id)); CHECK_OK(d); CHECK(jint(d, "result.voucher_id") == sale_id); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"61e\",\"cmd\":\"attachment.link\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"voucher_id\":%lld}}", g_session, (int)org_id, (long long)orphan, (long long)sale_id)); CHECK_STR(d, "error.code", "CONFLICT"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"61f\",\"cmd\":\"attachment.unlink\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"voucher_id\":%lld}}", g_session, (int)org_id, (long long)orphan, (long long)sale_id)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"61g\",\"cmd\":\"attachment.unlink\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"voucher_id\":%lld}}", g_session, (int)org_id, (long long)orphan, (long long)sale_id)); CHECK_STR(d, "error.code", "NOT_FOUND"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"61h\",\"cmd\":\"attachment.link\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"voucher_id\":999999}}", g_session, (int)org_id, (long long)orphan)); CHECK_STR(d, "error.code", "NOT_FOUND"); yyjson_doc_free(d); /* listing by voucher returns every linked attachment, with that voucher */ d = call(reqf("{\"v\":1,\"id\":\"61j\",\"cmd\":\"attachment.list\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"voucher_id\":%lld}}", g_session, (int)org_id, (long long)rent_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == 1); CHECK(jint(d, "result.items.0.id") == att_id); CHECK(jint(d, "result.items.0.voucher_id") == rent_id); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"61k\",\"cmd\":\"attachment.list\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"voucher_id\":%lld}}", g_session, (int)org_id, (long long)sale_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == 1); CHECK(jint(d, "result.items.0.id") == att_id); CHECK(jint(d, "result.items.0.voucher_id") == sale_id); yyjson_doc_free(d); /* voucher.list reports the attachment count */ d = call(reqf("{\"v\":1,\"id\":\"61i\",\"cmd\":\"voucher.list\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"limit\":5}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jint(d, "result.items.0.attachment_count") >= 1); CHECK(jint(d, "result.items.1.attachment_count") == 0); CHECK(jint(d, "result.items.2.attachment_count") >= 1); yyjson_doc_free(d); /* correction */ d = call(reqf("{\"v\":1,\"id\":\"62\",\"cmd\":\"voucher.correct\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"voucher\":%lld," "\"description\":\"Rättelse felaktig försäljning\"," "\"client_ref\":\"ref-3\"}}", g_session, (int)org_id, (long long)sale_id)); CHECK_OK(d); CHECK(jint(d, "result.corrects_voucher_id") == sale_id); int64_t corr_id = jint(d, "result.id"); CHECK(corr_id > sale_id); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"63\",\"cmd\":\"voucher.get\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)org_id, (long long)sale_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.rows")) == 3); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"64\",\"cmd\":\"report.vat\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"from\":\"2026-01-01\",\"to\":\"2026-01-31\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(vat_box(d, "05") == 50000); CHECK(vat_box(d, "10") == 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"65\",\"cmd\":\"report.income_statement\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jint(d, "result.result_ore") == 40000); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"66\",\"cmd\":\"report.balance_sheet\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(find_amount(d, "result.assets.accounts", "account", "1930", "amount_ore") == 40000); CHECK(jint(d, "result.assets.total_ore") == jint(d, "result.equity.total_ore")); yyjson_doc_free(d); /* attachment size limit is enforced by the handler */ { char big[600]; memset(big, 'A', 500); big[500] = '\0'; g_cfg.max_attachment_bytes = 100; d = call(reqf("{\"v\":1,\"id\":\"76\",\"cmd\":\"attachment.put\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"filename\":\"big.bin\",\"mime\":\"application/octet-stream\"," "\"content_base64\":\"%s\"}}", g_session, (int)org_id, big)); CHECK_STR(d, "error.code", "TOO_LARGE"); yyjson_doc_free(d); g_cfg.max_attachment_bytes = 10 * 1024 * 1024; } /* ---------------- templates (konteringsmallar) ------------------- */ d = call(reqf("{\"v\":1,\"id\":\"80\",\"cmd\":\"template.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"Testmall\"," "\"description\":\"Försäljning {x}\",\"rows\":[" "{\"account\":\"1930\",\"formula\":\"x\"}," "{\"account\":\"3001\",\"formula\":\"-x/1.25\"}," "{\"account\":\"2611\",\"formula\":\"-x/5\"}]}}", g_session, (int)org_id)); CHECK_OK(d); int64_t tpl_id = jint(d, "result.id"); CHECK(tpl_id > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"81\",\"cmd\":\"template.list\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"active_only\":true}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) >= 1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"82\",\"cmd\":\"template.get\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"name\":\"Testmall\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.rows")) == 3); CHECK_STR(d, "result.rows.0.formula", "x"); CHECK_STR(d, "result.rows.1.account", "3001"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"83\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," "\"args\":{\"template\":\"Testmall\",\"x\":\"1250\"," "\"date\":\"2026-03-05\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jint(d, "result.rows.0.debit_ore") == 125000); CHECK(jint(d, "result.rows.1.credit_ore") == 100000); CHECK(jint(d, "result.rows.2.credit_ore") == 25000); CHECK_STR(d, "result.description", "Försäljning 1250.00"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"84\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"template\":\"Testmall\",\"x\":\"1250\"," "\"date\":\"2026-03-05\",\"client_ref\":\"tplref\"}}", g_session, (int)org_id)); CHECK_OK(d); int64_t tpl_voucher = jint(d, "result.id"); CHECK(tpl_voucher > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"85\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"template\":\"Testmall\",\"x\":\"1250\"," "\"date\":\"2026-03-05\",\"client_ref\":\"tplref\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jbool(d, "result.replayed")); yyjson_doc_free(d); /* rounding: four credits of x/4 do not add up to x in öre */ d = call(reqf("{\"v\":1,\"id\":\"86\",\"cmd\":\"template.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"name\":\"Rundning\",\"rows\":[" "{\"account\":\"1930\",\"formula\":\"x\"}," "{\"account\":\"3001\",\"formula\":\"-x/4\"}," "{\"account\":\"2611\",\"formula\":\"-x/4\"}," "{\"account\":\"2641\",\"formula\":\"-x/4\"}," "{\"account\":\"5011\",\"formula\":\"-x/4\"}]}}", g_session, (int)org_id)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"87\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," "\"args\":{\"template\":\"Rundning\",\"x\":\"0.03\"," "\"date\":\"2026-03-06\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK(jint(d, "result.rows.0.debit_ore") == 4); CHECK(jint(d, "result.rows.1.credit_ore") == 1); CHECK(jint(d, "result.rows.4.credit_ore") == 1); yyjson_doc_free(d); /* invalid formula and unknown account are rejected at save time */ d = call(reqf("{\"v\":1,\"id\":\"88\",\"cmd\":\"template.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"Fel1\"," "\"rows\":[{\"account\":\"1930\",\"formula\":\"x**\"}," "{\"account\":\"3001\",\"formula\":\"-x\"}]}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"89\",\"cmd\":\"template.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"Fel2\"," "\"rows\":[{\"account\":\"9999\",\"formula\":\"x\"}," "{\"account\":\"3001\",\"formula\":\"-x\"}]}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "ACCOUNT_NOT_FOUND"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"90\",\"cmd\":\"template.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"name\":\"Testmall 2\"}}", g_session, (int)org_id, (long long)tpl_id)); CHECK_OK(d); CHECK_STR(d, "result.name", "Testmall 2"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"91\",\"cmd\":\"template.archive\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"name\":\"Testmall 2\"}}", g_session, (int)org_id)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"92\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," "\"args\":{\"template\":\"Testmall 2\",\"x\":\"100\"," "\"date\":\"2026-03-05\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "NOT_FOUND"); yyjson_doc_free(d); /* ---------------- ingående balans (series IB) -------------------- */ d = call(reqf("{\"v\":1,\"id\":\"93\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":\"%s\"," "\"description\":\"Ingående balans\",\"series\":\"IB\"," "\"rows\":[{\"account\":\"1930\",\"debit_ore\":50000}," "{\"account\":\"2010\",\"credit_ore\":50000}]}}", g_session, (int)org_id, "2026-01-01")); CHECK_OK(d); CHECK_STR(d, "result.series", "IB"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"94\",\"cmd\":\"report.trial_balance\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK(find_amount(d, "result.accounts", "account", "1930", "ib_ore") == 50000); CHECK(find_amount(d, "result.accounts", "account", "2010", "ib_ore") == -50000); yyjson_doc_free(d); /* period lock */ d = call(reqf("{\"v\":1,\"id\":\"67\",\"cmd\":\"period.lock\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":" "%lld,\"until\":\"2026-01-31\",\"reason\":\"bokslut\"}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"68\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," "\"args\":{\"date\":\"2026-01-15\",\"description\":\"Låst\"," "\"rows\":[{\"account\":\"1930\",\"debit_ore\":100}," "{\"account\":\"3001\",\"credit_ore\":100}]}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "PERIOD_LOCKED"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"69\",\"cmd\":\"period.unlock\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":" "%lld,\"reason\":\"test\"}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); yyjson_doc_free(d); /* SIE export */ d = call(reqf("{\"v\":1,\"id\":\"70\",\"cmd\":\"sie.export\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":" "%lld,\"inline\":true}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK(jint(d, "result.size") > 0); CHECK(jstr(d, "result.sha256") && strlen(jstr(d, "result.sha256")) == 64); char sie_path[512] = ""; snprintf(sie_path, sizeof sie_path, "%s", jstr(d, "result.path")); char *sie_b64 = xstrdup(jstr(d, "result.content_base64")); yyjson_doc_free(d); { FILE *sf = fopen(sie_path, "rb"); CHECK(sf != NULL); if (sf) { char sbuf[65536]; size_t sn = fread(sbuf, 1, sizeof sbuf - 1, sf); sbuf[sn] = '\0'; fclose(sf); CHECK(strstr(sbuf, "#SIETYP 4") != NULL); CHECK(strstr(sbuf, "#VER") != NULL); CHECK(strstr(sbuf, "#TRANS 1930") != NULL); } } /* SIE import into a second org */ d = call(reqf("{\"v\":1,\"id\":\"71\",\"cmd\":\"org.create\"," "\"session\":\"%s\",\"args\":{\"name\":\"AB Två\"}}", g_session)); CHECK_OK(d); int64_t org2 = jint(d, "result.id"); CHECK(jint(d, "result.fiscal_year_id") > 0); yyjson_doc_free(d); d = call_sie_import(g_session, org2, sie_b64, 1); CHECK_OK(d); CHECK(jbool(d, "result.dry_run")); CHECK(jint(d, "result.vouchers") >= 4); yyjson_doc_free(d); d = call_sie_import(g_session, org2, sie_b64, 0); CHECK_OK(d); CHECK(jint(d, "result.vouchers") >= 4); yyjson_doc_free(d); free(sie_b64); d = call(reqf("{\"v\":1,\"id\":\"72\",\"cmd\":\"report.trial_balance\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org2)); CHECK_OK(d); int64_t ub_org2 = find_amount(d, "result.accounts", "account", "1930", "ub_ore"); int64_t ib_org2 = find_amount(d, "result.accounts", "account", "1930", "ib_ore"); yyjson_doc_free(d); CHECK(ib_org2 == 50000); d = call(reqf("{\"v\":1,\"id\":\"72b\",\"cmd\":\"report.trial_balance\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); int64_t ub_org1 = find_amount(d, "result.accounts", "account", "1930", "ub_ore"); yyjson_doc_free(d); CHECK(ub_org1 > 0); CHECK(ub_org1 == ub_org2); d = call_sie_import(g_session, org2, "AAAA", 0); CHECK(!jbool(d, "ok")); yyjson_doc_free(d); /* CRLF, #RAR -1 ignored, zero-amount rows dropped */ { static const char sie_crlf[] = "#FLAGGA 0\r\n" "#FORMAT PC8\r\n" "#SIETYP 4\r\n" "#FNAMN \"CRLF AB\"\r\n" "#RAR 0 20240101 20241231\r\n" "#RAR -1 20230101 20231231\r\n" "#KONTO 1930 \"Företagskonto\"\r\n" "#KONTO 2010 \"Eget kapital\"\r\n" "#VER \"A\" \"1\" 20240201 \"Insättning\"\r\n" "{\r\n" "#TRANS 1930 {} 1000.00\r\n" "#TRANS 2010 {} -1000.00\r\n" "#TRANS 2010 {} 0.00\r\n" "}\r\n"; char *b64 = util_b64((const unsigned char *)sie_crlf, strlen(sie_crlf)); d = call_sie_import(g_session, org2, b64, 0); CHECK_OK(d); CHECK(jint(d, "result.vouchers") == 1); int64_t fy_a = jint(d, "result.fiscal_year_id"); yyjson_doc_free(d); free(b64); d = call(reqf("{\"v\":1,\"id\":\"77a\",\"cmd\":\"fiscal_year.get\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)org2, (long long)fy_a)); CHECK_OK(d); CHECK_STR(d, "result.start_date", "2024-01-01"); CHECK_STR(d, "result.end_date", "2024-12-31"); yyjson_doc_free(d); } /* a later year's #IB must not duplicate carried balances */ { static const char sie_year2[] = "#FLAGGA 0\n" "#FORMAT PC8\n" "#SIETYP 4\n" "#FNAMN \"CRLF AB\"\n" "#RAR 0 20250101 20251231\n" "#IB 0 1930 1000.00\n" "#IB 0 2010 -1000.00\n" "#VER \"A\" \"1\" 20250201 \"Insättning\"\n" "{\n" "#TRANS 1930 {} 50.00\n" "#TRANS 2010 {} -50.00\n" "}\n"; char *b64 = util_b64((const unsigned char *)sie_year2, strlen(sie_year2)); d = call_sie_import(g_session, org2, b64, 0); CHECK_OK(d); int64_t fy_b = jint(d, "result.fiscal_year_id"); yyjson_doc_free(d); free(b64); d = call(reqf("{\"v\":1,\"id\":\"77b\",\"cmd\":\"report.trial_balance\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"fiscal_year\":%lld}}", g_session, (int)org2, (long long)fy_b)); CHECK_OK(d); CHECK(find_amount(d, "result.accounts", "account", "1930", "ib_ore") == 100000); CHECK(find_amount(d, "result.accounts", "account", "1930", "ub_ore") == 105000); yyjson_doc_free(d); } /* ---------------- settings: default series ----------------------- */ d = call(reqf("{\"v\":1,\"id\":\"95\",\"cmd\":\"settings.get\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.default_series", "A"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"96\",\"cmd\":\"settings.set\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"key\":\"default_series\",\"value\":\"V-\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.value", "V-"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"96b\",\"cmd\":\"settings.get\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.default_series", "V-"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"97\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":\"%s\"," "\"description\":\"Serietest\",\"client_ref\":\"sertest\"," "\"rows\":[{\"account\":\"1930\",\"debit_ore\":100}," "{\"account\":\"3001\",\"credit_ore\":100}]}}", g_session, (int)org_id, "2026-04-01")); CHECK_OK(d); CHECK_STR(d, "result.series", "V-"); CHECK(jint(d, "result.number") == 1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"98\",\"cmd\":\"template.create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"name\":\"Seriemall\",\"rows\":[" "{\"account\":\"1930\",\"formula\":\"x\"}," "{\"account\":\"3001\",\"formula\":\"-x\"}]}}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.series", "V-"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"99\",\"cmd\":\"settings.set\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"key\":\"default_series\",\"value\":\"123456789\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"100\",\"cmd\":\"settings.set\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"key\":\"nope\",\"value\":\"x\"}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "UNSUPPORTED"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"99b\",\"cmd\":\"settings.set\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"key\":\"attachment_dir\",\"value\":\"/tmp/bilagor\"}}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.value", "/tmp/bilagor"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"101\",\"cmd\":\"settings.get\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.attachment_dir", "/tmp/bilagor"); yyjson_doc_free(d); /* ---------------- moms rules: ranges merge per box ------------- */ d = call(reqf("{\"v\":1,\"id\":\"110\",\"cmd\":\"org.create\"," "\"session\":\"%s\",\"args\":{\"name\":\"Moms AB\"}}", g_session)); CHECK_OK(d); int64_t vat_org = jint(d, "result.id"); CHECK(vat_org > 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"111\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-02-01\",\"description\":\"Försäljning A\",\"rows\":[" "{\"account\":\"1930\",\"debit_ore\":125000}," "{\"account\":\"3001\",\"credit_ore\":100000}," "{\"account\":\"2611\",\"credit_ore\":25000}]}}", g_session, (int)vat_org)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"112\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-02-02\",\"description\":\"Försäljning B\",\"rows\":[" "{\"account\":\"1930\",\"debit_ore\":62500}," "{\"account\":\"3105\",\"credit_ore\":50000}," "{\"account\":\"2611\",\"credit_ore\":12500}]}}", g_session, (int)vat_org)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"113\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-02-03\",\"description\":\"EU-inköp\",\"rows\":[" "{\"account\":\"4515\",\"debit_ore\":10000}," "{\"account\":\"2645\",\"debit_ore\":2500}," "{\"account\":\"2614\",\"credit_ore\":2500}," "{\"account\":\"2410\",\"credit_ore\":10000}]}}", g_session, (int)vat_org)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"114\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" "\"2026-02-04\",\"description\":\"IT-tjänst\",\"rows\":[" "{\"account\":\"6540\",\"debit_ore\":8000}," "{\"account\":\"2641\",\"debit_ore\":2000}," "{\"account\":\"2410\",\"credit_ore\":10000}]}}", g_session, (int)vat_org)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"115\",\"cmd\":\"report.vat\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"from\":\"2026-02-01\",\"to\":\"2026-02-28\"}}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(vat_box(d, "05") == 150000); CHECK(vat_box(d, "10") == 37500); CHECK(vat_box(d, "20") == 10000); CHECK(vat_box(d, "30") == 2500); CHECK(vat_box(d, "48") == -4500); CHECK(vat_box(d, "49") == 35500); int n05 = 0; yyjson_val *boxes = jget(d, "result.boxes"); for (size_t i = 0; i < yyjson_arr_size(boxes); i++) { yyjson_val *box = yyjson_obj_get(yyjson_arr_get(boxes, i), "box"); if (box && yyjson_is_str(box) && strcmp(yyjson_get_str(box), "05") == 0) n05++; } CHECK(n05 == 1); yyjson_doc_free(d); /* ---------------- moms rules: per-org editor -------------------- */ d = call(reqf("{\"v\":1,\"id\":\"116a\",\"cmd\":\"report.rule_list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)vat_org)); CHECK_OK(d); int nrules = (int)yyjson_arr_size(jget(d, "result.items")); CHECK(nrules >= 15); { yyjson_val *items = jget(d, "result.items"); for (size_t i = 0; i < yyjson_arr_size(items); i++) { yyjson_val *rv = yyjson_obj_get(yyjson_arr_get(items, i), "report"); CHECK(rv && yyjson_is_str(rv) && strcmp(yyjson_get_str(rv), "vat") == 0); } } CHECK_STR(d, "result.items.0.box", "05"); CHECK(jint(d, "result.items.0.sort_order") == 10); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116b\",\"cmd\":\"report.rule_list\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"report\":\"vat\"}}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == (size_t)nrules); yyjson_doc_free(d); /* dry run validates but must not create a rule */ d = call(reqf("{\"v\":1,\"id\":\"116c\",\"cmd\":\"report.rule_create\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":" "{\"report\":\"vat\",\"box\":\"05\",\"match_type\":\"type\"," "\"pattern\":\"revenue\",\"sign\":-1,\"sort_order\":1}}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(jbool(d, "result.dry_run")); CHECK(jint(d, "result.id") == 0); CHECK_STR(d, "result.match_type", "type"); CHECK_STR(d, "result.pattern", "revenue"); CHECK(jint(d, "result.sign") == -1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116d\",\"cmd\":\"report.rule_list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == (size_t)nrules); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116e\",\"cmd\":\"report.rule_create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"report\":\"vat\",\"box\":\"05\",\"match_type\":\"type\"," "\"pattern\":\"revenue\",\"sign\":-1,\"sort_order\":1}}", g_session, (int)vat_org)); CHECK_OK(d); int64_t rule_id = jint(d, "result.id"); CHECK(rule_id > 0); CHECK_STR(d, "result.report", "vat"); CHECK_STR(d, "result.box", "05"); CHECK(jint(d, "result.sign") == -1); CHECK(jint(d, "result.sort_order") == 1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116f\",\"cmd\":\"report.rule_list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == (size_t)nrules + 1); { int found = 0; yyjson_val *items = jget(d, "result.items"); for (size_t i = 0; i < yyjson_arr_size(items); i++) { yyjson_val *iv = yyjson_obj_get(yyjson_arr_get(items, i), "id"); if (iv && yyjson_is_int(iv) && yyjson_get_int(iv) == rule_id) found = 1; } CHECK(found); } yyjson_doc_free(d); /* the rule adds the revenue type on top of the seeded ranges */ d = call(reqf("{\"v\":1,\"id\":\"116g\",\"cmd\":\"report.vat\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"from\":\"2026-02-01\",\"to\":\"2026-02-28\"}}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(vat_box(d, "05") == 300000); CHECK(vat_box(d, "49") == 35500); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116h\",\"cmd\":\"report.rule_update\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"id\":%lld,\"sign\":1,\"sort_order\":2}}", g_session, (int)vat_org, (long long)rule_id)); CHECK_OK(d); CHECK_STR(d, "result.match_type", "type"); CHECK_STR(d, "result.pattern", "revenue"); CHECK(jint(d, "result.sign") == 1); CHECK(jint(d, "result.sort_order") == 2); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116i\",\"cmd\":\"report.vat\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"from\":\"2026-02-01\",\"to\":\"2026-02-28\"}}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(vat_box(d, "05") == 0); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116j\",\"cmd\":\"report.rule_update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"match_type\":\"range\",\"pattern\":\"3100-3000\"}}", g_session, (int)vat_org, (long long)rule_id)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116k\",\"cmd\":\"report.rule_update\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%d}}", g_session, (int)vat_org, 999999)); CHECK_STR(d, "error.code", "NOT_FOUND"); yyjson_doc_free(d); /* dry-run delete keeps the rule */ d = call(reqf("{\"v\":1,\"id\":\"116l\",\"cmd\":\"report.rule_delete\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," "\"args\":{\"id\":%lld}}", g_session, (int)vat_org, (long long)rule_id)); CHECK_OK(d); CHECK(jbool(d, "result.deleted")); CHECK(jbool(d, "result.dry_run")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116m\",\"cmd\":\"report.rule_delete\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)vat_org, (long long)rule_id)); CHECK_OK(d); CHECK(jint(d, "result.id") == rule_id); CHECK(jbool(d, "result.deleted")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116n\",\"cmd\":\"report.rule_list\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(yyjson_arr_size(jget(d, "result.items")) == (size_t)nrules); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116o\",\"cmd\":\"report.vat\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"from\":\"2026-02-01\",\"to\":\"2026-02-28\"}}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(vat_box(d, "05") == 150000); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116p\",\"cmd\":\"report.rule_delete\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)vat_org, (long long)rule_id)); CHECK_STR(d, "error.code", "NOT_FOUND"); yyjson_doc_free(d); /* invalid rules are rejected before any write */ d = call(reqf("{\"v\":1,\"id\":\"116q\",\"cmd\":\"report.rule_create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"report\":\"vat\",\"box\":\"05\"," "\"match_type\":\"bogus\",\"pattern\":\"3001\"}}", g_session, (int)vat_org)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116r\",\"cmd\":\"report.rule_create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"report\":\"vat\",\"box\":\"05\"," "\"match_type\":\"range\",\"pattern\":\"3100-3000\"}}", g_session, (int)vat_org)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116s\",\"cmd\":\"report.rule_create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"report\":\"vat\",\"box\":\"05\"," "\"match_type\":\"account\",\"pattern\":\"30a1\"}}", g_session, (int)vat_org)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"116t\",\"cmd\":\"report.rule_create\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"report\":\"vat\",\"box\":\"05\"," "\"match_type\":\"type\",\"pattern\":\"revenue\",\"sign\":0}}", g_session, (int)vat_org)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); /* ---------------- bokslut: dispositions, tax, transfer ---------- */ d = call(reqf("{\"v\":1,\"id\":\"120\",\"cmd\":\"bokslut.post\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":" "{\"tax_rate\":20.6}}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(jint(d, "result.result_before_ore") == 132000); CHECK(jint(d, "result.taxable_ore") == 132000); CHECK(jint(d, "result.tax_ore") == 27192); CHECK(jint(d, "result.result_after_ore") == 104808); CHECK(yyjson_arr_size(jget(d, "result.vouchers")) == 2); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"121\",\"cmd\":\"bokslut.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"tax_rate\":20.6,\"periodiseringsfond_ore\":50000}}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(jint(d, "result.entries_ore") == -50000); CHECK(jint(d, "result.taxable_ore") == 82000); CHECK(jint(d, "result.tax_ore") == 16892); CHECK(jint(d, "result.result_after_ore") == 65108); CHECK(yyjson_arr_size(jget(d, "result.vouchers")) == 3); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"122\",\"cmd\":\"report.balance_sheet\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)vat_org)); CHECK_OK(d); CHECK(find_amount(d, "result.equity.accounts", "account", "2110", "amount_ore") == 50000); CHECK(find_amount(d, "result.liabilities.accounts", "account", "2512", "amount_ore") == 16892); CHECK(find_amount(d, "result.equity.accounts", "account", "2099", "amount_ore") == 65108); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"123\",\"cmd\":\"bokslut.post\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"entries\":[{\"debit_account\":\"8910\"," "\"amount_ore\":100}]}}", g_session, (int)vat_org)); CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); /* ---------------- SRU (INK2/INK2R/INK2S) ----------------------- */ d = call(reqf("{\"v\":1,\"id\":\"124\",\"cmd\":\"org.update\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"org_nr\":\"559331-2126\",\"postal_code\":\"192 48\"," "\"city\":\"Sollentuna\"}}", g_session, (int)vat_org)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"125\",\"cmd\":\"sru.export\"," "\"session\":\"%s\",\"org\":%d,\"args\":" "{\"adjustments\":[{\"code\":\"7653\"," "\"amount_ore\":10000}]}}", g_session, (int)vat_org)); CHECK_OK(d); CHECK_STR(d, "result.period", "2026P4"); CHECK_STR(d, "result.blankett", "INK2-2026P4"); char *sb64 = jstr(d, "result.blanketter.content_base64"); unsigned char *srub = NULL; size_t srul = 0; CHECK(sb64 && util_b64_decode(sb64, strlen(sb64), &srub, &srul) == 0); char *sru = xmalloc(srul + 1); memcpy(sru, srub, srul); sru[srul] = '\0'; free(srub); CHECK(strstr(sru, "#BLANKETT INK2-2026P4") != NULL); CHECK(strstr(sru, "#BLANKETT INK2R-2026P4") != NULL); CHECK(strstr(sru, "#BLANKETT INK2S-2026P4") != NULL); CHECK(strstr(sru, "#UPPGIFT 7011 20260101") != NULL); CHECK(strstr(sru, "#UPPGIFT 7410 1500") != NULL); CHECK(strstr(sru, "#UPPGIFT 7513 80") != NULL); CHECK(strstr(sru, "#UPPGIFT 7525 500") != NULL); CHECK(strstr(sru, "#UPPGIFT 7528 168") != NULL); CHECK(strstr(sru, "#UPPGIFT 7450 651") != NULL); CHECK(strstr(sru, "#UPPGIFT 7281 1875") != NULL); CHECK(strstr(sru, "#UPPGIFT 7369 355") != NULL); CHECK(strstr(sru, "#UPPGIFT 7321 500") != NULL); CHECK(strstr(sru, "#UPPGIFT 7302 651") != NULL); CHECK(strstr(sru, "#UPPGIFT 7650 651") != NULL); CHECK(strstr(sru, "#UPPGIFT 7651 168") != NULL); CHECK(strstr(sru, "#UPPGIFT 7653 100") != NULL); CHECK(strstr(sru, "#UPPGIFT 7670 919") != NULL); CHECK(strstr(sru, "#UPPGIFT 7104 919") != NULL); CHECK(strstr(sru, "#FIL_SLUT") != NULL); free(sru); CHECK_STR(d, "result.from", "2026-01-01"); CHECK(find_amount(d, "result.ink2r", "code", "7281", "amount") == 1875); CHECK(find_amount(d, "result.ink2s", "code", "7670", "amount") == 919); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"126\",\"cmd\":\"sru.export\"," "\"session\":\"%s\",\"org\":%d,\"args\":{}}", g_session, (int)vat_org)); CHECK_OK(d); sb64 = jstr(d, "result.info.content_base64"); srub = NULL; srul = 0; CHECK(sb64 && util_b64_decode(sb64, strlen(sb64), &srub, &srul) == 0); sru = xmalloc(srul + 1); memcpy(sru, srub, srul); sru[srul] = '\0'; free(srub); CHECK(strstr(sru, "#ORGNR 165593312126") != NULL); CHECK(strstr(sru, "#POSTORT Sollentuna") != NULL); CHECK(strstr(sru, "#FILNAMN BLANKETTER.SRU") != NULL); free(sru); yyjson_doc_free(d); /* close fiscal year and verify hard stop */ d = call(reqf("{\"v\":1,\"id\":\"73\",\"cmd\":\"fiscal_year.close\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"confirm\":true}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"74\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," "\"args\":{\"date\":\"2026-03-01\",\"description\":\"Stängt\"," "\"rows\":[{\"account\":\"1930\",\"debit_ore\":100}," "{\"account\":\"3001\",\"credit_ore\":100}]}}", g_session, (int)org_id)); CHECK_STR(d, "error.code", "FISCAL_YEAR_CLOSED"); yyjson_doc_free(d); /* reopen is the owner-only undo */ d = call(reqf("{\"v\":1,\"id\":\"76\",\"cmd\":\"fiscal_year.reopen\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," "\"args\":{\"id\":%lld,\"confirm\":true}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK(jbool(d, "result.dry_run")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"77\",\"cmd\":\"fiscal_year.reopen\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"confirm\":true}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"78\",\"cmd\":\"fiscal_year.get\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", g_session, (int)org_id, (long long)fy1)); CHECK_OK(d); CHECK_STR(d, "result.status", "open"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"79\",\"cmd\":\"voucher.post\"," "\"session\":\"%s\",\"org\":%d,\"dry_run\":true," "\"args\":{\"date\":\"2026-03-01\",\"description\":\"Öppet\"," "\"rows\":[{\"account\":\"1930\",\"debit_ore\":100}," "{\"account\":\"3001\",\"credit_ore\":100}]}}", g_session, (int)org_id)); CHECK_OK(d); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"79b\",\"cmd\":\"fiscal_year.reopen\"," "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," "\"confirm\":true}}", g_session, (int)org_id, (long long)fy1)); CHECK_STR(d, "error.code", "NOT_FOUND"); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"75\",\"cmd\":\"audit.verify\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(jbool(d, "result.ok")); CHECK(jint(d, "result.checked") > 20); CHECK(jint(d, "result.vouchers_checked") > 10); CHECK(jint(d, "result.unbalanced_vouchers") == 0); yyjson_doc_free(d); /* ------------------- audit.verify tamper detection ---------------- */ d = call(reqf("{\"v\":1,\"id\":\"80\",\"cmd\":\"audit.verify\"," "\"session\":\"%s\",\"args\":{\"full\":true}}", g_session)); CHECK_OK(d); CHECK(jbool(d, "result.ok")); CHECK(jint(d, "result.vouchers_checked") > 10); CHECK(jint(d, "result.attachments_checked") >= 1); CHECK(jint(d, "result.unbalanced_vouchers") == 0); yyjson_doc_free(d); int64_t voucher_id = 0; char voucher_desc[256] = ""; sqlite3_stmt *vst = NULL; CHECK(sqlite3_prepare_v2(g_db, "SELECT id,description FROM vouchers" " ORDER BY id LIMIT 1", -1, &vst, NULL) == SQLITE_OK); if (sqlite3_step(vst) == SQLITE_ROW) { voucher_id = sqlite3_column_int64(vst, 0); snprintf(voucher_desc, sizeof voucher_desc, "%s", (const char *)sqlite3_column_text(vst, 1)); } sqlite3_finalize(vst); CHECK(voucher_id > 0); int64_t attachment_id = 0; sqlite3_stmt *ast = NULL; CHECK(sqlite3_prepare_v2(g_db, "SELECT id FROM attachments ORDER BY id LIMIT 1", -1, &ast, NULL) == SQLITE_OK); if (sqlite3_step(ast) == SQLITE_ROW) attachment_id = sqlite3_column_int64(ast, 0); sqlite3_finalize(ast); CHECK(attachment_id > 0); /* the append-only triggers block updates and deletes; dropping one simulates a modified database file for the re-hash check */ err = NULL; CHECK(db_exec(g_db, reqf("UPDATE attachments SET content=x'00'" " WHERE id=%lld", (long long)attachment_id), &err) != 0); free(err); err = NULL; CHECK(db_exec(g_db, reqf("DELETE FROM attachments WHERE id=%lld", (long long)attachment_id), &err) != 0); free(err); err = NULL; CHECK(db_exec(g_db, "DROP TRIGGER attachments_no_update", &err) == 0); free(err); err = NULL; CHECK(db_exec(g_db, reqf("UPDATE attachments SET content=x'00'" " WHERE id=%lld", (long long)attachment_id), &err) == 0); free(err); err = NULL; d = call(reqf("{\"v\":1,\"id\":\"81\",\"cmd\":\"audit.verify\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(jbool(d, "result.ok")); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"82\",\"cmd\":\"audit.verify\"," "\"session\":\"%s\",\"args\":{\"full\":true}}", g_session)); CHECK_OK(d); CHECK(!jbool(d, "result.ok")); CHECK(jint(d, "result.first_bad_attachment_id") == attachment_id); CHECK(jint(d, "result.vouchers_checked") > 10); yyjson_doc_free(d); /* the append-only trigger blocks the tamper; after dropping it the modified description must break the chain at that voucher */ err = NULL; CHECK(db_exec(g_db, reqf("UPDATE vouchers SET description='x'" " WHERE id=%lld", (long long)voucher_id), &err) != 0); free(err); err = NULL; CHECK(db_exec(g_db, "DROP TRIGGER vouchers_no_update", &err) == 0); free(err); err = NULL; CHECK(db_exec(g_db, reqf("UPDATE vouchers SET description='kapad'" " WHERE id=%lld", (long long)voucher_id), &err) == 0); free(err); err = NULL; d = call(reqf("{\"v\":1,\"id\":\"83\",\"cmd\":\"audit.verify\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(!jbool(d, "result.ok")); CHECK(jint(d, "result.first_bad_voucher_id") == voucher_id); CHECK(jint(d, "result.first_bad_seq") == -1); yyjson_doc_free(d); char *fix = sqlite3_mprintf("UPDATE vouchers SET description=%Q" " WHERE id=%lld", voucher_desc, (long long)voucher_id); CHECK(fix != NULL); err = NULL; CHECK(db_exec(g_db, fix, &err) == 0); sqlite3_free(fix); free(err); err = NULL; CHECK(db_exec(g_db, "CREATE TRIGGER vouchers_no_update BEFORE UPDATE ON" " vouchers BEGIN SELECT RAISE(ABORT," " 'vouchers are append-only'); END", &err) == 0); free(err); err = NULL; d = call(reqf("{\"v\":1,\"id\":\"84\",\"cmd\":\"audit.verify\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(jbool(d, "result.ok")); CHECK(jint(d, "result.first_bad_voucher_id") == -1); yyjson_doc_free(d); /* the balance backstop catches a row appended outside the ledger path */ err = NULL; CHECK(db_exec(g_db, reqf("INSERT INTO voucher_rows(org_id,voucher_id,line_no," "account_id,debit_ore,credit_ore,description)" " SELECT org_id,id,99," "(SELECT id FROM accounts WHERE org_id=%d" " AND number='3001'),1,0,NULL" " FROM vouchers WHERE id=%lld", (int)org_id, (long long)voucher_id), &err) == 0); free(err); err = NULL; d = call(reqf("{\"v\":1,\"id\":\"85\",\"cmd\":\"audit.verify\"," "\"session\":\"%s\"}", g_session)); CHECK_OK(d); CHECK(!jbool(d, "result.ok")); CHECK(jint(d, "result.first_bad_voucher_id") == voucher_id); CHECK(jint(d, "result.first_unbalanced_voucher_id") == voucher_id); CHECK(jint(d, "result.unbalanced_vouchers") == 1); yyjson_doc_free(d); test_transport(); test_pre_migration_snapshot(tmpdir, backupdir); /* rate limiting must stay last: it blocks the login key */ for (int i = 0; i < 5; i++) CHECK(!login("admin", "wrong")); d = call("{\"v\":1,\"id\":\"32\",\"cmd\":\"session.open\",\"args\":" "{\"method\":\"password\",\"username\":\"admin\"," "\"password\":\"secret123\"}}"); CHECK_STR(d, "error.code", "RATE_LIMITED"); yyjson_doc_free(d); sqlite3_close(g_db); char path[600]; snprintf(path, sizeof path, "%s-wal", dbpath); unlink(path); snprintf(path, sizeof path, "%s-shm", dbpath); unlink(path); unlink(dbpath); rmdir(backupdir); rmdir(tmpdir); config_free(); if (g_failures) { fprintf(stderr, "%d check(s) failed\n", g_failures); return 1; } printf("all tests passed\n"); return 0; }