summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/commands.c242
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];