summaryrefslogtreecommitdiff
path: root/src
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 /src
parentb9dc51446be2b8858cd0a9b41168fafaf115ae3a (diff)
downloadbokf-2a69643bf580514712276324fb8c4ea9a512494c.tar.gz
bokf-2a69643bf580514712276324fb8c4ea9a512494c.zip
invoice: customer, sequence, preview and issue commands
Diffstat (limited to 'src')
-rw-r--r--src/commands.c1680
1 files changed, 1677 insertions, 3 deletions
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];