aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 15:23:20 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 15:23:20 +0200
commit2a69643bf580514712276324fb8c4ea9a512494c (patch)
tree734ad2bf1f946b2d22fdcc38935ae136e24a3921
parentb9dc51446be2b8858cd0a9b41168fafaf115ae3a (diff)
invoice: customer, sequence, preview and issue commands
-rw-r--r--docs/PROTOCOL.md66
-rw-r--r--src/commands.c1680
-rw-r--r--tests/test_core.c518
3 files changed, 2259 insertions, 5 deletions
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 3f8aaff..e02d2f7 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -371,14 +371,17 @@ resolved rows in a dry run.
| Command | Args | Notes |
|---|---|---|
| `settings.get` | — | effective org settings (defaults included) |
-| `settings.set` | `key`, `value` | known keys: `default_series`, `attachment_dir`, `bank_account` |
+| `settings.set` | `key`, `value` | known keys: `default_series`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account` |
`default_series` (1–8 characters, e.g. `A`, `V-`, `A `) is used when
`voucher.post` carries no `series` and as the default series for new
templates. `attachment_dir` (a path, up to 255 characters) is the folder the
TUI file browser opens in when attaching underlag. `bank_account` (digits
only, up to 10 characters, default `1930`) is the account `bank.import` uses
-when the request carries no `account`. Verification ids are the concatenation of series and number
+when the request carries no `account`. `invoice_receivable_account` (default
+`1510`) and `invoice_revenue_account` (default `3001`) are the receivable and
+default revenue account of invoice postings, digits only, up to 10
+characters. Verification ids are the concatenation of series and number
(`V-8`), and series are free-form: only an unbroken numbering per series is
required.
@@ -546,6 +549,65 @@ removes one link and is a `NOT_FOUND` when it does not exist. Both mutate
`bank_matches` only and are audited (`bank.match`, `bank.unmatch`);
`bank.import` is audited as `bank.import`.
+### 7.10 Invoicing (fakturering)
+
+| Command | Args | Result |
+|---|---|---|
+| `customer.list` | `active_only?` | `items[]` ordered by name |
+| `customer.get` | `id` | one customer |
+| `customer.create` | `name`; `address`, `postal_code`, `city`, `country`, `vat_nr`, `email`, `your_ref`, `notes`, `payment_days?` | the customer |
+| `customer.update` | `id` plus any field (merged) | the effective customer |
+| `customer.archive` | `id`, `active` | `id`, `active` |
+| `invoice.sequence_get` | — | `next_number` (1 when no row) |
+| `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.pdf` | `id` | stored PDF as `content_base64` |
+
+The draft object is the argument set shared by `invoice.preview` and
+`invoice.issue`:
+
+```json
+{"customer_id":2,"invoice_date":"2026-09-20","due_date":"2026-10-20",
+ "delivery_date":"2026-09-20","your_ref":"Lars","our_ref":"Anders",
+ "notes":"", "rows":[{"article_no":"","description":"Utvecklingsarbete",
+ "quantity":"61","unit":"tim","unit_price_ore":120000,"note":"",
+ "vat_code":"25","account":""}]}
+```
+
+`quantity` is a decimal string with at most three decimals (`61`, `61,5`,
+`0,25`); it must be greater than zero. `amount_ore =
+(quantity_milli * unit_price_ore + 500) / 1000` (round half up), and
+`unit_price_ore` must be a non-negative integer. `vat_code` is one of `25`,
+`12`, `6`, `0`, `rc`, `eu` (default `25`). An empty `account` uses the
+setting `invoice_revenue_account` (default `3001`); unknown or inactive
+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`.
+
+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
+digit: `OCR = <number><check>`. `invoice.preview` renders the document with
+the next number but consumes nothing; `invoice.issue` takes the number,
+renders the PDF, stores it as an immutable `application/pdf` attachment
+(named `Faktura <number> <customer>.pdf`), posts the voucher and links
+invoice, document and voucher in one transaction. The voucher debits
+`invoice_receivable_account` (default `1510`) with the total and credits
+`2610`/`2620`/`2630` with the VAT per rate plus each row's revenue account
+with its net; its source is `invoice`. The renderer's totals equal the
+voucher rows exactly. `invoice.issue` ignores `client_ref` (the number series
+is the idempotency key).
+
+`invoice.preview` is a read. `customer.create/update/archive`,
+`invoice.sequence_set` and `invoice.issue` are audited; `invoice.issue`
+supports `dry_run`, which validates and renders but takes no number and
+writes nothing. `invoice.pdf` returns the stored document as base64
+(`JVBERi0` after decoding is the PDF magic). When the setting
+`invoice_bankgiro` is present it is printed in the document's Bankgiro field.
+
## 8. The TUI is just a client
`bokftui` logs in over the same socket, picks an org and issues the same
diff --git a/src/commands.c b/src/commands.c
index 06a7725..72a8f70 100644
--- a/src/commands.c
+++ b/src/commands.c
@@ -15,6 +15,7 @@
#include "config.h"
#include "db.h"
#include "formula.h"
+#include "invoice.h"
#include "ledger.h"
#include "log.h"
#include "reports.h"
@@ -1615,6 +1616,9 @@ static const char AGENT_INSTRUCTIONS[] =
"- Bank: `bank.import` stores a SEB CSV statement as read-only evidence and\n"
" `bank.list` suggests already-posted vouchers to match with `bank.match`;\n"
" reconciliation never posts vouchers by itself.\n"
+ "- Fakturering: `customer.create` keeps the customer register; `invoice.preview`\n"
+ " renders a draft without consuming a number, and `invoice.issue` takes the\n"
+ " next number, stores the PDF and posts the voucher in one transaction.\n"
"\n"
"## Discovery\n"
"- `describe` lists every implemented command with permissions.\n"
@@ -2607,7 +2611,7 @@ static int parse_attachment_ids(struct req *r, int64_t **out, size_t *out_n)
static yyjson_mut_val *h_settings_get(struct req *r)
{
yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
- int have_default = 0, have_bank = 0;
+ int have_default = 0, have_bank = 0, have_receivable = 0, have_revenue = 0;
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(
r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st,
@@ -2623,6 +2627,10 @@ static yyjson_mut_val *h_settings_get(struct req *r)
have_default = 1;
if (strcmp(k, "bank_account") == 0)
have_bank = 1;
+ if (strcmp(k, "invoice_receivable_account") == 0)
+ have_receivable = 1;
+ if (strcmp(k, "invoice_revenue_account") == 0)
+ have_revenue = 1;
}
}
sqlite3_finalize(st);
@@ -2631,6 +2639,12 @@ static yyjson_mut_val *h_settings_get(struct req *r)
yyjson_mut_obj_add_strcpy(r->rdoc, o, "default_series", "A");
if (!have_bank)
yyjson_mut_obj_add_strcpy(r->rdoc, o, "bank_account", "1930");
+ if (!have_receivable)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_receivable_account",
+ "1510");
+ if (!have_revenue)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_revenue_account",
+ "3001");
return o;
}
@@ -2646,7 +2660,9 @@ static yyjson_mut_val *h_settings_set(struct req *r)
maxlen = 8;
else if (strcmp(key, "attachment_dir") == 0)
maxlen = 255;
- else if (strcmp(key, "bank_account") == 0) {
+ else if (strcmp(key, "bank_account") == 0 ||
+ strcmp(key, "invoice_receivable_account") == 0 ||
+ strcmp(key, "invoice_revenue_account") == 0) {
maxlen = 10;
digits_only = 1;
} else
@@ -2694,6 +2710,1570 @@ static yyjson_mut_val *h_settings_set(struct req *r)
}
/* ------------------------------------------------------------------ */
+/* customer register */
+/* ------------------------------------------------------------------ */
+
+#define CUSTOMER_COLUMNS \
+ "id,name,address,postal_code,city,country,vat_nr,email,your_ref," \
+ "payment_days,notes,active,created_at,COALESCE(updated_at,'')"
+
+static yyjson_mut_val *customer_json(struct req *r, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "address",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code",
+ sq(sqlite3_column_text(st, 3)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "city",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "country",
+ sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr",
+ sq(sqlite3_column_text(st, 6)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "email",
+ sq(sqlite3_column_text(st, 7)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref",
+ sq(sqlite3_column_text(st, 8)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "payment_days",
+ sqlite3_column_int64(st, 9));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes",
+ sq(sqlite3_column_text(st, 10)));
+ yyjson_mut_obj_add_bool(r->rdoc, o, "active",
+ sqlite3_column_int(st, 11) != 0);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 12)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "updated_at",
+ sq(sqlite3_column_text(st, 13)));
+ return o;
+}
+
+struct customer_input {
+ const char *name;
+ const char *address;
+ const char *postal_code;
+ const char *city;
+ const char *country;
+ const char *vat_nr;
+ const char *email;
+ const char *your_ref;
+ const char *notes;
+ int64_t payment_days;
+ int have_payment;
+ int active;
+ int have_active;
+};
+
+static void customer_input_read(struct req *r, struct customer_input *in)
+{
+ memset(in, 0, sizeof *in);
+ in->name = arg_str(r->args, "name");
+ in->address = arg_str(r->args, "address");
+ in->postal_code = arg_str(r->args, "postal_code");
+ in->city = arg_str(r->args, "city");
+ in->country = arg_str(r->args, "country");
+ in->vat_nr = arg_str(r->args, "vat_nr");
+ in->email = arg_str(r->args, "email");
+ in->your_ref = arg_str(r->args, "your_ref");
+ in->notes = arg_str(r->args, "notes");
+ in->have_payment = arg_int(r->args, "payment_days", &in->payment_days);
+ in->have_active = arg_bool(r->args, "active", &in->active);
+}
+
+static int customer_input_validate(struct req *r,
+ const struct customer_input *in,
+ int is_create)
+{
+ if (is_create && (!in->name || !*in->name)) {
+ fail(r, "INVALID_ARGS", "name is required");
+ return -1;
+ }
+ if (in->name && !*in->name) {
+ fail(r, "INVALID_ARGS", "name cannot be empty");
+ return -1;
+ }
+ if (in->name && strlen(in->name) > 200) {
+ fail(r, "INVALID_ARGS", "name is too long");
+ return -1;
+ }
+ static const char *const names[] = {
+ "address", "postal_code", "city", "country", "vat_nr",
+ "email", "your_ref", "notes",
+ };
+ static const size_t maxlen[] = { 500, 32, 120, 64, 64, 254, 120, 2000 };
+ const char *values[] = { in->address, in->postal_code, in->city,
+ in->country, in->vat_nr, in->email,
+ in->your_ref, in->notes };
+ for (size_t i = 0; i < sizeof maxlen / sizeof maxlen[0]; i++) {
+ if (values[i] && strlen(values[i]) > maxlen[i]) {
+ failf(r, "INVALID_ARGS", "%s is too long", names[i]);
+ return -1;
+ }
+ }
+ if (in->have_payment && in->payment_days < 0) {
+ fail(r, "INVALID_ARGS", "payment_days must be >= 0");
+ return -1;
+ }
+ return 0;
+}
+
+static yyjson_mut_val *customer_lookup(struct req *r, int64_t id, int *found)
+{
+ sqlite3_stmt *st = NULL;
+ *found = 0;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT " CUSTOMER_COLUMNS " FROM customers"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK) {
+ *found = -1;
+ fail(r, "INTERNAL", "database error");
+ return NULL;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return NULL;
+ }
+ yyjson_mut_val *o = customer_json(r, st);
+ sqlite3_finalize(st);
+ *found = 1;
+ return o;
+}
+
+static int customer_name_taken(struct req *r, const char *name, int64_t except_id)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT id FROM customers WHERE org_id=?1 AND name=?2"
+ " AND id<>?3",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 3, except_id);
+ int taken = sqlite3_step(st) == SQLITE_ROW;
+ sqlite3_finalize(st);
+ return taken;
+}
+
+static yyjson_mut_val *h_customer_list(struct req *r)
+{
+ int active_only = 0;
+ arg_bool(r->args, "active_only", &active_only);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT " CUSTOMER_COLUMNS " FROM customers WHERE org_id=?1"
+ " AND (?2=0 OR active=1) ORDER BY name COLLATE NOCASE, id",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int(st, 2, active_only);
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ while (sqlite3_step(st) == SQLITE_ROW)
+ yyjson_mut_arr_add_val(items, customer_json(r, st));
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ return out;
+}
+
+static yyjson_mut_val *h_customer_get(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ int found = 0;
+ yyjson_mut_val *o = customer_lookup(r, id, &found);
+ if (found < 0)
+ return NULL;
+ if (!found)
+ return fail(r, "NOT_FOUND", "customer not found");
+ return o;
+}
+
+static yyjson_mut_val *h_customer_create(struct req *r)
+{
+ struct customer_input in;
+ customer_input_read(r, &in);
+ if (customer_input_validate(r, &in, 1) != 0)
+ return NULL;
+ if (!in.have_payment)
+ in.payment_days = 30;
+ const char *address = in.address ? in.address : "";
+ const char *postal = in.postal_code ? in.postal_code : "";
+ const char *city = in.city ? in.city : "";
+ const char *country = in.country ? in.country : "SE";
+ const char *vat = in.vat_nr ? in.vat_nr : "";
+ const char *email = in.email ? in.email : "";
+ const char *your_ref = in.your_ref ? in.your_ref : "";
+ const char *notes = in.notes ? in.notes : "";
+ int taken = customer_name_taken(r, in.name, 0);
+ if (taken < 0)
+ return NULL;
+ if (taken)
+ return fail(r, "CONFLICT", "a customer with this name already exists");
+
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", 0);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", in.name);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", address);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", postal);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", city);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", country);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", vat);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", email);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", your_ref);
+ yyjson_mut_obj_add_int(r->rdoc, o, "payment_days", in.payment_days);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "active", true);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+ }
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO customers(org_id,name,address,postal_code,city,country,"
+ "vat_nr,email,your_ref,payment_days,notes,created_at)"
+ " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, in.name, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, address, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, postal, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, city, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 6, country, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 7, vat, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 8, email, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 9, your_ref, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 10, in.payment_days);
+ sqlite3_bind_text(st, 11, notes, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 12, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ if ((rc & 0xff) == SQLITE_CONSTRAINT)
+ return fail(r, "CONFLICT", "a customer with this name already exists");
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ int64_t id = db_last_id(r->db);
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "customer.create", reqjson, "OK", NULL);
+ free(reqjson);
+ int found = 0;
+ yyjson_mut_val *o = customer_lookup(r, id, &found);
+ if (found < 0)
+ return NULL;
+ if (!found)
+ return fail(r, "INTERNAL", "could not read the new customer");
+ return o;
+}
+
+static yyjson_mut_val *h_customer_update(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ struct customer_input in;
+ customer_input_read(r, &in);
+ if (customer_input_validate(r, &in, 0) != 0)
+ return NULL;
+ if (!in.name && !in.address && !in.postal_code && !in.city && !in.country &&
+ !in.vat_nr && !in.email && !in.your_ref && !in.notes &&
+ !in.have_payment && !in.have_active)
+ return fail(r, "INVALID_ARGS", "nothing to update");
+
+ int found = 0;
+ yyjson_mut_val *existing = customer_lookup(r, id, &found);
+ (void)existing;
+ if (found < 0)
+ return NULL;
+ if (!found)
+ return fail(r, "NOT_FOUND", "customer not found");
+ if (in.name) {
+ int taken = customer_name_taken(r, in.name, id);
+ if (taken < 0)
+ return NULL;
+ if (taken)
+ return fail(r, "CONFLICT", "a customer with this name already exists");
+ }
+
+ if (r->dry_run) {
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT id,COALESCE(?3,name),COALESCE(?4,address),"
+ "COALESCE(?5,postal_code),COALESCE(?6,city),"
+ "COALESCE(?7,country),COALESCE(?8,vat_nr),"
+ "COALESCE(?9,email),COALESCE(?10,your_ref),"
+ "CASE WHEN ?11<0 THEN payment_days ELSE ?11 END,"
+ "COALESCE(?12,notes),"
+ "CASE WHEN ?13<0 THEN active ELSE ?13 END,"
+ "created_at,COALESCE(updated_at,'')"
+ " FROM customers WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ const char *texts[] = { in.name, in.address, in.postal_code, in.city,
+ in.country, in.vat_nr, in.email, in.your_ref,
+ in.notes };
+ static const int bind_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 };
+ for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) {
+ if (texts[i])
+ sqlite3_bind_text(st, bind_index[i], texts[i], -1,
+ SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, bind_index[i]);
+ }
+ sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1);
+ sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = customer_json(r, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "NOT_FOUND", "customer not found");
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+ }
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE customers SET"
+ " name=COALESCE(?3,name), address=COALESCE(?4,address),"
+ " postal_code=COALESCE(?5,postal_code), city=COALESCE(?6,city),"
+ " country=COALESCE(?7,country), vat_nr=COALESCE(?8,vat_nr),"
+ " email=COALESCE(?9,email), your_ref=COALESCE(?10,your_ref),"
+ " payment_days=CASE WHEN ?11<0 THEN payment_days ELSE ?11 END,"
+ " notes=COALESCE(?12,notes),"
+ " active=CASE WHEN ?13<0 THEN active ELSE ?13 END,"
+ " updated_at=?14 WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ const char *texts[] = { in.name, in.address, in.postal_code, in.city,
+ in.country, in.vat_nr, in.email, in.your_ref,
+ in.notes };
+ static const int text_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 };
+ for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) {
+ if (texts[i])
+ sqlite3_bind_text(st, text_index[i], texts[i], -1,
+ SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, text_index[i]);
+ }
+ sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1);
+ sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1);
+ sqlite3_bind_text(st, 14, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ if ((rc & 0xff) == SQLITE_CONSTRAINT)
+ return fail(r, "CONFLICT", "a customer with this name already exists");
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "customer not found");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "customer.update", reqjson, "OK", NULL);
+ free(reqjson);
+ found = 0;
+ yyjson_mut_val *o = customer_lookup(r, id, &found);
+ if (found < 0)
+ return NULL;
+ if (!found)
+ return fail(r, "INTERNAL", "could not read the customer");
+ return o;
+}
+
+static yyjson_mut_val *h_customer_archive(struct req *r)
+{
+ int64_t id = 0;
+ int active = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0 ||
+ !arg_bool(r->args, "active", &active))
+ return fail(r, "INVALID_ARGS", "id and active are required");
+ int found = 0;
+ yyjson_mut_val *existing = customer_lookup(r, id, &found);
+ (void)existing;
+ if (found < 0)
+ return NULL;
+ if (!found)
+ return fail(r, "NOT_FOUND", "customer not found");
+
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "active", active != 0);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+ }
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE customers SET active=?3, updated_at=?4"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ sqlite3_bind_int(st, 3, active);
+ sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "customer not found");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "customer.archive", 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_bool(r->rdoc, o, "active", active != 0);
+ return o;
+}
+
+/* ------------------------------------------------------------------ */
+/* invoice sequence */
+/* ------------------------------------------------------------------ */
+
+static int64_t invoice_next_number(struct req *r)
+{
+ sqlite3_stmt *st = NULL;
+ int64_t next = 1;
+ if (sqlite3_prepare_v2(
+ r->db, "SELECT next_number FROM invoice_sequence WHERE org_id=?1",
+ -1, &st, NULL) != SQLITE_OK)
+ return next;
+ sqlite3_bind_int64(st, 1, r->org_id);
+ if (sqlite3_step(st) == SQLITE_ROW)
+ next = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ return next > 0 ? next : 1;
+}
+
+static int invoice_take_number(struct req *r, int64_t *out)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db, "SELECT next_number FROM invoice_sequence WHERE org_id=?1",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ int have = sqlite3_step(st) == SQLITE_ROW;
+ int64_t number = have ? sqlite3_column_int64(st, 0) : 1;
+ sqlite3_finalize(st);
+ if (have) {
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE invoice_sequence SET next_number=next_number+1"
+ " WHERE org_id=?1",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ fail(r, "DB_BUSY", sqlite3_errmsg(r->db));
+ return -1;
+ }
+ } else {
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO invoice_sequence(org_id,next_number) VALUES(?1,2)",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ fail(r, "DB_BUSY", sqlite3_errmsg(r->db));
+ return -1;
+ }
+ }
+ *out = number;
+ return 0;
+}
+
+static yyjson_mut_val *h_invoice_sequence_get(struct req *r)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "next_number", invoice_next_number(r));
+ return o;
+}
+
+static yyjson_mut_val *h_invoice_sequence_set(struct req *r)
+{
+ int64_t next = 0;
+ if (!arg_int(r->args, "next_number", &next) || next <= 0)
+ return fail(r, "INVALID_ARGS",
+ "next_number must be a positive integer");
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "next_number", next);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+ }
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO invoice_sequence(org_id,next_number) VALUES(?1,?2)"
+ " ON CONFLICT(org_id) DO UPDATE SET next_number=excluded.next_number",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, next);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "invoice.sequence_set", reqjson, "OK", NULL);
+ free(reqjson);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "next_number", next);
+ return o;
+}
+
+/* ------------------------------------------------------------------ */
+/* invoice drafts, rendering and issue */
+/* ------------------------------------------------------------------ */
+
+struct draft_line {
+ const char *article_no;
+ const char *description;
+ int64_t quantity_milli;
+ const char *unit;
+ int64_t unit_price_ore;
+ int64_t amount_ore;
+ const char *note;
+ const char *vat_code;
+ char account[16];
+};
+
+struct invoice_draft {
+ int64_t customer_id;
+ const char *invoice_date;
+ const char *due_date;
+ const char *delivery_date;
+ const char *your_ref;
+ const char *our_ref;
+ const char *notes;
+ struct draft_line *lines;
+ size_t nlines;
+};
+
+static void invoice_draft_free(struct invoice_draft *d)
+{
+ free(d->lines);
+ d->lines = NULL;
+ d->nlines = 0;
+}
+
+static int parse_quantity(const char *s, int64_t *out)
+{
+ if (!s || !*s || strlen(s) > 24)
+ return -1;
+ int64_t whole = 0, frac = 0;
+ size_t int_digits = 0, frac_digits = 0;
+ int seen_sep = 0;
+ for (const char *p = s; *p; p++) {
+ char c = *p;
+ if (c == ',' || c == '.') {
+ if (seen_sep)
+ return -1;
+ seen_sep = 1;
+ continue;
+ }
+ if (c < '0' || c > '9')
+ return -1;
+ if (!seen_sep) {
+ if (int_digits >= 18)
+ return -1;
+ whole = whole * 10 + (c - '0');
+ int_digits++;
+ } else {
+ if (frac_digits >= 3)
+ return -1;
+ frac = frac * 10 + (c - '0');
+ frac_digits++;
+ }
+ }
+ if (!int_digits || (seen_sep && !frac_digits))
+ return -1;
+ for (size_t i = frac_digits; i < 3; i++)
+ frac *= 10;
+ if (whole > (INT64_MAX - frac) / 1000)
+ return -1;
+ int64_t v = whole * 1000 + frac;
+ if (v <= 0)
+ return -1;
+ *out = v;
+ return 0;
+}
+
+static int draft_line_parse(struct req *r, yyjson_val *item, size_t no,
+ const char *default_account, struct draft_line *l,
+ int64_t *net_total)
+{
+ const char *description = arg_str(item, "description");
+ if (!description || !*description) {
+ failf(r, "INVALID_ARGS", "row %zu: description is required", no);
+ return -1;
+ }
+ const char *qty = arg_str(item, "quantity");
+ int64_t quantity_milli = 0;
+ if (parse_quantity(qty, &quantity_milli) != 0) {
+ failf(r, "INVALID_ARGS",
+ "row %zu: quantity must be a positive decimal with at most 3"
+ " decimals",
+ no);
+ return -1;
+ }
+ int64_t price = 0;
+ if (!arg_int(item, "unit_price_ore", &price) || price < 0) {
+ failf(r, "INVALID_ARGS",
+ "row %zu: unit_price_ore must be a non-negative integer", no);
+ return -1;
+ }
+ const char *vat = arg_str(item, "vat_code");
+ if (!vat || !*vat)
+ vat = "25";
+ if (strcmp(vat, "25") != 0 && strcmp(vat, "12") != 0 &&
+ strcmp(vat, "6") != 0 && strcmp(vat, "0") != 0 &&
+ strcmp(vat, "rc") != 0 && strcmp(vat, "eu") != 0) {
+ failf(r, "INVALID_ARGS",
+ "row %zu: vat_code must be one of 25, 12, 6, 0, rc, eu", no);
+ return -1;
+ }
+ const char *account = arg_str(item, "account");
+ if (account && !*account)
+ account = NULL;
+ if (account) {
+ if (!is_digits(account) || strlen(account) > 10) {
+ failf(r, "INVALID_ARGS", "row %zu: account must be 1-10 digits",
+ no);
+ return -1;
+ }
+ snprintf(l->account, sizeof l->account, "%s", account);
+ } else {
+ snprintf(l->account, sizeof l->account, "%s", default_account);
+ }
+ l->article_no = arg_str(item, "article_no");
+ l->description = description;
+ l->quantity_milli = quantity_milli;
+ const char *unit = arg_str(item, "unit");
+ l->unit = unit && *unit ? unit : "st";
+ l->unit_price_ore = price;
+ l->note = arg_str(item, "note");
+ l->vat_code = vat;
+ int64_t product = 0;
+ if (__builtin_mul_overflow(quantity_milli, price, &product) ||
+ __builtin_add_overflow(product, (int64_t)500, &product)) {
+ failf(r, "INVALID_ARGS", "row %zu: amount overflows", no);
+ return -1;
+ }
+ l->amount_ore = product / 1000;
+ if (__builtin_add_overflow(*net_total, l->amount_ore, net_total) ||
+ *net_total > INT64_MAX / 100) {
+ failf(r, "INVALID_ARGS", "row %zu: invoice total overflows", no);
+ return -1;
+ }
+ return 0;
+}
+
+static int parse_invoice_draft(struct req *r, struct invoice_draft *d)
+{
+ memset(d, 0, sizeof *d);
+ if (!arg_int(r->args, "customer_id", &d->customer_id) ||
+ d->customer_id <= 0) {
+ fail(r, "INVALID_ARGS", "customer_id is required");
+ return -1;
+ }
+ d->invoice_date = arg_str(r->args, "invoice_date");
+ d->due_date = arg_str(r->args, "due_date");
+ d->delivery_date = arg_str(r->args, "delivery_date");
+ if (!d->invoice_date || !util_parse_iso_date(d->invoice_date)) {
+ fail(r, "INVALID_ARGS", "invoice_date must be YYYY-MM-DD");
+ return -1;
+ }
+ if (!d->due_date || !util_parse_iso_date(d->due_date)) {
+ fail(r, "INVALID_ARGS", "due_date must be YYYY-MM-DD");
+ return -1;
+ }
+ if (!d->delivery_date)
+ d->delivery_date = "";
+ if (*d->delivery_date && !util_parse_iso_date(d->delivery_date)) {
+ fail(r, "INVALID_ARGS", "delivery_date must be YYYY-MM-DD");
+ return -1;
+ }
+ d->your_ref = arg_str(r->args, "your_ref");
+ d->our_ref = arg_str(r->args, "our_ref");
+ d->notes = arg_str(r->args, "notes");
+ if (!d->your_ref)
+ d->your_ref = "";
+ if (!d->our_ref)
+ d->our_ref = "";
+ if (!d->notes)
+ d->notes = "";
+
+ yyjson_val *rows = r->args ? yyjson_obj_get(r->args, "rows") : NULL;
+ if (!rows || !yyjson_is_arr(rows) || yyjson_arr_size(rows) == 0) {
+ fail(r, "INVALID_ARGS", "rows must be a non-empty array");
+ return -1;
+ }
+ char *revenue = db_setting(r->db, r->org_id, "invoice_revenue_account");
+ const char *default_account =
+ revenue && *revenue ? revenue : "3001";
+ size_t n = yyjson_arr_size(rows);
+ struct draft_line *lines = xcalloc(n, sizeof *lines);
+ size_t k = 0;
+ int64_t net_total = 0;
+ yyjson_arr_iter it = yyjson_arr_iter_with(rows);
+ yyjson_val *item;
+ while ((item = yyjson_arr_iter_next(&it))) {
+ if (!yyjson_is_obj(item)) {
+ failf(r, "INVALID_ARGS", "row %zu: must be an object", k + 1);
+ free(lines);
+ free(revenue);
+ return -1;
+ }
+ if (draft_line_parse(r, item, k + 1, default_account, &lines[k],
+ &net_total) != 0) {
+ free(lines);
+ free(revenue);
+ return -1;
+ }
+ k++;
+ }
+ free(revenue);
+ if (net_total <= 0) {
+ fail(r, "INVALID_ARGS", "invoice total must be greater than zero");
+ free(lines);
+ return -1;
+ }
+ d->lines = lines;
+ d->nlines = k;
+ return 0;
+}
+
+struct invoice_view {
+ struct invoice_doc doc;
+ struct invoice_line *lines;
+ char number_str[32];
+ char ocr[40];
+ char filename[600];
+ char description[600];
+ char seller_name[256];
+ char seller_address[1024];
+ char seller_postal[64];
+ char seller_city[128];
+ char seller_phone[64];
+ char seller_email[256];
+ char seller_org_nr[64];
+ char seller_vat_nr[64];
+ char bankgiro[64];
+ char customer_name[256];
+ char customer_address[1024];
+ char customer_postal[64];
+ char customer_city[128];
+ char customer_vat_nr[64];
+};
+
+static void invoice_view_free(struct invoice_view *v)
+{
+ free(v->lines);
+ v->lines = NULL;
+}
+
+static int invoice_view_fill(struct req *r, const struct invoice_draft *d,
+ int64_t number, struct invoice_view *v)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT COALESCE(name,''),COALESCE(address,''),"
+ "COALESCE(postal_code,''),COALESCE(city,''),"
+ "COALESCE(phone,''),COALESCE(email,''),COALESCE(org_nr,''),"
+ "COALESCE(vat_nr,'') FROM orgs WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ int have_org = sqlite3_step(st) == SQLITE_ROW;
+ if (have_org) {
+ snprintf(v->seller_name, sizeof v->seller_name, "%s",
+ sq(sqlite3_column_text(st, 0)));
+ snprintf(v->seller_address, sizeof v->seller_address, "%s",
+ sq(sqlite3_column_text(st, 1)));
+ snprintf(v->seller_postal, sizeof v->seller_postal, "%s",
+ sq(sqlite3_column_text(st, 2)));
+ snprintf(v->seller_city, sizeof v->seller_city, "%s",
+ sq(sqlite3_column_text(st, 3)));
+ snprintf(v->seller_phone, sizeof v->seller_phone, "%s",
+ sq(sqlite3_column_text(st, 4)));
+ snprintf(v->seller_email, sizeof v->seller_email, "%s",
+ sq(sqlite3_column_text(st, 5)));
+ snprintf(v->seller_org_nr, sizeof v->seller_org_nr, "%s",
+ sq(sqlite3_column_text(st, 6)));
+ snprintf(v->seller_vat_nr, sizeof v->seller_vat_nr, "%s",
+ sq(sqlite3_column_text(st, 7)));
+ }
+ sqlite3_finalize(st);
+ if (!have_org) {
+ fail(r, "NOT_FOUND", "org not found");
+ return -1;
+ }
+
+ int64_t payment_days = 30;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT name,address,postal_code,city,vat_nr,payment_days,active"
+ " FROM customers WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, d->customer_id);
+ if (sqlite3_step(st) != SQLITE_ROW || !sqlite3_column_int(st, 6)) {
+ sqlite3_finalize(st);
+ fail(r, "NOT_FOUND", "customer not found");
+ return -1;
+ }
+ snprintf(v->customer_name, sizeof v->customer_name, "%s",
+ sq(sqlite3_column_text(st, 0)));
+ snprintf(v->customer_address, sizeof v->customer_address, "%s",
+ sq(sqlite3_column_text(st, 1)));
+ snprintf(v->customer_postal, sizeof v->customer_postal, "%s",
+ sq(sqlite3_column_text(st, 2)));
+ snprintf(v->customer_city, sizeof v->customer_city, "%s",
+ sq(sqlite3_column_text(st, 3)));
+ snprintf(v->customer_vat_nr, sizeof v->customer_vat_nr, "%s",
+ sq(sqlite3_column_text(st, 4)));
+ payment_days = sqlite3_column_int64(st, 5);
+ sqlite3_finalize(st);
+
+ char *bankgiro = db_setting(r->db, r->org_id, "invoice_bankgiro");
+ snprintf(v->bankgiro, sizeof v->bankgiro, "%s",
+ bankgiro && *bankgiro ? bankgiro : "");
+ free(bankgiro);
+
+ v->lines = xcalloc(d->nlines, sizeof *v->lines);
+ for (size_t i = 0; i < d->nlines; i++) {
+ v->lines[i].article_no = d->lines[i].article_no;
+ v->lines[i].description = d->lines[i].description;
+ v->lines[i].quantity_milli = d->lines[i].quantity_milli;
+ v->lines[i].unit = d->lines[i].unit;
+ v->lines[i].unit_price_ore = d->lines[i].unit_price_ore;
+ 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->doc.seller.name = v->seller_name;
+ v->doc.seller.address = v->seller_address;
+ v->doc.seller.postal_code = v->seller_postal;
+ v->doc.seller.city = v->seller_city;
+ v->doc.seller.phone = v->seller_phone;
+ v->doc.seller.email = v->seller_email;
+ v->doc.seller.org_nr = v->seller_org_nr;
+ v->doc.seller.vat_nr = v->seller_vat_nr;
+ v->doc.seller.bankgiro = v->bankgiro;
+ v->doc.customer.name = v->customer_name;
+ v->doc.customer.address = v->customer_address;
+ v->doc.customer.postal_code = v->customer_postal;
+ v->doc.customer.city = v->customer_city;
+ v->doc.customer.vat_nr = v->customer_vat_nr;
+ v->doc.number = number;
+ v->doc.ocr = v->ocr;
+ v->doc.invoice_date = d->invoice_date;
+ v->doc.due_date = d->due_date;
+ v->doc.delivery_date = d->delivery_date;
+ v->doc.our_ref = d->our_ref;
+ v->doc.your_ref = d->your_ref;
+ v->doc.notes = d->notes;
+ v->doc.payment_days = (int)payment_days;
+ v->doc.lines = v->lines;
+ v->doc.nlines = d->nlines;
+
+ snprintf(v->number_str, sizeof v->number_str, "%lld", (long long)number);
+ int check = invoice_ocr_check(v->number_str);
+ snprintf(v->ocr, sizeof v->ocr, "%s%c", v->number_str,
+ (char)('0' + (check > 0 ? check : 0)));
+ char safe_name[256];
+ snprintf(safe_name, sizeof safe_name, "%s", v->customer_name);
+ for (char *p = safe_name; *p; p++)
+ if (*p == '/')
+ *p = '-';
+ snprintf(v->filename, sizeof v->filename, "Faktura %lld %s.pdf",
+ (long long)number, safe_name);
+ snprintf(v->description, sizeof v->description, "Faktura %lld %s",
+ (long long)number, safe_name);
+ return 0;
+}
+
+/* Mirrors invoice.c vat_part(): round half up per rate base. */
+static int64_t invoice_vat_part(int64_t net, int64_t rate)
+{
+ int64_t v = net * rate;
+ if (v >= 0)
+ return (v + 50) / 100;
+ return -((-v + 50) / 100);
+}
+
+static int invoice_build_pdf(struct req *r, struct invoice_view *v,
+ struct invoice_totals *t, unsigned char **out,
+ size_t *out_len)
+{
+ invoice_totals(&v->doc, t);
+ *out = NULL;
+ *out_len = 0;
+ if (invoice_render_pdf(&v->doc, out, out_len) != 0 || !*out) {
+ free(*out);
+ *out = NULL;
+ fail(r, "TOO_LARGE", "invoice does not fit on one page");
+ return -1;
+ }
+ return 0;
+}
+
+static int invoice_prepare(struct req *r, struct invoice_draft *d,
+ int64_t number, struct invoice_view *v,
+ struct invoice_totals *t, unsigned char **pdf,
+ size_t *pdf_len)
+{
+ memset(v, 0, sizeof *v);
+ if (invoice_view_fill(r, d, number, v) != 0)
+ return -1;
+ if (invoice_build_pdf(r, v, t, pdf, pdf_len) != 0) {
+ invoice_view_free(v);
+ return -1;
+ }
+ return 0;
+}
+
+static yyjson_mut_val *h_invoice_preview(struct req *r)
+{
+ struct invoice_draft d;
+ if (parse_invoice_draft(r, &d) != 0)
+ return NULL;
+ int64_t number = invoice_next_number(r);
+ struct invoice_view v;
+ struct invoice_totals t;
+ unsigned char *pdf = NULL;
+ size_t pdf_len = 0;
+ int rc = invoice_prepare(r, &d, number, &v, &t, &pdf, &pdf_len);
+ invoice_draft_free(&d);
+ if (rc != 0)
+ return NULL;
+ char *b64 = util_b64(pdf, pdf_len);
+ free(pdf);
+ if (!b64) {
+ invoice_view_free(&v);
+ return fail(r, "INTERNAL", "could not encode the PDF");
+ }
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64);
+ yyjson_mut_obj_add_int(r->rdoc, o, "number", number);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr", v.ocr);
+ yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", t.net_ore);
+ yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", t.vat_ore);
+ yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", t.total_ore);
+ free(b64);
+ invoice_view_free(&v);
+ return o;
+}
+
+static int invoice_store_attachment(struct req *r, const struct invoice_view *v,
+ const unsigned char *pdf, size_t pdf_len,
+ int64_t *out_id)
+{
+ unsigned char hash[32];
+ util_sha256(pdf, pdf_len, hash);
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes,"
+ "content,created_at,created_by)"
+ " VALUES(?1,?2,?3,'application/pdf',?4,?5,?6,?7)",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, v->filename, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, (int64_t)pdf_len);
+ sqlite3_bind_blob(st, 5, pdf, (int)pdf_len, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 7, r->sess->user_id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ fail(r, "DB_BUSY", sqlite3_errmsg(r->db));
+ return -1;
+ }
+ *out_id = db_last_id(r->db);
+ return 0;
+}
+
+static int invoice_store_invoice(struct req *r, const struct invoice_draft *d,
+ const struct invoice_totals *t, int64_t number,
+ int64_t document_id, int64_t voucher_id,
+ int64_t *out_id)
+{
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO invoices(org_id,customer_id,number,ocr,invoice_date,"
+ "due_date,delivery_date,your_ref,our_ref,notes,net_ore,vat_ore,"
+ "total_ore,document_id,voucher_id,created_at,created_by)"
+ " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,"
+ "?17)",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, d->customer_id);
+ sqlite3_bind_int64(st, 3, number);
+ char number_str[32];
+ snprintf(number_str, sizeof number_str, "%lld", (long long)number);
+ int check = invoice_ocr_check(number_str);
+ char ocr[40];
+ snprintf(ocr, sizeof ocr, "%s%c", number_str,
+ (char)('0' + (check > 0 ? check : 0)));
+ sqlite3_bind_text(st, 4, ocr, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, d->invoice_date, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 6, d->due_date, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 7, d->delivery_date, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 8, d->your_ref, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 9, d->our_ref, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 10, d->notes, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 11, t->net_ore);
+ sqlite3_bind_int64(st, 12, t->vat_ore);
+ sqlite3_bind_int64(st, 13, t->total_ore);
+ sqlite3_bind_int64(st, 14, document_id);
+ sqlite3_bind_int64(st, 15, voucher_id);
+ sqlite3_bind_text(st, 16, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 17, r->sess->user_id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ if ((rc & 0xff) == SQLITE_CONSTRAINT) {
+ fail(r, "CONFLICT", "invoice number already exists");
+ return -1;
+ }
+ fail(r, "DB_BUSY", sqlite3_errmsg(r->db));
+ return -1;
+ }
+ int64_t id = db_last_id(r->db);
+ for (size_t i = 0; i < d->nlines; i++) {
+ const struct draft_line *l = &d->lines[i];
+ if (sqlite3_prepare_v2(
+ 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)",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ sqlite3_bind_int64(st, 3, (int64_t)i + 1);
+ sqlite3_bind_text(st, 4, l->article_no ? l->article_no : "", -1,
+ SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, l->description, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 6, l->quantity_milli);
+ sqlite3_bind_text(st, 7, l->unit, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 8, l->unit_price_ore);
+ sqlite3_bind_int64(st, 9, l->amount_ore);
+ 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);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ fail(r, "DB_BUSY", sqlite3_errmsg(r->db));
+ return -1;
+ }
+ }
+ *out_id = id;
+ return 0;
+}
+
+static yyjson_mut_val *invoice_issue_result(struct req *r, int dry_run,
+ int64_t id, int64_t number,
+ const char *ocr, int64_t document_id,
+ int64_t voucher_id,
+ const struct invoice_totals *t)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ if (dry_run) {
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", 0);
+ } else {
+ 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, "ocr", ocr);
+ if (dry_run) {
+ yyjson_mut_obj_add_null(r->rdoc, o, "document_id");
+ yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id");
+ } else {
+ yyjson_mut_obj_add_int(r->rdoc, o, "document_id", document_id);
+ yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id);
+ }
+ yyjson_mut_obj_add_int(r->rdoc, o, "net_ore", t->net_ore);
+ yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore", t->vat_ore);
+ yyjson_mut_obj_add_int(r->rdoc, o, "total_ore", t->total_ore);
+ return o;
+}
+
+static yyjson_mut_val *h_invoice_issue(struct req *r)
+{
+ struct invoice_draft d;
+ if (parse_invoice_draft(r, &d) != 0)
+ return NULL;
+ char *rec_setting =
+ db_setting(r->db, r->org_id, "invoice_receivable_account");
+ char receivable[16];
+ snprintf(receivable, sizeof receivable, "%s",
+ rec_setting && *rec_setting ? rec_setting : "1510");
+ free(rec_setting);
+
+ yyjson_mut_val *res = NULL;
+ struct invoice_view v;
+ struct invoice_totals t;
+ memset(&v, 0, sizeof v);
+ memset(&t, 0, sizeof t);
+ unsigned char *pdf = NULL;
+ size_t pdf_len = 0;
+ char *voucher_json = NULL;
+ struct ledger_row *vrows = NULL;
+ int64_t number = 0, attachment_id = 0, voucher_id = 0, invoice_id = 0;
+ int in_tx = 0;
+
+ if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) {
+ invoice_draft_free(&d);
+ return fail(r, "DB_BUSY", "could not start transaction");
+ }
+ in_tx = 1;
+
+ if (r->dry_run)
+ number = invoice_next_number(r);
+ else if (invoice_take_number(r, &number) != 0)
+ goto done;
+
+ if (invoice_prepare(r, &d, number, &v, &t, &pdf, &pdf_len) != 0)
+ goto done;
+
+ size_t cap = 1 + 3 + d.nlines;
+ vrows = xcalloc(cap, sizeof *vrows);
+ size_t vn = 0;
+ vrows[vn].account = receivable;
+ vrows[vn].debit_ore = t.total_ore;
+ vrows[vn].description = NULL;
+ vn++;
+ struct {
+ int rate;
+ const char *account;
+ int64_t net;
+ } legs[3] = {
+ { 25, "2610", t.net_25 },
+ { 12, "2620", t.net_12 },
+ { 6, "2630", t.net_6 },
+ };
+ int64_t vat_sum = 0;
+ for (size_t i = 0; i < 3; i++) {
+ int64_t vat = invoice_vat_part(legs[i].net, legs[i].rate);
+ if (vat <= 0)
+ continue;
+ vrows[vn].account = legs[i].account;
+ vrows[vn].credit_ore = vat;
+ if (i == 0)
+ vrows[vn].description = "Moms 25%";
+ else if (i == 1)
+ vrows[vn].description = "Moms 12%";
+ else
+ vrows[vn].description = "Moms 6%";
+ vat_sum += vat;
+ vn++;
+ }
+ for (size_t i = 0; i < d.nlines; i++) {
+ if (d.lines[i].amount_ore <= 0)
+ continue;
+ vrows[vn].account = d.lines[i].account;
+ vrows[vn].credit_ore = d.lines[i].amount_ore;
+ vrows[vn].description = d.lines[i].description;
+ vn++;
+ }
+ int64_t sum_debit = 0, sum_credit = 0;
+ for (size_t i = 0; i < vn; i++) {
+ sum_debit += vrows[i].debit_ore;
+ sum_credit += vrows[i].credit_ore;
+ }
+ if (vat_sum != t.vat_ore || sum_debit != t.total_ore ||
+ sum_debit != sum_credit) {
+ fail(r, "INTERNAL", "invoice totals do not match the voucher");
+ goto done;
+ }
+
+ if (!r->dry_run &&
+ invoice_store_attachment(r, &v, pdf, pdf_len, &attachment_id) != 0)
+ goto done;
+
+ struct ledger_post_opts o;
+ memset(&o, 0, sizeof o);
+ o.org_id = r->org_id;
+ o.user_id = r->sess->user_id;
+ o.token_id = r->sess->token_id;
+ o.date = d.invoice_date;
+ o.description = v.description;
+ o.rows = vrows;
+ o.nrows = vn;
+ o.source = "invoice";
+ o.dry_run = r->dry_run;
+ o.already_in_tx = 1;
+ struct ledger_error e;
+ if (ledger_post(r->db, &o, &e, &voucher_json) != 0) {
+ fail(r, e.code ? e.code : "INTERNAL", e.msg);
+ goto done;
+ }
+
+ if (!r->dry_run) {
+ if (!voucher_json) {
+ fail(r, "INTERNAL", "empty voucher result");
+ goto done;
+ }
+ yyjson_doc *vd = yyjson_read(voucher_json, strlen(voucher_json), 0);
+ if (vd) {
+ yyjson_val *idv = yyjson_obj_get(yyjson_doc_get_root(vd), "id");
+ if (idv && yyjson_is_int(idv))
+ voucher_id = yyjson_get_int(idv);
+ yyjson_doc_free(vd);
+ }
+ if (voucher_id <= 0) {
+ fail(r, "INTERNAL", "voucher id missing from the posting");
+ goto done;
+ }
+ if (invoice_store_invoice(r, &d, &t, number, attachment_id, voucher_id,
+ &invoice_id) != 0)
+ goto done;
+ if (sqlite3_exec(r->db, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
+ fail(r, "DB_BUSY", "commit failed");
+ goto done;
+ }
+ in_tx = 0;
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "invoice.issue", reqjson, "OK", NULL);
+ free(reqjson);
+ }
+
+ res = invoice_issue_result(r, r->dry_run, invoice_id, number, v.ocr,
+ attachment_id, voucher_id, &t);
+
+done:
+ if (in_tx)
+ sqlite3_exec(r->db, "ROLLBACK", NULL, NULL, NULL);
+ free(pdf);
+ free(voucher_json);
+ free(vrows);
+ invoice_draft_free(&d);
+ invoice_view_free(&v);
+ return res;
+}
+
+static yyjson_mut_val *invoice_row_json(struct req *r, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "line_no",
+ sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "article_no",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "quantity_milli",
+ sqlite3_column_int64(st, 3));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "unit",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "unit_price_ore",
+ sqlite3_column_int64(st, 5));
+ yyjson_mut_obj_add_int(r->rdoc, o, "amount_ore",
+ sqlite3_column_int64(st, 6));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "note",
+ sq(sqlite3_column_text(st, 7)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_code",
+ sq(sqlite3_column_text(st, 8)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "account",
+ sq(sqlite3_column_text(st, 9)));
+ return o;
+}
+
+#define INVOICE_ROW_COLUMNS \
+ "line_no,article_no,description,quantity_milli,unit,unit_price_ore," \
+ "amount_ore,note,vat_code,account"
+
+static yyjson_mut_val *h_invoice_get(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "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"
+ " FROM invoices i JOIN customers c"
+ " ON c.org_id=i.org_id AND c.id=i.customer_id"
+ " WHERE i.org_id=?1 AND i.id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "invoice not found");
+ }
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_int(r->rdoc, o, "customer_id",
+ sqlite3_column_int64(st, 1));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "customer_name",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "number", sqlite3_column_int64(st, 3));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_date",
+ sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "due_date",
+ sq(sqlite3_column_text(st, 6)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "delivery_date",
+ sq(sqlite3_column_text(st, 7)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref",
+ sq(sqlite3_column_text(st, 8)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "our_ref",
+ sq(sqlite3_column_text(st, 9)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes",
+ sq(sqlite3_column_text(st, 10)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "net_ore",
+ sqlite3_column_int64(st, 11));
+ yyjson_mut_obj_add_int(r->rdoc, o, "vat_ore",
+ sqlite3_column_int64(st, 12));
+ yyjson_mut_obj_add_int(r->rdoc, o, "total_ore",
+ sqlite3_column_int64(st, 13));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "status",
+ sq(sqlite3_column_text(st, 14)));
+ if (sqlite3_column_type(st, 15) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "document_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "document_id",
+ sqlite3_column_int64(st, 15));
+ if (sqlite3_column_type(st, 16) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id",
+ sqlite3_column_int64(st, 16));
+ if (sqlite3_column_type(st, 17) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_at");
+ else
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_at",
+ sq(sqlite3_column_text(st, 17)));
+ if (sqlite3_column_type(st, 18) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_to");
+ else
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to",
+ sq(sqlite3_column_text(st, 18)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 19)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "created_by",
+ sqlite3_column_int64(st, 20));
+ sqlite3_finalize(st);
+
+ yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc);
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT " INVOICE_ROW_COLUMNS " FROM invoice_rows"
+ " WHERE org_id=?1 AND invoice_id=?2 ORDER BY line_no",
+ -1, &st, NULL) == SQLITE_OK) {
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ while (sqlite3_step(st) == SQLITE_ROW)
+ yyjson_mut_arr_add_val(rows, invoice_row_json(r, st));
+ sqlite3_finalize(st);
+ }
+ yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows);
+ return o;
+}
+
+static yyjson_mut_val *h_invoice_list(struct req *r)
+{
+ int64_t customer_id = 0, limit = 200;
+ arg_int(r->args, "customer_id", &customer_id);
+ arg_int(r->args, "limit", &limit);
+ const char *status = arg_str(r->args, "status");
+ if (!status)
+ status = "";
+ if (limit < 1)
+ limit = 200;
+ if (limit > 1000)
+ limit = 1000;
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ 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"
+ " FROM invoices i JOIN customers c"
+ " ON c.org_id=i.org_id AND c.id=i.customer_id"
+ " WHERE i.org_id=?1"
+ " AND (?2=0 OR i.customer_id=?2)"
+ " AND (?3='' OR i.status=?3)"
+ " ORDER BY i.number DESC, i.id DESC LIMIT ?4",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, customer_id);
+ sqlite3_bind_text(st, 3, status, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, limit);
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_int(r->rdoc, o, "number",
+ sqlite3_column_int64(st, 1));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "ocr",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "customer_id",
+ sqlite3_column_int64(st, 3));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "customer_name",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_date",
+ sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "due_date",
+ sq(sqlite3_column_text(st, 6)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "total_ore",
+ sqlite3_column_int64(st, 7));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "status",
+ sq(sqlite3_column_text(st, 8)));
+ if (sqlite3_column_type(st, 9) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "document_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "document_id",
+ sqlite3_column_int64(st, 9));
+ if (sqlite3_column_type(st, 10) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id",
+ sqlite3_column_int64(st, 10));
+ if (sqlite3_column_type(st, 11) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_at");
+ else
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_at",
+ sq(sqlite3_column_text(st, 11)));
+ if (sqlite3_column_type(st, 12) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "last_sent_to");
+ else
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to",
+ sq(sqlite3_column_text(st, 12)));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ return out;
+}
+
+static yyjson_mut_val *h_invoice_pdf(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT document_id FROM invoices WHERE org_id=?1 AND id=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "invoice not found");
+ }
+ int64_t document_id = sqlite3_column_type(st, 0) == SQLITE_NULL
+ ? 0
+ : sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ if (document_id <= 0)
+ return fail(r, "NOT_FOUND", "invoice has no stored PDF");
+ const void *content = NULL;
+ size_t len = 0;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT content FROM attachments WHERE org_id=?1 AND id=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, document_id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "invoice PDF not found");
+ }
+ content = sqlite3_column_blob(st, 0);
+ len = (size_t)sqlite3_column_bytes(st, 0);
+ char *b64 = util_b64(content ? content : (const unsigned char *)"", len);
+ sqlite3_finalize(st);
+ if (!b64)
+ return fail(r, "INTERNAL", "could not encode the PDF");
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64);
+ free(b64);
+ return o;
+}
+
+/* ------------------------------------------------------------------ */
/* report rules (per-org moms mapping) */
/* ------------------------------------------------------------------ */
@@ -6057,7 +7637,8 @@ static const struct cmd_arg args_bokslut_post[] = {
static const struct cmd_arg args_settings_set[] = {
{ "key", ARG_STR, 1, NULL, NULL,
- "default_series, attachment_dir or bank_account" },
+ "default_series, attachment_dir, bank_account,"
+ " invoice_receivable_account or invoice_revenue_account" },
{ "value", ARG_STR, 1, NULL, NULL, "Setting value" },
};
@@ -6255,6 +7836,74 @@ static const struct cmd_arg args_sie_import[] = {
"Server-side path; alternative to content_base64" },
};
+static const struct cmd_arg args_customer_list[] = {
+ { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active customers" },
+};
+
+static const struct cmd_arg args_customer_get[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Customer id" },
+};
+
+static const struct cmd_arg args_customer_create[] = {
+ { "name", ARG_STR, 1, NULL, NULL, "Customer name, unique per org" },
+ { "address", ARG_STR, 0, NULL, NULL, "Street address; may contain newlines" },
+ { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" },
+ { "city", ARG_STR, 0, NULL, NULL, "City" },
+ { "country", ARG_STR, 0, "SE", NULL, "Country code" },
+ { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" },
+ { "email", ARG_STR, 0, NULL, NULL, "E-mail address" },
+ { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" },
+ { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" },
+ { "payment_days", ARG_INT, 0, "30", NULL, "Payment terms in days" },
+};
+
+static const struct cmd_arg args_customer_update[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Customer id" },
+ { "name", ARG_STR, 0, NULL, NULL, "Customer name, unique per org" },
+ { "address", ARG_STR, 0, NULL, NULL, "Street address" },
+ { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" },
+ { "city", ARG_STR, 0, NULL, NULL, "City" },
+ { "country", ARG_STR, 0, NULL, NULL, "Country code" },
+ { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" },
+ { "email", ARG_STR, 0, NULL, NULL, "E-mail address" },
+ { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" },
+ { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" },
+ { "payment_days", ARG_INT, 0, NULL, NULL, "Payment terms in days" },
+ { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" },
+};
+
+static const struct cmd_arg args_customer_archive[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Customer id" },
+ { "active", ARG_BOOL, 1, NULL, NULL, "false archives, true reactivates" },
+};
+
+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_draft[] = {
+ { "customer_id", ARG_INT, 1, NULL, NULL, "Customer id" },
+ { "invoice_date", ARG_DATE, 1, NULL, NULL, "Invoice date (YYYY-MM-DD)" },
+ { "due_date", ARG_DATE, 1, NULL, NULL, "Due date (YYYY-MM-DD)" },
+ { "delivery_date", ARG_STR, 0, NULL, NULL, "Delivery date or empty" },
+ { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" },
+ { "our_ref", ARG_STR, 0, NULL, NULL, "Our reference" },
+ { "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}" },
+};
+
+static const struct cmd_arg args_invoice_get[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Invoice id" },
+};
+
+static const struct cmd_arg args_invoice_list[] = {
+ { "customer_id", ARG_INT, 0, NULL, NULL, "Customer filter" },
+ { "status", ARG_ENUM, 0, NULL, "issued,credited", "Status filter" },
+ { "limit", ARG_INT, 0, "200", NULL, "Page size, 1-1000" },
+};
+
const struct command g_commands[] = {
{ "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health, NULL, 0 },
{ "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta, NULL,
@@ -6407,6 +8056,31 @@ const struct command g_commands[] = {
CMD_ARGS(args_sie_export) },
{ "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1,
1, h_sie_import, CMD_ARGS(args_sie_import) },
+ { "customer.list", "List customers ordered by name", PERM_READ, 1, 0, 0,
+ h_customer_list, CMD_ARGS(args_customer_list) },
+ { "customer.get", "Get one customer", PERM_READ, 1, 0, 0, h_customer_get,
+ CMD_ARGS(args_customer_get) },
+ { "customer.create", "Create a customer", PERM_WRITE, 1, 1, 1,
+ h_customer_create, CMD_ARGS(args_customer_create) },
+ { "customer.update", "Update a customer (merged)", PERM_WRITE, 1, 1, 1,
+ h_customer_update, CMD_ARGS(args_customer_update) },
+ { "customer.archive", "Archive or reactivate a customer", PERM_WRITE, 1, 1,
+ 1, h_customer_archive, CMD_ARGS(args_customer_archive) },
+ { "invoice.sequence_get", "Read the next invoice number", PERM_READ, 1, 0,
+ 0, h_invoice_sequence_get, NULL, 0 },
+ { "invoice.sequence_set", "Set the next invoice number (owner)",
+ PERM_OWNER, 1, 1, 1, h_invoice_sequence_set,
+ CMD_ARGS(args_invoice_sequence_set) },
+ { "invoice.preview", "Render an invoice draft without storing it",
+ PERM_READ, 1, 0, 0, h_invoice_preview, CMD_ARGS(args_invoice_draft) },
+ { "invoice.issue", "Issue an invoice: number, PDF and voucher",
+ PERM_WRITE, 1, 1, 1, h_invoice_issue, CMD_ARGS(args_invoice_draft) },
+ { "invoice.get", "Get an invoice with rows", PERM_READ, 1, 0, 0,
+ h_invoice_get, CMD_ARGS(args_invoice_get) },
+ { "invoice.list", "List invoices, newest first", PERM_READ, 1, 0, 0,
+ h_invoice_list, CMD_ARGS(args_invoice_list) },
+ { "invoice.pdf", "Fetch the stored invoice PDF", PERM_READ, 1, 0, 0,
+ h_invoice_pdf, CMD_ARGS(args_invoice_get) },
};
const size_t g_commands_count = sizeof g_commands / sizeof g_commands[0];
diff --git a/tests/test_core.c b/tests/test_core.c
index a78a940..fa7b5e4 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -16,6 +16,7 @@
#include "client.h"
#include "config.h"
#include "db.h"
+#include "invoice.h"
#include "protocol.h"
#include "sessions.h"
#include "util.h"
@@ -2199,6 +2200,523 @@ int main(void)
CHECK_STR(d, "result.items.0.account", "1940");
yyjson_doc_free(d);
+ /* ---------------- invoicing: customers, sequence, issue ---------- */
+
+ d = call(reqf("{\"v\":1,\"id\":\"300\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.invoice_receivable_account", "1510");
+ CHECK_STR(d, "result.invoice_revenue_account", "3001");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"301\",\"cmd\":\"customer.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":"
+ "\"Andra bygg AB\",\"address\":\"Storgatan 1\","
+ "\"postal_code\":\"171 63\",\"city\":\"Solna\","
+ "\"vat_nr\":\"SE559232855201\","
+ "\"email\":\"faktura@andra.se\",\"your_ref\":\"Eric\","
+ "\"payment_days\":30}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ int64_t cust1 = jint(d, "result.id");
+ CHECK(cust1 > 0);
+ CHECK_STR(d, "result.name", "Andra bygg AB");
+ CHECK_STR(d, "result.city", "Solna");
+ CHECK_STR(d, "result.email", "faktura@andra.se");
+ CHECK(jint(d, "result.payment_days") == 30);
+ CHECK(jbool(d, "result.active"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"302\",\"cmd\":\"customer.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":"
+ "\"Andra bygg AB\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "CONFLICT");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"303\",\"cmd\":\"customer.create\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":"
+ "{\"name\":\"Tillfällig AB\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ CHECK_STR(d, "result.name", "Tillfällig AB");
+ CHECK(jint(d, "result.payment_days") == 30);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"304\",\"cmd\":\"customer.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":"
+ "\"NZ Bygg AB\",\"city\":\"Bromma\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ int64_t cust2 = jint(d, "result.id");
+ CHECK(cust2 > 0);
+ CHECK(jint(d, "result.payment_days") == 30);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"305\",\"cmd\":\"customer.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.name", "Andra bygg AB");
+ CHECK_STR(d, "result.address", "Storgatan 1");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"306\",\"cmd\":\"customer.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":999999}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"307\",\"cmd\":\"customer.list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 2);
+ CHECK_STR(d, "result.items.0.name", "Andra bygg AB");
+ CHECK_STR(d, "result.items.1.name", "NZ Bygg AB");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"308\",\"cmd\":\"customer.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"city\":\"Sundbyberg\",\"your_ref\":\"Lars\"}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.city", "Sundbyberg");
+ CHECK_STR(d, "result.your_ref", "Lars");
+ CHECK_STR(d, "result.address", "Storgatan 1");
+ CHECK_STR(d, "result.name", "Andra bygg AB");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"309\",\"cmd\":\"customer.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"name\":\"Andra bygg AB\"}}",
+ g_session, (int)org_id, (long long)cust2));
+ CHECK_STR(d, "error.code", "CONFLICT");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"310\",\"cmd\":\"customer.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":999999,"
+ "\"city\":\"X\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"311\",\"cmd\":\"customer.update\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":"
+ "{\"id\":%lld,\"city\":\"Nacka\"}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ CHECK_STR(d, "result.city", "Nacka");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"312\",\"cmd\":\"customer.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.city", "Sundbyberg");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"313\",\"cmd\":\"customer.archive\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"active\":false}}",
+ g_session, (int)org_id, (long long)cust2));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.id") == cust2);
+ CHECK(!jbool(d, "result.active"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"314\",\"cmd\":\"customer.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"active_only\":true}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 1);
+ CHECK_STR(d, "result.items.0.name", "Andra bygg AB");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"315\",\"cmd\":\"customer.archive\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":999999,"
+ "\"active\":false}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"320\",\"cmd\":\"invoice.sequence_get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.next_number") == 1);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"321\",\"cmd\":\"invoice.sequence_set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"next_number\":"
+ "0}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"322\",\"cmd\":\"invoice.sequence_set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"next_number\":"
+ "17761}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.next_number") == 17761);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"323\",\"cmd\":\"invoice.sequence_get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.next_number") == 17761);
+ yyjson_doc_free(d);
+
+ char draft_args[2048];
+ snprintf(draft_args, sizeof draft_args,
+ "{\"customer_id\":%lld,\"invoice_date\":\"2026-09-20\","
+ "\"due_date\":\"2026-10-20\",\"delivery_date\":\"2026-09-20\","
+ "\"your_ref\":\"Lars\",\"our_ref\":\"Anders\",\"notes\":\"\","
+ "\"rows\":[{\"article_no\":\"\",\"description\":"
+ "\"Utvecklingsarbete\",\"quantity\":\"61\",\"unit\":\"tim\","
+ "\"unit_price_ore\":120000,\"note\":\"\",\"vat_code\":\"25\","
+ "\"account\":\"\"}]}",
+ (long long)cust1);
+
+ d = call(reqf("{\"v\":1,\"id\":\"330\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":%s}",
+ g_session, (int)org_id, draft_args));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.number") == 17761);
+ char number_str[32];
+ snprintf(number_str, sizeof number_str, "%lld",
+ (long long)jint(d, "result.number"));
+ char expect_ocr[40];
+ snprintf(expect_ocr, sizeof expect_ocr, "%s%d", number_str,
+ invoice_ocr_check(number_str));
+ CHECK_STR(d, "result.ocr", expect_ocr);
+ CHECK(jint(d, "result.net_ore") == 7320000);
+ CHECK(jint(d, "result.vat_ore") == 1830000);
+ CHECK(jint(d, "result.total_ore") == 9150000);
+ const char *pdf_b64 = jstr(d, "result.content_base64");
+ char *preview_b64 = pdf_b64 ? xstrdup(pdf_b64) : NULL;
+ CHECK(preview_b64 && strncmp(preview_b64, "JVBERi0", 7) == 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"331\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":%s}",
+ g_session, (int)org_id, draft_args));
+ CHECK_OK(d);
+ CHECK(preview_b64 && jstr(d, "result.content_base64") &&
+ strcmp(jstr(d, "result.content_base64"), preview_b64) == 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"332\",\"cmd\":\"invoice.sequence_get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.next_number") == 17761);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"333\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"X\","
+ "\"quantity\":\"1\",\"unit_price_ore\":100,"
+ "\"vat_code\":\"99\"}]}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"334\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"X\","
+ "\"quantity\":\"1,2345\",\"unit_price_ore\":100,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"335\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"X\","
+ "\"quantity\":\"0\",\"unit_price_ore\":100,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"336\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "999999,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"X\","
+ "\"quantity\":\"1\",\"unit_price_ore\":100,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"337\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"X\","
+ "\"quantity\":\"1\",\"unit_price_ore\":100,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, (int)org_id, (long long)cust2));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"337a\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"X\","
+ "\"quantity\":\"1000\",\"unit_price_ore\":"
+ "9223372036854775807,\"vat_code\":\"25\"}]}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"337b\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"X\","
+ "\"quantity\":\"1\",\"unit_price_ore\":-1,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ {
+ char rows_json[8192];
+ size_t off = 0;
+ off += (size_t)snprintf(rows_json + off, sizeof rows_json - off, "[");
+ for (int i = 0; i < 20 && off < sizeof rows_json - 256; i++)
+ off += (size_t)snprintf(
+ rows_json + off, sizeof rows_json - off,
+ "%s{\"description\":\"Rad %d\",\"quantity\":\"1\","
+ "\"unit_price_ore\":100,\"vat_code\":\"25\"}",
+ i ? "," : "", i + 1);
+ snprintf(rows_json + off, sizeof rows_json - off, "]");
+ d = call(reqf("{\"v\":1,\"id\":\"337c\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":%s}}",
+ g_session, (int)org_id, (long long)cust1, rows_json));
+ CHECK_STR(d, "error.code", "TOO_LARGE");
+ yyjson_doc_free(d);
+ }
+
+ d = call(reqf("{\"v\":1,\"id\":\"338\",\"cmd\":\"invoice.issue\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":"
+ "{\"customer_id\":%lld,\"invoice_date\":\"2026-09-20\","
+ "\"due_date\":\"2026-10-20\",\"rows\":[{\"description\":"
+ "\"X\",\"quantity\":\"1\",\"unit_price_ore\":100,"
+ "\"vat_code\":\"25\",\"account\":\"9999\"}]}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_STR(d, "error.code", "ACCOUNT_NOT_FOUND");
+ yyjson_doc_free(d);
+
+ int64_t vouchers_before = db_count(g_db, "SELECT count(*) FROM vouchers");
+ int64_t attachments_before =
+ db_count(g_db, "SELECT count(*) FROM attachments");
+
+ d = call(reqf("{\"v\":1,\"id\":\"340\",\"cmd\":\"invoice.issue\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":%s}",
+ g_session, (int)org_id, draft_args));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ CHECK(jint(d, "result.number") == 17761);
+ CHECK_STR(d, "result.ocr", expect_ocr);
+ CHECK(jint(d, "result.net_ore") == 7320000);
+ CHECK(jint(d, "result.vat_ore") == 1830000);
+ CHECK(jint(d, "result.total_ore") == 9150000);
+ yyjson_val *dry_doc = jget(d, "result.document_id");
+ CHECK(dry_doc && yyjson_is_null(dry_doc));
+ yyjson_doc_free(d);
+ CHECK(db_count(g_db, "SELECT count(*) FROM vouchers") == vouchers_before);
+ CHECK(db_count(g_db, "SELECT count(*) FROM attachments") ==
+ attachments_before);
+
+ d = call(reqf("{\"v\":1,\"id\":\"341\",\"cmd\":\"invoice.sequence_get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.next_number") == 17761);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"350\",\"cmd\":\"invoice.issue\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":%s}",
+ g_session, (int)org_id, draft_args));
+ CHECK_OK(d);
+ int64_t inv1 = jint(d, "result.id");
+ int64_t doc1 = jint(d, "result.document_id");
+ int64_t vou1 = jint(d, "result.voucher_id");
+ CHECK(inv1 > 0);
+ CHECK(doc1 > 0);
+ CHECK(vou1 > 0);
+ CHECK(jint(d, "result.number") == 17761);
+ CHECK_STR(d, "result.ocr", expect_ocr);
+ CHECK(jint(d, "result.net_ore") == 7320000);
+ CHECK(jint(d, "result.vat_ore") == 1830000);
+ CHECK(jint(d, "result.total_ore") == 9150000);
+ yyjson_doc_free(d);
+ CHECK(db_count(g_db, "SELECT count(*) FROM vouchers") == vouchers_before + 1);
+ CHECK(db_count(g_db, "SELECT count(*) FROM attachments") ==
+ attachments_before + 1);
+
+ d = call(reqf("{\"v\":1,\"id\":\"351\",\"cmd\":\"voucher.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)vou1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.source", "invoice");
+ CHECK_STR(d, "result.date", "2026-09-20");
+ CHECK(find_amount(d, "result.rows", "account", "1510", "debit_ore") ==
+ 9150000);
+ CHECK(find_amount(d, "result.rows", "account", "3001", "credit_ore") ==
+ 7320000);
+ CHECK(find_amount(d, "result.rows", "account", "2610", "credit_ore") ==
+ 1830000);
+ CHECK(find_amount(d, "result.rows", "account", "1510", "credit_ore") == 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"352\",\"cmd\":\"invoice.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.status", "issued");
+ CHECK(jint(d, "result.customer_id") == cust1);
+ CHECK_STR(d, "result.customer_name", "Andra bygg AB");
+ CHECK_STR(d, "result.ocr", expect_ocr);
+ CHECK(jint(d, "result.document_id") == doc1);
+ CHECK(jint(d, "result.voucher_id") == vou1);
+ CHECK(yyjson_arr_size(jget(d, "result.rows")) == 1);
+ CHECK(jint(d, "result.rows.0.quantity_milli") == 61000);
+ CHECK(jint(d, "result.rows.0.amount_ore") == 7320000);
+ CHECK_STR(d, "result.rows.0.vat_code", "25");
+ CHECK_STR(d, "result.rows.0.account", "3001");
+ CHECK_STR(d, "result.rows.0.unit", "tim");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"353\",\"cmd\":\"invoice.pdf\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ const char *stored = jstr(d, "result.content_base64");
+ CHECK(stored && preview_b64 && strcmp(stored, preview_b64) == 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"355\",\"cmd\":\"invoice.issue\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-21\",\"due_date\":"
+ "\"2026-10-21\",\"rows\":[{\"description\":\"Konsult\","
+ "\"quantity\":\"2,5\",\"unit\":\"tim\","
+ "\"unit_price_ore\":100000,\"vat_code\":\"12\","
+ "\"account\":\"3002\"}]}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_OK(d);
+ int64_t inv2 = jint(d, "result.id");
+ int64_t doc2 = jint(d, "result.document_id");
+ int64_t vou2 = jint(d, "result.voucher_id");
+ CHECK(inv2 > inv1);
+ CHECK(doc2 > doc1);
+ CHECK(vou2 > vou1);
+ CHECK(jint(d, "result.number") == 17762);
+ CHECK(jint(d, "result.net_ore") == 250000);
+ CHECK(jint(d, "result.vat_ore") == 30000);
+ CHECK(jint(d, "result.total_ore") == 280000);
+ yyjson_doc_free(d);
+ CHECK(db_count(g_db, "SELECT count(*) FROM vouchers") == vouchers_before + 2);
+ CHECK(db_count(g_db, "SELECT count(*) FROM attachments") ==
+ attachments_before + 2);
+
+ d = call(reqf("{\"v\":1,\"id\":\"356\",\"cmd\":\"voucher.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)vou2));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.source", "invoice");
+ CHECK(find_amount(d, "result.rows", "account", "1510", "debit_ore") ==
+ 280000);
+ CHECK(find_amount(d, "result.rows", "account", "2620", "credit_ore") ==
+ 30000);
+ CHECK(find_amount(d, "result.rows", "account", "3002", "credit_ore") ==
+ 250000);
+ CHECK(find_amount(d, "result.rows", "account", "2610", "credit_ore") == -1);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"357\",\"cmd\":\"invoice.list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 2);
+ CHECK(jint(d, "result.items.0.number") == 17762);
+ CHECK(jint(d, "result.items.1.number") == 17761);
+ CHECK_STR(d, "result.items.0.customer_name", "Andra bygg AB");
+ CHECK_STR(d, "result.items.0.status", "issued");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"358\",\"cmd\":\"invoice.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld}}",
+ g_session, (int)org_id, (long long)cust1));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 2);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"359\",\"cmd\":\"invoice.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld}}",
+ g_session, (int)org_id, (long long)cust2));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"360\",\"cmd\":\"invoice.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"status\":"
+ "\"credited\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"361\",\"cmd\":\"invoice.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"limit\":1}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 1);
+ CHECK(jint(d, "result.items.0.number") == 17762);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"362\",\"cmd\":\"invoice.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"status\":"
+ "\"nope\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"363\",\"cmd\":\"invoice.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":999999}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"364\",\"cmd\":\"invoice.pdf\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":999999}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"365\",\"cmd\":\"invoice.sequence_get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.next_number") == 17763);
+ yyjson_doc_free(d);
+
+ free(preview_b64);
+
/* ---------------- moms rules: ranges merge per box ------------- */
d = call(reqf("{\"v\":1,\"id\":\"110\",\"cmd\":\"org.create\","
"\"session\":\"%s\",\"args\":{\"name\":\"Moms AB\"}}",