diff options
Diffstat (limited to 'clients/bokftui.c')
| -rw-r--r-- | clients/bokftui.c | 375 |
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"); |
