diff options
Diffstat (limited to 'tests/test_core.c')
| -rw-r--r-- | tests/test_core.c | 618 |
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, "" }, |
