#include "commands.h" #include "cmd_util.h" #include #include #include #include #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) { db_error(r); 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) { db_error(r); 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) { db_error(r); 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 db_error(r); 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 db_sqlite_error(r); 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) { db_error(r); 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) { db_error(r); 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) { db_error(r); 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) { db_error(r); 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) { db_error(r); 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) { return db_row_json(r->rdoc, st, "line_no:i,article_no:s,description:s," "quantity_milli:i,unit:s,unit_price_ore:i," "amount_ore:i,note:s,vat_code:s,account:s"); } #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 db_error(r); 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 db_error(r); 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 db_error(r); 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 db_error(r); 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 db_error(r); 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 db_error(r); 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) { db_error(r); 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] };