From 2a69643bf580514712276324fb8c4ea9a512494c Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Sun, 20 Sep 2026 15:23:20 +0200 Subject: invoice: customer, sequence, preview and issue commands --- src/commands.c | 1680 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1677 insertions(+), 3 deletions(-) (limited to 'src/commands.c') 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 @@ -2693,6 +2709,1570 @@ static yyjson_mut_val *h_settings_set(struct req *r) return o; } +/* ------------------------------------------------------------------ */ +/* 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]; -- cgit v1.3