tests and eurc
All checks were successful
Deploy / build-deploy (push) Successful in 4m19s

This commit is contained in:
Jorijn van der Graaf 2026-08-15 00:54:05 +02:00
commit 749f525f83
44 changed files with 5380 additions and 3532 deletions

View file

@ -69,9 +69,10 @@ export namespace Catcrafts::Server {
// 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 payUrl; // the provider's hosted checkout link;
// for the EURC rail, the order page itself
std::string payId; // provider payment id ("tr_…" at Mollie,
// a decimal order id at CoinGate)
// "<address>@<deadline>" at the EURC rail)
std::string paidVia; // method that settled it ("ideal", "bitcoin")
std::string paidAt; // ISO 8601 of the FIRST paid event; empty =
// never paid. A later cancel (a refund)
@ -239,7 +240,7 @@ export namespace Catcrafts::Server {
// 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.
// authored content; the ShouldBuildInvoices test 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";
@ -326,16 +327,35 @@ export namespace Catcrafts::Server {
};
// What a poll learned about one payment. Pending and Dead are different
// 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.
// answers on purpose: an unpaid order does not stay payable forever —
// Mollie expires its payments after its own window, and the EURC rail
// closes its own (24 hours by default) — 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" | "bitcoin" | …
};
// 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 {
std::string address; // where the money goes
std::string amount; // decimal token amount ("570.43")
std::int64_t deadlineUnix = 0;
std::vector<PayChainOption> chains;
};
class PaymentRail {
public:
virtual ~PaymentRail() = default;
@ -349,6 +369,15 @@ export namespace Catcrafts::Server {
// expired/canceled/failed kills an order.
virtual std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) = 0;
// 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;
}
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.
@ -356,11 +385,19 @@ export namespace Catcrafts::Server {
};
struct RailConfig {
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::string mode; // "off" | "fake" | "mollie" | "eurc"
std::string apiKey; // mollie: live_… or test_…
std::filesystem::path statePath; // fake: the paid marker
std::string redirectBase = "https://catcrafts.net";
// 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
};
// nullptr for mode "off" — that slot then offers no payment choice.
@ -372,7 +409,7 @@ export namespace Catcrafts::Server {
// 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
std::unique_ptr<PaymentRail> crypto; // EURC: self-hosted, on-chain
bool Any() const { return bank != nullptr || crypto != nullptr; }
// The rail that owns a stored order, by its recorded choice. Total on
@ -399,28 +436,47 @@ export namespace Catcrafts::Server {
};
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
// 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
// Exact decimal-string-to-minor-units parser for the amounts Mollie's API
// quotes 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);
// 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"
std::string rpcUrl;
std::string contract; // the EURC token contract on this chain
std::string blockTag = "finalized";
int decimals = 6;
// 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);
// 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);
// ── shipping rates ────────────────────────────────────────────────
//
// Live per-country, per-weight-bracket rates from Sendcloud's