summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--clients/bokftui.c375
1 files changed, 363 insertions, 12 deletions
diff --git a/clients/bokftui.c b/clients/bokftui.c
index fef9100..5b68460 100644
--- a/clients/bokftui.c
+++ b/clients/bokftui.c
@@ -3320,6 +3320,352 @@ static void inbox_screen(struct app *a)
}
}
+/* ------------------------------------------------------------------ */
+/* bankavstämning */
+/* ------------------------------------------------------------------ */
+
+struct bank_match {
+ int64_t voucher_id;
+ char series[16];
+ int64_t number;
+};
+
+struct bank_sugg {
+ int64_t voucher_id;
+ char series[16];
+ int64_t number;
+ char date[16];
+ int64_t amount_ore;
+};
+
+struct bank_tx {
+ int64_t id;
+ char booked_at[16];
+ char text[256];
+ int64_t amount_ore;
+ struct bank_match *matches;
+ size_t nmatches;
+ struct bank_sugg *suggestions;
+ size_t nsuggestions;
+};
+
+static void bank_ver(char *buf, size_t n, const char *series, int64_t number)
+{
+ snprintf(buf, n, "%s%lld", series ? series : "", (long long)number);
+}
+
+/* Unmatched first, then newest booked_at first. */
+static int bank_tx_cmp(const void *pa, const void *pb)
+{
+ const struct bank_tx *x = pa, *y = pb;
+ int xm = x->nmatches > 0, ym = y->nmatches > 0;
+ if (xm != ym)
+ return xm - ym;
+ int c = strcmp(y->booked_at, x->booked_at);
+ if (c)
+ return c;
+ return x->id < y->id ? -1 : x->id > y->id;
+}
+
+static void bank_txs_free(struct bank_tx *txs, size_t n)
+{
+ for (size_t i = 0; i < n; i++) {
+ free(txs[i].matches);
+ free(txs[i].suggestions);
+ }
+ free(txs);
+}
+
+static void bank_tx_row(const struct bank_tx *t, char *line, size_t cap)
+{
+ char text[256], amt[48], tail[64] = "";
+ snprintf(text, sizeof text, "%s", t->text);
+ tui_pad_field(text, sizeof text, 32);
+ tui_amt_col(amt, sizeof amt, 13, t->amount_ore);
+ if (t->nmatches > 0) {
+ char ver[32];
+ bank_ver(ver, sizeof ver, t->matches[0].series,
+ t->matches[0].number);
+ if (t->nmatches > 1)
+ snprintf(tail, sizeof tail, "→ %s +%zu", ver, t->nmatches - 1);
+ else
+ snprintf(tail, sizeof tail, "→ %s", ver);
+ } else if (t->nsuggestions > 0) {
+ char ver[32];
+ bank_ver(ver, sizeof ver, t->suggestions[0].series,
+ t->suggestions[0].number);
+ snprintf(tail, sizeof tail, "(förslag %s)", ver);
+ }
+ snprintf(line, cap, "%s %s %s %s %s", t->nmatches > 0 ? "✓" : "·",
+ t->booked_at, text, amt, tail);
+}
+
+/* Enter on an unmatched row: suggest vouchers, or pick one freely. */
+static void bank_match_tx(struct app *a, const struct bank_tx *t)
+{
+ char **items = xcalloc(t->nsuggestions + 1, sizeof(char *));
+ for (size_t i = 0; i < t->nsuggestions; i++) {
+ const struct bank_sugg *s = &t->suggestions[i];
+ char ver[32], amt[48], diff[48], line[256];
+ bank_ver(ver, sizeof ver, s->series, s->number);
+ tui_amt_col(amt, sizeof amt, 13, s->amount_ore);
+ tui_amt_col(diff, sizeof diff, 13, t->amount_ore - s->amount_ore);
+ snprintf(line, sizeof line, "%s %-8s %s %s", s->date, ver, amt,
+ diff);
+ items[i] = xstrdup(line);
+ }
+ items[t->nsuggestions] = xstrdup("Välj annat verifikat…");
+ int sel = tui_select_list("Matcha transaktion", items,
+ (int)t->nsuggestions + 1, 0, 0, NULL, 0, NULL,
+ 0);
+ for (size_t i = 0; i <= t->nsuggestions; i++)
+ free(items[i]);
+ free(items);
+ if (sel < 0)
+ return;
+ int64_t vid;
+ if ((size_t)sel < t->nsuggestions) {
+ vid = t->suggestions[sel].voucher_id;
+ } else {
+ vid = pick_voucher(a);
+ if (!vid)
+ return;
+ }
+ char args[64];
+ snprintf(args, sizeof args,
+ "{\"transaction_id\":%lld,\"voucher_id\":%lld}",
+ (long long)t->id, (long long)vid);
+ char *resp = client_rpc(&a->conn, "bank.match", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Bankavstämning", resp);
+ free(resp);
+ return;
+ }
+ int64_t diff = jint_val(resp, "result.difference_ore", 0);
+ if (diff) {
+ char dbuf[48];
+ tui_kr_format(diff, dbuf, sizeof dbuf);
+ tui_message("Bankavstämning", "Matchat. Differens: %s", dbuf);
+ } else {
+ tui_message("Bankavstämning", "Matchat.");
+ }
+ free(resp);
+}
+
+static void bank_unmatch_tx(struct app *a, const struct bank_tx *t)
+{
+ if (t->nmatches == 0)
+ return;
+ if (t->nmatches == 1) {
+ if (!tui_confirm("Bankavstämning",
+ "Koppla bort transaktionen %s från verifikatet?",
+ t->text))
+ return;
+ } else if (!tui_confirm("Bankavstämning",
+ "Koppla bort transaktionen %s från %zu verifikat?",
+ t->text, t->nmatches)) {
+ return;
+ }
+ int ok = 1;
+ for (size_t i = 0; i < t->nmatches; i++) {
+ char args[64];
+ snprintf(args, sizeof args,
+ "{\"transaction_id\":%lld,\"voucher_id\":%lld}",
+ (long long)t->id, (long long)t->matches[i].voucher_id);
+ char *resp = client_rpc(&a->conn, "bank.unmatch", a->session, a->org,
+ args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Bankavstämning", resp);
+ ok = 0;
+ }
+ free(resp);
+ }
+ if (ok)
+ tui_message("Bankavstämning", "Matchningen borttagen.");
+}
+
+static void bank_import_file(struct app *a)
+{
+ char *path = file_browser(a, a->attachment_dir);
+ if (!path)
+ return;
+ char *b64 = read_file_b64(path);
+ if (!b64) {
+ tui_message("Fel", "Kunde inte läsa %s", path);
+ free(path);
+ return;
+ }
+ free(path);
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "format", "seb");
+ yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
+ yyjson_mut_obj_add_strcpy(d, o, "account", "1930");
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ free(b64);
+ if (!args)
+ return;
+ char *resp = rpc_dry(a, "bank.import", args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Bankavstämning", resp);
+ free(resp);
+ free(args);
+ return;
+ }
+ int64_t imported = jint_val(resp, "result.imported", 0);
+ int64_t duplicates = jint_val(resp, "result.duplicates", 0);
+ free(resp);
+ if (!tui_confirm("Bankavstämning",
+ "%lld transaktioner (%lld dubbletter). Importera?",
+ (long long)imported, (long long)duplicates)) {
+ free(args);
+ return;
+ }
+ resp = client_rpc(&a->conn, "bank.import", a->session, a->org, args);
+ free(args);
+ if (resp && client_ok(resp))
+ tui_message("Bankavstämning", "%lld transaktioner (%lld dubbletter).",
+ (long long)jint_val(resp, "result.imported", 0),
+ (long long)jint_val(resp, "result.duplicates", 0));
+ else
+ show_error("Bankavstämning", resp);
+ free(resp);
+}
+
+struct bankctx {
+ struct app *a;
+ struct bank_tx *txs;
+ size_t n;
+ int cursor;
+};
+
+static int bank_key(void *ud, int ch)
+{
+ struct bankctx *ctx = ud;
+ if (ch == 'a' || ch == 'A') {
+ bank_import_file(ctx->a);
+ return TUI_HOOK_REFRESH;
+ }
+ if ((ch == 'u' || ch == 'U') && ctx->cursor >= 0 &&
+ (size_t)ctx->cursor < ctx->n) {
+ bank_unmatch_tx(ctx->a, &ctx->txs[ctx->cursor]);
+ return TUI_HOOK_REFRESH;
+ }
+ return TUI_HOOK_NONE;
+}
+
+static void bank_screen(struct app *a)
+{
+ int64_t keep_id = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp = client_rpc(&a->conn, "bank.list", a->session, a->org,
+ "{\"status\":\"all\",\"limit\":500}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Bankavstämning", resp);
+ free(resp);
+ return;
+ }
+ yyjson_doc *doc = parse(resp);
+ yyjson_val *items =
+ doc ? jget(yyjson_doc_get_root(doc), "result.items") : NULL;
+ size_t n = items && yyjson_is_arr(items) ? yyjson_arr_size(items) : 0;
+ struct bank_tx *txs = xcalloc(n ? n : 1, sizeof *txs);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *it = yyjson_arr_get(items, i);
+ txs[i].id = vint(it, "id");
+ snprintf(txs[i].booked_at, sizeof txs[i].booked_at, "%s",
+ vstr(it, "booked_at"));
+ snprintf(txs[i].text, sizeof txs[i].text, "%s", vstr(it, "text"));
+ txs[i].amount_ore = vint(it, "amount_ore");
+ yyjson_val *ms = yyjson_obj_get(it, "matches");
+ size_t nm = ms && yyjson_is_arr(ms) ? yyjson_arr_size(ms) : 0;
+ if (nm > 0) {
+ txs[i].matches = xcalloc(nm, sizeof *txs[i].matches);
+ txs[i].nmatches = nm;
+ for (size_t k = 0; k < nm; k++) {
+ yyjson_val *m = yyjson_arr_get(ms, k);
+ txs[i].matches[k].voucher_id = vint(m, "voucher_id");
+ snprintf(txs[i].matches[k].series,
+ sizeof txs[i].matches[k].series, "%s",
+ vstr(m, "series"));
+ txs[i].matches[k].number = vint(m, "number");
+ }
+ }
+ yyjson_val *ss = yyjson_obj_get(it, "suggestions");
+ size_t ns = ss && yyjson_is_arr(ss) ? yyjson_arr_size(ss) : 0;
+ if (ns > 0) {
+ txs[i].suggestions = xcalloc(ns, sizeof *txs[i].suggestions);
+ txs[i].nsuggestions = ns;
+ for (size_t k = 0; k < ns; k++) {
+ yyjson_val *s = yyjson_arr_get(ss, k);
+ txs[i].suggestions[k].voucher_id = vint(s, "voucher_id");
+ snprintf(txs[i].suggestions[k].series,
+ sizeof txs[i].suggestions[k].series, "%s",
+ vstr(s, "series"));
+ txs[i].suggestions[k].number = vint(s, "number");
+ snprintf(txs[i].suggestions[k].date,
+ sizeof txs[i].suggestions[k].date, "%s",
+ vstr(s, "date"));
+ txs[i].suggestions[k].amount_ore = vint(s, "amount_ore");
+ }
+ }
+ }
+ qsort(txs, n, sizeof *txs, bank_tx_cmp);
+ int start = 0;
+ for (size_t i = 0; i < n; i++)
+ if (txs[i].id == keep_id)
+ start = (int)i;
+ int64_t unmatched = jint_val(resp, "result.summary.unmatched", -1);
+ if (unmatched < 0) {
+ unmatched = 0;
+ for (size_t i = 0; i < n; i++)
+ if (txs[i].nmatches == 0)
+ unmatched++;
+ }
+ char title[64];
+ snprintf(title, sizeof title, "Bankavstämning (%lld omatchade)",
+ (long long)unmatched);
+ size_t rows = n > 0 ? n : 1;
+ char **lines = xcalloc(rows, sizeof(char *));
+ if (n == 0) {
+ lines[0] = xstrdup("+ Importera bankfil (^N)");
+ } else {
+ for (size_t i = 0; i < n; i++) {
+ char line[512];
+ bank_tx_row(&txs[i], line, sizeof line);
+ lines[i] = xstrdup(line);
+ }
+ }
+ struct bankctx ctx = { a, txs, n, start };
+ int sel = tui_select_list_hook(title, lines, (int)rows, start, 1,
+ &ctx.cursor, 1, NULL, 0, bank_key,
+ &ctx);
+ for (size_t i = 0; i < rows; i++)
+ free(lines[i]);
+ free(lines);
+ if (sel == -1) {
+ bank_txs_free(txs, n);
+ free(resp);
+ yyjson_doc_free(doc);
+ return;
+ }
+ if (sel >= 0 && (size_t)sel < n) {
+ keep_id = txs[sel].id;
+ if (txs[sel].nmatches == 0)
+ bank_match_tx(a, &txs[sel]);
+ } else if (sel >= 0) {
+ bank_import_file(a);
+ }
+ bank_txs_free(txs, n);
+ free(resp);
+ yyjson_doc_free(doc);
+ }
+}
+
static void audit_verify_run(struct app *a, int full)
{
char *resp = client_rpc(&a->conn, "audit.verify", a->session, a->org,
@@ -5598,6 +5944,8 @@ static void open_scene(struct app *a, const char *scene)
vouchers_screen(a);
else if (strcmp(scene, "inbox") == 0)
inbox_screen(a);
+ else if (strcmp(scene, "bank") == 0)
+ bank_screen(a);
else if (strcmp(scene, "ib") == 0)
ib_screen(a);
else if (strcmp(scene, "templates") == 0)
@@ -5650,7 +5998,7 @@ static void do_reload(struct app *a, const char *self)
static const char *const MAIN_ITEMS[] = {
MK_SECTION "Bokföring",
- "Verifikat", "Underlag (inkorg)",
+ "Verifikat", "Underlag (inkorg)", "Bankavstämning",
MK_SECTION "Rapporter & bokslut",
"Rapporter", "Bokslut",
MK_SECTION "Register",
@@ -5688,34 +6036,37 @@ static void dashboard(struct app *a)
case 2:
open_scene(a, "inbox");
break;
- case 4:
- open_scene(a, "reports");
+ case 3:
+ open_scene(a, "bank");
break;
case 5:
+ open_scene(a, "reports");
+ break;
+ case 6:
open_scene(a, "bokslut");
break;
- case 7:
+ case 8:
open_scene(a, "templates");
break;
- case 8:
+ case 9:
open_scene(a, "rules");
break;
- case 9:
+ case 10:
open_scene(a, "company");
break;
- case 10:
+ case 11:
open_scene(a, "settings");
break;
- case 12:
+ case 13:
open_scene(a, "ib");
break;
- case 13:
+ case 14:
select_fiscal_year(a);
break;
- case 15:
+ case 16:
open_scene(a, "audit");
break;
- case 16:
+ case 17:
return;
default:
break; /* section header */
@@ -5998,7 +6349,7 @@ static void usage(FILE *f)
" --fy ID select fiscal year directly\n"
" --session ID reuse an open session (or BOKFD_SESSION)\n"
" --screen NAME start in a view: dashboard, vouchers, inbox,\n"
- " ib, templates, rules, reports, bokslut\n"
+ " bank, ib, templates, rules, reports, bokslut\n"
" (yearinfo is an alias), audit, settings,\n"
" company\n"
" --version\n");