summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-21 23:09:57 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-21 23:09:57 +0200
commit8350815a8c6987b288551848eda1c678739c8d4a (patch)
tree04ef3c56e10fa8b06768465910d10a57779e8731
parentbf9012dad7ef34bbbfb073bc0f37b0161f0496f3 (diff)
downloadbokf-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.c328
-rw-r--r--clients/screens_vouchers.c22
-rw-r--r--clients/ui.h1
-rw-r--r--docs/DECISIONS.md14
-rw-r--r--docs/INVOICING.md21
-rw-r--r--docs/PROTOCOL.md56
-rw-r--r--docs/SCHEMA.md12
-rw-r--r--docs/TUI-GUIDELINES.md1
-rwxr-xr-xscripts/tui-golden.py46
-rw-r--r--src/cmd_invoices.c172
-rw-r--r--src/cmd_settings.c5
-rw-r--r--src/db.c54
-rw-r--r--src/db.h2
-rw-r--r--src/invoice.c18
-rw-r--r--src/invoice.h1
-rw-r--r--src/mail.c23
-rw-r--r--src/mail.h4
-rw-r--r--tests/invoice_check.c18
-rw-r--r--tests/test_core.c140
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;
diff --git a/src/db.c b/src/db.c
index 4618a73..200fff8 100644
--- a/src/db.c
+++ b/src/db.c
@@ -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);
diff --git a/src/db.h b/src/db.h
index e8d891b..b541f66 100644
--- a/src/db.h
+++ b/src/db.h
@@ -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 {
diff --git a/src/mail.c b/src/mail.c
index ca35f6d..e1b2131 100644
--- a/src/mail.c
+++ b/src/mail.c
@@ -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,
diff --git a/src/mail.h b/src/mail.h
index 5efade5..2c5da2f 100644
--- a/src/mail.h
+++ b/src/mail.h
@@ -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" },