From e5abd28a9806ee8f4239a1db068876be82363975 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Sun, 20 Sep 2026 12:59:05 +0200 Subject: bank: import SEB CSV and match against vouchers (schema v8) --- src/commands.c | 891 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/db.c | 81 ++++++ src/db.h | 2 +- 3 files changed, 969 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/commands.c b/src/commands.c index 2abe0d4..06a7725 100644 --- a/src/commands.c +++ b/src/commands.c @@ -1612,6 +1612,9 @@ static const char AGENT_INSTRUCTIONS[] = "- Konteringsmallar: list with `template.list`, apply with `voucher.post`\n" " `{\"template\":\"name\",\"x\":1250}` where x is kronor; formulas use x,\n" " numbers and + - * /. Positive results debit, negative credit.\n" + "- Bank: `bank.import` stores a SEB CSV statement as read-only evidence and\n" + " `bank.list` suggests already-posted vouchers to match with `bank.match`;\n" + " reconciliation never posts vouchers by itself.\n" "\n" "## Discovery\n" "- `describe` lists every implemented command with permissions.\n" @@ -2604,7 +2607,7 @@ static int parse_attachment_ids(struct req *r, int64_t **out, size_t *out_n) static yyjson_mut_val *h_settings_get(struct req *r) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - int have_default = 0; + int have_default = 0, have_bank = 0; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st, @@ -2618,12 +2621,16 @@ static yyjson_mut_val *h_settings_get(struct req *r) yyjson_mut_strcpy(r->rdoc, v ? v : "")); if (strcmp(k, "default_series") == 0) have_default = 1; + if (strcmp(k, "bank_account") == 0) + have_bank = 1; } } sqlite3_finalize(st); } if (!have_default) yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A"); + if (!have_bank) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "bank_account", "1930"); return o; } @@ -2634,20 +2641,27 @@ static yyjson_mut_val *h_settings_set(struct req *r) if (!key || !value) return fail(r, "INVALID_ARGS", "key and value are required"); size_t maxlen; + int digits_only = 0; if (strcmp(key, "default_series") == 0) maxlen = 8; else if (strcmp(key, "attachment_dir") == 0) maxlen = 255; - else + else if (strcmp(key, "bank_account") == 0) { + maxlen = 10; + digits_only = 1; + } else return fail(r, "UNSUPPORTED", "unknown setting"); size_t len = strlen(value); if (len == 0 || len > maxlen) return failf(r, "INVALID_ARGS", "%s must be 1-%zu characters", key, maxlen); - for (const char *p = value; *p; p++) + for (const char *p = value; *p; p++) { + if (digits_only && (*p < '0' || *p > '9')) + return failf(r, "INVALID_ARGS", "%s must be digits only", key); if ((unsigned char)*p < 32) return failf(r, "INVALID_ARGS", "%s must not contain control characters", key); + } if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); @@ -4975,6 +4989,842 @@ static yyjson_mut_val *h_sie_import(struct req *r) return o; } +/* ------------------------------------------------------------------ */ +/* bank reconciliation (phase 1: import and match, never book) */ +/* ------------------------------------------------------------------ */ + +struct seb_row { + const char *booked_at; + const char *value_date; + const char *text; + const char *type; + int64_t amount_ore; + int has_balance; + int64_t balance_ore; +}; + +static int csv_split(char *line, char **fields, int max) +{ + int nf = 0, in_quotes = 0; + char *dst = line; + fields[nf++] = dst; + for (char *p = line; *p; p++) { + if (*p == '"') { + if (in_quotes && p[1] == '"') { + *dst++ = '"'; + p++; + } else { + in_quotes = !in_quotes; + } + } else if (*p == ';' && !in_quotes) { + *dst++ = '\0'; + if (nf >= max) + return -1; + fields[nf++] = dst; + } else { + *dst++ = *p; + } + } + *dst = '\0'; + return in_quotes ? -1 : nf; +} + +static int seb_amount(const char *s, int64_t *out) +{ + while (*s == ' ') + s++; + int neg = 0; + if (*s == '-') { + neg = 1; + s++; + } else if (*s == '+') { + s++; + } + int64_t whole = 0, frac = 0; + int digits = 0, fdigits = 0, comma = 0; + for (; *s; s++) { + if (*s == ' ' || *s == '.') + continue; + if (*s == ',') { + if (comma) + return -1; + comma = 1; + continue; + } + if (*s < '0' || *s > '9') + return -1; + if (comma) { + if (fdigits >= 2) + return -1; + frac = frac * 10 + (*s - '0'); + fdigits++; + } else { + if (whole > (INT64_MAX - 9) / 10) + return -1; + whole = whole * 10 + (*s - '0'); + digits++; + } + } + if (digits == 0) + return -1; + while (fdigits < 2) { + frac *= 10; + fdigits++; + } + if (whole > (INT64_MAX - frac) / 100) + return -1; + int64_t v = whole * 100 + frac; + *out = neg ? -v : v; + return 0; +} + +static int seb_parse(char *csv, struct seb_row **out, size_t *out_n, + char *err, size_t errlen) +{ + static const char *header[7] = { + "Bokförd", "Valutadatum", "Text", "Typ", + "Insättningar", "Uttag", "Bokfört saldo", + }; + struct seb_row *rows = NULL; + size_t n = 0, cap = 0; + char *cur = csv; + int lineno = 0; + while (*cur) { + lineno++; + char *nl = strchr(cur, '\n'); + if (nl) + *nl = '\0'; + size_t llen = strlen(cur); + if (llen && cur[llen - 1] == '\r') + cur[--llen] = '\0'; + char *fields[8]; + int nf = csv_split(cur, fields, 8); + if (nf < 0) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + if (lineno == 1) { + int ok = nf == 7; + for (int i = 0; ok && i < 7; i++) + if (strcmp(fields[i], header[i]) != 0) + ok = 0; + if (!ok) { + snprintf(err, errlen, "not a SEB CSV export: header mismatch"); + goto bad; + } + if (!nl) + break; + cur = nl + 1; + continue; + } + if (llen == 0) { + if (!nl) + break; + cur = nl + 1; + continue; + } + if (nf != 7) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + struct seb_row row; + row.booked_at = fields[0]; + row.value_date = fields[1]; + row.text = fields[2]; + row.type = fields[3]; + row.has_balance = fields[6][0] != '\0'; + row.balance_ore = 0; + int has_dep = fields[4][0] != '\0'; + int has_wd = fields[5][0] != '\0'; + if (!util_date_valid(fields[0]) || !util_date_valid(fields[1]) || + has_dep + has_wd != 1) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + if (has_dep) { + if (seb_amount(fields[4], &row.amount_ore) != 0) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + } else { + int64_t w = 0; + if (seb_amount(fields[5], &w) != 0) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + row.amount_ore = w > 0 ? -w : w; + } + if (row.has_balance && seb_amount(fields[6], &row.balance_ore) != 0) { + snprintf(err, errlen, "malformed SEB CSV on line %d", lineno); + goto bad; + } + if (n == cap) { + cap = cap ? cap * 2 : 64; + rows = xrealloc(rows, cap * sizeof *rows); + } + rows[n++] = row; + if (!nl) + break; + cur = nl + 1; + } + *out = rows; + *out_n = n; + return 0; +bad: + free(rows); + return -1; +} + +static void bank_field(struct buf *b, const char *s, int wide) +{ + size_t n = strlen(s); + if (wide) + buf_append_u32be(b, (uint32_t)n); + else + buf_append_u16be(b, (uint16_t)n); + buf_append(b, s, n); +} + +static void bank_row_hash(const char *account, const struct seb_row *row, + unsigned char out[32]) +{ + static const char tag[] = "bokf-v1-bank-tx"; + struct buf b; + buf_init(&b); + buf_append(&b, tag, sizeof tag); + bank_field(&b, account, 0); + bank_field(&b, row->booked_at, 0); + bank_field(&b, row->value_date, 0); + bank_field(&b, row->text, 1); + bank_field(&b, row->type, 1); + buf_append_u64be(&b, (uint64_t)row->amount_ore); + unsigned char flag = row->has_balance ? 1 : 0; + buf_append(&b, &flag, 1); + if (row->has_balance) + buf_append_u64be(&b, (uint64_t)row->balance_ore); + util_sha256(b.p, b.len, out); + buf_free(&b); +} + +static int bank_hash_cmp(const void *a, const void *b) +{ + return memcmp(a, b, 32); +} + +static int bank_account_exists(struct req *r, const char *account) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, "SELECT 1 FROM accounts WHERE org_id=?1 AND number=?2", -1, + &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); + int found = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + return found; +} + +static yyjson_mut_val *h_bank_import(struct req *r) +{ + const char *format = arg_str(r->args, "format"); + const char *b64 = arg_str(r->args, "content_base64"); + const char *path = arg_str(r->args, "path"); + const char *account = arg_str(r->args, "account"); + if (!format || strcmp(format, "seb") != 0) + return fail(r, "INVALID_ARGS", "format must be \"seb\""); + unsigned char *content = NULL; + size_t len = 0; + if (b64) { + if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) + return fail(r, "INVALID_ARGS", + "content_base64 is not valid base64"); + } else if (path) { + content = read_file(path, &len); + if (!content) + return fail(r, "NOT_FOUND", "could not read file"); + } else { + return fail(r, "INVALID_ARGS", "content_base64 or path is required"); + } + if (len > 64u * 1024u * 1024u) { + free(content); + return fail(r, "TOO_LARGE", "bank statement is too large"); + } + if (memchr(content, '\0', len) != NULL) { + free(content); + return fail(r, "INVALID_ARGS", "not a SEB CSV export: header mismatch"); + } + char *csv = xmalloc(len + 1); + memcpy(csv, content, len); + csv[len] = '\0'; + free(content); + char *start = csv; + if (len >= 3 && memcmp(start, "\xEF\xBB\xBF", 3) == 0) + start += 3; + char perr[128]; + struct seb_row *rows = NULL; + size_t nrows = 0; + if (seb_parse(start, &rows, &nrows, perr, sizeof perr) != 0) { + free(csv); + return fail(r, "INVALID_ARGS", perr); + } + + char *setting = account && *account + ? NULL + : db_setting(r->db, r->org_id, "bank_account"); + const char *acct = account && *account + ? account + : (setting ? setting : "1930"); + int found = bank_account_exists(r, acct); + if (found < 0) { + free(setting); + free(rows); + free(csv); + return fail(r, "INTERNAL", "database error"); + } + if (!found) { + yyjson_mut_val *res = + failf(r, "ACCOUNT_NOT_FOUND", "account %s not found", acct); + free(setting); + free(rows); + free(csv); + return res; + } + + unsigned char(*hashes)[32] = xmalloc((nrows ? nrows : 1) * 32); + for (size_t i = 0; i < nrows; i++) + bank_row_hash(acct, &rows[i], hashes[i]); + + int64_t imported = 0; + if (r->dry_run) { + unsigned char(*sorted)[32] = xmalloc((nrows ? nrows : 1) * 32); + memcpy(sorted, hashes, nrows * 32); + qsort(sorted, nrows, 32, bank_hash_cmp); + for (size_t i = 0; i < nrows;) { + size_t j = i + 1; + while (j < nrows && memcmp(sorted[i], sorted[j], 32) == 0) + j++; + sqlite3_stmt *q = NULL; + int exists = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT 1 FROM bank_transactions WHERE org_id=?1" + " AND source_hash=?2", + -1, &q, NULL) != SQLITE_OK) { + free(sorted); + free(hashes); + free(setting); + free(rows); + free(csv); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(q, 1, r->org_id); + sqlite3_bind_blob(q, 2, sorted[i], 32, SQLITE_TRANSIENT); + exists = sqlite3_step(q) == SQLITE_ROW; + sqlite3_finalize(q); + if (!exists) + imported++; + i = j; + } + free(sorted); + } else { + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + int failed = db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0; + for (size_t i = 0; i < nrows && !failed; i++) { + sqlite3_stmt *ins = NULL; + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO bank_transactions(org_id,account,booked_at," + "value_date,text,type,amount_ore,balance_ore,source," + "source_hash,imported_at,imported_by)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,'seb-csv',?9,?10,?11)" + " ON CONFLICT(org_id,source_hash) DO NOTHING", + -1, &ins, NULL) != SQLITE_OK) { + failed = 1; + break; + } + sqlite3_bind_int64(ins, 1, r->org_id); + sqlite3_bind_text(ins, 2, acct, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 3, rows[i].booked_at, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 4, rows[i].value_date, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 5, rows[i].text, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 6, rows[i].type, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(ins, 7, rows[i].amount_ore); + if (rows[i].has_balance) + sqlite3_bind_int64(ins, 8, rows[i].balance_ore); + else + sqlite3_bind_null(ins, 8); + sqlite3_bind_blob(ins, 9, hashes[i], 32, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 10, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(ins, 11, r->sess->user_id); + int rc = sqlite3_step(ins); + sqlite3_finalize(ins); + if (rc != SQLITE_DONE) { + failed = 1; + break; + } + if (sqlite3_changes(r->db) > 0) + imported++; + } + if (failed) { + db_exec(r->db, "ROLLBACK", NULL); + free(hashes); + free(setting); + free(rows); + free(csv); + return fail(r, "INTERNAL", "database error"); + } + if (db_exec(r->db, "COMMIT", NULL) != 0) { + db_exec(r->db, "ROLLBACK", NULL); + free(hashes); + free(setting); + free(rows); + free(csv); + return fail(r, "INTERNAL", "database error"); + } + } + int64_t duplicates = (int64_t)nrows - imported; + const char *first = NULL, *last = NULL; + for (size_t i = 0; i < nrows; i++) { + if (!first || strcmp(rows[i].booked_at, first) < 0) + first = rows[i].booked_at; + if (!last || strcmp(rows[i].booked_at, last) > 0) + last = rows[i].booked_at; + } + if (!r->dry_run) { + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "bank.import", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "format", "seb"); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", acct); + yyjson_mut_obj_add_int(r->rdoc, o, "total", (int64_t)nrows); + yyjson_mut_obj_add_int(r->rdoc, o, "imported", imported); + yyjson_mut_obj_add_int(r->rdoc, o, "duplicates", duplicates); + if (first) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "first_date", first); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_date"); + if (last) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_date", last); + else + yyjson_mut_obj_add_null(r->rdoc, o, "last_date"); + if (r->dry_run) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + free(hashes); + free(setting); + free(rows); + free(csv); + return o; +} + +static yyjson_mut_val *h_bank_list(struct req *r) +{ + const char *status = arg_str(r->args, "status"); + const char *from = arg_str(r->args, "from"); + const char *to = arg_str(r->args, "to"); + const char *account = arg_str(r->args, "account"); + int64_t limit = 200; + arg_int(r->args, "limit", &limit); + if (limit < 1) + limit = 200; + if (limit > 1000) + limit = 1000; + int status_code = 0; + if (status && strcmp(status, "unmatched") == 0) + status_code = 1; + else if (status && strcmp(status, "matched") == 0) + status_code = 2; + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT t.id,t.account,t.booked_at,t.value_date,t.text,t.type," + "t.amount_ore,t.balance_ore FROM bank_transactions t" + " WHERE t.org_id=?1" + " AND (?2 IS NULL OR t.account=?2)" + " AND (?3 IS NULL OR t.booked_at>=?3)" + " AND (?4 IS NULL OR t.booked_at<=?4)" + " AND (?5=0" + " OR (?5=1 AND NOT EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=t.org_id AND m.transaction_id=t.id))" + " OR (?5=2 AND EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)))" + " ORDER BY t.booked_at,t.id LIMIT ?6", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (account) + sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 2); + if (from) + sqlite3_bind_text(st, 3, from, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 3); + if (to) + sqlite3_bind_text(st, 4, to, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 4); + sqlite3_bind_int64(st, 5, status_code); + sqlite3_bind_int64(st, 6, limit); + + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + while (sqlite3_step(st) == SQLITE_ROW) { + int64_t tx_id = sqlite3_column_int64(st, 0); + char tx_account[16]; + snprintf(tx_account, sizeof tx_account, "%s", + sq(sqlite3_column_text(st, 1))); + const char *booked_at = sq(sqlite3_column_text(st, 2)); + yyjson_mut_val *item = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, item, "id", tx_id); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "account", tx_account); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "booked_at", booked_at); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "value_date", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "text", + sq(sqlite3_column_text(st, 4))); + yyjson_mut_obj_add_strcpy(r->rdoc, item, "type", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_int(r->rdoc, item, "amount_ore", + sqlite3_column_int64(st, 6)); + if (sqlite3_column_type(st, 7) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, item, "balance_ore"); + else + yyjson_mut_obj_add_int(r->rdoc, item, "balance_ore", + sqlite3_column_int64(st, 7)); + + yyjson_mut_val *matches = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *ms = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT v.id,v.series,v.number,v.date," + "COALESCE(SUM(r.debit_ore-r.credit_ore),0)" + " FROM bank_matches m" + " JOIN vouchers v ON v.org_id=m.org_id AND v.id=m.voucher_id" + " LEFT JOIN accounts a ON a.org_id=v.org_id AND a.number=?2" + " LEFT JOIN voucher_rows r ON r.org_id=v.org_id" + " AND r.voucher_id=v.id AND r.account_id=a.id" + " WHERE m.org_id=?1 AND m.transaction_id=?3" + " GROUP BY v.id,v.series,v.number,v.date ORDER BY v.id", + -1, &ms, NULL) != SQLITE_OK) { + sqlite3_finalize(st); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(ms, 1, r->org_id); + sqlite3_bind_text(ms, 2, tx_account, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(ms, 3, tx_id); + while (sqlite3_step(ms) == SQLITE_ROW) { + yyjson_mut_val *m = yyjson_mut_arr_add_obj(r->rdoc, matches); + yyjson_mut_obj_add_int(r->rdoc, m, "voucher_id", + sqlite3_column_int64(ms, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, m, "series", + sq(sqlite3_column_text(ms, 1))); + yyjson_mut_obj_add_int(r->rdoc, m, "number", + sqlite3_column_int64(ms, 2)); + yyjson_mut_obj_add_strcpy(r->rdoc, m, "date", + sq(sqlite3_column_text(ms, 3))); + yyjson_mut_obj_add_int(r->rdoc, m, "bank_amount_ore", + sqlite3_column_int64(ms, 4)); + } + sqlite3_finalize(ms); + yyjson_mut_obj_add_val(r->rdoc, item, "matches", matches); + + yyjson_mut_val *suggestions = yyjson_mut_arr(r->rdoc); + if (yyjson_mut_arr_size(matches) == 0) { + sqlite3_stmt *ss = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT v.id,v.series,v.number,v.date," + "SUM(r.debit_ore-r.credit_ore)" + " FROM vouchers v" + " JOIN voucher_rows r ON r.org_id=v.org_id" + " AND r.voucher_id=v.id" + " JOIN accounts a ON a.org_id=r.org_id" + " AND a.id=r.account_id" + " WHERE v.org_id=?1 AND a.number=?2" + " AND ABS(julianday(v.date)-julianday(?3))<=5" + " AND NOT EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=v.org_id AND m.voucher_id=v.id)" + " GROUP BY v.id,v.series,v.number,v.date" + " HAVING SUM(r.debit_ore-r.credit_ore)=?4" + " ORDER BY ABS(julianday(v.date)-julianday(?3)),v.date," + "v.id LIMIT 3", + -1, &ss, NULL) != SQLITE_OK) { + sqlite3_finalize(st); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(ss, 1, r->org_id); + sqlite3_bind_text(ss, 2, tx_account, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ss, 3, booked_at, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(ss, 4, sqlite3_column_int64(st, 6)); + while (sqlite3_step(ss) == SQLITE_ROW) { + yyjson_mut_val *s = + yyjson_mut_arr_add_obj(r->rdoc, suggestions); + yyjson_mut_obj_add_int(r->rdoc, s, "voucher_id", + sqlite3_column_int64(ss, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, s, "series", + sq(sqlite3_column_text(ss, 1))); + yyjson_mut_obj_add_int(r->rdoc, s, "number", + sqlite3_column_int64(ss, 2)); + yyjson_mut_obj_add_strcpy(r->rdoc, s, "date", + sq(sqlite3_column_text(ss, 3))); + yyjson_mut_obj_add_int(r->rdoc, s, "amount_ore", + sqlite3_column_int64(ss, 4)); + } + sqlite3_finalize(ss); + } + yyjson_mut_obj_add_val(r->rdoc, item, "suggestions", suggestions); + } + sqlite3_finalize(st); + + sqlite3_stmt *sum = NULL; + int64_t unmatched = 0, matched = 0, unmatched_amount = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT COUNT(*)," + "COALESCE(SUM(EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)),0)," + "COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM bank_matches m" + " WHERE m.org_id=t.org_id AND m.transaction_id=t.id)" + " THEN 0 ELSE t.amount_ore END),0)" + " FROM bank_transactions t" + " WHERE t.org_id=?1 AND (?2 IS NULL OR t.account=?2)", + -1, &sum, NULL) == SQLITE_OK) { + sqlite3_bind_int64(sum, 1, r->org_id); + if (account) + sqlite3_bind_text(sum, 2, account, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(sum, 2); + if (sqlite3_step(sum) == SQLITE_ROW) { + int64_t total = sqlite3_column_int64(sum, 0); + matched = sqlite3_column_int64(sum, 1); + unmatched = total - matched; + unmatched_amount = sqlite3_column_int64(sum, 2); + } + sqlite3_finalize(sum); + } + yyjson_mut_val *res = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, res, "items", items); + yyjson_mut_val *s = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, s, "unmatched", unmatched); + yyjson_mut_obj_add_int(r->rdoc, s, "matched", matched); + yyjson_mut_obj_add_int(r->rdoc, s, "unmatched_amount_ore", + unmatched_amount); + yyjson_mut_obj_add_val(r->rdoc, res, "summary", s); + return res; +} + +static int bank_legs_sum(sqlite3 *db, int64_t org_id, int64_t tx_id, + const char *account, int64_t *out) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT COALESCE(SUM(r.debit_ore-r.credit_ore),0)" + " FROM bank_matches m" + " JOIN voucher_rows r ON r.org_id=m.org_id" + " AND r.voucher_id=m.voucher_id" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE m.org_id=?1 AND m.transaction_id=?2 AND a.number=?3", + -1, &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_text(st, 3, account, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + if (rc == SQLITE_ROW) + *out = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return rc == SQLITE_ROW ? 0 : -1; +} + +static yyjson_mut_val *h_bank_match(struct req *r) +{ + int64_t tx_id = 0, voucher_id = 0; + if (!arg_int(r->args, "transaction_id", &tx_id) || tx_id <= 0 || + !arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) + return fail(r, "INVALID_ARGS", + "transaction_id and voucher_id are required"); + sqlite3_stmt *st = NULL; + char account[16]; + int64_t amount = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT account,amount_ore FROM bank_transactions" + " WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tx_id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "bank transaction not found"); + } + snprintf(account, sizeof account, "%s", sq(sqlite3_column_text(st, 0))); + amount = sqlite3_column_int64(st, 1); + sqlite3_finalize(st); + + int found = 0; + if (sqlite3_prepare_v2( + r->db, "SELECT 1 FROM vouchers WHERE org_id=?1 AND id=?2", -1, + &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + found = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + if (!found) + return fail(r, "NOT_FOUND", "voucher not found"); + + int touches = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT 1 FROM voucher_rows r" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE r.org_id=?1 AND r.voucher_id=?2 AND a.number=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_text(st, 3, account, -1, SQLITE_TRANSIENT); + touches = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + if (!touches) + return failf(r, "INVALID_ARGS", + "voucher does not post to account %s", account); + + int linked = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT 1 FROM bank_matches WHERE org_id=?1 AND transaction_id=?2" + " AND voucher_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_int64(st, 3, voucher_id); + linked = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + if (linked) + return fail(r, "CONFLICT", + "transaction is already matched to this voucher"); + + int64_t legs = 0; + if (bank_legs_sum(r->db, r->org_id, tx_id, account, &legs) != 0) + return fail(r, "INTERNAL", "database error"); + if (r->dry_run) { + sqlite3_stmt *vs = NULL; + int64_t voucher_leg = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT COALESCE(SUM(r.debit_ore-r.credit_ore),0)" + " FROM voucher_rows r" + " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE r.org_id=?1 AND r.voucher_id=?2 AND a.number=?3", + -1, &vs, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(vs, 1, r->org_id); + sqlite3_bind_int64(vs, 2, voucher_id); + sqlite3_bind_text(vs, 3, account, -1, SQLITE_TRANSIENT); + if (sqlite3_step(vs) == SQLITE_ROW) + voucher_leg = sqlite3_column_int64(vs, 0); + sqlite3_finalize(vs); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); + yyjson_mut_obj_add_int(r->rdoc, o, "difference_ore", + amount - (legs + voucher_leg)); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO bank_matches(org_id,transaction_id,voucher_id," + "matched_at,matched_by,kind) VALUES(?1,?2,?3,?4,?5,'manual')", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_int64(st, 3, voucher_id); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 5, r->sess->user_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", "database error"); + + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "bank.match", reqjson, "OK", NULL); + free(reqjson); + + if (bank_legs_sum(r->db, r->org_id, tx_id, account, &legs) != 0) + return fail(r, "INTERNAL", "database error"); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); + yyjson_mut_obj_add_int(r->rdoc, o, "difference_ore", amount - legs); + return o; +} + +static yyjson_mut_val *h_bank_unmatch(struct req *r) +{ + int64_t tx_id = 0, voucher_id = 0; + if (!arg_int(r->args, "transaction_id", &tx_id) || tx_id <= 0 || + !arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) + return fail(r, "INVALID_ARGS", + "transaction_id and voucher_id are required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT 1 FROM bank_matches WHERE org_id=?1 AND transaction_id=?2" + " AND voucher_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_int64(st, 3, voucher_id); + int found = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + if (!found) + return fail(r, "NOT_FOUND", "match not found"); + if (!r->dry_run) { + if (sqlite3_prepare_v2( + r->db, + "DELETE FROM bank_matches WHERE org_id=?1" + " AND transaction_id=?2 AND voucher_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tx_id); + sqlite3_bind_int64(st, 3, voucher_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", "database error"); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "match not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "bank.unmatch", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "transaction_id", tx_id); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); + yyjson_mut_obj_add_bool(r->rdoc, o, "unmatched", true); + if (r->dry_run) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; +} + /* command table */ /* ------------------------------------------------------------------ */ @@ -5206,10 +6056,35 @@ static const struct cmd_arg args_bokslut_post[] = { }; static const struct cmd_arg args_settings_set[] = { - { "key", ARG_STR, 1, NULL, NULL, "default_series or attachment_dir" }, + { "key", ARG_STR, 1, NULL, NULL, + "default_series, attachment_dir or bank_account" }, { "value", ARG_STR, 1, NULL, NULL, "Setting value" }, }; +static const struct cmd_arg args_bank_import[] = { + { "format", ARG_ENUM, 1, NULL, "seb", "Statement format" }, + { "content_base64", ARG_STR, 0, NULL, NULL, + "Statement content, base64; alternative to path" }, + { "path", ARG_STR, 0, NULL, NULL, + "Server-side path; alternative to content_base64" }, + { "account", ARG_STR, 0, NULL, NULL, + "Bank account number; defaults to settings.bank_account" }, +}; + +static const struct cmd_arg args_bank_list[] = { + { "status", ARG_ENUM, 0, "all", "all,unmatched,matched", + "Match status filter" }, + { "from", ARG_DATE, 0, NULL, NULL, "Earliest booked date" }, + { "to", ARG_DATE, 0, NULL, NULL, "Latest booked date" }, + { "account", ARG_STR, 0, NULL, NULL, "Bank account number filter" }, + { "limit", ARG_INT, 0, "200", NULL, "Page size, 1-1000" }, +}; + +static const struct cmd_arg args_bank_match[] = { + { "transaction_id", ARG_INT, 1, NULL, NULL, "Bank transaction id" }, + { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, +}; + static const struct cmd_arg args_report_rule_list[] = { { "report", ARG_ENUM, 0, NULL, "vat", "Report filter; omit for all reports" }, @@ -5476,6 +6351,14 @@ const struct command g_commands[] = { h_settings_get, NULL, 0 }, { "settings.set", "Change an org setting", PERM_WRITE, 1, 1, 1, h_settings_set, CMD_ARGS(args_settings_set) }, + { "bank.import", "Import a bank statement (SEB CSV)", PERM_WRITE, 1, 1, 1, + h_bank_import, CMD_ARGS(args_bank_import) }, + { "bank.list", "List bank transactions, matches and suggestions", + PERM_READ, 1, 0, 0, h_bank_list, CMD_ARGS(args_bank_list) }, + { "bank.match", "Match a bank transaction to a voucher", PERM_WRITE, 1, 1, + 1, h_bank_match, CMD_ARGS(args_bank_match) }, + { "bank.unmatch", "Remove a transaction/voucher match", PERM_WRITE, 1, 1, + 1, h_bank_unmatch, CMD_ARGS(args_bank_match) }, { "report.rule_list", "List per-org reporting rules", PERM_READ, 1, 0, 0, h_report_rule_list, CMD_ARGS(args_report_rule_list) }, { "report.rule_create", "Create a reporting rule (owner)", PERM_OWNER, 1, 1, diff --git a/src/db.c b/src/db.c index c809bea..7e23cb0 100644 --- a/src/db.c +++ b/src/db.c @@ -271,6 +271,39 @@ static const char SCHEMA_V1[] = " key TEXT NOT NULL," " value TEXT NOT NULL," " PRIMARY KEY (org_id, key)" + ") STRICT;\n" + + "CREATE TABLE bank_transactions (" + " org_id INTEGER NOT NULL REFERENCES orgs(id)," + " id INTEGER PRIMARY KEY," + " account TEXT NOT NULL," + " booked_at TEXT NOT NULL," + " value_date TEXT NOT NULL," + " text TEXT NOT NULL," + " type TEXT NOT NULL," + " amount_ore INTEGER NOT NULL," + " balance_ore INTEGER," + " source TEXT NOT NULL," + " source_hash BLOB NOT NULL CHECK (length(source_hash) = 32)," + " imported_at TEXT NOT NULL," + " imported_by INTEGER NOT NULL REFERENCES users(id)," + " UNIQUE (org_id, id)," + " UNIQUE (org_id, source_hash)" + ") STRICT;\n" + "CREATE INDEX idx_bank_tx_date ON bank_transactions(org_id, booked_at);\n" + + "CREATE TABLE bank_matches (" + " org_id INTEGER NOT NULL," + " transaction_id INTEGER NOT NULL," + " voucher_id INTEGER NOT NULL," + " matched_at TEXT NOT NULL," + " matched_by INTEGER NOT NULL REFERENCES users(id)," + " kind TEXT NOT NULL DEFAULT 'manual'" + " CHECK (kind IN ('manual','auto'))," + " PRIMARY KEY (org_id, transaction_id, voucher_id)," + " FOREIGN KEY (org_id, transaction_id)" + " REFERENCES bank_transactions(org_id, id)," + " FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id)" ") STRICT;\n"; static const char SCHEMA_V2[] = @@ -458,6 +491,50 @@ static int db_upgrade_v7(sqlite3 *db, char **err) err); } +/* v8: bank reconciliation — imported statement evidence and the mutable + transaction/voucher links (phase 1 never books anything). */ +static int db_upgrade_v8(sqlite3 *db, char **err) +{ + if (db_exec(db, + "CREATE TABLE IF NOT EXISTS bank_transactions (" + " org_id INTEGER NOT NULL REFERENCES orgs(id)," + " id INTEGER PRIMARY KEY," + " account TEXT NOT NULL," + " booked_at TEXT NOT NULL," + " value_date TEXT NOT NULL," + " text TEXT NOT NULL," + " type TEXT NOT NULL," + " amount_ore INTEGER NOT NULL," + " balance_ore INTEGER," + " source TEXT NOT NULL," + " source_hash BLOB NOT NULL CHECK (length(source_hash) = 32)," + " imported_at TEXT NOT NULL," + " imported_by INTEGER NOT NULL REFERENCES users(id)," + " UNIQUE (org_id, id)," + " UNIQUE (org_id, source_hash)" + ") STRICT;" + "CREATE INDEX IF NOT EXISTS idx_bank_tx_date" + " ON bank_transactions(org_id, booked_at);", + err) != 0) + return -1; + return db_exec(db, + "CREATE TABLE IF NOT EXISTS bank_matches (" + " org_id INTEGER NOT NULL," + " transaction_id INTEGER NOT NULL," + " voucher_id INTEGER NOT NULL," + " matched_at TEXT NOT NULL," + " matched_by INTEGER NOT NULL REFERENCES users(id)," + " kind TEXT NOT NULL DEFAULT 'manual'" + " CHECK (kind IN ('manual','auto'))," + " PRIMARY KEY (org_id, transaction_id, voucher_id)," + " FOREIGN KEY (org_id, transaction_id)" + " REFERENCES bank_transactions(org_id, id)," + " FOREIGN KEY (org_id, voucher_id)" + " REFERENCES vouchers(org_id, id)" + ") STRICT", + err); +} + static int db_upgrade(sqlite3 *db, int from, char **err) { if (db_exec(db, "BEGIN IMMEDIATE", err) != 0) @@ -486,6 +563,10 @@ static int db_upgrade(sqlite3 *db, int from, char **err) db_exec(db, "ROLLBACK", NULL); return -1; } + if (from < 8 && db_upgrade_v8(db, err) != 0) { + db_exec(db, "ROLLBACK", NULL); + return -1; + } char *sql = sqlite3_mprintf( "UPDATE meta SET value='%d' WHERE key='schema_version'", BOKF_SCHEMA_VERSION); diff --git a/src/db.h b/src/db.h index 03ee5e5..8a3cb24 100644 --- a/src/db.h +++ b/src/db.h @@ -4,7 +4,7 @@ #include #include -#define BOKF_SCHEMA_VERSION 7 +#define BOKF_SCHEMA_VERSION 8 int db_open(const char *path, sqlite3 **out, char **err); int db_migrate(sqlite3 *db, char **err); -- cgit v1.3