diff options
Diffstat (limited to 'src/commands.c')
| -rw-r--r-- | src/commands.c | 242 |
1 files changed, 242 insertions, 0 deletions
diff --git a/src/commands.c b/src/commands.c index 0ac9ace..5dd920e 100644 --- a/src/commands.c +++ b/src/commands.c @@ -22,6 +22,7 @@ #include "secret.h" #include "seed.h" #include "sie.h" +#include "smtp.h" #include "sru.h" #include "util.h" #include "version.h" @@ -1620,6 +1621,8 @@ static const char AGENT_INSTRUCTIONS[] = "- Fakturering: `customer.create` keeps the customer register; `invoice.preview`\n" " renders a draft without consuming a number, and `invoice.issue` takes the\n" " next number, stores the PDF and posts the voucher in one transaction.\n" + " `invoice.send` e-mails the stored PDF; it needs the org's SMTP settings\n" + " and sends to the customer's address unless `to` overrides it.\n" "\n" "## Discovery\n" "- `describe` lists every implemented command with permissions.\n" @@ -4367,6 +4370,237 @@ static yyjson_mut_val *h_invoice_pdf(struct req *r) 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; +} + /* ------------------------------------------------------------------ */ /* report rules (per-org moms mapping) */ /* ------------------------------------------------------------------ */ @@ -8001,6 +8235,12 @@ static const struct cmd_arg args_invoice_list[] = { { "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_commands[] = { { "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health, NULL, 0 }, { "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta, NULL, @@ -8178,6 +8418,8 @@ const struct command g_commands[] = { 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 size_t g_commands_count = sizeof g_commands / sizeof g_commands[0]; |
