order confirmation
All checks were successful
Deploy / build-deploy (push) Successful in 6m14s

This commit is contained in:
Jorijn van der Graaf 2026-08-09 00:14:09 +02:00
commit 2fa6e70af1
10 changed files with 619 additions and 20 deletions

View file

@ -38,11 +38,12 @@ export namespace Catcrafts::Server {
// ── orders ────────────────────────────────────────────────────────
//
// An append-only JSON-lines EVENT LOG, not a database. Two event types:
// "order" (the full record, written once) and "status" (a transition).
// Current state is a fold over the file — later events win. Nothing is
// ever rewritten in place, so the file is also the audit trail, and a
// crash mid-append costs at most the line being written.
// An append-only JSON-lines EVENT LOG, not a database. Four event types:
// "order" (the full record, written once), "status" (a transition),
// "invoice" (the number assignment) and "notified" (the confirmation
// email left). Current state is a fold over the file — later events win.
// Nothing is ever rewritten in place, so the file is also the audit
// trail, and a crash mid-append costs at most the line being written.
//
// The volume argument: this sells single-digit units per week. When that
// is wrong by two orders of magnitude, the log imports into SQLite in one
@ -69,6 +70,8 @@ export namespace Catcrafts::Server {
std::string paidVia; // method that settled it ("ideal", "creditcard")
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
std::string invoicedAt; // ISO 8601 of the invoice event
std::string confirmationSentAt; // ISO 8601 of the confirmation-email
// event; empty = not (yet) emailed
};
void SetOrdersPath(const std::filesystem::path& path);
@ -95,12 +98,28 @@ export namespace Catcrafts::Server {
std::optional<std::string> AssignInvoiceNumber(std::string_view token,
std::string_view isoTimestamp);
// Appends the notified event: this order's confirmation email was
// accepted by the mail command. Written AFTER the handoff succeeds, so a
// crash between send and append errs toward a duplicate email — an
// apology — never toward a buyer who paid and heard nothing.
bool AppendOrderNotified(std::string_view token, std::string_view isoTimestamp);
// ── invoices ──────────────────────────────────────────────────────
//
// A paid order's invoice: plain markdown, clearsigned with the shop's
// GPG key so its authenticity outlives this server. The page invites the
// buyer to download it rather than promising to host receipts forever.
// The registered business identity — on every invoice and at the foot of
// every order email. One definition, like the rest of the compiled-in
// authored content; the selftest pins the values.
inline constexpr std::string_view kSellerName = "Catcrafts";
inline constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
inline constexpr std::string_view kSellerCity = "3315NN Dordrecht";
inline constexpr std::string_view kSellerKvk = "78437059";
inline constexpr std::string_view kSellerVat = "NL003329281B38";
inline constexpr std::string_view kSellerSite = "catcrafts.net";
// Pure and exported for the self-test: everything on a Dutch invoice —
// seller identity (KVK/VAT), sequential number, dates, buyer address,
// per-line amounts, VAT treatment for EU and export.
@ -125,6 +144,44 @@ export namespace Catcrafts::Server {
std::string NewOrderToken();
std::string ReferenceFromToken(std::string_view token);
// ── the order confirmation email ──────────────────────────────────
//
// A paid order gets ONE email: the confirmation, with the clearsigned
// invoice attached. Delivery shells out to a sendmail-compatible command
// (msmtp -t on the server) exactly as signing shells out to gpg — TLS,
// AUTH and deliverability are the parts a hand-rolled SMTP client
// reimplements badly, and this sends a handful of messages per week.
// The command reads the complete RFC 5322 message on stdin and takes the
// recipient from its headers; the buyer's address was validated at
// checkout to be header-safe (Form::LooksLikeEmail rejects CR, LF,
// commas and angle brackets for exactly this moment).
struct MailConfig {
std::string command; // MAIL_COMMAND, e.g. "msmtp -t"; empty = no email
std::string from; // MAIL_FROM header; defaults to the shop inbox
};
void ConfigureMail(MailConfig config);
bool MailConfigured();
// The configured From header — the mailer passes it to the builder.
std::string MailFrom();
// Pure and exported for the self-test: the complete MIME message, a
// plain-text confirmation plus the invoice as a markdown attachment.
// Returns an empty string when the buyer's address fails the envelope
// shape check — the last line of defence sits where the envelope is
// built, not in the history of the record.
std::string BuildOrderConfirmationEmail(const OrderRecord& order,
std::string_view productName,
std::string_view colorLabel,
std::string_view from,
std::string_view orderUrl,
std::string_view invoiceAttachment,
std::string_view dateRfc2822);
// Pipe one message into the configured command. False means "not sent,
// keep it queued": the mailer never writes a notified event on failure.
bool SendMailMessage(const std::string& message);
// ── payments ──────────────────────────────────────────────────────
//
// A rail turns "this order wants €X" into a URL a buyer can pay at, and