aboutsummaryrefslogtreecommitdiff
path: root/tests/test_core.c
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_core.c')
-rw-r--r--tests/test_core.c618
1 files changed, 618 insertions, 0 deletions
diff --git a/tests/test_core.c b/tests/test_core.c
index 96361cb..945c308 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -20,6 +20,7 @@
#include "protocol.h"
#include "secret.h"
#include "sessions.h"
+#include "tax_table.h"
#include "util.h"
#include "yyjson.h"
@@ -703,6 +704,26 @@ static void test_pre_migration_snapshot(struct tctx *ctx)
" AND name='bank_matches'");
CHECK(v && strcmp(v, "bank_matches") == 0);
free(v);
+ v = db_text(db, "SELECT name FROM sqlite_master WHERE type='table'"
+ " AND name='employees'");
+ CHECK(v && strcmp(v, "employees") == 0);
+ free(v);
+ v = db_text(db, "SELECT name FROM sqlite_master WHERE type='table'"
+ " AND name='payroll_runs'");
+ CHECK(v && strcmp(v, "payroll_runs") == 0);
+ free(v);
+ v = db_text(db, "SELECT name FROM sqlite_master WHERE type='table'"
+ " AND name='payroll_run_lines'");
+ CHECK(v && strcmp(v, "payroll_run_lines") == 0);
+ free(v);
+ v = db_text(db, "SELECT name FROM sqlite_master WHERE type='table'"
+ " AND name='tax_tables'");
+ CHECK(v && strcmp(v, "tax_tables") == 0);
+ free(v);
+ v = db_text(db, "SELECT name FROM sqlite_master WHERE type='table'"
+ " AND name='tax_table_meta'");
+ CHECK(v && strcmp(v, "tax_table_meta") == 0);
+ free(v);
int64_t user_id = 0;
CHECK(db_create_user(db, "migrated", "Migrated", "secret123", 0, &user_id,
&err) == 0);
@@ -4130,6 +4151,600 @@ static void test_rate_limit(struct tctx *t)
yyjson_doc_free(d);
}
+static void test_employees(struct tctx *t)
+{
+ static const char hexkey[] =
+ "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
+ yyjson_doc *d;
+
+ /* without a key the personnummer cannot be encrypted */
+ unsetenv("BOKFD_SECRET_KEY");
+ d = call(reqf("{\"v\":1,\"id\":\"e1\",\"cmd\":\"employee.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"Ingen\","
+ "\"personal_no\":\"19700101-1234\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INTERNAL");
+ yyjson_doc_free(d);
+ setenv("BOKFD_SECRET_KEY", hexkey, 1);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e2\",\"cmd\":\"employee.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"Fel\","
+ "\"personal_no\":\"1970010-1234\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e3\",\"cmd\":\"employee.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"name\":\"Anna Testsson\",\"personal_no\":"
+ "\"19700101-1234\",\"address\":\"Storgatan 1\","
+ "\"postal_code\":\"111 22\",\"city\":\"Stockholm\","
+ "\"bank_account\":\"5001-1234\",\"monthly_salary_ore\":"
+ "2500000,\"tax_table\":30,\"tax_column\":1}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ int64_t emp = jint(d, "result.id");
+ CHECK(emp > 0);
+ CHECK_STR(d, "result.personal_no", "********-1234");
+ CHECK_STR(d, "result.salary_account", "7210");
+ CHECK(jint(d, "result.tax_table") == 30);
+ CHECK(jint(d, "result.tax_column") == 1);
+ yyjson_doc_free(d);
+
+ /* the register holds the AES-GCM envelope, never the number */
+ sqlite3_stmt *st = NULL;
+ CHECK(sqlite3_prepare_v2(g_db, "SELECT personal_no_enc FROM employees"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) == SQLITE_OK);
+ sqlite3_bind_int64(st, 1, t->org_id);
+ sqlite3_bind_int64(st, 2, emp);
+ const char *enc = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ enc = (const char *)sqlite3_column_text(st, 0);
+ CHECK(enc && strncmp(enc, "enc:v1:", 7) == 0);
+ CHECK(enc && strstr(enc, "19700101") == NULL);
+ sqlite3_finalize(st);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e4\",\"cmd\":\"employee.list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 1);
+ CHECK_STR(d, "result.items.0.personal_no", "********-1234");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e5\",\"cmd\":\"employee.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)t->org_id, (long long)emp));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.name", "Anna Testsson");
+ CHECK_STR(d, "result.city", "Stockholm");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e6\",\"cmd\":\"employee.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"city\":\"Göteborg\",\"monthly_salary_ore\":2600000}}",
+ g_session, (int)t->org_id, (long long)emp));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.city", "Göteborg");
+ CHECK(jint(d, "result.monthly_salary_ore") == 2600000);
+ CHECK_STR(d, "result.personal_no", "********-1234");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e7\",\"cmd\":\"employee.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)t->org_id, (long long)emp));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e8\",\"cmd\":\"employee.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":999999,"
+ "\"city\":\"X\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e9\",\"cmd\":\"employee.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"tax_table\":28}}",
+ g_session, (int)t->org_id, (long long)emp));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e10\",\"cmd\":\"employee.archive\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"active\":false}}",
+ g_session, (int)t->org_id, (long long)emp));
+ CHECK_OK(d);
+ CHECK(!jbool(d, "result.active"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e11\",\"cmd\":\"employee.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"active_only\":true}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"e12\",\"cmd\":\"employee.archive\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"active\":true}}",
+ g_session, (int)t->org_id, (long long)emp));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.active"));
+ yyjson_doc_free(d);
+
+ /* reads degrade to a fully masked number when the key is gone */
+ unsetenv("BOKFD_SECRET_KEY");
+ d = call(reqf("{\"v\":1,\"id\":\"e13\",\"cmd\":\"employee.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)t->org_id, (long long)emp));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.personal_no", "********");
+ yyjson_doc_free(d);
+ setenv("BOKFD_SECRET_KEY", hexkey, 1);
+
+ /* leave the register quiet for the payroll tests */
+ d = call(reqf("{\"v\":1,\"id\":\"e14\",\"cmd\":\"employee.archive\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"active\":false}}",
+ g_session, (int)t->org_id, (long long)emp));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+}
+
+static void test_tax_tables(struct tctx *t)
+{
+ yyjson_doc *d;
+
+ /* nothing stored yet: the current year is stale */
+ d = call(reqf("{\"v\":1,\"id\":\"t1\",\"cmd\":"
+ "\"payroll.tax_tables_status\",\"session\":\"%s\","
+ "\"org\":%d}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.stale"));
+ CHECK(yyjson_arr_size(jget(d, "result.stored_years")) == 0);
+ CHECK(!jstr(d, "result.fetched_at"));
+ yyjson_doc_free(d);
+
+ /* real-looking fixed-width fixture: BOM, four B records (the values are
+ from the 2026 file for table 30 column 1) and two % records with an
+ open-ended top range */
+ static const char fixture[] =
+ "\xEF\xBB\xBF"
+ "30B30" " 1" " 2000" " 0" " 0" " 0" " 0" " 0"
+ " 0" "\n"
+ "30B30" " 24801" " 25000" " 4208" " 4108" " 2006" " 4208" " 6623"
+ " 6623" "\n"
+ "30B30" " 25001" " 25200" " 4254" " 4155" " 2025" " 4254" " 6688"
+ " 6688" "\n"
+ "30B30" " 29801" " 30000" " 5352" " 5225" " 2400" " 5352" " 8273"
+ " 8273" "\n"
+ "30%30" " 80001" " 99999" " 31" " 31" " 27" " 27" " 36"
+ " 36" "\n"
+ "30%30" " 100000" " " " 48" " 48" " 48" " 41" " 48"
+ " 48" "\n";
+
+ struct tax_row *rows = NULL;
+ size_t n = 0;
+ char *err = NULL;
+ CHECK(tax_table_parse((const unsigned char *)fixture, strlen(fixture),
+ &rows, &n, &err) == 0);
+ CHECK(n == 36);
+ CHECK(rows[0].table_no == 30);
+ CHECK(rows[0].column_no == 1);
+ CHECK(rows[0].income_from_ore == 100);
+ CHECK(rows[0].income_to_ore == 200000);
+ CHECK(rows[0].tax_ore == 0);
+ CHECK(!rows[0].is_pct);
+ CHECK(rows[6].column_no == 1);
+ CHECK(rows[6].tax_ore == 420800);
+ CHECK(rows[7].column_no == 2);
+ CHECK(rows[7].tax_ore == 410800);
+ CHECK(rows[12].income_from_ore == 2500100);
+ CHECK(rows[12].tax_ore == 425400);
+ CHECK(rows[24].is_pct);
+ CHECK(rows[24].pct == 3100);
+ CHECK(rows[24].tax_ore == 0);
+ CHECK(rows[24].income_to_ore == 9999900);
+ CHECK(rows[30].is_pct);
+ CHECK(rows[30].income_from_ore == 10000000);
+ CHECK(rows[30].income_to_ore == -1);
+ CHECK(rows[35].pct == 4800);
+ free(rows);
+
+ rows = NULL;
+ err = NULL;
+ CHECK(tax_table_parse((const unsigned char *)"30B30 1 2000\n", 14, &rows,
+ &n, &err) != 0);
+ CHECK(rows == NULL);
+ free(err);
+ err = NULL;
+ static const char bad_table[] =
+ "30B28" " 1" " 2000" " 0" " 0" " 0" " 0" " 0"
+ " 0" "\n";
+ CHECK(tax_table_parse((const unsigned char *)bad_table,
+ strlen(bad_table), &rows, &n, &err) != 0);
+ CHECK(rows == NULL);
+ free(err);
+
+ /* offline import replaces the year in one transaction */
+ char *b64 = util_b64((const unsigned char *)fixture, strlen(fixture));
+ d = call(reqf("{\"v\":1,\"id\":\"t2\",\"cmd\":"
+ "\"payroll.tax_tables_import\",\"session\":\"%s\","
+ "\"org\":%d,\"args\":{\"year\":2026,"
+ "\"content_base64\":\"%s\"}}",
+ g_session, (int)t->org_id, b64));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.rows") == 36);
+ CHECK(jint(d, "result.year") == 2026);
+ CHECK_STR(d, "result.source_url", "import");
+ yyjson_doc_free(d);
+ free(b64);
+
+ CHECK(db_count(g_db, "SELECT count(*) FROM tax_tables WHERE in_year=2026")
+ == 36);
+ CHECK(db_count(g_db, "SELECT count(*) FROM tax_tables WHERE in_year=2026"
+ " AND pct=4800 AND income_to_ore IS NULL") == 5);
+ CHECK(db_count(g_db, "SELECT count(*) FROM tax_tables WHERE in_year=2026"
+ " AND pct=4100 AND income_to_ore IS NULL") == 1);
+
+ /* lookup: exact boundaries and the 80,000 kr top-range limit */
+ int64_t tax = -1;
+ CHECK(tax_table_lookup(g_db, 2026, 30, 1, 2500000, &tax) == 0);
+ CHECK(tax == 420800);
+ CHECK(tax_table_lookup(g_db, 2026, 30, 1, 2499999, &tax) == 0);
+ CHECK(tax == 420800);
+ CHECK(tax_table_lookup(g_db, 2026, 30, 1, 2500100, &tax) == 0);
+ CHECK(tax == 425400);
+ CHECK(tax_table_lookup(g_db, 2026, 30, 1, 8000000, &tax) == 1);
+ CHECK(tax_table_lookup(g_db, 2025, 30, 1, 2500000, &tax) == 2);
+
+ d = call(reqf("{\"v\":1,\"id\":\"t3\",\"cmd\":"
+ "\"payroll.tax_tables_status\",\"session\":\"%s\","
+ "\"org\":%d}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_val *years = jget(d, "result.stored_years");
+ int found = 0;
+ for (size_t i = 0; i < yyjson_arr_size(years); i++)
+ if (yyjson_get_int(yyjson_arr_get(years, i)) == 2026)
+ found = 1;
+ CHECK(found);
+ CHECK_STR(d, "result.source_url", "import");
+ CHECK(jstr(d, "result.fetched_at") && *jstr(d, "result.fetched_at"));
+ yyjson_doc_free(d);
+
+ /* a dry run with garbage changes nothing */
+ char *junk = util_b64((const unsigned char *)"nonsense", 8);
+ d = call(reqf("{\"v\":1,\"id\":\"t4\",\"cmd\":"
+ "\"payroll.tax_tables_import\",\"session\":\"%s\","
+ "\"org\":%d,\"dry_run\":true,\"args\":{\"year\":2027,"
+ "\"content_base64\":\"%s\"}}",
+ g_session, (int)t->org_id, junk));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+ free(junk);
+ CHECK(db_count(g_db, "SELECT count(*) FROM tax_tables WHERE in_year=2027")
+ == 0);
+}
+
+static void test_payroll(struct tctx *t)
+{
+ static const char hexkey[] =
+ "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
+ setenv("BOKFD_SECRET_KEY", hexkey, 1);
+ yyjson_doc *d;
+
+ /* payroll settings: effective defaults and digits-only validation */
+ d = call(reqf("{\"v\":1,\"id\":\"p1\",\"cmd\":\"payroll.settings_get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.payroll_salary_account", "7210");
+ CHECK_STR(d, "result.payroll_tax_account", "2710");
+ CHECK(jint(d, "result.payroll_avgift_rate_bp") == 3142);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p2\",\"cmd\":\"payroll.settings_set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"payroll_tax_account\",\"value\":\"27A0\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p3\",\"cmd\":\"payroll.settings_set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"payroll_avgift_rate_bp\",\"value\":\"99999\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p4\",\"cmd\":\"payroll.settings_set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"payroll_bogus\",\"value\":\"1\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "UNSUPPORTED");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p5\",\"cmd\":\"payroll.settings_set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"payroll_salary_account\",\"value\":\"7010\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "7010");
+ yyjson_doc_free(d);
+
+ /* two employees; the salary account follows the setting */
+ d = call(reqf("{\"v\":1,\"id\":\"p6\",\"cmd\":\"employee.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"name\":\"Anna Anställd\",\"personal_no\":"
+ "\"19750505-1111\",\"monthly_salary_ore\":2500000,"
+ "\"tax_table\":30,\"tax_column\":1}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.salary_account", "7010");
+ int64_t anna = jint(d, "result.id");
+ CHECK(anna > 0);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p7\",\"cmd\":\"employee.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"name\":\"Bertil Bygg\",\"personal_no\":"
+ "\"19800202-5678\",\"monthly_salary_ore\":3000000,"
+ "\"tax_table\":30,\"tax_column\":1}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ int64_t bertil = jint(d, "result.id");
+ CHECK(bertil > 0);
+ yyjson_doc_free(d);
+
+ /* preview: gross, table tax, rounded contributions and net */
+ d = call(reqf("{\"v\":1,\"id\":\"p8\",\"cmd\":\"payroll.run_preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-08\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.period", "2026-08");
+ CHECK(jint(d, "result.tax_year") == 2026);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 2);
+ CHECK(jint(d, "result.totals.gross_ore") == 5500000);
+ CHECK(jint(d, "result.totals.tax_ore") == 956000);
+ CHECK(jint(d, "result.totals.avgifter_ore") == 1728100);
+ CHECK(jint(d, "result.totals.net_ore") == 4544000);
+ CHECK(jint(d, "result.items.0.gross_ore") == 2500000);
+ CHECK(jint(d, "result.items.0.tax_ore") == 420800);
+ CHECK(jint(d, "result.items.0.avgifter_ore") == 785500);
+ CHECK(jint(d, "result.items.0.net_ore") == 2079200);
+ yyjson_doc_free(d);
+ CHECK(db_count(g_db, "SELECT count(*) FROM payroll_runs") == 0);
+
+ /* dry run writes nothing */
+ d = call(reqf("{\"v\":1,\"id\":\"p9\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":"
+ "{\"period\":\"2026-08\",\"pay_date\":\"2026-08-25\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ CHECK(jint(d, "result.totals.gross_ore") == 5500000);
+ yyjson_doc_free(d);
+ CHECK(db_count(g_db, "SELECT count(*) FROM payroll_runs") == 0);
+
+ /* period and fiscal-year validation */
+ d = call(reqf("{\"v\":1,\"id\":\"p10\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-13\",\"pay_date\":\"2026-08-25\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p11\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2025-12\",\"pay_date\":\"2026-01-25\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p12\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2027-05\",\"pay_date\":\"2027-05-25\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "DATE_OUT_OF_RANGE");
+ yyjson_doc_free(d);
+
+ /* post: one balanced voucher, the run and its lines */
+ d = call(reqf("{\"v\":1,\"id\":\"p13\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-08\",\"pay_date\":\"2026-08-25\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ int64_t run = jint(d, "result.id");
+ int64_t voucher = jint(d, "result.voucher_id");
+ CHECK(run > 0);
+ CHECK(voucher > 0);
+ CHECK_STR(d, "result.status", "posted");
+ CHECK_STR(d, "result.pay_date", "2026-08-25");
+ CHECK(jint(d, "result.totals.tax_ore") == 956000);
+ yyjson_doc_free(d);
+ CHECK(db_count(g_db, "SELECT count(*) FROM payroll_runs") == 1);
+ CHECK(db_count(g_db, "SELECT count(*) FROM payroll_run_lines") == 2);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p14\",\"cmd\":\"voucher.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)t->org_id, (long long)voucher));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.source", "payroll");
+ yyjson_doc_free(d);
+
+ char sql[512];
+ static const struct {
+ const char *account;
+ const char *side;
+ int64_t amount;
+ } legs[] = {
+ { "7010", "debit_ore", 5500000 },
+ { "7510", "debit_ore", 1728100 },
+ { "2710", "credit_ore", 956000 },
+ { "1930", "credit_ore", 4544000 },
+ { "2731", "credit_ore", 1728100 },
+ };
+ for (size_t i = 0; i < sizeof legs / sizeof legs[0]; i++) {
+ snprintf(sql, sizeof sql,
+ "SELECT COALESCE(SUM(r.%s),0) FROM voucher_rows r"
+ " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
+ " WHERE r.voucher_id=%lld AND a.number='%s'",
+ legs[i].side, (long long)voucher, legs[i].account);
+ CHECK(db_count(g_db, sql) == legs[i].amount);
+ }
+ snprintf(sql, sizeof sql,
+ "SELECT count(*) FROM voucher_rows WHERE voucher_id=%lld",
+ (long long)voucher);
+ CHECK(db_count(g_db, sql) == 6);
+ snprintf(sql, sizeof sql,
+ "SELECT SUM(debit_ore)-SUM(credit_ore) FROM voucher_rows"
+ " WHERE voucher_id=%lld",
+ (long long)voucher);
+ CHECK(db_count(g_db, sql) == 0);
+ snprintf(sql, sizeof sql,
+ "SELECT count(*) FROM payroll_run_lines WHERE run_id=%lld",
+ (long long)run);
+ CHECK(db_count(g_db, sql) == 2);
+
+ /* a second run for the same period is a conflict */
+ d = call(reqf("{\"v\":1,\"id\":\"p16\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-08\",\"pay_date\":\"2026-08-25\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "CONFLICT");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p17\",\"cmd\":\"payroll.run_get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)t->org_id, (long long)run));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.period", "2026-08");
+ CHECK_STR(d, "result.status", "posted");
+ CHECK(yyjson_arr_size(jget(d, "result.lines")) == 2);
+ CHECK(jint(d, "result.lines.0.gross_ore") == 2500000);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p18\",\"cmd\":\"payroll.run_list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 1);
+ CHECK(jint(d, "result.items.0.line_count") == 2);
+ yyjson_doc_free(d);
+
+ /* AGI: the owner gets the personnummer in clear */
+ d = call(reqf("{\"v\":1,\"id\":\"p19\",\"cmd\":\"payroll.agi\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-08\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 2);
+ CHECK_STR(d, "result.items.0.personal_no", "19750505-1111");
+ CHECK_STR(d, "result.items.0.name", "Anna Anställd");
+ CHECK(jint(d, "result.items.0.tax_ore") == 420800);
+ CHECK(jint(d, "result.totals.gross_ore") == 5500000);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p20\",\"cmd\":\"payroll.agi\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-07\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ /* an income above the tabulated B range is refused, per SKV 433 */
+ d = call(reqf("{\"v\":1,\"id\":\"p21\",\"cmd\":\"employee.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"name\":\"Cecilia Chef\",\"personal_no\":"
+ "\"19900303-9012\",\"monthly_salary_ore\":9000000,"
+ "\"tax_table\":30,\"tax_column\":1}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ int64_t cecilia = jint(d, "result.id");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p22\",\"cmd\":\"payroll.run_preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-09\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ {
+ const char *msg = jstr(d, "error.message");
+ CHECK(msg && strstr(msg,
+ "income above the tabulated range is not supported"
+ " yet") != NULL);
+ }
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p23\",\"cmd\":\"employee.archive\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"active\":false}}",
+ g_session, (int)t->org_id, (long long)cecilia));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ /* pay the tax and contributions: its own voucher, then paid */
+ d = call(reqf("{\"v\":1,\"id\":\"p24\",\"cmd\":\"payroll.pay_tax\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":"
+ "{\"run_id\":%lld,\"date\":\"2026-09-12\"}}",
+ g_session, (int)t->org_id, (long long)run));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ CHECK(jint(d, "result.total_ore") == 2684100);
+ yyjson_doc_free(d);
+ CHECK(db_count(g_db, "SELECT count(*) FROM payroll_runs"
+ " WHERE status='paid'") == 0);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p25\",\"cmd\":\"payroll.pay_tax\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"run_id\":%lld,"
+ "\"date\":\"2026-09-12\"}}",
+ g_session, (int)t->org_id, (long long)run));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.status", "paid");
+ CHECK(jint(d, "result.total_ore") == 2684100);
+ int64_t payment = jint(d, "result.payment_voucher_id");
+ CHECK(payment > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p26\",\"cmd\":\"voucher.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)t->org_id, (long long)payment));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.source", "payroll_tax");
+ yyjson_doc_free(d);
+ snprintf(sql, sizeof sql,
+ "SELECT COALESCE(SUM(debit_ore),0) FROM voucher_rows r"
+ " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
+ " WHERE r.voucher_id=%lld AND a.number='2710'",
+ (long long)payment);
+ CHECK(db_count(g_db, sql) == 956000);
+ snprintf(sql, sizeof sql,
+ "SELECT COALESCE(SUM(debit_ore),0) FROM voucher_rows r"
+ " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
+ " WHERE r.voucher_id=%lld AND a.number='2731'",
+ (long long)payment);
+ CHECK(db_count(g_db, sql) == 1728100);
+ snprintf(sql, sizeof sql,
+ "SELECT COALESCE(SUM(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.voucher_id=%lld AND a.number='1630'",
+ (long long)payment);
+ CHECK(db_count(g_db, sql) == 2684100);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p27\",\"cmd\":\"payroll.run_get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)t->org_id, (long long)run));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.status", "paid");
+ CHECK(jint(d, "result.payment_voucher_id") == payment);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p28\",\"cmd\":\"payroll.pay_tax\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"run_id\":%lld}}",
+ g_session, (int)t->org_id, (long long)run));
+ CHECK_STR(d, "error.code", "CONFLICT");
+ yyjson_doc_free(d);
+}
+
struct ttest {
const char *name;
void (*fn)(struct tctx *);
@@ -4169,6 +4784,9 @@ static const struct ttest TESTS[] = {
{ "year_close", test_year_close, "fiscal_years" },
{ "audit_verify", test_audit_verify, "sie_import" },
{ "audit_tamper", test_audit_tamper, "audit_verify" },
+ { "employees", test_employees, "org_members" },
+ { "tax_tables", test_tax_tables, "org_members" },
+ { "payroll", test_payroll, "employees tax_tables" },
{ "transport", test_transport, "" },
{ "pre_migration_snapshot", test_pre_migration_snapshot, "" },
{ "rate_limit", test_rate_limit, "" },