coingate
All checks were successful
Deploy / build-deploy (push) Successful in 3m10s

This commit is contained in:
Jorijn van der Graaf 2026-08-13 23:34:19 +02:00
commit 70668af8f5
20 changed files with 2354 additions and 1048 deletions

View file

@ -6,13 +6,13 @@ 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.
// The native server: server-rendered pages, order storage, and the payment
// rails.
//
// 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.
// 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.
export module Catcrafts.Server;
import std;
@ -65,9 +65,14 @@ export namespace Catcrafts::Server {
std::int64_t totalMinor = 0;
bool vatIncluded = false;
std::string status = "awaiting_payment"; // -> paid -> shipped | cancelled
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.
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 payId; // provider payment id ("tr_…" at Mollie,
// a decimal order id at CoinGate)
std::string paidVia; // method that settled it ("ideal", "bitcoin")
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
@ -186,8 +191,12 @@ export namespace Catcrafts::Server {
//
// 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.
// 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.
struct PaymentLink {
std::string payUrl;
@ -195,13 +204,14 @@ export namespace Catcrafts::Server {
};
// 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.
// answers on purpose: both providers EXPIRE unpaid orders — Mollie after
// its own window, CoinGate after two hours (twenty minutes once a coin is
// picked) — 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" | …
std::string method; // "ideal" | "creditcard" | "bitcoin" | …
};
class PaymentRail {
@ -224,16 +234,37 @@ export namespace Catcrafts::Server {
};
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 mode; // "off" | "fake" | "mollie" | "coingate"
std::string apiKey; // mollie: live_… or test_…; coingate: its token
bool sandbox = false; // coingate: api-sandbox.coingate.com
std::filesystem::path statePath; // fake: the paid marker
std::string redirectBase = "https://catcrafts.net";
};
// nullptr for mode "off" — the shop then renders but refuses checkout.
// nullptr for mode "off" — that slot then offers no payment choice.
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config);
// 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 {
std::unique_ptr<PaymentRail> bank; // Mollie: iDEAL, cards, transfer
std::unique_ptr<PaymentRail> crypto; // CoinGate: on-chain and Lightning
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();
}
};
// 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.
@ -246,66 +277,135 @@ export namespace Catcrafts::Server {
};
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.
// Parsed essentials of a CoinGate /api/v2/orders object. Same shape and
// same reason as MolliePayment: the parser is the part worth testing.
//
// `id` is a JSON NUMBER on the wire ("id":538) rather than a string, so it
// is rendered to decimal here and travels through the ledger as text like
// every other payment id.
struct CoingateOrder {
std::string id;
std::string status; // new|pending|confirming|paid|invalid|
// expired|canceled|refunded|partially_refunded
std::string payCurrency; // the coin the shopper picked; empty until then
std::string payUrl; // the hosted invoice, present while payable
std::int64_t priceMinor = 0; // price_amount, and only when EUR
};
std::optional<CoingateOrder> ParseCoingateOrder(std::string_view json);
// Exact decimal-string-to-minor-units parser for the amounts both provider
// APIs quote as strings ("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.
// 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.
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
std::filesystem::path cachePath; // survives restarts; also the dev seed
};
// Country -> price in cents, EUR. Empty when nothing loaded.
// 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.
struct ShippingTable {
std::string method; // the matched Sendcloud method name
std::string method; // the matched Sendcloud method name(s)
std::string fetchedAt; // ISO 8601, for the operator
std::vector<std::pair<std::string, std::int64_t>> perCountry;
std::vector<Money::ShipRates> perCountry;
std::int64_t Find(std::string_view cc) const {
for (const auto& [k, v] : perCountry) {
if (k == cc) return v;
}
return 0;
// 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);
}
};
// Parse a Sendcloud /api/v2/shipping_methods response into a table, taking
// the first method whose name contains `methodName` (case-sensitive).
// Parse a Sendcloud /api/v2/shipping_methods response into a table.
// 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.
// Install the config and start using it. Loads the cache even without
// credentials, so a hand-placed cache file is a complete rate table.
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);
// 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);
// 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);
// 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();
// ── 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);
// Bind and serve until killed. Blocks. Starts the payment reconciler
// thread when a rail is configured.