diff options
Diffstat (limited to 'src/cmd_invoices.c')
| -rw-r--r-- | src/cmd_invoices.c | 1422 |
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] +}; |
