aboutsummaryrefslogtreecommitdiff
path: root/src/cmd_invoices.c
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 20:11:19 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 20:11:19 +0200
commit8351fa7752bb8341ef405a37ff7c73142715e981 (patch)
treedde603d313f4f26934f2ce20a22dfd9b69324cf0 /src/cmd_invoices.c
parentbeafdbe0d64340387d619c38554976410129f05b (diff)
downloadbokf-8351fa7752bb8341ef405a37ff7c73142715e981.tar.gz
bokf-8351fa7752bb8341ef405a37ff7c73142715e981.zip
commands: split the command table by domain
Diffstat (limited to 'src/cmd_invoices.c')
-rw-r--r--src/cmd_invoices.c1422
1 files changed, 1422 insertions, 0 deletions
diff --git a/src/cmd_invoices.c b/src/cmd_invoices.c
new file mode 100644
index 0000000..b8d44e3
--- /dev/null
+++ b/src/cmd_invoices.c
@@ -0,0 +1,1422 @@
+#include "commands.h"
+#include "cmd_util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#include "audit.h"
+#include "config.h"
+#include "db.h"
+#include "invoice.h"
+#include "ledger.h"
+#include "pdf.h"
+#include "secret.h"
+#include "smtp.h"
+#include "util.h"
+
+/* ------------------------------------------------------------------ */
+/* 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;
+}
+
+/* Whole kronor with a space as thousands separator, e.g. 91500 -> "91 500". */
+static void invoice_send_amount(int64_t ore, char *out, size_t n)
+{
+ char digits[24];
+ snprintf(digits, sizeof digits, "%lld", (long long)((ore + 50) / 100));
+ size_t len = strlen(digits);
+ size_t o = 0;
+ for (size_t i = 0; i < len && o + 1 < n; i++) {
+ if (i > 0 && (len - i) % 3 == 0)
+ out[o++] = ' ';
+ out[o++] = digits[i];
+ }
+ out[o] = '\0';
+}
+
+static yyjson_mut_val *h_invoice_send(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.number,i.ocr,i.due_date,i.document_id,i.total_ore,"
+ "c.name,COALESCE(c.email,''),COALESCE(o.name,'')"
+ " FROM invoices i"
+ " JOIN customers c ON c.org_id=i.org_id AND c.id=i.customer_id"
+ " JOIN orgs o ON o.id=i.org_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");
+ }
+ int64_t number = sqlite3_column_int64(st, 0);
+ int64_t document_id = sqlite3_column_type(st, 3) == SQLITE_NULL
+ ? 0
+ : sqlite3_column_int64(st, 3);
+ int64_t total_ore = sqlite3_column_int64(st, 4);
+ char ocr[40], due_date[16], customer_name[256], customer_email[256];
+ char org_name[256];
+ snprintf(ocr, sizeof ocr, "%s", sq(sqlite3_column_text(st, 1)));
+ snprintf(due_date, sizeof due_date, "%s", sq(sqlite3_column_text(st, 2)));
+ snprintf(customer_name, sizeof customer_name, "%s",
+ sq(sqlite3_column_text(st, 5)));
+ snprintf(customer_email, sizeof customer_email, "%s",
+ sq(sqlite3_column_text(st, 6)));
+ snprintf(org_name, sizeof org_name, "%s", sq(sqlite3_column_text(st, 7)));
+ sqlite3_finalize(st);
+ st = NULL;
+
+ if (document_id <= 0)
+ return fail(r, "NOT_FOUND", "invoice has no stored PDF");
+ 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");
+ }
+ const void *blob = sqlite3_column_blob(st, 0);
+ size_t pdf_len = (size_t)sqlite3_column_bytes(st, 0);
+ unsigned char *pdf = xmalloc(pdf_len ? pdf_len : 1);
+ if (blob && pdf_len)
+ memcpy(pdf, blob, pdf_len);
+ sqlite3_finalize(st);
+ st = NULL;
+
+ const char *to = arg_str(r->args, "to");
+ if (!to || !*to)
+ to = customer_email;
+ if (!*to) {
+ free(pdf);
+ return fail(r, "INVALID_ARGS", "customer has no e-mail address");
+ }
+
+ char *smtp_host = db_setting(r->db, r->org_id, "smtp_host");
+ char *smtp_port = db_setting(r->db, r->org_id, "smtp_port");
+ char *smtp_user = db_setting(r->db, r->org_id, "smtp_user");
+ char *smtp_from = db_setting(r->db, r->org_id, "smtp_from");
+ char *smtp_security = db_setting(r->db, r->org_id, "smtp_security");
+ char *smtp_password = db_setting(r->db, r->org_id, "smtp_password");
+ char *password = NULL;
+ char *body = NULL;
+ yyjson_mut_val *res = NULL;
+
+ if (!smtp_host || !*smtp_host || !smtp_from || !*smtp_from) {
+ fail(r, "SMTP_NOT_CONFIGURED", "smtp_host and smtp_from must be set");
+ goto done;
+ }
+ const char *user = smtp_user && *smtp_user ? smtp_user : "";
+ if (*user) {
+ if (!smtp_password || !*smtp_password) {
+ fail(r, "SMTP_NOT_CONFIGURED",
+ "smtp_user is set but smtp_password is missing");
+ goto done;
+ }
+ if (!secret_available()) {
+ fail(r, "SMTP_NOT_CONFIGURED",
+ "smtp_password is set but BOKFD_SECRET_KEY is missing or"
+ " invalid");
+ goto done;
+ }
+ if (secret_decrypt(smtp_password, &password) != 0 || !password) {
+ fail(r, "SMTP_NOT_CONFIGURED",
+ "cannot decrypt smtp_password, check BOKFD_SECRET_KEY");
+ goto done;
+ }
+ }
+ int port = 587;
+ if (smtp_port && *smtp_port) {
+ long v = strtol(smtp_port, NULL, 10);
+ if (v >= 1 && v <= 65535)
+ port = (int)v;
+ }
+ const char *security =
+ smtp_security && *smtp_security ? smtp_security : "starttls";
+
+ char subject[64];
+ snprintf(subject, sizeof subject, "Faktura %lld", (long long)number);
+ char amount[32];
+ invoice_send_amount(total_ore, amount, sizeof amount);
+ size_t body_len = strlen(org_name) + 320;
+ body = xmalloc(body_len);
+ snprintf(body, body_len,
+ "Hej,\n\nBifogat finner du faktura %lld på %s kr med"
+ " förfallodatum %s.\nAnge OCR %s vid betalning.\n\n"
+ "Med vänlig hälsning\n%s\n",
+ (long long)number, amount, due_date, ocr, org_name);
+
+ char safe_name[256];
+ snprintf(safe_name, sizeof safe_name, "%s", customer_name);
+ for (char *p = safe_name; *p; p++)
+ if (*p == '/')
+ *p = '-';
+ char attach_name[320];
+ snprintf(attach_name, sizeof attach_name, "Faktura %lld %s.pdf",
+ (long long)number, safe_name);
+
+ 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_strcpy(r->rdoc, o, "to", to);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "subject", subject);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ res = o;
+ goto done;
+ }
+
+ struct smtp_message m;
+ memset(&m, 0, sizeof m);
+ m.host = smtp_host;
+ m.port = port;
+ m.security = security;
+ m.user = user;
+ m.password = password ? password : "";
+ m.from = smtp_from;
+ m.from_name = org_name;
+ m.to = to;
+ m.subject = subject;
+ m.body = body;
+ m.attach_name = attach_name;
+ m.attach = pdf;
+ m.attach_len = pdf_len;
+
+ char errbuf[512];
+ if (smtp_send(&m, errbuf, sizeof errbuf) != 0) {
+ fail(r, "SMTP_FAILED", errbuf[0] ? errbuf : "smtp send failed");
+ goto done;
+ }
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE invoices SET last_sent_at=?1,last_sent_to=?2"
+ " WHERE org_id=?3 AND id=?4",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ goto done;
+ }
+ sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 2, to, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 3, r->org_id);
+ sqlite3_bind_int64(st, 4, id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ st = NULL;
+ if (rc != SQLITE_DONE) {
+ fail(r, "DB_BUSY", sqlite3_errmsg(r->db));
+ goto done;
+ }
+
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *a = yyjson_mut_obj(doc);
+ yyjson_mut_doc_set_root(doc, a);
+ yyjson_mut_obj_add_int(doc, a, "id", id);
+ yyjson_mut_obj_add_strcpy(doc, a, "to", to);
+ yyjson_mut_obj_add_strcpy(doc, a, "subject", subject);
+ char *reqjson = yyjson_mut_write(doc, 0, NULL);
+ yyjson_mut_doc_free(doc);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "invoice.send", reqjson ? 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_strcpy(r->rdoc, o, "sent_to", to);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts);
+ res = o;
+
+done:
+ free(smtp_host);
+ free(smtp_port);
+ free(smtp_user);
+ free(smtp_from);
+ free(smtp_security);
+ free(smtp_password);
+ free(password);
+ free(body);
+ free(pdf);
+ return res;
+}
+
+
+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" },
+};
+
+static const struct cmd_arg args_invoice_send[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Invoice id" },
+ { "to", ARG_STR, 0, NULL, NULL,
+ "Recipient e-mail; defaults to the customer's address" },
+};
+
+const struct command g_cmd_invoices[] = {
+ { "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) },
+ { "invoice.send", "E-mail the stored invoice PDF to the customer",
+ PERM_WRITE, 1, 1, 1, h_invoice_send, CMD_ARGS(args_invoice_send) },
+};
+
+const struct cmd_table g_cmd_table_invoices = {
+ g_cmd_invoices, sizeof g_cmd_invoices / sizeof g_cmd_invoices[0]
+};