This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue