This commit is contained in:
parent
33c68c2f44
commit
749f525f83
44 changed files with 5380 additions and 3532 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue