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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// The native server: server-rendered pages, order storage, and the payment
|
|
|
|
|
// rails.
|
2026-08-05 04:18:37 +02:00
|
|
|
//
|
|
|
|
|
// Unlike Catcrafts.Shared this module is host-only and may import whatever it
|
2026-08-13 23:34:19 +02:00
|
|
|
// needs — Crafter.Network here. 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
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;
|
2026-08-17 11:04:03 +02:00
|
|
|
// A donation: buyer-named amount, nothing ships, no VAT, and — the
|
|
|
|
|
// consequences downstream — no invoice number is ever assigned, the
|
|
|
|
|
// mailer sends a thank-you without an attachment (or nothing, when no
|
|
|
|
|
// email was given), and /financials counts it under donations rather
|
|
|
|
|
// than sales. Additive ledger key: absent reads false, so every order
|
|
|
|
|
// written before donations existed stays a sale.
|
|
|
|
|
bool donation = false;
|
2026-08-05 04:18:37 +02:00
|
|
|
std::string status = "awaiting_payment"; // -> paid -> shipped | cancelled
|
2026-08-13 23:34:19 +02:00
|
|
|
std::string payChoice; // Form::kPayBank | Form::kPayCrypto; which
|
|
|
|
|
// rail issued the link, and so which one
|
|
|
|
|
// may confirm it. Always set: checkout
|
|
|
|
|
// normalises before writing.
|
2026-08-15 00:54:05 +02:00
|
|
|
std::string payUrl; // the provider's hosted checkout link;
|
|
|
|
|
// for the EURC rail, the order page itself
|
2026-08-20 20:15:47 +02:00
|
|
|
std::string payId; // the rail's own handle on the payment:
|
|
|
|
|
// "<reference>@<deadline>" for a bank
|
|
|
|
|
// transfer, "<address>@<deadline>" for EURC
|
2026-08-13 23:34:19 +02:00
|
|
|
std::string paidVia; // method that settled it ("ideal", "bitcoin")
|
2026-08-14 02:50:58 +02:00
|
|
|
std::string paidAt; // ISO 8601 of the FIRST paid event; empty =
|
|
|
|
|
// never paid. A later cancel (a refund)
|
|
|
|
|
// does not clear it: "was this ever paid"
|
|
|
|
|
// is what the public sales totals count.
|
2026-08-05 04:18:37 +02:00
|
|
|
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-14 02:50:58 +02:00
|
|
|
// ── the public financials page ────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// /financials shows lifetime sales as two integers: how many orders were
|
|
|
|
|
// ever paid, and what they summed to. Ever-paid on purpose — a refund is
|
2026-08-17 11:04:03 +02:00
|
|
|
// an expense on that page, it does not un-happen the sale. Donations paid
|
|
|
|
|
// through the shop land in the same ledger but are NOT sales — they fold
|
|
|
|
|
// into their own pair here and join the bank-side donations on the page.
|
|
|
|
|
// Pure and exported for the self-test.
|
2026-08-14 02:50:58 +02:00
|
|
|
struct SalesSummary {
|
|
|
|
|
std::int64_t count = 0;
|
|
|
|
|
std::int64_t totalMinor = 0;
|
2026-08-17 11:04:03 +02:00
|
|
|
std::int64_t donationCount = 0;
|
|
|
|
|
std::int64_t donationsMinor = 0;
|
2026-08-14 02:50:58 +02:00
|
|
|
};
|
|
|
|
|
SalesSummary SummarizeSales(std::span<const OrderRecord> orders);
|
|
|
|
|
|
|
|
|
|
// ── the bank side of /financials ──────────────────────────────────
|
|
|
|
|
//
|
2026-08-17 11:36:54 +02:00
|
|
|
// Bank-side donations and expenses come from an aggregates file written
|
|
|
|
|
// by the owner's own tooling, off this box — no bank credential and no
|
|
|
|
|
// bank callback exists here (the bunq mutation callback was retired
|
|
|
|
|
// 2026-08-17; its code is in git history). Aggregates by construction:
|
|
|
|
|
// category totals and an as-of date are all the file can carry, which is
|
|
|
|
|
// the page's privacy design.
|
2026-08-14 02:50:58 +02:00
|
|
|
|
|
|
|
|
struct FinancialsConfig {
|
|
|
|
|
std::filesystem::path publicPath; // <orders>.financials.json — what
|
2026-08-17 11:36:54 +02:00
|
|
|
// the page reads; written by the
|
|
|
|
|
// owner's reconciliation
|
2026-08-14 02:50:58 +02:00
|
|
|
};
|
|
|
|
|
void ConfigureFinancials(FinancialsConfig config);
|
|
|
|
|
|
|
|
|
|
// The published aggregates, re-read per request — live is the page's
|
|
|
|
|
// promise and the file is a few hundred bytes. Empty asOf = nothing
|
|
|
|
|
// published yet, which the page states rather than showing zeros.
|
|
|
|
|
Financials CurrentFinancials();
|
|
|
|
|
|
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
|
2026-08-15 00:54:05 +02:00
|
|
|
// authored content; the ShouldBuildInvoices test pins the values.
|
2026-08-09 00:14:09 +02:00
|
|
|
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-20 20:15:47 +02:00
|
|
|
// The same reference again, as an ISO 11649 structured creditor reference:
|
|
|
|
|
// "RF" + two ISO 7064 mod-97-10 check digits + the CC-style body. Derived
|
|
|
|
|
// from the same token for the same reason, so the two can never disagree.
|
|
|
|
|
//
|
|
|
|
|
// Why it is worth the arithmetic: Dutch consumer banking gives this a
|
|
|
|
|
// dedicated payment-reference field, and the PAYER'S OWN BANK verifies the
|
|
|
|
|
// check digits before the transfer leaves. A correct reference travels as
|
|
|
|
|
// structured remittance information; a mistyped one is caught at the other
|
|
|
|
|
// end rather than arriving here as money nobody can match to an order.
|
|
|
|
|
// That is what makes an unattended bank-transfer reconciler trustworthy —
|
|
|
|
|
// exact match on a validated key instead of a substring hunt through
|
|
|
|
|
// free text a human retyped.
|
|
|
|
|
std::string CreditorReferenceFromToken(std::string_view token);
|
|
|
|
|
|
|
|
|
|
// Whether `s` is a well-formed ISO 11649 reference: the RF prefix, a length
|
|
|
|
|
// within the standard, an alphanumeric body and check digits that verify.
|
|
|
|
|
// Exported because this is where a mistake is invisible — a generator that
|
|
|
|
|
// computes the digits wrong still produces something that LOOKS like a
|
|
|
|
|
// reference, and every payer's bank would then reject it while our own
|
|
|
|
|
// logs showed nothing wrong at all.
|
|
|
|
|
bool IsValidCreditorReference(std::string_view s);
|
|
|
|
|
|
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,
|
2026-08-13 23:34:19 +02:00
|
|
|
// reconciling — is rail-agnostic, which is what lets two of them run side
|
|
|
|
|
// by side without reshaping orders.
|
|
|
|
|
|
|
|
|
|
// The buyer's choice at checkout is Form::kPayBank / Form::kPayCrypto —
|
|
|
|
|
// defined in Shared because the form emits those strings and this module
|
|
|
|
|
// stores them, and one wire format deserves one definition.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
struct PaymentLink {
|
|
|
|
|
std::string payUrl;
|
|
|
|
|
std::string payId;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// What a poll learned about one payment. Pending and Dead are different
|
2026-08-15 00:54:05 +02:00
|
|
|
// answers on purpose: an unpaid order does not stay payable forever —
|
2026-08-20 20:15:47 +02:00
|
|
|
// each rail closes its own window (14 days for a bank transfer, 24 hours
|
|
|
|
|
// for EURC) — and an order whose payment can never arrive should lapse
|
|
|
|
|
// rather than sit "awaiting" forever. Neither window means bounced money:
|
|
|
|
|
// the account and the address stay ours, so a late payment still lands and
|
|
|
|
|
// is settled by hand.
|
2026-08-05 04:18:37 +02:00
|
|
|
enum class PayState { Pending, Paid, Dead };
|
|
|
|
|
struct PaidStatus {
|
|
|
|
|
PayState state = PayState::Pending;
|
2026-08-13 23:34:19 +02:00
|
|
|
std::string method; // "ideal" | "creditcard" | "bitcoin" | …
|
2026-08-20 02:50:28 +02:00
|
|
|
// Pending only: the money is visible but not yet trusted — for the
|
|
|
|
|
// EURC rail, a covering balance at "latest" while the settlement tag
|
|
|
|
|
// still reads short. NEVER a settlement input; it exists so the order
|
|
|
|
|
// page can tell a buyer their in-flight payment has been noticed
|
|
|
|
|
// during the ~15 minutes finality takes.
|
|
|
|
|
bool seen = false;
|
2026-08-05 04:18:37 +02:00
|
|
|
};
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
// Self-hosted payment instructions for the order page. A hosted rail sends
|
|
|
|
|
// the buyer to the provider's checkout and returns nullopt here; a
|
|
|
|
|
// self-hosted rail has no such page, so the order page must itself say
|
|
|
|
|
// where the money goes. One chain option per network the rail watches, in
|
|
|
|
|
// the rail's configured order — the file order IS the display order, which
|
|
|
|
|
// is how "the cheap chain first" stays configuration.
|
|
|
|
|
struct PayChainOption {
|
|
|
|
|
std::string name; // "base" — also the ledger via suffix
|
|
|
|
|
std::string contract; // token contract, for the buyer to verify
|
|
|
|
|
std::string link; // EIP-681 URI a wallet can open; may be empty
|
|
|
|
|
std::string note; // optional display hint ("lowest fees")
|
|
|
|
|
};
|
|
|
|
|
struct PayInstructions {
|
2026-08-20 20:15:47 +02:00
|
|
|
std::string address; // where the money goes: a token address, or an IBAN
|
|
|
|
|
std::string amount; // decimal amount ("570.43")
|
2026-08-15 00:54:05 +02:00
|
|
|
std::int64_t deadlineUnix = 0;
|
|
|
|
|
std::vector<PayChainOption> chains;
|
2026-08-20 20:15:47 +02:00
|
|
|
|
|
|
|
|
// ── bank-transfer rails only; empty for on-chain ones ──────────
|
|
|
|
|
//
|
|
|
|
|
// The beneficiary name is NOT decoration. Since 2025-10-09 every
|
|
|
|
|
// euro-area transfer is checked by Verification of Payee, and the
|
|
|
|
|
// payer sees a mismatch warning at the moment of payment if the name
|
|
|
|
|
// they were given does not match the one holding the IBAN. So this
|
|
|
|
|
// must be the name the BANK holds, character for character, not the
|
|
|
|
|
// trading name — a well-meaning "Catcrafts" where the bank says
|
|
|
|
|
// something else scares buyers off at the last step.
|
|
|
|
|
std::string beneficiary;
|
|
|
|
|
// The structured creditor reference the payer must quote, which is
|
|
|
|
|
// what makes the incoming money matchable to this order.
|
|
|
|
|
std::string reference;
|
|
|
|
|
// Empty unless configured; only a payer sending from outside SEPA
|
|
|
|
|
// needs it. See RailConfig::transferBic.
|
|
|
|
|
std::string bic;
|
2026-08-15 00:54:05 +02:00
|
|
|
};
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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;
|
2026-08-15 00:54:05 +02:00
|
|
|
// What the order page should tell the buyer to do, for rails without a
|
|
|
|
|
// hosted checkout. Default nullopt: rails with a provider page keep
|
|
|
|
|
// sending the buyer there. Const and lock-free by contract — it reads
|
|
|
|
|
// only configuration fixed at load.
|
|
|
|
|
virtual std::optional<PayInstructions> Instructions(const std::string& payId,
|
|
|
|
|
std::int64_t totalMinor) const {
|
|
|
|
|
(void)payId; (void)totalMinor;
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
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 {
|
2026-08-20 20:15:47 +02:00
|
|
|
std::string mode; // "off" | "fake" | "fake-crypto"
|
|
|
|
|
// | "transfer" | "eurc"
|
2026-08-13 23:34:19 +02:00
|
|
|
std::filesystem::path statePath; // fake: the paid marker
|
2026-08-05 04:18:37 +02:00
|
|
|
std::string redirectBase = "https://catcrafts.net";
|
2026-08-15 00:54:05 +02:00
|
|
|
|
|
|
|
|
// eurc: the self-hosted rail holds no credential at all — what it needs
|
|
|
|
|
// instead is a list of chains to watch and a list of addresses it is
|
|
|
|
|
// allowed to hand out. Both are files rather than environment values
|
|
|
|
|
// because both are lists, and the pool in particular is edited by a
|
|
|
|
|
// human topping it up from the wallet.
|
|
|
|
|
std::filesystem::path eurcChainsPath;
|
|
|
|
|
std::filesystem::path eurcPoolPath;
|
|
|
|
|
int eurcWindowHours = 24; // 0 or less means the 24h default
|
2026-08-20 20:15:47 +02:00
|
|
|
|
|
|
|
|
// transfer: also credential-free. What it needs is where the money
|
|
|
|
|
// goes and what the payer must be told — see PayInstructions on why
|
|
|
|
|
// the beneficiary name is load-bearing rather than cosmetic.
|
|
|
|
|
std::string transferIban;
|
|
|
|
|
std::string transferBeneficiary;
|
|
|
|
|
// Optional. Inside SEPA an IBAN is sufficient and has been since 2016,
|
|
|
|
|
// so this is shown only when set, and labelled for the case that
|
|
|
|
|
// actually needs it: a payer whose bank is outside SEPA and who is
|
|
|
|
|
// sending by SWIFT, where the form asks for a BIC and cannot proceed
|
|
|
|
|
// without one. Rendering it unconditionally would invite every Dutch
|
|
|
|
|
// buyer to type a field their bank does not want.
|
|
|
|
|
std::string transferBic;
|
|
|
|
|
std::filesystem::path transferCreditsPath;
|
|
|
|
|
// How often the reconciler asks this rail about an order. 60 s suits a
|
|
|
|
|
// real bank: money does not arrive faster than that even under instant
|
|
|
|
|
// payments, and the rail shares ONE account read across a whole sweep
|
|
|
|
|
// so the cadence is about politeness rather than cost. Configurable
|
|
|
|
|
// because a suite driving a local credits FILE has nobody to be polite
|
|
|
|
|
// to, and a 60 s wait per assertion makes a test unusable.
|
|
|
|
|
int transferPollSeconds = 60;
|
|
|
|
|
// A bank transfer has no provider-side expiry, so this window is
|
|
|
|
|
// purely ours: how long an order waits before it is treated as
|
|
|
|
|
// abandoned. Generous on purpose, and lapsing is NOT bounced money —
|
|
|
|
|
// the IBAN stays ours and a late payment still arrives, to be settled
|
|
|
|
|
// by hand. Same semantics as the EURC window.
|
|
|
|
|
int transferWindowHours = 14 * 24;
|
2026-08-05 04:18:37 +02:00
|
|
|
};
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// nullptr for mode "off" — that slot then offers no payment choice.
|
2026-08-05 04:18:37 +02:00
|
|
|
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config);
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// The two slots a buyer chooses between. Either may be null, which is how
|
|
|
|
|
// a shop with only one provider configured offers only that one: the
|
|
|
|
|
// checkout form renders the choices that exist, so a page can never
|
|
|
|
|
// advertise a way to pay the server would then refuse.
|
|
|
|
|
struct PaymentRails {
|
2026-08-20 20:15:47 +02:00
|
|
|
std::unique_ptr<PaymentRail> bank; // SEPA transfer to our own account
|
2026-08-15 00:54:05 +02:00
|
|
|
std::unique_ptr<PaymentRail> crypto; // EURC: self-hosted, on-chain
|
2026-08-13 23:34:19 +02:00
|
|
|
|
|
|
|
|
bool Any() const { return bank != nullptr || crypto != nullptr; }
|
|
|
|
|
// The rail that owns a stored order, by its recorded choice. Total on
|
|
|
|
|
// purpose: anything that is not the crypto choice is the bank one, so
|
|
|
|
|
// a hand-edited or truncated ledger line resolves somewhere safe
|
|
|
|
|
// instead of nowhere. Null when that slot is not configured, and the
|
|
|
|
|
// caller must then leave the order alone rather than ask the other
|
|
|
|
|
// provider about an id it never issued.
|
|
|
|
|
PaymentRail* For(std::string_view choice) const {
|
|
|
|
|
if (choice == Form::kPayCrypto) return crypto.get();
|
|
|
|
|
return bank.get();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-20 20:15:47 +02:00
|
|
|
// Exact decimal-string-to-minor-units parser for amounts that arrive as
|
|
|
|
|
// strings ("614.00" -> 61400), which is how every external source quotes
|
|
|
|
|
// them. Rejects anything that is not a plain non-negative decimal with at
|
|
|
|
|
// most two fraction digits — no floats touch money on the way in, and a
|
|
|
|
|
// sign is refused here so it cannot quietly halve a total (see
|
|
|
|
|
// ParseSignedAmountToMinor, which handles the one case where the sign is
|
|
|
|
|
// meaningful). Exported for the self-test.
|
2026-08-05 04:18:37 +02:00
|
|
|
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
// One chain the EURC rail watches. Every field is configuration because
|
|
|
|
|
// every field is a fact about the world rather than about this shop:
|
|
|
|
|
// Circle deploys to a new chain, an RPC endpoint moves, a token is
|
|
|
|
|
// redeployed. See Catcrafts.Server-Eurc.cpp for why one address covers all
|
|
|
|
|
// of them at once.
|
|
|
|
|
struct EurcChain {
|
|
|
|
|
std::string name; // ledger via suffix: "base" -> "eurc-base"
|
2026-08-20 00:09:08 +02:00
|
|
|
// One or more independent JSON-RPC endpoints for the SAME chain, in
|
|
|
|
|
// preference order. More than one is the point: a payment is confirmed
|
|
|
|
|
// only when minConfirmations of them independently agree that the money
|
|
|
|
|
// is there, so no single node's word can settle an order. The config
|
|
|
|
|
// spells this "rpcs": [...]; the older single "rpc" still parses and
|
|
|
|
|
// lands here as a one-element list.
|
|
|
|
|
std::vector<std::string> rpcUrls;
|
2026-08-15 00:54:05 +02:00
|
|
|
std::string contract; // the EURC token contract on this chain
|
|
|
|
|
std::string blockTag = "finalized";
|
|
|
|
|
int decimals = 6;
|
2026-08-20 00:09:08 +02:00
|
|
|
// How many endpoints must independently report a covering balance
|
|
|
|
|
// before an order settles. Defaults to 2 when at least two endpoints
|
|
|
|
|
// are configured, 1 when only one is (which is the old behaviour, and
|
|
|
|
|
// is warned about at load — one source means trusting one operator).
|
|
|
|
|
// Never exceeds rpcUrls.size().
|
|
|
|
|
int minConfirmations = 1;
|
2026-08-15 00:54:05 +02:00
|
|
|
// For the order page. chainId names the network in the EIP-681 wallet
|
|
|
|
|
// link (1 = Ethereum, 8453 = Base); 0 omits the link rather than
|
|
|
|
|
// guessing. note is a short display hint ("lowest fees") — copy is
|
|
|
|
|
// configuration here because fee facts change without a deploy.
|
|
|
|
|
std::int64_t chainId = 0;
|
|
|
|
|
std::string note;
|
|
|
|
|
};
|
|
|
|
|
// nullopt for a malformed file — partial success is refused, because a
|
|
|
|
|
// chain that silently dropped out of the list is a chain whose payments
|
|
|
|
|
// stop being noticed while the shop still advertises it.
|
|
|
|
|
std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json);
|
|
|
|
|
|
|
|
|
|
// A uint256 hex word as eth_call returns it, reduced to an int64 and
|
|
|
|
|
// saturating rather than wrapping. nullopt covers a JSON-RPC error object
|
|
|
|
|
// too: "the node refused" must never read as "the balance is zero".
|
|
|
|
|
// Exported for the self-test — the decoding is where a mistake costs money,
|
|
|
|
|
// the HTTP around it is thin.
|
|
|
|
|
std::optional<std::int64_t> ParseEthCallUint(std::string_view json);
|
|
|
|
|
|
2026-08-20 01:36:42 +02:00
|
|
|
// The eth_call data for balanceOf(address): the 4-byte selector, then the
|
|
|
|
|
// address left-padded into ONE 32-byte ABI word. Exported for the
|
|
|
|
|
// self-test for the same reason as ParseEthCallUint, learned the hard
|
|
|
|
|
// way: this encoding once padded 24 zero bytes instead of 12 (a
|
|
|
|
|
// bytes-vs-hex-digits slip), which pushed the address out of the argument
|
|
|
|
|
// word — every node then answered balanceOf of a zero-balance garbage
|
|
|
|
|
// address, cleanly, and paid orders read as unpaid forever. No error
|
|
|
|
|
// anywhere; the testnet e2e suite is what caught it.
|
|
|
|
|
std::string BalanceOfCallData(std::string_view address);
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
// nullptr when the chains file or the address pool will not load. The rail
|
|
|
|
|
// holds no key and no credential; it can only ever hand out an address it
|
|
|
|
|
// was given.
|
|
|
|
|
std::unique_ptr<PaymentRail> MakeEurcRail(const RailConfig& config);
|
|
|
|
|
|
2026-08-20 20:15:47 +02:00
|
|
|
// ── the bank-transfer rail ────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// The other self-hosted rail: the buyer sends a plain SEPA transfer to our
|
|
|
|
|
// own IBAN quoting the order's creditor reference, and the rail settles the
|
|
|
|
|
// order when a matching credit shows up on the account. No provider stands
|
|
|
|
|
// in the payment path, which is the entire point — the only third party is
|
|
|
|
|
// the bank the money was always going to land in anyway.
|
|
|
|
|
|
|
|
|
|
// One incoming credit on the account, reduced to what matching needs.
|
|
|
|
|
struct BankCredit {
|
|
|
|
|
std::string id; // the bank's own payment id; dedupe and logs
|
|
|
|
|
std::string reference; // remittance information, as the bank has it
|
|
|
|
|
std::int64_t amountMinor = 0;
|
|
|
|
|
std::string method; // ledger `via`: "sepa", "ideal", …
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// What the credits say about one order.
|
|
|
|
|
struct TransferMatch {
|
|
|
|
|
std::int64_t paidMinor = 0; // summed over every credit carrying the reference
|
|
|
|
|
int count = 0; // how many credits carried it
|
|
|
|
|
std::string method; // the method of the last matching credit
|
|
|
|
|
// Set when a matching credit's remittance text ALSO carries something
|
|
|
|
|
// shaped like a second order reference. One transfer quoting two
|
|
|
|
|
// references cannot be attributed by a per-order matcher, and at that
|
|
|
|
|
// point a human should look rather than two orders both settling on the
|
|
|
|
|
// same money. Advisory: the caller logs it, it does not block.
|
|
|
|
|
bool ambiguous = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Sum the credits that quote `reference`, in whatever form the payer typed
|
|
|
|
|
// it. Pure, and exported for the self-test, because this is the decision
|
|
|
|
|
// that releases goods — the same reason ParseEthCallUint is exported.
|
|
|
|
|
//
|
|
|
|
|
// Matching is on the reference BODY ("CC2B6457") after reducing both sides
|
|
|
|
|
// to upper-case alphanumerics. That one choice covers every form a payer
|
|
|
|
|
// might quote — "CC-2B6457", "cc2b6457", or the full structured
|
|
|
|
|
// "RF70CC2B6457" — because the body is a substring of all of them, and it
|
|
|
|
|
// survives whatever spacing a bank puts in the field. The RF check digits
|
|
|
|
|
// deliberately do NO work here: their job was done at the payer's own bank,
|
|
|
|
|
// which refuses a mistyped reference before the transfer ever leaves.
|
|
|
|
|
TransferMatch MatchCredits(std::span<const BankCredit> credits,
|
|
|
|
|
std::string_view reference);
|
|
|
|
|
|
|
|
|
|
// Where a transfer rail gets its incoming credits. One implementation talks
|
|
|
|
|
// to the bank; the suites use a file-backed one so the whole rail — payment
|
|
|
|
|
// instructions, matching, settlement, the window — runs with no network.
|
|
|
|
|
class CreditSource {
|
|
|
|
|
public:
|
|
|
|
|
virtual ~CreditSource() = default;
|
|
|
|
|
// nullopt = the bank could not be reached. NEVER an empty vector for
|
|
|
|
|
// that case: "no credits yet" and "cannot ask" must not look alike, or
|
|
|
|
|
// an outage would read as a shop full of unpaid orders.
|
|
|
|
|
virtual std::optional<std::vector<BankCredit>> Recent() = 0;
|
|
|
|
|
virtual std::string_view Name() const = 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// The rail itself. Takes its credit source so the bank is swappable — a
|
|
|
|
|
// deliberate hedge, since the account that reconciles the shop is also the
|
|
|
|
|
// shop's bank account, and replacing one adapter must not mean rewriting
|
|
|
|
|
// the rail.
|
|
|
|
|
std::unique_ptr<PaymentRail> MakeTransferRail(const RailConfig& config,
|
|
|
|
|
std::unique_ptr<CreditSource> credits);
|
|
|
|
|
|
|
|
|
|
// A CreditSource reading newline-delimited JSON from a file, one credit per
|
|
|
|
|
// line: {"id":…,"reference":…,"amount_minor":…,"method":…}. This is how the
|
|
|
|
|
// suites drive real settlement, and how an operator can settle a transfer
|
|
|
|
|
// by hand without touching the ledger. A missing file is an EMPTY list, not
|
|
|
|
|
// a failure: no transfers yet is a normal state.
|
|
|
|
|
std::unique_ptr<CreditSource> MakeFileCreditSource(std::filesystem::path path);
|
|
|
|
|
|
|
|
|
|
// ── the bunq credit source ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct BunqConfig {
|
|
|
|
|
std::string apiKey; // BUNQ_API_KEY; can MOVE MONEY, see below
|
|
|
|
|
std::filesystem::path statePath; // keypair + tokens, 0600
|
|
|
|
|
// Which account, when the key can see more than one. Compared to the
|
|
|
|
|
// account's IBAN ignoring spacing and case; without it a key that sees
|
|
|
|
|
// several active accounts is a refusal rather than a guess, because
|
|
|
|
|
// guessing means reconciling the shop against its savings.
|
|
|
|
|
std::string iban;
|
|
|
|
|
// Registered with bunq ONCE, at device-server time. bunq has no
|
|
|
|
|
// read-only key scope, so this is the only thing standing between a
|
|
|
|
|
// leaked key and someone spending the balance. "*" works and is
|
|
|
|
|
// announced loudly; an explicit egress address is what should be used.
|
|
|
|
|
std::string permittedIps;
|
|
|
|
|
int count = 50; // payments per read
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// nullptr when no key is configured. NOTE the deployment rule that goes
|
|
|
|
|
// with this: a bunq key can initiate payments and bunq offers no read-only
|
|
|
|
|
// scope, so the project's standing policy is that it does NOT live on the
|
|
|
|
|
// internet-facing host. Run `--pull-credits` on a trusted machine and ship
|
|
|
|
|
// the credits file to the server, which then holds no credential at all.
|
|
|
|
|
std::unique_ptr<CreditSource> MakeBunqCreditSource(const BunqConfig& config);
|
|
|
|
|
|
|
|
|
|
// bunq's payment list -> credits. Exported for the self-test: the money
|
|
|
|
|
// decisions downstream are only as good as this decoding, and the HTTP
|
|
|
|
|
// around it is thin. Same reasoning as ParseEthCallUint.
|
|
|
|
|
std::vector<BankCredit> ParseBunqPayments(std::string_view json);
|
|
|
|
|
|
|
|
|
|
// Amount strings as bunq quotes them, INCLUDING the leading minus an
|
|
|
|
|
// outgoing payment carries. Exported because the sign is the difference
|
|
|
|
|
// between income and a refund, and getting it wrong would let a refund pay
|
|
|
|
|
// for the order it reversed.
|
|
|
|
|
std::optional<std::int64_t> ParseSignedAmountToMinor(std::string_view s);
|
|
|
|
|
|
|
|
|
|
// bunq's Payment.type -> the ledger's `via` vocabulary. Exported so the
|
|
|
|
|
// suite can pin the mapping that decides whether an order is safe to ship:
|
|
|
|
|
// "sepa" is final, "card" can be reversed for months.
|
|
|
|
|
std::string_view BunqMethodFor(std::string_view paymentType);
|
|
|
|
|
|
2026-08-20 23:33:50 +02:00
|
|
|
// ── the bunq callback (webhook) ───────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// bunq pushes a notification when the account changes, which is the only
|
|
|
|
|
// way this shop learns about money when the API key's IP allowlist forbids
|
|
|
|
|
// the web host from asking. Shape:
|
|
|
|
|
//
|
|
|
|
|
// {"NotificationUrl":{"category":"MUTATION",
|
|
|
|
|
// "object":{"Payment":{…the same Payment object…}}}}
|
|
|
|
|
//
|
|
|
|
|
// SECURITY, stated plainly because the design depends on understanding it:
|
|
|
|
|
// **bunq does not sign these.** Verified against doc.bunq.com — no HMAC, no
|
|
|
|
|
// server signature; certificate pinning authenticates US to bunq, not bunq
|
|
|
|
|
// to us. So the body is an unauthenticated claim that money arrived, and
|
|
|
|
|
// the only things standing between it and the ledger are transport-level:
|
|
|
|
|
// a source-IP allowlist for bunq's published range (185.40.108.0/22, which
|
|
|
|
|
// bunq warns may change), a secret path segment, and the fact that nothing
|
|
|
|
|
// ships without a human. Treat a callback as evidence exactly as strong as
|
|
|
|
|
// those controls, and keep a periodic --pull-credits as the backstop:
|
|
|
|
|
// bunq retries roughly six times and then drops the notification forever,
|
|
|
|
|
// so a backend that was down during a deploy loses that payment silently.
|
|
|
|
|
std::optional<BankCredit> ParseBunqCallback(std::string_view json);
|
|
|
|
|
|
|
|
|
|
// Append one credit to the file the transfer rail reads, unless an entry
|
|
|
|
|
// with the same id is already there. Returns false on a write failure or a
|
|
|
|
|
// duplicate — the caller answers the webhook 200 either way, because a
|
|
|
|
|
// duplicate is a SUCCESS from bunq's point of view and retrying it would
|
|
|
|
|
// achieve nothing.
|
|
|
|
|
bool AppendCreditTo(const std::filesystem::path& creditsPath,
|
|
|
|
|
const BankCredit& credit);
|
|
|
|
|
|
|
|
|
|
// Enable the bunq callback endpoint. Both arguments are required and the
|
|
|
|
|
// secret must be at least 24 characters, or the endpoint stays off — see
|
|
|
|
|
// ParseBunqCallback for why the path IS the authentication here, and why
|
|
|
|
|
// that is only acceptable alongside Caddy's source-IP allowlist.
|
|
|
|
|
void ConfigureBunqCallback(std::filesystem::path creditsPath,
|
|
|
|
|
std::string secretPath);
|
|
|
|
|
|
2026-08-20 20:15:47 +02:00
|
|
|
// One pull: read the account and append every credit not already in the
|
|
|
|
|
// file to it, newest last. Returns the number appended, or nullopt if the
|
|
|
|
|
// bank could not be reached. This is what `--pull-credits` runs, and it is
|
|
|
|
|
// deliberately a separate entry point from the rail so the machine holding
|
|
|
|
|
// the key need not be the machine serving the shop.
|
|
|
|
|
std::optional<int> PullCreditsInto(CreditSource& source,
|
|
|
|
|
const std::filesystem::path& creditsPath);
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
// ── shipping rates ────────────────────────────────────────────────
|
|
|
|
|
//
|
2026-08-13 23:34:19 +02:00
|
|
|
// Live per-country, per-weight-bracket rates from Sendcloud's
|
|
|
|
|
// shipping_methods API, cached to a state file and refreshed daily by a
|
|
|
|
|
// background thread. This table is the SOLE source of shipping prices:
|
|
|
|
|
// there is no compiled-in fallback, because a destination Sendcloud has no
|
|
|
|
|
// rate for is a destination the shop cannot actually post a parcel to, and
|
|
|
|
|
// inventing a price for it only sells an order that then has to be
|
|
|
|
|
// refunded or absorbed.
|
|
|
|
|
//
|
|
|
|
|
// The consequence is deliberate and load-bearing: with no table, checkout
|
|
|
|
|
// refuses everything. The disk cache is therefore the resilience layer
|
|
|
|
|
// rather than an optimisation — it is written on every successful fetch,
|
|
|
|
|
// read unconditionally at startup (credentials or not, which is also how
|
|
|
|
|
// dev and e2e get a table), and a Sendcloud outage merely means the last
|
|
|
|
|
// known prices keep selling.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
struct ShippingConfig {
|
|
|
|
|
std::string publicKey; // SENDCLOUD_PUBLIC_KEY
|
|
|
|
|
std::string secretKey; // SENDCLOUD_SECRET_KEY
|
|
|
|
|
std::string methodName; // substring match on the method name
|
2026-08-13 23:34:19 +02:00
|
|
|
std::filesystem::path cachePath; // survives restarts; also the dev seed
|
2026-08-05 04:18:37 +02:00
|
|
|
};
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// Country -> weight-bracket ladder, prices in cents EUR, already grossed up
|
|
|
|
|
// to consumer prices. Empty when nothing loaded, which means "cannot ship
|
|
|
|
|
// anywhere" and is reported loudly at startup.
|
2026-08-05 04:18:37 +02:00
|
|
|
struct ShippingTable {
|
2026-08-13 23:34:19 +02:00
|
|
|
std::string method; // the matched Sendcloud method name(s)
|
2026-08-05 04:18:37 +02:00
|
|
|
std::string fetchedAt; // ISO 8601, for the operator
|
2026-08-13 23:34:19 +02:00
|
|
|
std::vector<Money::ShipRates> perCountry;
|
2026-08-05 04:18:37 +02:00
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// The rate for a parcel of `grams`, or 0 when this table cannot ship it
|
|
|
|
|
// to `cc` at all. The bracket rule lives in Money so the page's total
|
|
|
|
|
// preview picks the identical bracket.
|
|
|
|
|
std::int64_t Find(std::string_view cc, std::int64_t grams) const {
|
|
|
|
|
return Money::RateFor(Money::LadderFor(perCountry, cc), grams);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Units of `unitGrams` that fit the heaviest bracket for `cc`; 0 when
|
|
|
|
|
// the destination is uncovered. This is the real quantity ceiling.
|
|
|
|
|
std::int64_t MaxUnits(std::string_view cc, std::int64_t unitGrams) const {
|
|
|
|
|
return Money::MaxUnitsFor(Money::LadderFor(perCountry, cc), unitGrams);
|
2026-08-05 04:18:37 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// Parse a Sendcloud /api/v2/shipping_methods response into a table.
|
2026-08-05 04:18:37 +02:00
|
|
|
// Exported for the self-test — the network fetch is thin around this.
|
|
|
|
|
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName);
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// Install the config and start using it. Loads the cache even without
|
|
|
|
|
// credentials, so a hand-placed cache file is a complete rate table.
|
2026-08-05 04:18:37 +02:00
|
|
|
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();
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// The rate the checkout charges to send `grams` to `country`, or nullopt
|
|
|
|
|
// when no bracket covers it — the caller must then refuse the order rather
|
|
|
|
|
// than substitute a number.
|
|
|
|
|
std::optional<std::int64_t> ShipCostFor(std::string_view country,
|
|
|
|
|
std::int64_t grams);
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// Install the rails used by Serve()'s checkout handler and reconciler.
|
|
|
|
|
// Call before Serve. Two null rails disables checkout entirely. (Rates
|
|
|
|
|
// travel with the content — LoadContent reads rates.json.)
|
|
|
|
|
void ConfigurePayments(PaymentRails rails, std::string redirectBase);
|
|
|
|
|
|
|
|
|
|
// Whether the crypto slot is live, for the renderer: the checkout form
|
|
|
|
|
// offers the crypto choice only when something can actually serve it.
|
|
|
|
|
bool CryptoPaymentAvailable();
|
|
|
|
|
|
2026-08-20 20:15:47 +02:00
|
|
|
// The same question for the bank slot. It exists because the asymmetry
|
|
|
|
|
// was an outage: when the bank rail went away the form kept rendering a
|
|
|
|
|
// pre-selected "Bank or card" option that checkout could only refuse with
|
|
|
|
|
// a 503. Every renderer that knows must pass both.
|
|
|
|
|
bool BankPaymentAvailable();
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// ── request provenance ────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Two questions a reverse-proxied process has to answer carefully, both
|
|
|
|
|
// pure string work, both exported for the self-test.
|
|
|
|
|
|
|
|
|
|
// The client address as the reverse proxy saw it, from X-Forwarded-For.
|
|
|
|
|
//
|
|
|
|
|
// Caddy APPENDS the real peer to whatever X-Forwarded-For the client sent,
|
|
|
|
|
// so the value reads "<anything the client claimed>, <real peer>" and only
|
|
|
|
|
// the RIGHTMOST entry is trustworthy. Taking the leftmost — the usual
|
|
|
|
|
// mistake — would hand every client an unlimited supply of free rate-limit
|
|
|
|
|
// identities, which is worse than not limiting at all.
|
|
|
|
|
//
|
|
|
|
|
// Trustworthy only because nothing but Caddy can reach this listener: it
|
|
|
|
|
// binds loopback and the Caddyfile says in as many words not to expose the
|
|
|
|
|
// port. Empty in, empty out — no header means nothing proxied this request
|
|
|
|
|
// (dev, e2e, a direct curl), and the caller must fall back to the global
|
|
|
|
|
// limit rather than invent a peer.
|
|
|
|
|
std::string_view ClientAddressFromForwarded(std::string_view forwarded);
|
|
|
|
|
|
|
|
|
|
// Whether a state-changing POST may proceed, given its Origin header.
|
|
|
|
|
//
|
|
|
|
|
// A MISSING Origin is allowed: browsers have sent it on form POSTs for
|
|
|
|
|
// years, so a request without one is a non-browser client (curl, the e2e
|
|
|
|
|
// suite), and a non-browser client cannot be a cross-site forgery — there
|
|
|
|
|
// is no victim's session to ride on. A PRESENT but mismatched Origin is
|
|
|
|
|
// precisely the forgery case, and that is refused. "null" — a sandboxed
|
|
|
|
|
// iframe or a privacy-stripped origin — is refused too: it is present, and
|
|
|
|
|
// it is not us.
|
|
|
|
|
bool OriginAllowed(std::string_view origin, std::string_view redirectBase);
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|