diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-21 23:09:57 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-21 23:09:57 +0200 |
| commit | 8350815a8c6987b288551848eda1c678739c8d4a (patch) | |
| tree | 04ef3c56e10fa8b06768465910d10a57779e8731 | |
| parent | bf9012dad7ef34bbbfb073bc0f37b0161f0496f3 (diff) | |
| download | bokf-8350815a8c6987b288551848eda1c678739c8d4a.tar.gz bokf-8350815a8c6987b288551848eda1c678739c8d4a.zip | |
invoices: text rows, duplicate and payment link (schema v12); smtp address checksv0.1.62
| -rw-r--r-- | clients/screens_invoices.c | 328 | ||||
| -rw-r--r-- | clients/screens_vouchers.c | 22 | ||||
| -rw-r--r-- | clients/ui.h | 1 | ||||
| -rw-r--r-- | docs/DECISIONS.md | 14 | ||||
| -rw-r--r-- | docs/INVOICING.md | 21 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 56 | ||||
| -rw-r--r-- | docs/SCHEMA.md | 12 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 1 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 46 | ||||
| -rw-r--r-- | src/cmd_invoices.c | 172 | ||||
| -rw-r--r-- | src/cmd_settings.c | 5 | ||||
| -rw-r--r-- | src/db.c | 54 | ||||
| -rw-r--r-- | src/db.h | 2 | ||||
| -rw-r--r-- | src/invoice.c | 18 | ||||
| -rw-r--r-- | src/invoice.h | 1 | ||||
| -rw-r--r-- | src/mail.c | 23 | ||||
| -rw-r--r-- | src/mail.h | 4 | ||||
| -rw-r--r-- | tests/invoice_check.c | 18 | ||||
| -rw-r--r-- | tests/test_core.c | 140 |
19 files changed, 834 insertions, 104 deletions
diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c index d685e83..352c184 100644 --- a/clients/screens_invoices.c +++ b/clients/screens_invoices.c @@ -457,9 +457,13 @@ struct invctx { int64_t id; int64_t number; int64_t customer_id; + int64_t total_ore; char customer[256]; + char paid_date[16]; }; +static int64_t invoices_new_from(struct app *a, int64_t id); + static void inv_detail_pdf(struct invctx *ctx) { char args[64]; @@ -523,6 +527,61 @@ static void inv_detail_send(struct invctx *ctx) free(r); } +/* Prefills the new-invoice form with the payment voucher: D the org's + bank account, K the invoice receivable account, both editable. */ +static void inv_detail_pay(struct invctx *ctx) +{ + struct app *a = ctx->a; + if (ctx->paid_date[0]) { + tui_message("Kvittera betalning", "Fakturan är redan betald."); + return; + } + char pay[16] = "1930", rec[16] = "1510"; + char *resp = client_rpc(&a->conn, "settings.get", a->session, a->org, + "{}"); + if (resp && client_ok(resp)) { + char *v = jstr_dup(resp, "result.bank_account"); + if (v && *v) + snprintf(pay, sizeof pay, "%s", v); + free(v); + v = jstr_dup(resp, "result.invoice_receivable_account"); + if (v && *v) + snprintf(rec, sizeof rec, "%s", v); + free(v); + } + free(resp); + char desc[160]; + snprintf(desc, sizeof desc, "Betalning faktura %lld %.120s", + (long long)ctx->number, ctx->customer); + struct voucher_prefill p; + memset(&p, 0, sizeof p); + char date[16]; + today_iso(date, sizeof date); + p.date = date; + p.description = desc; + p.bank_account = pay; + p.counter_account = rec; + p.amount_ore = ctx->total_ore; + int64_t vid = vouchers_new_prefill(a, &p); + if (vid <= 0) + return; + char args[96]; + snprintf(args, sizeof args, "{\"id\":%lld,\"voucher_id\":%lld}", + (long long)ctx->id, (long long)vid); + resp = client_rpc(&a->conn, "invoice.pay", a->session, a->org, args); + if (resp && client_ok(resp)) { + char *ser = jstr_dup(resp, "result.voucher_series"); + int64_t num = jint_val(resp, "result.voucher_number", 0); + tui_message("Kvittera betalning", "Faktura %lld kvitterad (%s%lld).", + (long long)ctx->number, ser ? ser : "", + (long long)num); + free(ser); + } else { + show_error("Kvittera betalning", resp); + } + free(resp); +} + static int inv_key(void *ud, int ch) { struct invctx *ctx = ud; @@ -534,6 +593,14 @@ static int inv_key(void *ud, int ch) inv_detail_send(ctx); return TUI_HOOK_STAY; } + if (ch == 'u') { + invoices_new_from(ctx->a, ctx->id); + return TUI_HOOK_REFRESH; + } + if (ch == 'b') { + inv_detail_pay(ctx); + return TUI_HOOK_REFRESH; + } return TUI_HOOK_NONE; } @@ -565,10 +632,19 @@ static void invoices_detail(struct app *a, int64_t id) char *ocr = jstr_dup(resp, "result.ocr"); char *sent_at = jstr_dup(resp, "result.last_sent_at"); char *sent_to = jstr_dup(resp, "result.last_sent_to"); + char *paid_date = jstr_dup(resp, "result.paid_date"); + char *paid_ser = jstr_dup(resp, "result.paid_voucher_series"); + int64_t paid_num = jint_val(resp, "result.paid_voucher_number", 0); + char statbuf[96]; + if (paid_date && *paid_date) + snprintf(statbuf, sizeof statbuf, "betald %s", paid_date); + else + snprintf(statbuf, sizeof statbuf, "%s", + invoice_status_sv(status)); struct buf t; buf_init(&t); buf_line(&t, MK_HEAD "Faktura %lld %s\n", (long long)number, - invoice_status_sv(status)); + statbuf); buf_line(&t, MK_DIM "%s\n\n", cust ? cust : ""); buf_line(&t, "Fakturadatum: %s\n", date ? date : ""); buf_line(&t, "Förfallodatum: %s\n", due ? due : ""); @@ -584,6 +660,9 @@ static void invoices_detail(struct app *a, int64_t id) buf_line(&t, "Skickad: %s%s%s\n", sent_at, sent_to && *sent_to ? " till " : "", sent_to && *sent_to ? sent_to : ""); + if (paid_ser && *paid_ser && paid_num > 0) + buf_line(&t, "Betalningsverifikat: %s%lld\n", paid_ser, + (long long)paid_num); if (notes && *notes) buf_line(&t, "\nFritext:\n%s\n", notes); buf_line(&t, "\n"); @@ -609,6 +688,18 @@ static void invoices_detail(struct app *a, int64_t id) char *note = jstr_dup(resp, path); snprintf(path, sizeof path, "result.rows.%zu.amount_ore", i); int64_t amount = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.rows.%zu.is_text", i); + if (jbool_val(resp, path, 0)) { + char dbuf[256]; + snprintf(dbuf, sizeof dbuf, "%s", desc ? desc : ""); + tui_pad_field(dbuf, sizeof dbuf, 34); + buf_line(&t, "%s\n", dbuf); + free(desc); + free(unit); + free(vat_code); + free(note); + continue; + } char dbuf[256], qbuf[24], qcol[24], ubuf[24], pcol[40], vbuf[24], nbuf[128], acol[40]; snprintf(dbuf, sizeof dbuf, "%s", desc ? desc : ""); @@ -644,10 +735,16 @@ static void invoices_detail(struct app *a, int64_t id) ctx.id = id; ctx.number = number; ctx.customer_id = customer_id; + ctx.total_ore = total; snprintf(ctx.customer, sizeof ctx.customer, "%s", cust ? cust : ""); - int ret = tui_pager_hook("Faktura", (const char *)t.p, - "p = visa PDF s = skicka", 0, inv_key, - &ctx); + if (paid_date && *paid_date) + snprintf(ctx.paid_date, sizeof ctx.paid_date, "%s", paid_date); + char ahint[192]; + snprintf(ahint, sizeof ahint, + "p = visa PDF s = skicka u = duplicera%s", + ctx.paid_date[0] ? "" : " b = kvittera betalning"); + int ret = tui_pager_hook("Faktura", (const char *)t.p, ahint, 0, + inv_key, &ctx); free(cust); free(status); free(date); @@ -659,6 +756,8 @@ static void invoices_detail(struct app *a, int64_t id) free(ocr); free(sent_at); free(sent_to); + free(paid_date); + free(paid_ser); buf_free(&t); free(resp); if (ret != 1) @@ -890,7 +989,7 @@ static char *iform_draft_args(struct iform *f, char *err, size_t errn) yyjson_mut_obj_add_strcpy(d, o, "our_ref", f->our_ref); yyjson_mut_obj_add_strcpy(d, o, "notes", f->notes); yyjson_mut_val *arr = yyjson_mut_arr(d); - int used = 0; + int used = 0, priced = 0; for (int i = 0; i < f->rt.nrows; i++) { struct irow *r = &f->rows[i]; if (!r->description[0] && !r->quantity[0] && !r->unit[0] && @@ -901,6 +1000,13 @@ static char *iform_draft_args(struct iform *f, char *err, size_t errn) yyjson_mut_doc_free(d); return NULL; } + if (!r->quantity[0] && !r->unit[0] && !r->price[0] && !r->vat[0]) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr); + yyjson_mut_obj_add_strcpy(d, ro, "description", r->description); + yyjson_mut_obj_add_bool(d, ro, "text", true); + used++; + continue; + } const char *qty = r->quantity[0] ? r->quantity : "1"; if (!qty_is_valid(qty)) { snprintf(err, errn, @@ -937,12 +1043,19 @@ static char *iform_draft_args(struct iform *f, char *err, size_t errn) yyjson_mut_obj_add_strcpy(d, ro, "note", r->note); yyjson_mut_obj_add_strcpy(d, ro, "vat_code", vat); used++; + priced++; } if (used == 0) { snprintf(err, errn, "Minst en rad krävs."); yyjson_mut_doc_free(d); return NULL; } + if (priced == 0) { + snprintf(err, errn, + "Minst en prissatt rad krävs (fritextrader saknar belopp)."); + yyjson_mut_doc_free(d); + return NULL; + } yyjson_mut_obj_add_val(d, o, "rows", arr); char *out = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); @@ -1021,33 +1134,8 @@ static int64_t iform_issue(struct iform *f) return id; } -static int64_t invoices_new(struct app *a) +static int64_t invoices_form_run(struct iform *f) { - struct iform f; - memset(&f, 0, sizeof f); - f.a = a; - today_iso(f.date, sizeof f.date); - snprintf(f.delivery, sizeof f.delivery, "%s", f.date); - snprintf(f.due, sizeof f.due, "%s", f.date); - snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due); - if (iform_load_customers(&f) != 0) { - iform_customers_free(&f); - return 0; - } - char *resp = - client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); - if (resp && client_ok(resp)) { - char *v = jstr_dup(resp, "result.invoice_our_ref"); - if (v) { - snprintf(f.our_ref, sizeof f.our_ref, "%s", v); - free(v); - } - } - free(resp); - if (f.ncust > 0) { - f.cust_sel = 0; - iform_customer_changed(&f); - } struct tui_rt_col cols[6] = { { "Beskrivning", 2, 34, TUI_RT_EDIT }, { "Antal", 37, 8, TUI_RT_EDIT }, @@ -1056,58 +1144,181 @@ static int64_t invoices_new(struct app *a) { "Moms", 68, 14, TUI_RT_EDIT }, { "Anm", 83, 32, TUI_RT_EDIT }, }; - tui_rt_init(&f.rt, 1, IFORM_ROWS, 6, cols, 0, iform_cell, NULL, &f); - tui_rt_set_key(&f.rt, iform_key); + tui_rt_init(&f->rt, 1, IFORM_ROWS, 6, cols, 0, iform_cell, NULL, f); + tui_rt_set_key(&f->rt, iform_key); struct tui_form_field ff[7]; memset(ff, 0, sizeof ff); ff[0].label = "Kund"; ff[0].kind = TUI_F_CHOICE; - ff[0].choices = (const char *const *)f.cust_names; - ff[0].nchoices = (int)f.ncust; - ff[0].choice = &f.cust_sel; + ff[0].choices = (const char *const *)f->cust_names; + ff[0].nchoices = (int)f->ncust; + ff[0].choice = &f->cust_sel; ff[1].label = "Fakturadatum"; - ff[1].value = f.date; - ff[1].cap = sizeof f.date; + ff[1].value = f->date; + ff[1].cap = sizeof f->date; ff[1].kind = TUI_F_DATE; ff[2].label = "Förfallodatum"; - ff[2].value = f.due; - ff[2].cap = sizeof f.due; + ff[2].value = f->due; + ff[2].cap = sizeof f->due; ff[2].kind = TUI_F_DATE; ff[3].label = "Leveransdatum"; - ff[3].value = f.delivery; - ff[3].cap = sizeof f.delivery; + ff[3].value = f->delivery; + ff[3].cap = sizeof f->delivery; ff[3].kind = TUI_F_DATE; ff[4].label = "Er referens"; - ff[4].value = f.your_ref; - ff[4].cap = sizeof f.your_ref; + ff[4].value = f->your_ref; + ff[4].cap = sizeof f->your_ref; ff[4].kind = TUI_F_TEXT; ff[5].label = "Vår referens"; - ff[5].value = f.our_ref; - ff[5].cap = sizeof f.our_ref; + ff[5].value = f->our_ref; + ff[5].cap = sizeof f->our_ref; ff[5].kind = TUI_F_TEXT; ff[6].label = "Fritext"; - ff[6].value = f.notes; - ff[6].cap = sizeof f.notes; + ff[6].value = f->notes; + ff[6].cap = sizeof f->notes; ff[6].kind = TUI_F_TEXT; - tui_rt_set_fields(&f.rt, ff, 7); + tui_rt_set_fields(&f->rt, ff, 7); + tui_rt_normalize(&f->rt); const char *hint = "Enter = välj/ändra Tab = byta fält F5 = förhandsvisa" " F9 = utfärda Esc = avbryt"; int64_t out = 0; for (;;) { - int rr = tui_rt_run("Ny faktura", &f.rt, hint); + int rr = tui_rt_run("Ny faktura", &f->rt, hint); if (rr == -1) break; if (rr == -2) { - iform_preview(&f); + iform_preview(f); continue; } if (rr == -3) { - out = iform_issue(&f); + out = iform_issue(f); if (out > 0) break; } } + return out; +} + +static int64_t invoices_new(struct app *a) +{ + struct iform f; + memset(&f, 0, sizeof f); + f.a = a; + today_iso(f.date, sizeof f.date); + snprintf(f.delivery, sizeof f.delivery, "%s", f.date); + snprintf(f.due, sizeof f.due, "%s", f.date); + snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due); + if (iform_load_customers(&f) != 0) { + iform_customers_free(&f); + return 0; + } + char *resp = + client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); + if (resp && client_ok(resp)) { + char *v = jstr_dup(resp, "result.invoice_our_ref"); + if (v) { + snprintf(f.our_ref, sizeof f.our_ref, "%s", v); + free(v); + } + } + free(resp); + if (f.ncust > 0) { + f.cust_sel = 0; + iform_customer_changed(&f); + } + int64_t out = invoices_form_run(&f); + iform_customers_free(&f); + return out; +} + +/* Duplicates an issued invoice: same customer, rows and references, with + the dates reset (invoice and delivery today, due today + payment days). */ +static int64_t invoices_new_from(struct app *a, int64_t id) +{ + char iargs[64]; + snprintf(iargs, sizeof iargs, "{\"id\":%lld}", (long long)id); + char *resp = + client_rpc(&a->conn, "invoice.get", a->session, a->org, iargs); + if (!resp || !client_ok(resp)) { + show_error("Faktura", resp); + free(resp); + return 0; + } + struct iform f; + memset(&f, 0, sizeof f); + f.a = a; + today_iso(f.date, sizeof f.date); + snprintf(f.delivery, sizeof f.delivery, "%s", f.date); + snprintf(f.due, sizeof f.due, "%s", f.date); + snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due); + if (iform_load_customers(&f) != 0) { + iform_customers_free(&f); + free(resp); + return 0; + } + int64_t cid = jint_val(resp, "result.customer_id", 0); + int found = -1; + for (size_t i = 0; i < f.ncust; i++) + if (f.cust_ids[i] == cid) { + found = (int)i; + break; + } + if (found < 0) { + tui_message("Ny faktura", + "Kunden är arkiverad eller finns inte kvar."); + iform_customers_free(&f); + free(resp); + return 0; + } + f.cust_sel = found; + iform_customer_changed(&f); + char *v = jstr_dup(resp, "result.our_ref"); + if (v) { + snprintf(f.our_ref, sizeof f.our_ref, "%s", v); + free(v); + } + v = jstr_dup(resp, "result.notes"); + if (v) { + snprintf(f.notes, sizeof f.notes, "%s", v); + free(v); + } + size_t nrows = jarr_size(resp, "result.rows"); + if (nrows > IFORM_ROWS) + nrows = IFORM_ROWS; + for (size_t i = 0; i < nrows; i++) { + struct irow *r = &f.rows[i]; + char path[64]; + snprintf(path, sizeof path, "result.rows.%zu.description", i); + char *desc = jstr_dup(resp, path); + if (desc) + snprintf(r->description, sizeof r->description, "%s", desc); + snprintf(path, sizeof path, "result.rows.%zu.is_text", i); + if (jbool_val(resp, path, 0)) { + free(desc); + continue; + } + snprintf(path, sizeof path, "result.rows.%zu.quantity_milli", i); + int64_t qm = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.rows.%zu.unit", i); + char *unit = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.rows.%zu.unit_price_ore", i); + int64_t price = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.rows.%zu.vat_code", i); + char *vat = jstr_dup(resp, path); + qty_sv(qm, r->quantity, sizeof r->quantity); + if (unit) + snprintf(r->unit, sizeof r->unit, "%s", unit); + char pbuf[24]; + tui_kr_format(price, pbuf, sizeof pbuf); + snprintf(r->price, sizeof r->price, "%s", pbuf); + snprintf(r->vat, sizeof r->vat, "%s", vat_label(vat)); + free(desc); + free(unit); + free(vat); + } + free(resp); + int64_t out = invoices_form_run(&f); iform_customers_free(&f); return out; } @@ -1187,18 +1398,25 @@ void invoices_screen(struct app *a) int64_t total = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.status", i); char *status = jstr_dup(resp, path); - char nbuf[32], cbuf[256], abuf[40]; + snprintf(path, sizeof path, "result.items.%zu.paid_date", i); + char *paid = jstr_dup(resp, path); + char nbuf[32], cbuf[256], abuf[40], stbuf[64]; snprintf(nbuf, sizeof nbuf, "Faktura %lld", (long long)number); snprintf(cbuf, sizeof cbuf, "%s", cust ? cust : ""); tui_pad_field(cbuf, sizeof cbuf, 28); tui_amt_col(abuf, sizeof abuf, 14, total); + if (paid && *paid) + snprintf(stbuf, sizeof stbuf, "betald %s", paid); + else + snprintf(stbuf, sizeof stbuf, "%s", + invoice_status_sv(status)); snprintf(line, sizeof line, "%s %s %s %s %s", nbuf, - date ? date : "", cbuf, abuf, - invoice_status_sv(status)); + date ? date : "", cbuf, abuf, stbuf); items[i] = xstrdup(line); free(date); free(cust); free(status); + free(paid); } free(resp); items[n] = xstrdup("+ Ny faktura (^N)"); diff --git a/clients/screens_vouchers.c b/clients/screens_vouchers.c index 52d29da..4cb6d9d 100644 --- a/clients/screens_vouchers.c +++ b/clients/screens_vouchers.c @@ -806,21 +806,33 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p) if (p->description && *p->description) snprintf(vf.desc, sizeof vf.desc, "%s", p->description); if (p->bank_account && *p->bank_account) { - snprintf(vf.rows[0].account, sizeof vf.rows[0].account, "%s", - p->bank_account); int64_t amt = p->amount_ore < 0 ? -p->amount_ore : p->amount_ore; char tmp[32]; tui_kr_format(amt, tmp, sizeof tmp); - if (p->amount_ore > 0) + snprintf(vf.rows[0].account, sizeof vf.rows[0].account, "%s", + p->bank_account); + if (p->counter_account && *p->counter_account && + p->amount_ore > 0) { snprintf(vf.rows[0].debit, sizeof vf.rows[0].debit, "%s", tmp); - else if (p->amount_ore < 0) + snprintf(vf.rows[1].account, sizeof vf.rows[1].account, "%s", + p->counter_account); + snprintf(vf.rows[1].credit, sizeof vf.rows[1].credit, "%s", + tmp); + } else if (p->amount_ore > 0) { + snprintf(vf.rows[0].debit, sizeof vf.rows[0].debit, "%s", + tmp); + } else if (p->amount_ore < 0) { snprintf(vf.rows[0].credit, sizeof vf.rows[0].credit, "%s", tmp); + } } - if (p->description && *p->description) + if (p->description && *p->description) { snprintf(vf.rows[0].text, sizeof vf.rows[0].text, "%s", p->description); + snprintf(vf.rows[1].text, sizeof vf.rows[1].text, "%s", + p->description); + } } int text_w = COLS - 60; if (text_w < 8) diff --git a/clients/ui.h b/clients/ui.h index 393d5ff..c546c91 100644 --- a/clients/ui.h +++ b/clients/ui.h @@ -112,6 +112,7 @@ struct voucher_prefill { const char *date; /* YYYY-MM-DD, may be NULL */ const char *description; /* voucher text, may be NULL */ const char *bank_account; /* account number for the prefilled row */ + const char *counter_account; /* with amount > 0: credit this account */ int64_t amount_ore; /* > 0 debits the bank account, < 0 credits it */ }; diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b52f8ec..125d19b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -213,6 +213,20 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21). configured `series_ib` and the historical `IB` series as ingående balans, so old books keep working. "Nästa fakturanummer" is editable in Bolaget → Fakturauppgifter (owner only). +26. **Invoicing follow-ups (2026-09-21)**: schema v12 adds + `invoice_rows.is_text` and `invoices.paid_date`/`payment_voucher_id`. + Invoices accept **text rows** (`"text": true`): description only, no + amount, excluded from totals and the posting voucher; at least one + priced row is required. The invoice detail gets `u = duplicera` (same + customer, rows and references, dates reset to today, due = today + + payment days) and `b = kvittera betalning`, which prefills the ordinary + voucher form (D `bank_account`, K `invoice_receivable_account`, both + editable, underlag attachable) and, after posting, calls + `invoice.pay`, which requires the voucher to credit the receivable with + exactly the invoice total. Partial payments are out of scope. Lists + show `betald <datum>`. SMTP: `smtp_from`/`smtp_reply_to` are validated + as e-mail addresses (settings.set and mail config), with the sender's + display name taken from the org name. ## Completed work formerly listed under "Pending decisions" diff --git a/docs/INVOICING.md b/docs/INVOICING.md index 7c6ce2e..a04c869 100644 --- a/docs/INVOICING.md +++ b/docs/INVOICING.md @@ -206,6 +206,8 @@ CREATE TABLE invoices ( CHECK (status IN ('issued','credited')), document_id INTEGER, voucher_id INTEGER, + paid_date TEXT NOT NULL DEFAULT '', + payment_voucher_id INTEGER, last_sent_at TEXT, last_sent_to TEXT, created_at TEXT NOT NULL, @@ -214,7 +216,9 @@ CREATE TABLE invoices ( UNIQUE (org_id, number), FOREIGN KEY (org_id, customer_id) REFERENCES customers(org_id, id), FOREIGN KEY (org_id, document_id) REFERENCES attachments(org_id, id), - FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id) + FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id), + FOREIGN KEY (org_id, payment_voucher_id) + REFERENCES vouchers(org_id, id) ) STRICT; CREATE TABLE invoice_rows ( @@ -232,6 +236,7 @@ CREATE TABLE invoice_rows ( vat_code TEXT NOT NULL DEFAULT '25' CHECK (vat_code IN ('25','12','6','0','rc','eu')), account TEXT NOT NULL DEFAULT '', + is_text INTEGER NOT NULL DEFAULT 0, UNIQUE (org_id, id), UNIQUE (org_id, invoice_id, line_no), FOREIGN KEY (org_id, invoice_id) REFERENCES invoices(org_id, id) @@ -240,7 +245,19 @@ CREATE TABLE invoice_rows ( `vouchers.source` gains `invoice` (and later `credit`): the CHECK constraint must be widened. `invoice_rows` are written once at issue; `invoices` only -changes `status`, `last_sent_*` and (later) credit links. +changes `status`, `last_sent_*`, `paid_date`/`payment_voucher_id` and (later) +credit links. Schema v12 adds `invoice_rows.is_text` and the two payment +columns with forward `ALTER TABLE`s; the composite foreign key on +`payment_voucher_id` exists in fresh databases only (SQLite cannot add one +later), and `invoice.pay` validates the reference in code either way. + +A **text row** (`is_text`) is a free-text line in the table: only +`description` is meaningful, it has no quantity, unit, price or VAT and +contributes nothing to the totals or the posting voucher. It renders in the +description column only. Every invoice still needs at least one priced row. +When an invoice is marked **paid** (`invoice.pay`), `paid_date` is the +payment voucher's date and `payment_voucher_id` links it; partial payments +are not modelled. Customer seed: Andra bygg AB (Solna, SE559232855201, Eric Lejeby, 30), NZ Bygg AB (Bromma, SE559264837101, Valentyne Schnelle, 30), diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 81eeb67..75f1990 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -396,10 +396,11 @@ characters. Verification ids are the concatenation of series and number required. `smtp_host` (up to 255 characters, no control characters), `smtp_user` (up to -255), `smtp_from` and `smtp_reply_to` (up to 254), `smtp_port` (digits, -1–65535) and `smtp_security` (`starttls`, `tls` or `plain`, default -`starttls` when unset) configure the outgoing mail used when invoices are -sent. +255), `smtp_from` and `smtp_reply_to` (up to 254) must be e-mail addresses +(one `@`, no spaces), `smtp_port` (digits, 1–65535) and `smtp_security` +(`starttls`, `tls` or `plain`, default `starttls` when unset) configure the +outgoing mail used when invoices are sent. The sender's display name is the +organization name; `smtp_from` is the address. `smtp_password` is a secret setting. `settings.set` encrypts the value with AES-256-GCM under the key in the `BOKFD_SECRET_KEY` environment variable (32 @@ -591,10 +592,11 @@ removes one link and is a `NOT_FOUND` when it does not exist. Both mutate | `invoice.sequence_set` | `next_number` (owner) | `next_number` | | `invoice.preview` | draft (below) | `content_base64`, `number`, `ocr`, `net_ore`, `vat_ore`, `total_ore` | | `invoice.issue` | draft, `dry_run?` | `id`, `number`, `ocr`, `document_id`, `voucher_id`, totals | -| `invoice.get` | `id` | header, `rows[]`, `document_id`, `voucher_id`, `last_sent_at`, `last_sent_to` | -| `invoice.list` | `customer_id?`, `status?` (`issued`/`credited`), `limit?` | `items[]`, newest first | +| `invoice.get` | `id` | header, `rows[]`, `document_id`, `voucher_id`, `paid_date`, `payment_voucher_id`, `last_sent_at`, `last_sent_to` | +| `invoice.list` | `customer_id?`, `status?` (`issued`/`credited`), `limit?` | `items[]`, newest first, with `paid_date` | | `invoice.pdf` | `id` | stored PDF as `content_base64` | | `invoice.send` | `id`, `to?` | `id`, `sent_to`, `at`; `dry_run` returns `to`, `subject` | +| `invoice.pay` | `id`, `voucher_id`, `dry_run?` | `id`, `number`, `paid_date`, `payment_voucher_id`, `voucher_series`, `voucher_number` | The draft object is the argument set shared by `invoice.preview` and `invoice.issue`: @@ -617,6 +619,13 @@ accounts are `ACCOUNT_NOT_FOUND`/`ACCOUNT_INACTIVE`. The customer must exist and be active (`NOT_FOUND`). A draft whose rows do not fit the single page is rejected with `TOO_LARGE`. +A row with `"text": true` is a free-text line: only `description` is used, +it has no quantity, unit, price or VAT, contributes nothing to the totals +and only prints its description in the document. Every draft still needs at +least one priced row (`INVALID_ARGS` otherwise, "invoice total must be +greater than zero"). `invoice.get` and `invoice.list` return rows with +`is_text`. + Numbering is a per-org, global series: `invoice_sequence.next_number` starts at 1, is set by the owner and is incremented by exactly one per issued invoice. The OCR reference is the number followed by its MOD10 (Luhn) check @@ -653,6 +662,15 @@ updated and the `invoice.send` audit entry stores `{id,to,subject}` only. `dry_run` validates configuration, recipient and stored document and returns the recipient and subject without sending or updating anything. +`invoice.pay` links a payment voucher (created by the client, normally from +the TUI's **Kvittera betalning** action, which prefills debit `bank_account` +and credit `invoice_receivable_account` in the ordinary voucher form) and +stamps `paid_date` with the voucher's date. The voucher must credit the +`invoice_receivable_account` (default `1510`) with exactly the invoice +total, else `INVALID_ARGS`; an already paid invoice and a `credited` one are +rejected (`CONFLICT` and `INVALID_ARGS`). `dry_run` validates without +writing, and the command is audited. + ### 7.11 Anställda (employees) The employee register. `personal_no` is checked for shape (10 or 12 digits, @@ -863,6 +881,7 @@ Args: `name:type(values)[!][=default]`, `!` = required. | `invoice.list` | viewer | yes | no | no | `customer_id:int`, `status:enum(issued\|credited)`, `limit:int=200` | | `invoice.pdf` | viewer | yes | no | no | `id:int!` | | `invoice.send` | bookkeeper | yes | yes | yes | `id:int!`, `to:string` | +| `invoice.pay` | bookkeeper | yes | yes | yes | `id:int!`, `voucher_id:int!` | | `employee.list` | viewer | yes | no | no | `active_only:bool` | | `employee.get` | viewer | yes | no | no | `id:int!` | | `employee.create` | bookkeeper | yes | yes | yes | `name:string!`, `personal_no:string!`, `address:string`, `postal_code:string`, `city:string`, `bank_account:string`, `email:string`, `salary_account:string`, `monthly_salary_ore:int=0`, `tax_table:int=30`, `tax_column:int=1` | @@ -921,16 +940,21 @@ commands. Implemented screens (0.1.0-dev): returns to the list. A failed auto-match keeps the posted voucher and shows the server error. - **Fakturor** — invoice list (`invoice.list`, newest first) with number, - date, customer, total and status (`utfärdad`/`krediterad`). Ctrl+N opens - the form, Enter the detail. The form has the customer picker, invoice/due - (due defaults from the customer's payment days) and delivery dates, er/var - referens and rows (beskrivning, antal, enhet, à-pris, moms, anm); `F5` - previews the real PDF (`invoice.preview`, nothing stored, no number - consumed), `Ctrl+Enter` issues (`invoice.issue`) and then asks - "Skicka faktura <nr> till <e-post>?". The detail shows header and rows; - `p` fetches the stored PDF (`invoice.pdf`) and `s` sends it - (`invoice.send`). In the list, `n` sets the next invoice number - (`invoice.sequence_get`/`sequence_set`, owner-only). + date, customer, total and status (`utfärdad`/`krediterad`/`betald + <datum>`). Ctrl+N opens the form, Enter the detail. The form has the + customer picker, invoice/due (due defaults from the customer's payment + days) and delivery dates, er/var referens and rows (beskrivning, antal, + enhet, à-pris, moms, anm); a row with only beskrivning is a free-text line + (`text` rows, no amount). `F5` previews the real PDF (`invoice.preview`, + nothing stored, no number consumed), `F9` issues (`invoice.issue`) and + then asks "Skicka faktura <nr> till <e-post>?". The detail shows header + and rows and offers `p = visa PDF` (`invoice.pdf`), `s = skicka` + (`invoice.send`), `u = duplicera` (a new draft with the same rows and + today's dates) and, on unpaid invoices, `b = kvittera betalning`: a + prefilled payment voucher (debit `bank_account`, credit + `invoice_receivable_account`) is opened in the ordinary voucher form and, + once posted, linked with `invoice.pay`. In the list, `n` sets the next + invoice number (`invoice.sequence_get`/`sequence_set`, owner-only). - **Kunder** — the customer register (name, address, postal code, city, VAT number, e-mail, your reference, payment days, notes). Ctrl+N creates, Enter edits (F5 validates with a dry run, Ctrl+Enter saves), `d` diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 9818e1c..9199691 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -560,12 +560,12 @@ another voucher is posted in between) — clients must not persist it. ## 12. Migrations and versioning - `meta(key TEXT PRIMARY KEY, value TEXT)` holds `schema_version` (integer) - and `created_at`. Current version: **11** (v11 adds the employee e-mail, - v10 adds the payroll tables and the `payroll`/`payroll_tax` voucher - sources, v9 adds the invoicing tables and `invoice`, v8 the two bank - reconciliation tables, v7 makes attachments append-only, v3 replaces the - seeded moms rules with the corrected mapping; v2 adds the two template - tables). + and `created_at`. Current version: **12** (v12 adds invoice text rows and + the invoice payment link, v11 the employee e-mail, v10 the payroll tables + and the `payroll`/`payroll_tax` voucher sources, v9 the invoicing tables + and `invoice`, v8 the two bank reconciliation tables, v7 makes attachments + append-only, v3 replaces the seeded moms rules with the corrected mapping; + v2 adds the two template tables). - Migrations are forward-only, applied automatically at daemon start, each in one transaction. Before the first migration statement a consistent `VACUUM INTO` snapshot is written to diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index 433b0b2..c81b3a7 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -30,6 +30,7 @@ there. | `c` | Correct (voucher detail) | | `d` | Delete/arkivera the selected row (only where the action exists; asks for confirmation) | | `f` | Voucher detail: list the voucher's underlag — Enter opens Granska (text in a pager, PDFs/images in the desktop viewer) or Ladda ned…, `d` removes the link (asks first). Underlag: Enter does the same | +| `u` / `b` | Faktura detail: `u` duplicates the invoice into a new draft (same rows, dates reset to today), `b` (unpaid invoices) prefills and posts the payment voucher, then marks the invoice paid | | `Ctrl+F` | Attach a file via the file browser (voucher form and voucher detail) | | `k` | Underlag: link the highlighted attachment to a voucher picked from a list | | `Ctrl+X` | Clear the current row — only inside row editors (never "new") | diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py index 6af4d4f..b339f2d 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -268,6 +268,52 @@ SCENARIOS = [ ], }, { + "name": "invoice-duplicate", + "screen": "invoices", + "steps": [ + { + "keys": ["enter"], + "expect": ["Att betala", "12 500,00"], + }, + { + "keys": ["u"], + "expect": ["Ny faktura", "Testkund AB", + "Konsulttjänster"], + }, + { + "keys": ["esc"], + "expect": ["Faktura", "Att betala"], + }, + ], + }, + { + "name": "invoice-pay", + "screen": "invoices", + "steps": [ + { + "keys": ["enter"], + "expect": ["Att betala", "12 500,00"], + }, + { + "keys": ["b"], + "expect": ["Nytt verifikat", "1930", "1510", + "Betalning faktura"], + }, + { + "keys": ["f9"], + "expect": ["Bokfört"], + }, + { + "keys": ["enter"], + "expect": ["kvitterad"], + }, + { + "keys": ["enter"], + "expect": ["betald", "Betalningsverifikat"], + }, + ], + }, + { "name": "employees", "screen": "employees", "expect": ["Anställda", "Testanställd", "Ny anställd"], diff --git a/src/cmd_invoices.c b/src/cmd_invoices.c index 73273dc..2a6a9c6 100644 --- a/src/cmd_invoices.c +++ b/src/cmd_invoices.c @@ -11,6 +11,7 @@ #include "db.h" #include "invoice.h" #include "ledger.h" +#include "mail.h" #include "pdf.h" #include "secret.h" #include "smtp.h" @@ -139,6 +140,7 @@ struct draft_line { const char *note; const char *vat_code; char account[16]; + int is_text; }; struct invoice_draft { @@ -211,6 +213,21 @@ static int draft_line_parse(struct req *r, yyjson_val *item, size_t no, failf(r, "INVALID_ARGS", "row %zu: description is required", no); return -1; } + int is_text = 0; + arg_bool(item, "text", &is_text); + if (is_text) { + l->article_no = NULL; + l->description = description; + l->quantity_milli = 1; + l->unit = ""; + l->unit_price_ore = 0; + l->amount_ore = 0; + l->note = ""; + l->vat_code = "0"; + l->is_text = 1; + snprintf(l->account, sizeof l->account, "%s", default_account); + return 0; + } const char *qty = arg_str(item, "quantity"); int64_t quantity_milli = 0; if (parse_quantity(qty, &quantity_milli) != 0) { @@ -460,6 +477,7 @@ static int invoice_view_fill(struct req *r, const struct invoice_draft *d, v->lines[i].amount_ore = d->lines[i].amount_ore; v->lines[i].note = d->lines[i].note; v->lines[i].vat_code = d->lines[i].vat_code; + v->lines[i].is_text = d->lines[i].is_text; } v->doc.seller.name = v->seller_name; @@ -670,8 +688,8 @@ static int invoice_store_invoice(struct req *r, const struct invoice_draft *d, r->db, "INSERT INTO invoice_rows(org_id,invoice_id,line_no,article_no," "description,quantity_milli,unit,unit_price_ore,amount_ore,note," - "vat_code,account)" - " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", + "vat_code,account,is_text)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)", -1, &st, NULL) != SQLITE_OK) { db_error(r); return -1; @@ -689,6 +707,7 @@ static int invoice_store_invoice(struct req *r, const struct invoice_draft *d, sqlite3_bind_text(st, 10, l->note ? l->note : "", -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 11, l->vat_code, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 12, l->account, -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 13, l->is_text); rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { @@ -892,12 +911,12 @@ static yyjson_mut_val *invoice_row_json(struct req *r, sqlite3_stmt *st) return db_row_json(r->rdoc, st, "line_no:i,article_no:s,description:s," "quantity_milli:i,unit:s,unit_price_ore:i," - "amount_ore:i,note:s,vat_code:s,account:s"); + "amount_ore:i,note:s,vat_code:s,account:s,is_text:b"); } #define INVOICE_ROW_COLUMNS \ "line_no,article_no,description,quantity_milli,unit,unit_price_ore," \ - "amount_ore,note,vat_code,account" + "amount_ore,note,vat_code,account,is_text" static yyjson_mut_val *h_invoice_get(struct req *r) { @@ -910,9 +929,13 @@ static yyjson_mut_val *h_invoice_get(struct req *r) "SELECT i.id,i.customer_id,c.name,i.number,i.ocr,i.invoice_date," "i.due_date,i.delivery_date,i.your_ref,i.our_ref,i.notes,i.net_ore," "i.vat_ore,i.total_ore,i.status,i.document_id,i.voucher_id," - "i.last_sent_at,i.last_sent_to,i.created_at,i.created_by" + "i.last_sent_at,i.last_sent_to,i.created_at,i.created_by," + "i.paid_date,i.payment_voucher_id,COALESCE(pv.series,'')," + "COALESCE(pv.number,0)" " FROM invoices i JOIN customers c" " ON c.org_id=i.org_id AND c.id=i.customer_id" + " LEFT JOIN vouchers pv" + " ON pv.org_id=i.org_id AND pv.id=i.payment_voucher_id" " WHERE i.org_id=?1 AND i.id=?2", -1, &st, NULL) != SQLITE_OK) return db_error(r); @@ -975,6 +998,17 @@ static yyjson_mut_val *h_invoice_get(struct req *r) sq(sqlite3_column_text(st, 19))); yyjson_mut_obj_add_int(r->rdoc, o, "created_by", sqlite3_column_int64(st, 20)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_date", + sq(sqlite3_column_text(st, 21))); + if (sqlite3_column_type(st, 22) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "payment_voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "payment_voucher_id", + sqlite3_column_int64(st, 22)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_voucher_series", + sq(sqlite3_column_text(st, 23))); + yyjson_mut_obj_add_int(r->rdoc, o, "paid_voucher_number", + sqlite3_column_int64(st, 24)); sqlite3_finalize(st); yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); @@ -1010,7 +1044,7 @@ static yyjson_mut_val *h_invoice_list(struct req *r) r->db, "SELECT i.id,i.number,i.ocr,i.customer_id,c.name,i.invoice_date," "i.due_date,i.total_ore,i.status,i.document_id,i.voucher_id," - "i.last_sent_at,i.last_sent_to" + "i.last_sent_at,i.last_sent_to,i.paid_date" " FROM invoices i JOIN customers c" " ON c.org_id=i.org_id AND c.id=i.customer_id" " WHERE i.org_id=?1" @@ -1063,6 +1097,8 @@ static yyjson_mut_val *h_invoice_list(struct req *r) else yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to", sq(sqlite3_column_text(st, 12))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_date", + sq(sqlite3_column_text(st, 13))); } sqlite3_finalize(st); yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); @@ -1215,6 +1251,10 @@ static yyjson_mut_val *h_invoice_send(struct req *r) fail(r, "SMTP_NOT_CONFIGURED", "smtp_host and smtp_from must be set"); goto done; } + if (!mail_addr_valid(smtp_from)) { + fail(r, "SMTP_NOT_CONFIGURED", "smtp_from must be an email address"); + goto done; + } const char *user = smtp_user && *smtp_user ? smtp_user : ""; if (*user) { if (!smtp_password || !*smtp_password) { @@ -1354,6 +1394,122 @@ static const struct cmd_arg args_invoice_sequence_set[] = { { "next_number", ARG_INT, 1, NULL, NULL, "Next invoice number" }, }; +static const struct cmd_arg args_invoice_pay[] = { + { "id", ARG_INT, 1, NULL, NULL, "Invoice id" }, + { "voucher_id", ARG_INT, 1, NULL, NULL, "Payment voucher id" }, +}; + +/* Marks an invoice paid and links the voucher that settles it. The voucher + must credit the invoice receivable account with the invoice total. */ +static yyjson_mut_val *h_invoice_pay(struct req *r) +{ + int64_t id = 0, voucher_id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + if (!arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0) + return fail(r, "INVALID_ARGS", "voucher_id is required"); + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT number,total_ore,status,paid_date FROM invoices" + " WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) + return db_error(r); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "invoice not found"); + } + int64_t number = sqlite3_column_int64(st, 0); + int64_t total = sqlite3_column_int64(st, 1); + char status[16], paid[16]; + snprintf(status, sizeof status, "%s", sq(sqlite3_column_text(st, 2))); + snprintf(paid, sizeof paid, "%s", sq(sqlite3_column_text(st, 3))); + sqlite3_finalize(st); + if (*paid) + return fail(r, "CONFLICT", "invoice is already paid"); + if (strcmp(status, "credited") == 0) + return fail(r, "INVALID_ARGS", "a credited invoice cannot be paid"); + + char receivable[16]; + db_setting_copy(r->db, r->org_id, "invoice_receivable_account", "1510", + receivable, sizeof receivable); + + char date[16], series[16] = ""; + int64_t vnumber = 0; + if (sqlite3_prepare_v2( + r->db, + "SELECT date,series,number FROM vouchers WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) + return db_error(r); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "voucher not found"); + } + snprintf(date, sizeof date, "%s", sq(sqlite3_column_text(st, 0))); + snprintf(series, sizeof series, "%s", sq(sqlite3_column_text(st, 1))); + vnumber = sqlite3_column_int64(st, 2); + sqlite3_finalize(st); + + if (sqlite3_prepare_v2( + r->db, + "SELECT COALESCE(SUM(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, &st, NULL) != SQLITE_OK) + return db_error(r); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_text(st, 3, receivable, -1, SQLITE_TRANSIENT); + int64_t credited = 0; + if (sqlite3_step(st) == SQLITE_ROW) + credited = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + if (credited != total) + return failf(r, "INVALID_ARGS", + "the payment voucher must credit %s with the invoice" + " total (%lld), not (%lld)", + receivable, (long long)total, (long long)credited); + + if (!r->dry_run) { + if (sqlite3_prepare_v2( + r->db, + "UPDATE invoices SET paid_date=?3,payment_voucher_id=?4" + " WHERE org_id=?1 AND id=?2 AND paid_date=''", + -1, &st, NULL) != SQLITE_OK) + return db_error(r); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + sqlite3_bind_text(st, 3, date, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 4, voucher_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return db_sqlite_error(r); + if (sqlite3_changes(r->db) == 0) + return fail(r, "CONFLICT", "invoice is already paid"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "invoice.pay", reqjson, "OK", NULL); + free(reqjson); + } + + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_int(r->rdoc, o, "number", number); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_date", date); + yyjson_mut_obj_add_int(r->rdoc, o, "payment_voucher_id", voucher_id); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "voucher_series", series); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_number", vnumber); + if (r->dry_run) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; +} + static const struct cmd_arg args_invoice_draft[] = { { "customer_id", ARG_INT, 1, NULL, NULL, "Customer id" }, { "invoice_date", ARG_DATE, 1, NULL, NULL, "Invoice date (YYYY-MM-DD)" }, @@ -1364,7 +1520,7 @@ static const struct cmd_arg args_invoice_draft[] = { { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, { "rows", ARG_JSON, 1, NULL, NULL, "Array of {article_no,description,quantity,unit,unit_price_ore,note," - "vat_code,account}" }, + "vat_code,account,text}; text rows carry only the description" }, }; static const struct cmd_arg args_invoice_get[] = { @@ -1401,6 +1557,8 @@ const struct command g_cmd_invoices[] = { h_invoice_pdf, CMD_ARGS(args_invoice_get) }, { "invoice.send", "E-mail the stored invoice PDF to the customer", PERM_WRITE, 1, 1, 1, h_invoice_send, CMD_ARGS(args_invoice_send) }, + { "invoice.pay", "Mark an invoice paid with a payment voucher", + PERM_WRITE, 1, 1, 1, h_invoice_pay, CMD_ARGS(args_invoice_pay) }, }; const struct cmd_table g_cmd_table_invoices = { diff --git a/src/cmd_settings.c b/src/cmd_settings.c index 28167de..f41f904 100644 --- a/src/cmd_settings.c +++ b/src/cmd_settings.c @@ -8,6 +8,7 @@ #include "audit.h" #include "config.h" #include "db.h" +#include "mail.h" #include "secret.h" #include "util.h" @@ -217,6 +218,10 @@ static yyjson_mut_val *h_settings_set(struct req *r) strcmp(value, "tls") != 0 && strcmp(value, "plain") != 0) return failf(r, "INVALID_ARGS", "%s must be starttls, tls or plain", key); + if ((strcmp(key, "smtp_from") == 0 || + strcmp(key, "smtp_reply_to") == 0) && + !mail_addr_valid(value)) + return failf(r, "INVALID_ARGS", "%s must be an email address", key); if (r->dry_run) return settings_result(r, key, value, 1); sqlite3_stmt *st = NULL; @@ -351,6 +351,8 @@ static const char SCHEMA_V1[] = " CHECK (status IN ('issued','credited'))," " document_id INTEGER," " voucher_id INTEGER," + " paid_date TEXT NOT NULL DEFAULT ''," + " payment_voucher_id INTEGER," " last_sent_at TEXT," " last_sent_to TEXT," " created_at TEXT NOT NULL," @@ -359,7 +361,9 @@ static const char SCHEMA_V1[] = " UNIQUE (org_id, number)," " FOREIGN KEY (org_id, customer_id) REFERENCES customers(org_id, id)," " FOREIGN KEY (org_id, document_id) REFERENCES attachments(org_id, id)," - " FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id)" + " FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id)," + " FOREIGN KEY (org_id, payment_voucher_id)" + " REFERENCES vouchers(org_id, id)" ") STRICT;\n" "CREATE TABLE invoice_rows (" @@ -377,6 +381,7 @@ static const char SCHEMA_V1[] = " vat_code TEXT NOT NULL DEFAULT '25'" " CHECK (vat_code IN ('25','12','6','0','rc','eu'))," " account TEXT NOT NULL DEFAULT ''," + " is_text INTEGER NOT NULL DEFAULT 0," " UNIQUE (org_id, id)," " UNIQUE (org_id, invoice_id, line_no)," " FOREIGN KEY (org_id, invoice_id) REFERENCES invoices(org_id, id)" @@ -911,6 +916,49 @@ static int db_upgrade_v11(sqlite3 *db, char **err) err); } +static int db_column_exists(sqlite3 *db, const char *table, const char *col, + char **err) +{ + sqlite3_stmt *st = NULL; + char sql[160]; + snprintf(sql, sizeof sql, + "SELECT count(*) FROM pragma_table_info('%s') WHERE name='%s'", + table, col); + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + set_err(err, "database error"); + return -1; + } + int have = sqlite3_step(st) == SQLITE_ROW && sqlite3_column_int(st, 0) > 0; + sqlite3_finalize(st); + return have; +} + +static int db_add_column(sqlite3 *db, const char *table, const char *col, + const char *decl, char **err) +{ + int have = db_column_exists(db, table, col, err); + if (have != 0) + return have < 0 ? -1 : 0; + char sql[384]; + snprintf(sql, sizeof sql, "ALTER TABLE %s ADD COLUMN %s %s", table, col, + decl); + return db_exec(db, sql, err); +} + +/* v12: invoice text rows (is_text) and the payment link (paid_date, + payment_voucher_id). Fresh databases already carry the columns. */ +static int db_upgrade_v12(sqlite3 *db, char **err) +{ + if (db_add_column(db, "invoice_rows", "is_text", + "INTEGER NOT NULL DEFAULT 0", err) != 0) + return -1; + if (db_add_column(db, "invoices", "paid_date", + "TEXT NOT NULL DEFAULT ''", err) != 0) + return -1; + return db_add_column(db, "invoices", "payment_voucher_id", "INTEGER", + err); +} + static int db_upgrade(sqlite3 *db, int from, char **err) { if (db_exec(db, "BEGIN IMMEDIATE", err) != 0) @@ -955,6 +1003,10 @@ static int db_upgrade(sqlite3 *db, int from, char **err) db_exec(db, "ROLLBACK", NULL); return -1; } + if (from < 12 && db_upgrade_v12(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); @@ -5,7 +5,7 @@ #include <stddef.h> #include <stdint.h> -#define BOKF_SCHEMA_VERSION 11 +#define BOKF_SCHEMA_VERSION 12 int db_open(const char *path, sqlite3 **out, char **err); int db_migrate(sqlite3 *db, char **err); diff --git a/src/invoice.c b/src/invoice.c index 622c05a..34b2a85 100644 --- a/src/invoice.c +++ b/src/invoice.c @@ -430,6 +430,24 @@ static void draw_table(struct pdf *p, const struct invoice_doc *d) const char *s = l->description ? l->description : ""; size_t line = 0; + if (l->is_text) { + for (;;) { + const char *nl = strchr(s, '\n'); + + if (nl) { + put_desc_line(p, y + desc_offset(line), s, + (size_t)(nl - s)); + s = nl + 1; + line++; + } else { + put_desc_line(p, y + desc_offset(line), s, strlen(s)); + break; + } + } + y += (double)(line + 2) * ROW_STEP; + continue; + } + fmt_quantity(l->quantity_milli, qty, sizeof qty); fmt_kronor(l->unit_price_ore, price, sizeof price, 0, 0); fmt_kronor(l->amount_ore, amount, sizeof amount, 0, 0); diff --git a/src/invoice.h b/src/invoice.h index c92677d..7d64f21 100644 --- a/src/invoice.h +++ b/src/invoice.h @@ -33,6 +33,7 @@ struct invoice_line { int64_t amount_ore; const char *note; const char *vat_code; + int is_text; }; struct invoice_doc { @@ -38,6 +38,21 @@ static void set_err(char *err, size_t errlen, const char *msg) snprintf(err, errlen, "%s", msg); } +int mail_addr_valid(const char *addr) +{ + if (!addr || !*addr) + return 0; + const char *at = strchr(addr, '@'); + if (!at || at == addr || !at[1] || strchr(at + 1, '@')) + return 0; + for (const char *p = addr; *p; p++) { + unsigned char ch = (unsigned char)*p; + if (ch <= 0x20 || ch == 0x7f || ch == '<' || ch == '>') + return 0; + } + return 1; +} + static int mail_cfg_load(sqlite3 *db, int64_t org_id, struct mail_cfg *c, char *err, size_t errlen) { @@ -54,6 +69,14 @@ static int mail_cfg_load(sqlite3 *db, int64_t org_id, struct mail_cfg *c, set_err(err, errlen, "smtp_host and smtp_from must be set"); goto fail; } + if (!mail_addr_valid(c->from)) { + set_err(err, errlen, "smtp_from must be an email address"); + goto fail; + } + if (c->reply_to && *c->reply_to && !mail_addr_valid(c->reply_to)) { + set_err(err, errlen, "smtp_reply_to must be an email address"); + goto fail; + } if (c->user && *c->user) { if (!c->password || !*c->password) { set_err(err, errlen, @@ -14,6 +14,10 @@ struct mail_message { size_t attach_len; }; +/* True for an address the SMTP client can send to: one @, no spaces or + control characters, not empty. */ +int mail_addr_valid(const char *addr); + /* Checks the org's SMTP settings (including decrypting the stored smtp_password) without sending. 0 configured, -1 not configured. */ int mail_config_check(sqlite3 *db, int64_t org_id, char *err, size_t errlen); diff --git a/tests/invoice_check.c b/tests/invoice_check.c index 46b1621..01b5ca3 100644 --- a/tests/invoice_check.c +++ b/tests/invoice_check.c @@ -29,8 +29,8 @@ static void eq64(const char *what, int64_t got, int64_t want) static const struct invoice_line synthetic_lines[] = { { "", "Utvecklingsarbete\nPeriod 2026-01-01 tom 2026-01-31", 61000, "tim", - 120000, 7320000, "", "25" }, - { "A-1", "Konsult", 12500, "tim", 80000, 1000000, "Omvänd moms", "rc" }, + 120000, 7320000, "", "25", 0 }, + { "A-1", "Konsult", 12500, "tim", 80000, 1000000, "Omvänd moms", "rc", 0 }, }; static void make_doc(struct invoice_doc *d) @@ -65,12 +65,12 @@ static void make_doc(struct invoice_doc *d) static void test_totals(void) { static const struct invoice_line lines[] = { - { "", "a", 1000, "st", 1000, 100000, "", "25" }, - { "", "b", 1000, "st", 1000, 50000, "", "12" }, - { "", "c", 1000, "st", 1000, 10000, "", "6" }, - { "", "d", 1000, "st", 1000, 25000, "", "rc" }, - { "", "e", 1000, "st", 1000, 15000, "", "0" }, - { "", "f", 1000, "st", 1000, 4000, "", "eu" }, + { "", "a", 1000, "st", 1000, 100000, "", "25", 0 }, + { "", "b", 1000, "st", 1000, 50000, "", "12", 0 }, + { "", "c", 1000, "st", 1000, 10000, "", "6", 0 }, + { "", "d", 1000, "st", 1000, 25000, "", "rc", 0 }, + { "", "e", 1000, "st", 1000, 15000, "", "0", 0 }, + { "", "f", 1000, "st", 1000, 4000, "", "eu", 0 }, }; struct invoice_doc d; struct invoice_totals t; @@ -89,7 +89,7 @@ static void test_totals(void) { static const struct invoice_line half[] = { - { "", "half", 1000, "st", 100, 50, "", "25" }, + { "", "half", 1000, "st", 100, 50, "", "25", 0 }, }; d.lines = half; diff --git a/tests/test_core.c b/tests/test_core.c index a0b2468..e56c5d3 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -2315,6 +2315,27 @@ static void test_settings(struct tctx *t) CHECK_OK(d); CHECK_STR(d, "result.attachment_dir", "/tmp/bilagor"); yyjson_doc_free(d); + + /* smtp addresses are checked when they are saved */ + d = call(reqf("{\"v\":1,\"id\":\"smtpaddr1\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"smtp_from\",\"value\":\"Anders Bergsten\"}}", + g_session, (int)t->org_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"smtpaddr2\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"smtp_reply_to\",\"value\":\"inte en adress\"}}", + g_session, (int)t->org_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"smtpaddr3\",\"cmd\":\"settings.set\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":" + "\"smtp_from\",\"value\":\"anders@example.com\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + CHECK_STR(d, "result.value", "anders@example.com"); + yyjson_doc_free(d); } static void test_series(struct tctx *t) @@ -2511,7 +2532,7 @@ static void test_smtp_settings(struct tctx *t) d = call(reqf("{\"v\":1,\"id\":\"110\",\"cmd\":\"settings.set\"," "\"session\":\"%s\",\"org\":%d,\"args\":" - "{\"key\":\"smtp_from\",\"value\":\"Bokf AB <a@b.se>\"}}", + "{\"key\":\"smtp_from\",\"value\":\"faktura@example.se\"}}", g_session, (int)t->org_id)); CHECK_OK(d); yyjson_doc_free(d); @@ -2538,7 +2559,7 @@ static void test_smtp_settings(struct tctx *t) CHECK_STR(d, "result.smtp_host", "smtp.example.se"); CHECK_STR(d, "result.smtp_port", "587"); CHECK_STR(d, "result.smtp_user", "faktura@example.se"); - CHECK_STR(d, "result.smtp_from", "Bokf AB <a@b.se>"); + CHECK_STR(d, "result.smtp_from", "faktura@example.se"); CHECK_STR(d, "result.smtp_reply_to", "svar@example.se"); CHECK_STR(d, "result.smtp_security", "tls"); yyjson_doc_free(d); @@ -3405,6 +3426,120 @@ static void test_invoices(struct tctx *t) free(preview_b64); } +static void test_invoice_extras(struct tctx *t) +{ + yyjson_doc *d; + + d = call(reqf("{\"v\":1,\"id\":\"ex1\",\"cmd\":\"customer.create\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":" + "\"Extras AB\"}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + int64_t cust = jint(d, "result.id"); + CHECK(cust > 0); + yyjson_doc_free(d); + + /* a text row contributes nothing and is flagged in the response */ + d = call(reqf("{\"v\":1,\"id\":\"ex2\",\"cmd\":\"invoice.issue\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":" + "%lld,\"invoice_date\":\"2026-09-21\",\"due_date\":" + "\"2026-10-21\",\"rows\":[{\"description\":\"Rubrik\"," + "\"text\":true},{\"description\":\"Konsult\"," + "\"quantity\":\"1\",\"unit\":\"st\",\"unit_price_ore\":" + "100000,\"vat_code\":\"25\"}]}}", + g_session, (int)t->org_id, (long long)cust)); + CHECK_OK(d); + int64_t inv = jint(d, "result.id"); + CHECK(jint(d, "result.total_ore") == 125000); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ex3\",\"cmd\":\"invoice.get\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, (int)t->org_id, (long long)inv)); + CHECK_OK(d); + CHECK(jbool(d, "result.rows.0.is_text")); + CHECK_STR(d, "result.rows.0.description", "Rubrik"); + CHECK(jint(d, "result.rows.0.amount_ore") == 0); + CHECK(!jbool(d, "result.rows.1.is_text")); + CHECK(jint(d, "result.rows.1.amount_ore") == 100000); + CHECK_STR(d, "result.paid_date", ""); + yyjson_doc_free(d); + + /* an all-text draft has no total */ + d = call(reqf("{\"v\":1,\"id\":\"ex4\",\"cmd\":\"invoice.issue\"," + "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":" + "{\"customer_id\":%lld,\"invoice_date\":\"2026-09-21\"," + "\"due_date\":\"2026-10-21\",\"rows\":[{\"description\":" + "\"Bara text\",\"text\":true}]}}", + g_session, (int)t->org_id, (long long)cust)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + /* the payment voucher must credit the receivable with the total */ + d = call(reqf("{\"v\":1,\"id\":\"ex5\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-09-21\",\"description\":\"Fel belopp\",\"rows\":" + "[{\"account\":\"1930\",\"debit_ore\":100},{\"account\":" + "\"1510\",\"credit_ore\":100}]}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + int64_t badvoucher = jint(d, "result.id"); + CHECK(badvoucher > 0); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex6\",\"cmd\":\"invoice.pay\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"voucher_id\":%lld}}", + g_session, (int)t->org_id, (long long)inv, + (long long)badvoucher)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + /* the right amount passes, also as a dry run first */ + d = call(reqf("{\"v\":1,\"id\":\"ex7\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-09-22\",\"description\":\"Betalning\",\"rows\":" + "[{\"account\":\"1930\",\"debit_ore\":125000}," + "{\"account\":\"1510\",\"credit_ore\":125000}]}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + int64_t voucher = jint(d, "result.id"); + CHECK(voucher > 0); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex8\",\"cmd\":\"invoice.pay\"," + "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":" + "{\"id\":%lld,\"voucher_id\":%lld}}", + g_session, (int)t->org_id, (long long)inv, + (long long)voucher)); + CHECK_OK(d); + CHECK(jbool(d, "result.dry_run")); + CHECK_STR(d, "result.paid_date", "2026-09-22"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex9\",\"cmd\":\"invoice.pay\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"voucher_id\":%lld}}", + g_session, (int)t->org_id, (long long)inv, + (long long)voucher)); + CHECK_OK(d); + CHECK_STR(d, "result.paid_date", "2026-09-22"); + const char *ser = jstr(d, "result.voucher_series"); + CHECK(ser && *ser); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex10\",\"cmd\":\"invoice.get\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, (int)t->org_id, (long long)inv)); + CHECK_OK(d); + CHECK_STR(d, "result.paid_date", "2026-09-22"); + CHECK(jint(d, "result.payment_voucher_id") == voucher); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ex11\",\"cmd\":\"invoice.pay\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"voucher_id\":%lld}}", + g_session, (int)t->org_id, (long long)inv, + (long long)voucher)); + CHECK_STR(d, "error.code", "CONFLICT"); + yyjson_doc_free(d); +} + static void test_invoice_send(struct tctx *t) { yyjson_doc *d; @@ -5267,6 +5402,7 @@ static const struct ttest TESTS[] = { { "smtp_settings", test_smtp_settings, "org_members" }, { "bank", test_bank, "settings" }, { "invoices", test_invoices, "org_members" }, + { "invoice_extras", test_invoice_extras, "invoices" }, { "invoice_send", test_invoice_send, "invoices" }, { "moms_rules", test_moms_rules, "org_members" }, { "rules_editor", test_rules_editor, "moms_rules" }, |
