diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-21 09:09:39 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-21 09:09:39 +0200 |
| commit | e692f0fbe16342195297048534ad1be227493107 (patch) | |
| tree | 1537266f3f896f8259da20f5433965cfbcf1008c /src/tax_table.c | |
| parent | 5ba2cfc29a0f8d3628a0fa7de2d04e4e39edf69a (diff) | |
| download | bokf-e692f0fbe16342195297048534ad1be227493107.tar.gz bokf-e692f0fbe16342195297048534ad1be227493107.zip | |
payroll: employees, tax tables and the monthly run (schema v10)
Diffstat (limited to 'src/tax_table.c')
| -rw-r--r-- | src/tax_table.c | 745 |
1 files changed, 745 insertions, 0 deletions
diff --git a/src/tax_table.c b/src/tax_table.c new file mode 100644 index 0000000..1cf40c1 --- /dev/null +++ b/src/tax_table.c @@ -0,0 +1,745 @@ +#include "tax_table.h" + +#include <errno.h> +#include <limits.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <sys/socket.h> +#include <sys/time.h> +#include <time.h> +#include <unistd.h> + +#include <openssl/err.h> +#include <openssl/ssl.h> +#include <openssl/x509.h> + +#include "db.h" +#include "version.h" + +#define TT_LINE_LEN 49 +#define TT_TIMEOUT_SEC 30 +#define TT_MAX_REDIRECTS 5 +#define TT_TABLE_FILE "allmanna-tabeller-manad.txt" +#define TT_PAGE_URL \ + "https://www.skatteverket.se/foretag/arbetsgivare/" \ + "arbetsgivaravgifterochskatteavdrag/skattetabeller/" \ + "specialversionerforprogramforetagmfl.4.319dc1451507f2f99e86ee.html" + +static void set_err(char **err, const char *fmt, ...) + __attribute__((format(printf, 2, 3))); + +static void set_err(char **err, const char *fmt, ...) +{ + if (!err || *err) + return; + char buf[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + *err = xstrdup(buf); +} + +/* ------------------------------------------------------------------ */ +/* parser */ +/* ------------------------------------------------------------------ */ + +static int parse_int_field(const char *s, int len, int64_t *v, int *empty) +{ + int i = 0, j = len; + while (i < j && s[i] == ' ') + i++; + while (j > i && s[j - 1] == ' ') + j--; + *empty = i == j; + if (*empty) { + *v = 0; + return 0; + } + int64_t x = 0; + for (int k = i; k < j; k++) { + if (s[k] < '0' || s[k] > '9') + return -1; + x = x * 10 + (s[k] - '0'); + } + *v = x; + return 0; +} + +int tax_table_parse(const unsigned char *data, size_t len, + struct tax_row **out, size_t *out_n, char **err) +{ + *out = NULL; + *out_n = 0; + if (len >= 3 && data[0] == 0xef && data[1] == 0xbb && data[2] == 0xbf) { + data += 3; + len -= 3; + } + struct tax_row *rows = NULL; + size_t n = 0, cap = 0; + size_t pos = 0; + int line_no = 0; + while (pos < len) { + const char *line = (const char *)data + pos; + size_t end = pos; + while (end < len && data[end] != '\n') + end++; + size_t llen = end - pos; + pos = end + 1; + line_no++; + while (llen > 0 && (line[llen - 1] == '\r' || + line[llen - 1] == ' ' || line[llen - 1] == '\t')) + llen--; + if (llen == 0) + continue; + if (llen != TT_LINE_LEN) { + set_err(err, "line %d: expected %d characters, got %zu", line_no, + TT_LINE_LEN, llen); + goto bad; + } + if (line[0] != '3' || line[1] != '0' || + (line[2] != 'B' && line[2] != '%')) { + set_err(err, "line %d: not a 30B/30%% record", line_no); + goto bad; + } + int table_no = (line[3] - '0') * 10 + (line[4] - '0'); + if (table_no < 29 || table_no > 42) { + set_err(err, "line %d: table %d is out of range", line_no, + table_no); + goto bad; + } + int is_pct = line[2] == '%'; + int64_t from_kr = 0, to_kr = 0, col_kr = 0; + int empty = 0; + if (parse_int_field(line + 5, 7, &from_kr, &empty) != 0 || empty) { + set_err(err, "line %d: bad income from", line_no); + goto bad; + } + if (parse_int_field(line + 12, 7, &to_kr, &empty) != 0) { + set_err(err, "line %d: bad income to", line_no); + goto bad; + } + int has_to = !empty; + if (!has_to && !is_pct) { + set_err(err, "line %d: B record needs an income to", line_no); + goto bad; + } + if (has_to && to_kr < from_kr) { + set_err(err, "line %d: income range is reversed", line_no); + goto bad; + } + for (int c = 0; c < 6; c++) { + if (parse_int_field(line + 19 + 5 * c, 5, &col_kr, &empty) != 0 || + empty) { + set_err(err, "line %d: bad column %d", line_no, c + 1); + goto bad; + } + if (n == cap) { + cap = cap ? cap * 2 : 1024; + rows = xrealloc(rows, cap * sizeof *rows); + } + struct tax_row *row = &rows[n++]; + row->table_no = table_no; + row->column_no = c + 1; + row->income_from_ore = from_kr * 100; + row->income_to_ore = has_to ? to_kr * 100 : -1; + row->tax_ore = is_pct ? 0 : col_kr * 100; + row->pct = is_pct ? col_kr * 100 : 0; + row->is_pct = is_pct; + } + } + if (n == 0) { + set_err(err, "no table records found"); + goto bad; + } + *out = rows; + *out_n = n; + return 0; + +bad: + free(rows); + return -1; +} + +/* ------------------------------------------------------------------ */ +/* storage */ +/* ------------------------------------------------------------------ */ + +int tax_table_store(sqlite3 *db, int year, const struct tax_row *rows, + size_t n, const char *source_url, + const unsigned char sha256[32], const char *fetched_at, + char **err) +{ + char *del = sqlite3_mprintf("DELETE FROM tax_tables WHERE in_year=%d", + year); + if (!del) { + set_err(err, "out of memory"); + return -1; + } + int drc = db_exec(db, del, err); + sqlite3_free(del); + if (drc != 0) + return -1; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "INSERT INTO tax_tables(in_year,table_no,column_no,income_from_ore," + "income_to_ore,tax_ore,pct)" + " VALUES(?1,?2,?3,?4,?5,?6,?7)", + -1, &st, NULL) != SQLITE_OK) { + set_err(err, "database error: %s", sqlite3_errmsg(db)); + return -1; + } + for (size_t i = 0; i < n; i++) { + sqlite3_bind_int(st, 1, year); + sqlite3_bind_int(st, 2, rows[i].table_no); + sqlite3_bind_int(st, 3, rows[i].column_no); + sqlite3_bind_int64(st, 4, rows[i].income_from_ore); + if (rows[i].income_to_ore < 0) + sqlite3_bind_null(st, 5); + else + sqlite3_bind_int64(st, 5, rows[i].income_to_ore); + sqlite3_bind_int64(st, 6, rows[i].tax_ore); + if (rows[i].is_pct) + sqlite3_bind_int64(st, 7, rows[i].pct); + else + sqlite3_bind_null(st, 7); + int rc = sqlite3_step(st); + sqlite3_reset(st); + sqlite3_clear_bindings(st); + if (rc != SQLITE_DONE) { + set_err(err, "could not store row %zu: %s", i + 1, + sqlite3_errmsg(db)); + sqlite3_finalize(st); + return -1; + } + } + sqlite3_finalize(st); + if (sqlite3_prepare_v2( + db, + "INSERT INTO tax_table_meta(in_year,source_url,sha256,fetched_at)" + " VALUES(?1,?2,?3,?4)" + " ON CONFLICT(in_year) DO UPDATE SET" + " source_url=excluded.source_url, sha256=excluded.sha256," + " fetched_at=excluded.fetched_at", + -1, &st, NULL) != SQLITE_OK) { + set_err(err, "database error: %s", sqlite3_errmsg(db)); + return -1; + } + sqlite3_bind_int(st, 1, year); + sqlite3_bind_text(st, 2, source_url, -1, SQLITE_TRANSIENT); + sqlite3_bind_blob(st, 3, sha256, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, fetched_at, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + set_err(err, "could not store metadata: %s", sqlite3_errmsg(db)); + return -1; + } + return 0; +} + +int tax_table_lookup(sqlite3 *db, int year, int table_no, int column_no, + int64_t gross_ore, int64_t *tax_ore) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT MAX(income_to_ore) FROM tax_tables" + " WHERE in_year=?1 AND table_no=?2 AND column_no=?3" + " AND pct IS NULL", + -1, &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_int(st, 1, year); + sqlite3_bind_int(st, 2, table_no); + sqlite3_bind_int(st, 3, column_no); + int step = sqlite3_step(st); + if (step != SQLITE_ROW || + sqlite3_column_type(st, 0) == SQLITE_NULL) { + sqlite3_finalize(st); + return 2; + } + int64_t top = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + if (gross_ore > top) + return 1; + if (sqlite3_prepare_v2( + db, + "SELECT tax_ore FROM tax_tables" + " WHERE in_year=?1 AND table_no=?2 AND column_no=?3" + " AND pct IS NULL AND income_from_ore<=?4 AND income_to_ore>=?4", + -1, &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_int(st, 1, year); + sqlite3_bind_int(st, 2, table_no); + sqlite3_bind_int(st, 3, column_no); + sqlite3_bind_int64(st, 4, gross_ore); + step = sqlite3_step(st); + if (step != SQLITE_ROW) { + sqlite3_finalize(st); + return 2; + } + *tax_ore = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* HTTPS GET */ +/* ------------------------------------------------------------------ */ + +struct tt_url { + char host[256]; + char path[2048]; + int port; +}; + +static int tt_url_parse(const char *url, struct tt_url *u, char **err) +{ + if (strncmp(url, "https://", 8) != 0) { + set_err(err, "not an https URL: %.120s", url); + return -1; + } + const char *p = url + 8; + const char *slash = strchr(p, '/'); + const char *host_end = slash ? slash : p + strlen(p); + const char *colon = memchr(p, ':', (size_t)(host_end - p)); + size_t hl = colon ? (size_t)(colon - p) : (size_t)(host_end - p); + if (hl == 0 || hl >= sizeof u->host) { + set_err(err, "bad host in URL: %.120s", url); + return -1; + } + memcpy(u->host, p, hl); + u->host[hl] = '\0'; + u->port = 443; + if (colon) { + long port = strtol(colon + 1, NULL, 10); + if (port < 1 || port > 65535) { + set_err(err, "bad port in URL: %.120s", url); + return -1; + } + u->port = (int)port; + } + snprintf(u->path, sizeof u->path, "%s", slash ? slash : "/"); + return 0; +} + +static int tt_tcp_connect(const struct tt_url *u, char **err) +{ + char portstr[16]; + snprintf(portstr, sizeof portstr, "%d", u->port); + struct addrinfo hints; + memset(&hints, 0, sizeof hints); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo *res = NULL; + int gai = getaddrinfo(u->host, portstr, &hints, &res); + if (gai != 0) { + set_err(err, "cannot resolve %s: %s", u->host, gai_strerror(gai)); + return -1; + } + int last = 0; + for (struct addrinfo *ai = res; ai; ai = ai->ai_next) { + int fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, + ai->ai_protocol); + if (fd < 0) { + last = errno; + continue; + } + struct timeval tv; + tv.tv_sec = TT_TIMEOUT_SEC; + tv.tv_usec = 0; + (void)setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + (void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) { + freeaddrinfo(res); + return fd; + } + last = errno; + close(fd); + } + freeaddrinfo(res); + set_err(err, "cannot connect to %s:%d: %s", u->host, u->port, + last ? strerror(last) : "unknown error"); + return -1; +} + +static int tt_tls_start(SSL_CTX *ctx, SSL **ssl, int fd, const char *host, + char **err) +{ + *ssl = SSL_new(ctx); + if (!*ssl) { + set_err(err, "cannot create TLS connection"); + return -1; + } + if (SSL_set_fd(*ssl, fd) != 1 || + SSL_set_tlsext_host_name(*ssl, host) != 1 || + SSL_set1_host(*ssl, host) != 1) { + set_err(err, "cannot set up TLS connection"); + return -1; + } + if (SSL_connect(*ssl) != 1) { + long vr = SSL_get_verify_result(*ssl); + unsigned long ec = ERR_get_error(); + if (vr != X509_V_OK) + set_err(err, "TLS certificate verification failed: %s", + X509_verify_cert_error_string(vr)); + else if (ec) + set_err(err, "TLS handshake failed: %s", + ERR_error_string(ec, NULL)); + else + set_err(err, "TLS handshake failed"); + return -1; + } + if (SSL_get_verify_result(*ssl) != X509_V_OK) { + set_err(err, "TLS certificate verification failed: %s", + X509_verify_cert_error_string(SSL_get_verify_result(*ssl))); + return -1; + } + return 0; +} + +static int tt_write(SSL *ssl, const void *data, size_t n, char **err) +{ + const unsigned char *p = data; + while (n > 0) { + int chunk = n > (size_t)INT_MAX ? INT_MAX : (int)n; + int w = SSL_write(ssl, p, chunk); + if (w <= 0) { + int e = SSL_get_error(ssl, w); + if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE) + continue; + set_err(err, "TLS write failed"); + return -1; + } + p += w; + n -= (size_t)w; + } + return 0; +} + +static int tt_read_all(SSL *ssl, struct buf *out, char **err) +{ + for (;;) { + unsigned char chunk[65536]; + int r = SSL_read(ssl, chunk, (int)sizeof chunk); + if (r > 0) { + buf_append(out, chunk, (size_t)r); + continue; + } + int e = SSL_get_error(ssl, r); + if (e == SSL_ERROR_ZERO_RETURN) + return 0; + if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE) + continue; + if (e == SSL_ERROR_SYSCALL && r == 0) + return 0; +#ifdef SSL_R_UNEXPECTED_EOF_WHILE_READING + if (e == SSL_ERROR_SSL) { + unsigned long ec = ERR_peek_last_error(); + if (ec && + ERR_GET_REASON(ec) == SSL_R_UNEXPECTED_EOF_WHILE_READING) + return 0; + } +#endif + if (e == SSL_ERROR_SYSCALL && + (errno == EAGAIN || errno == EWOULDBLOCK)) { + set_err(err, "receive timed out"); + return -1; + } + unsigned long ec = ERR_get_error(); + if (ec) + set_err(err, "TLS read failed: %s", ERR_error_string(ec, NULL)); + else + set_err(err, "TLS read failed"); + return -1; + } +} + +/* One GET with Connection: close; returns status, Location (or NULL) and + the response body. */ +static int tt_get_once(const struct tt_url *u, struct buf *body, int *status, + char **location, char **err) +{ + int fd = tt_tcp_connect(u, err); + if (fd < 0) + return -1; + SSL_CTX *ctx = SSL_CTX_new(TLS_client_method()); + SSL *ssl = NULL; + int rc = -1; + if (!ctx) { + set_err(err, "cannot create TLS context"); + goto done; + } + SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); + SSL_CTX_set_options(ctx, SSL_OP_NO_COMPRESSION | SSL_OP_NO_RENEGOTIATION); + SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL); + if (SSL_CTX_set_default_verify_paths(ctx) != 1) { + set_err(err, "cannot load system CA certificates"); + goto done; + } + if (tt_tls_start(ctx, &ssl, fd, u->host, err) != 0) + goto done; + + struct buf req; + buf_init(&req); + static const char head_start[] = "GET "; + static const char head_mid[] = " HTTP/1.1\r\nHost: "; + static const char head_end[] = "\r\nUser-Agent: bokf/" BOKF_VERSION + "\r\nAccept: */*\r\nConnection: close\r\n\r\n"; + buf_append(&req, head_start, sizeof head_start - 1); + buf_append(&req, u->path, strlen(u->path)); + buf_append(&req, head_mid, sizeof head_mid - 1); + buf_append(&req, u->host, strlen(u->host)); + buf_append(&req, head_end, sizeof head_end - 1); + int ok = tt_write(ssl, req.p, req.len, err); + buf_free(&req); + if (ok != 0) + goto done; + + struct buf raw; + buf_init(&raw); + if (tt_read_all(ssl, &raw, err) != 0) { + buf_free(&raw); + goto done; + } + size_t hdr_end = 0; + int found = 0; + for (size_t i = 0; i + 3 < raw.len; i++) { + if (raw.p[i] == '\r' && raw.p[i + 1] == '\n' && + raw.p[i + 2] == '\r' && raw.p[i + 3] == '\n') { + hdr_end = i + 4; + found = 1; + break; + } + } + if (!found) { + set_err(err, "malformed HTTP response"); + buf_free(&raw); + goto done; + } + *status = 0; + if (raw.len >= 12 && memcmp(raw.p, "HTTP/", 5) == 0) + *status = atoi((const char *)raw.p + 9); + char line[1024]; + size_t p = 0; + int first = 1; + while (p < hdr_end) { + size_t e = p; + while (e < hdr_end && raw.p[e] != '\n') + e++; + size_t n = e - p; + if (n > 0 && raw.p[e - 1] == '\r') + n--; + if (n >= sizeof line) + n = sizeof line - 1; + memcpy(line, raw.p + p, n); + line[n] = '\0'; + if (!first && n >= 9 && strncasecmp(line, "location:", 9) == 0) { + const char *v = line + 9; + while (*v == ' ' || *v == '\t') + v++; + free(*location); + *location = xstrdup(v); + } + first = 0; + p = e + 1; + } + for (size_t i = hdr_end; i < raw.len; i++) + buf_append(body, raw.p + i, 1); + buf_free(&raw); + rc = 0; + +done: + if (ssl) { + SSL_shutdown(ssl); + SSL_free(ssl); + } + if (ctx) + SSL_CTX_free(ctx); + close(fd); + return rc; +} + +static int tt_is_absolute(const char *u) +{ + return strncmp(u, "https://", 8) == 0 || + strncmp(u, "http://", 7) == 0; +} + +static char *tt_resolve(const char *base, const char *location, char **err) +{ + if (tt_is_absolute(location)) + return xstrdup(location); + struct tt_url u; + if (tt_url_parse(base, &u, err) != 0) + return NULL; + char out[2560]; + if (location[0] == '/') { + if (u.port == 443) + snprintf(out, sizeof out, "https://%s%s", u.host, location); + else + snprintf(out, sizeof out, "https://%s:%d%s", u.host, u.port, + location); + return xstrdup(out); + } + const char *last = strrchr(u.path, '/'); + size_t dir = last ? (size_t)(last - u.path + 1) : 0; + char path[2048]; + if (dir >= sizeof path) { + set_err(err, "redirect path is too long"); + return NULL; + } + memcpy(path, u.path, dir); + snprintf(path + dir, sizeof path - dir, "%s", location); + if (u.port == 443) + snprintf(out, sizeof out, "https://%s%s", u.host, path); + else + snprintf(out, sizeof out, "https://%s:%d%s", u.host, u.port, path); + return xstrdup(out); +} + +int tax_table_https_get(const char *url, struct buf *out, char **err) +{ + char *current = xstrdup(url); + for (int hop = 0; hop <= TT_MAX_REDIRECTS; hop++) { + struct tt_url u; + if (tt_url_parse(current, &u, err) != 0) { + free(current); + return -1; + } + struct buf body; + buf_init(&body); + int status = 0; + char *location = NULL; + int rc = tt_get_once(&u, &body, &status, &location, err); + if (rc != 0) { + free(location); + buf_free(&body); + free(current); + return -1; + } + if (status >= 300 && status < 400 && location && *location) { + char *next = tt_resolve(current, location, err); + free(location); + buf_free(&body); + free(current); + if (!next) + return -1; + current = next; + continue; + } + if (status != 200) { + set_err(err, "HTTP %d for %.160s", status, current); + free(location); + buf_free(&body); + free(current); + return -1; + } + free(location); + free(current); + *out = body; + return 0; + } + set_err(err, "too many redirects for %.160s", url); + free(current); + return -1; +} + +/* ------------------------------------------------------------------ */ +/* Skatteverket page and download */ +/* ------------------------------------------------------------------ */ + +static char *find_link(const char *html, size_t len, const char *suffix, + int index) +{ + size_t slen = strlen(suffix); + int seen = 0; + size_t i = 0; + while (i + 6 < len) { + if (strncasecmp(html + i, "href=", 5) != 0) { + i++; + continue; + } + char quote = html[i + 5]; + if (quote != '"' && quote != '\'') { + i += 5; + continue; + } + size_t start = i + 6; + size_t end = start; + while (end < len && html[end] != quote) + end++; + size_t hlen = end - start; + if (hlen >= slen && + strncmp(html + end - slen, suffix, slen) == 0) { + if (seen == index) { + char *href = xmalloc(hlen + 1); + memcpy(href, html + start, hlen); + href[hlen] = '\0'; + char *amp = href; + while ((amp = strstr(amp, "&")) != NULL) { + *amp = '&'; + memmove(amp + 1, amp + 5, strlen(amp + 5) + 1); + } + return href; + } + seen++; + } + i = end + 1; + } + return NULL; +} + +int tax_table_fetch_year(int year, unsigned char **data, size_t *len, + char **source_url, char **err) +{ + *data = NULL; + *len = 0; + *source_url = NULL; + time_t now = time(NULL); + struct tm tm; + gmtime_r(&now, &tm); + int current_year = tm.tm_year + 1900; + if (year < 2000 || year > current_year) { + set_err(err, "no published table for year %d", year); + return -1; + } + + struct buf page; + buf_init(&page); + if (tax_table_https_get(TT_PAGE_URL, &page, err) != 0) { + buf_free(&page); + return -1; + } + size_t page_len = page.len; + buf_append(&page, "", 1); + char *href = find_link((const char *)page.p, page_len, TT_TABLE_FILE, + current_year - year); + buf_free(&page); + if (!href) { + set_err(err, "no %s link for %d on Skatteverket's page", TT_TABLE_FILE, + year); + return -1; + } + char *abs = tt_resolve(TT_PAGE_URL, href, err); + free(href); + if (!abs) + return -1; + + struct buf file; + buf_init(&file); + if (tax_table_https_get(abs, &file, err) != 0) { + buf_free(&file); + free(abs); + return -1; + } + *data = file.p; + *len = file.len; + *source_url = abs; + return 0; +} |
