/* 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. */ // The payment-rail roster, the fake rail the suites run on, and the one money // parser both real rails share. // // Every rail this shop has is SELF-HOSTED: a bank transfer to its own account, // or EURC to an address it generated itself. There is no hosted provider in the // payment path and no credential that a third party can revoke. // // That is a scar, not a philosophy. The shop ran on a hosted payment provider // until 2026-08-20, when that provider closed the account after a risk review, // with no appeal and no reason beyond "outside our acceptance criteria". Every // payment method died in one email: iDEAL, cards, the lot. Its rail // implementation was removed once the decision proved final — keeping a dead // integration alive costs a CI gate, a secret, and a steady trickle of // confusion about which rail is actually serving. // // What that history is worth remembering FOR: a hosted rail can be switched // off by someone else, and a self-hosted one cannot. The bank can still close // the account, because every euro has to land somewhere, but it cannot decline // a payment method while leaving the business running. That is the property // the two current rails were chosen for, and the reason not to trade it away // for convenience later. module; module Catcrafts.Server; import std; import Catcrafts.Shared; namespace Catcrafts::Server { std::optional ParseAmountToMinor(std::string_view s) { // Exactly: 1*DIGIT ["." 1*2DIGIT]. Anything else — signs, exponents, // spaces, thousands separators — is rejected. Money parsing has no // "probably fine" mode. // // Shared because every amount that arrives from outside comes as a decimal // string: the bank quotes "57.38", and the crypto rail's own decoding // reduces to the same question. The sign is deliberately NOT accepted here // (see ParseSignedAmountToMinor, which peels it off first) so that a stray // minus can never quietly halve a total. if (s.empty() || s.size() > 15) return std::nullopt; std::int64_t units = 0; std::size_t i = 0; if (s[i] < '0' || s[i] > '9') return std::nullopt; for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) { units = units * 10 + (s[i] - '0'); } std::int64_t cents = 0; if (i < s.size()) { if (s[i] != '.') return std::nullopt; ++i; const std::size_t fracStart = i; for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) { cents = cents * 10 + (s[i] - '0'); } const std::size_t digits = i - fracStart; if (i != s.size() || digits == 0 || digits > 2) return std::nullopt; if (digits == 1) cents *= 10; } return units * 100 + cents; } namespace { // ── the fake rail ───────────────────────────────────────────────────── // // Exists so the ENTIRE order lifecycle — checkout, storage, status page, // reconciler, paid transition — runs in e2e with zero network. Payment links // point at a made-up URL; CheckPaid answers true once a marker file exists, // which the test creates when it wants "the customer has paid" to happen. // // It can stand in for EITHER slot, which is what lets the e2e suite drive the // bank and crypto paths through the same machinery: what it proves is that the // choice is carried from the form to the ledger to the poll, and that is // rail-independent by design. // // Note what it is NOT for any more. Both live rails are self-hosted and render // instructions rather than a button, and each has its own black-box coverage // against the real implementation (ShouldSettleBankTransfers, // ShouldSettleEurcOnTestnet). So the fake rail's remaining job is the parts // that are about the SHOP rather than about a provider: the choice, the // ledger, the reconciler, and the hosted-button branch that no live rail takes // but the renderer still has to be able to draw. class FakeRail final : public PaymentRail { public: FakeRail(std::filesystem::path marker, std::string name) : marker_(std::move(marker)), name_(std::move(name)) {} std::optional CreateLink(std::int64_t, const std::string&, const std::string& redirectUrl) override { static std::atomic counter{1}; PaymentLink link; link.payId = std::format("fake-{}", counter.fetch_add(1)); // Checkout 303s the buyer to payUrl. The fake rail has no checkout to // send anyone to, so it points at the order page itself — which keeps // the browser flow usable in dev and the e2e redirect parseable. link.payUrl = redirectUrl; return link; } std::optional CheckPaid(const std::string&, std::int64_t) override { std::error_code ec; if (std::filesystem::exists(marker_, ec)) { return PaidStatus{ PayState::Paid, "fake" }; } PaidStatus out; out.state = PayState::Pending; out.method = "fake"; // ".seen" is the in-flight state: money visible on the // network, finality still pending. It exists so the e2e suite can // drive the order page's "your payment is on its way" notice the // same way the marker itself drives "paid". std::filesystem::path seenMarker = marker_; seenMarker += ".seen"; out.seen = std::filesystem::exists(seenMarker, ec); return out; } // The crypto slot's fake renders payment INSTRUCTIONS, like the real EURC // rail, so the suites exercise the order page's self-hosted branch // (address, window, the in-flight notice). The bank slot's fake keeps the // BUTTON on purpose: no live rail takes that branch any more, and without // one fake still drawing it, the renderer's hosted-payment path would go // completely uncovered. Fixed values, so assertions can pin them. std::optional Instructions(const std::string&, std::int64_t totalMinor) const override { if (name_ != "fake-crypto" || totalMinor <= 0) return std::nullopt; PayInstructions out; out.address = "0x" + std::string(40, 'f'); out.amount = Money::FormatMinor(totalMinor); out.deadlineUnix = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count() + 24 * 3600; PayChainOption chain; chain.name = "fake-chain"; chain.contract = "0x" + std::string(40, 'f'); out.chains.push_back(std::move(chain)); return out; } std::string_view Name() const override { return name_; } std::chrono::seconds PollInterval() const override { return std::chrono::seconds(1); } private: std::filesystem::path marker_; std::string name_; }; } // namespace // The roster has one home, here. An unrecognised mode is "off" rather than an // error, and the caller (main) is what refuses to start on a mode it did not // expect — a rail that silently half-exists would be worse than either. std::unique_ptr MakeRail(const RailConfig& config) { // The fake rail keeps the slot's own name so the ledger, the startup line // and the logs still say which half of the checkout ran in a test. if (config.mode == "fake") { return std::make_unique(config.statePath, "fake"); } if (config.mode == "fake-crypto") { return std::make_unique(config.statePath, "fake-crypto"); } // The two live rails. Both can fail to construct for a reason other than a // typo — a missing IBAN or beneficiary name, a chains file or address pool // that will not load — and both return nullptr there, which main reports // as a rail that could not load rather than as an unknown one. if (config.mode == "transfer") { // Which credit source depends on where the operator chose to keep the // bank key. With BUNQ_API_KEY set here, this process reads the account // itself — simpler, and strictly worse, because that key can move // money and this process is reachable from the internet. Without it, // the rail reads the credits file that `--pull-credits` fills from a // trusted machine, and this host holds nothing that can spend. std::unique_ptr credits; if (const char* key = std::getenv("BUNQ_API_KEY"); key && *key) { std::println(std::cerr, "transfer: WARNING — reading the bank directly with " "BUNQ_API_KEY present in this process. A bunq key can " "initiate payments and bunq has no read-only scope, so this " "host now holds a credential that can spend the account. The " "intended shape is `--pull-credits` on a trusted machine " "writing the credits file this rail reads."); BunqConfig bunq; bunq.apiKey = key; bunq.iban = config.transferIban; if (const char* v = std::getenv("BUNQ_PERMITTED_IPS"); v) { bunq.permittedIps = v; } if (const char* v = std::getenv("BUNQ_STATE"); v && *v) { bunq.statePath = v; } else { bunq.statePath = config.transferCreditsPath; bunq.statePath += ".bunq-context.json"; } credits = MakeBunqCreditSource(bunq); if (!credits) return nullptr; } else { credits = MakeFileCreditSource(config.transferCreditsPath); } return MakeTransferRail(config, std::move(credits)); } if (config.mode == "eurc") return MakeEurcRail(config); return nullptr; // "off" } } // namespace Catcrafts::Server