2026-08-05 04:18:37 +02:00
|
|
|
/*
|
|
|
|
|
catcrafts.net
|
|
|
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
|
|
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
|
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// The native server: server-rendered pages, order storage, and the bunq
|
|
|
|
|
// payment rail.
|
|
|
|
|
//
|
|
|
|
|
// Unlike Catcrafts.Shared this module is host-only and may import whatever it
|
|
|
|
|
// needs — Crafter.Network here, OpenSSL for request signing. The division of
|
|
|
|
|
// labour is that Shared decides what the markup IS and Server decides how it
|
|
|
|
|
// reaches a socket, where orders live, and how money moves.
|
|
|
|
|
|
|
|
|
|
export module Catcrafts.Server;
|
|
|
|
|
import std;
|
|
|
|
|
import Catcrafts.Shared;
|
|
|
|
|
|
|
|
|
|
export namespace Catcrafts::Server {
|
|
|
|
|
|
|
|
|
|
// Parse content/*.json and lift the wasm bundle's <script> tags.
|
|
|
|
|
//
|
|
|
|
|
// `bundleIndexHtml` is the generated index.html from the wasm build; the
|
|
|
|
|
// script tags are extracted from it verbatim because they carry a
|
|
|
|
|
// ?v=<buildId> cache buster that changes every build. Pass an empty path to
|
|
|
|
|
// serve no wasm at all (every page then renders as plain HTML).
|
|
|
|
|
//
|
|
|
|
|
// Call before Serve. Content is immutable afterwards: it is generated at
|
|
|
|
|
// build time, so nothing can change it under a running process.
|
|
|
|
|
void LoadContent(const std::filesystem::path& contentDir,
|
|
|
|
|
const std::filesystem::path& bundleIndexHtml);
|
|
|
|
|
|
|
|
|
|
std::size_t ContentPostCount();
|
|
|
|
|
std::size_t ContentProjectCount();
|
|
|
|
|
std::size_t ContentProductCount();
|
|
|
|
|
|
|
|
|
|
// ── orders ────────────────────────────────────────────────────────
|
|
|
|
|
//
|
2026-08-09 00:14:09 +02:00
|
|
|
// 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
//
|
|
|
|
|
// 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
|
|
|
|
|
// sitting — the reverse migration would not be so kind.
|
|
|
|
|
|
|
|
|
|
struct OrderRecord {
|
|
|
|
|
std::string token; // 32-hex capability; the /order/<token> URL
|
|
|
|
|
std::string reference; // "CC-XXXXXX", quoted in bank transfers
|
|
|
|
|
std::string product; // product slug
|
|
|
|
|
std::string color; // variant slug ("green"), empty pre-variants
|
|
|
|
|
std::int64_t quantity = 1;
|
|
|
|
|
std::int64_t unitMinor = 0; // per-unit gross at order time — prices
|
|
|
|
|
// change; the record must not
|
|
|
|
|
std::string createdAt; // ISO 8601 UTC
|
|
|
|
|
std::string updatedAt; // of the newest event folded in
|
|
|
|
|
Form::Checkout buyer;
|
|
|
|
|
std::int64_t goodsMinor = 0;
|
|
|
|
|
std::int64_t shippingMinor = 0;
|
|
|
|
|
std::int64_t totalMinor = 0;
|
|
|
|
|
bool vatIncluded = false;
|
|
|
|
|
std::string status = "awaiting_payment"; // -> paid -> shipped | cancelled
|
|
|
|
|
std::string payUrl; // the provider's hosted checkout link
|
|
|
|
|
std::string payId; // provider payment id ("tr_…" at Mollie)
|
|
|
|
|
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
|
2026-08-09 00:14:09 +02:00
|
|
|
std::string confirmationSentAt; // ISO 8601 of the confirmation-email
|
|
|
|
|
// event; empty = not (yet) emailed
|
2026-08-05 04:18:37 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void SetOrdersPath(const std::filesystem::path& path);
|
|
|
|
|
bool CreateOrder(const OrderRecord& order);
|
|
|
|
|
// Appends a status event. Never mutates prior lines; the fold applies it.
|
|
|
|
|
// `via` records HOW a payment settled ("ideal", "creditcard") on the paid
|
|
|
|
|
// transition — card money stays reversible for months, so the ledger must
|
|
|
|
|
// show at a glance which orders carry that tail risk.
|
|
|
|
|
bool AppendOrderStatus(std::string_view token, std::string_view status,
|
|
|
|
|
std::string_view isoTimestamp,
|
|
|
|
|
std::string_view via = {});
|
|
|
|
|
std::optional<OrderRecord> FindOrder(std::string_view token);
|
|
|
|
|
std::vector<OrderRecord> ListOrders();
|
|
|
|
|
|
|
|
|
|
// Assigns the next invoice number in the CUSTOMER's series and appends
|
|
|
|
|
// the invoice event. The scheme continues the owner's pre-shop
|
|
|
|
|
// administration: customer number is a random UUID, invoices count
|
|
|
|
|
// sequentially within it ("f57c6512-…-3"). Art. 226(2) permits "one or
|
|
|
|
|
// more series"; per-customer is the established practice here, and the
|
|
|
|
|
// ledger + payment-provider records carry the completeness proof.
|
|
|
|
|
// Idempotent: an order that already has a number keeps it. Fold and
|
|
|
|
|
// append happen under one lock, so two paid transitions cannot race the
|
|
|
|
|
// same number.
|
|
|
|
|
std::optional<std::string> AssignInvoiceNumber(std::string_view token,
|
|
|
|
|
std::string_view isoTimestamp);
|
|
|
|
|
|
2026-08-09 00:14:09 +02:00
|
|
|
// 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);
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// ── 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.
|
|
|
|
|
|
2026-08-09 00:14:09 +02:00
|
|
|
// 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";
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// 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.
|
|
|
|
|
std::string BuildInvoiceMarkdown(const OrderRecord& order,
|
|
|
|
|
std::string_view productName,
|
|
|
|
|
std::string_view colorLabel);
|
|
|
|
|
|
|
|
|
|
// The GPG key (uid or fingerprint) invoices are clearsigned with; empty
|
|
|
|
|
// disables signing and invoices carry an UNSIGNED marker instead —
|
|
|
|
|
// honest in dev, wrong in production.
|
|
|
|
|
void ConfigureInvoicing(std::string gpgKeyId);
|
|
|
|
|
|
|
|
|
|
// Clearsign via the gpg binary (GNUPGHOME decides the keyring). nullopt
|
|
|
|
|
// when signing is configured but fails — the caller must NOT serve an
|
|
|
|
|
// unsigned invoice in that case.
|
|
|
|
|
std::optional<std::string> ClearsignInvoice(const std::string& markdown);
|
|
|
|
|
bool InvoiceSigningConfigured();
|
|
|
|
|
|
|
|
|
|
// 128 bits of CSPRNG entropy as 32 lowercase hex — the whole capability to
|
|
|
|
|
// read one order. And its human-sized companion, derived (not random) so a
|
|
|
|
|
// record can never carry a mismatched pair.
|
|
|
|
|
std::string NewOrderToken();
|
|
|
|
|
std::string ReferenceFromToken(std::string_view token);
|
|
|
|
|
|
2026-08-09 00:14:09 +02:00
|
|
|
// ── 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);
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// ── payments ──────────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// A rail turns "this order wants €X" into a URL a buyer can pay at, and
|
|
|
|
|
// answers "has it been paid?". Everything else — storage, rendering,
|
|
|
|
|
// reconciling — is rail-agnostic, which is what will let a crypto rail
|
|
|
|
|
// slot in later without reshaping orders.
|
|
|
|
|
|
|
|
|
|
struct PaymentLink {
|
|
|
|
|
std::string payUrl;
|
|
|
|
|
std::string payId;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// What a poll learned about one payment. Pending and Dead are different
|
|
|
|
|
// answers on purpose: a Mollie payment EXPIRES (unlike a bunq tab), and an
|
|
|
|
|
// order whose payment can never arrive should lapse rather than sit
|
|
|
|
|
// "awaiting" forever.
|
|
|
|
|
enum class PayState { Pending, Paid, Dead };
|
|
|
|
|
struct PaidStatus {
|
|
|
|
|
PayState state = PayState::Pending;
|
|
|
|
|
std::string method; // "ideal" | "creditcard" | "banktransfer" | …
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
class PaymentRail {
|
|
|
|
|
public:
|
|
|
|
|
virtual ~PaymentRail() = default;
|
|
|
|
|
// nullopt = the provider could not be reached / refused. The checkout
|
|
|
|
|
// surfaces that honestly instead of creating an unpayable order.
|
|
|
|
|
virtual std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
|
|
|
|
const std::string& description,
|
|
|
|
|
const std::string& redirectUrl) = 0;
|
|
|
|
|
// nullopt = could not determine (network, auth) — retry later. Never
|
|
|
|
|
// guess Dead from a transport error: only the provider saying
|
|
|
|
|
// expired/canceled/failed kills an order.
|
|
|
|
|
virtual std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
|
|
|
|
std::int64_t expectedMinor) = 0;
|
|
|
|
|
virtual std::string_view Name() const = 0;
|
|
|
|
|
// How often the reconciler sweeps. The fake rail returns something
|
|
|
|
|
// tiny so tests are fast; the real providers get a respectful cadence.
|
|
|
|
|
virtual std::chrono::seconds PollInterval() const = 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
struct RailConfig {
|
|
|
|
|
std::string mode; // "off" | "fake" | "mollie" | "bunq"
|
|
|
|
|
std::string apiKey; // mollie: live_… or test_…; bunq: its key
|
|
|
|
|
bool sandbox = false; // bunq only: public-api.sandbox.bunq.com
|
|
|
|
|
std::filesystem::path statePath; // bunq: session context; fake: paid marker
|
|
|
|
|
std::string redirectBase = "https://catcrafts.net";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// nullptr for mode "off" — the shop then renders but refuses checkout.
|
|
|
|
|
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config);
|
|
|
|
|
|
|
|
|
|
// Parsed essentials of a Mollie /v2/payments object. Exported so the
|
|
|
|
|
// self-test can drive the parser with canned responses — the HTTP around
|
|
|
|
|
// it is thin.
|
|
|
|
|
struct MolliePayment {
|
|
|
|
|
std::string id;
|
|
|
|
|
std::string status; // open|pending|authorized|paid|canceled|expired|failed
|
|
|
|
|
std::string method; // may be empty until the payer picks one
|
|
|
|
|
std::string checkoutUrl; // present while payable
|
|
|
|
|
std::int64_t amountMinor = 0;
|
|
|
|
|
};
|
|
|
|
|
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
|
|
|
|
|
|
|
|
|
|
// Exact decimal-string-to-minor-units parser for amounts coming back from
|
|
|
|
|
// the bunq API ("614.00" -> 61400). Rejects anything that is not a plain
|
|
|
|
|
// non-negative decimal with at most two fraction digits — no floats touch
|
|
|
|
|
// money on the way in either. Exported for the self-test.
|
|
|
|
|
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
|
|
|
|
|
|
|
|
|
|
// ── shipping rates ────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Live per-country rates from Sendcloud's shipping_methods API, cached to
|
|
|
|
|
// a state file and refreshed daily by a background thread. The compiled-in
|
|
|
|
|
// zone table (Catcrafts.Shared:Content) remains the fallback for any country the carrier table
|
|
|
|
|
// does not cover — and the whole feature when no credentials exist, so
|
|
|
|
|
// the shop never depends on Sendcloud being up.
|
|
|
|
|
|
|
|
|
|
struct ShippingConfig {
|
|
|
|
|
std::string publicKey; // SENDCLOUD_PUBLIC_KEY
|
|
|
|
|
std::string secretKey; // SENDCLOUD_SECRET_KEY
|
|
|
|
|
std::string methodName; // substring match on the method name
|
|
|
|
|
std::filesystem::path cachePath; // survives restarts
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Country -> price in cents, EUR. Empty when nothing loaded.
|
|
|
|
|
struct ShippingTable {
|
|
|
|
|
std::string method; // the matched Sendcloud method name
|
|
|
|
|
std::string fetchedAt; // ISO 8601, for the operator
|
|
|
|
|
std::vector<std::pair<std::string, std::int64_t>> perCountry;
|
|
|
|
|
|
|
|
|
|
std::int64_t Find(std::string_view cc) const {
|
|
|
|
|
for (const auto& [k, v] : perCountry) {
|
|
|
|
|
if (k == cc) return v;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Parse a Sendcloud /api/v2/shipping_methods response into a table, taking
|
|
|
|
|
// the first method whose name contains `methodName` (case-sensitive).
|
|
|
|
|
// Exported for the self-test — the network fetch is thin around this.
|
|
|
|
|
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName);
|
|
|
|
|
|
|
|
|
|
// Install the config and start using it. Safe to skip entirely.
|
|
|
|
|
void ConfigureShipping(const ShippingConfig& config);
|
|
|
|
|
|
|
|
|
|
// One fetch attempt; failure leaves the previous table standing. The HTTP
|
|
|
|
|
// layer's background thread calls this on start and daily after.
|
|
|
|
|
void RefreshShippingTable();
|
|
|
|
|
|
|
|
|
|
// The rate the checkout charges for `country`: the live table's price if
|
|
|
|
|
// present, the product's zone fallback otherwise.
|
|
|
|
|
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
|
|
|
|
std::int64_t zoneEu, std::int64_t zoneWorld);
|
|
|
|
|
|
|
|
|
|
// A snapshot of the live table for embedding into the checkout preview —
|
|
|
|
|
// the page must show the same numbers the server will charge.
|
|
|
|
|
ShippingTable CurrentShippingTable();
|
|
|
|
|
|
|
|
|
|
// Install the rail used by Serve()'s checkout handler and reconciler.
|
|
|
|
|
// Call before Serve. Passing nullptr disables checkout. (Rates travel with
|
|
|
|
|
// the content — LoadContent reads rates.json.)
|
|
|
|
|
void ConfigurePayments(std::unique_ptr<PaymentRail> rail, std::string redirectBase);
|
|
|
|
|
|
|
|
|
|
// Bind and serve until killed. Blocks. Starts the payment reconciler
|
|
|
|
|
// thread when a rail is configured.
|
|
|
|
|
//
|
|
|
|
|
// Plaintext HTTP/1.1 by design: Caddy terminates TLS and reverse-proxies to
|
|
|
|
|
// localhost. Do not expose this port directly.
|
|
|
|
|
int Serve(std::uint16_t port);
|
|
|
|
|
}
|