/* 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 Mollie payment rail — the bank half of the checkout — plus the fake rail // the tests run on, and the roster that hands out both. // // Chosen over bunq.me after measuring bunq.me's limits (€500/transaction on // cards, no method for a non-EU buyer at phone prices — it is a P2P tool, not // a checkout). Mollie is a Dutch licensed PSP built for exactly this size of // shop: iDEAL at a flat per-transaction fee, cards behind SCA/3DS, and a // hosted checkout so card data never touches this server. // // The API is small: one bearer-token key, no signing, no session dance. // // POST /v2/payments {amount, description, redirectUrl} -> id + checkout URL // GET /v2/payments/{id} -> status, method // // Trust direction is the design rule: the ?redirect back to the order page is // ignored; an order becomes paid ONLY when an authenticated GET says // status=paid with a covering amount. A Mollie payment can EXPIRE // (canceled/expired/failed are terminal), so the poll distinguishes // Pending / Paid / Dead and the reconciler lapses orders whose payment can // never arrive. // // A test API key (test_…) works against the real endpoints from the moment a // Mollie account is created — verify with that before going live. module; module Catcrafts.Server; import std; import Catcrafts.Shared; import Crafter.Network; using namespace Crafter; namespace Catcrafts::Server { namespace { std::string JsonEscapeM(std::string_view s) { std::string out; out.reserve(s.size() + 8); for (const char c : s) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: if (static_cast(c) < 0x20) { out += std::format("\\u{:04x}", static_cast(c)); } else { out += c; } } } return out; } } // namespace 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. Both providers quote amounts as decimal strings, // so both come through here. 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; } std::optional ParseMolliePayment(std::string_view json) { auto doc = Json::Parse(json); if (!doc || !doc->IsObject()) return std::nullopt; MolliePayment p; p.id = std::string(doc->Str("id")); p.status = std::string(doc->Str("status")); p.method = std::string(doc->Str("method")); if (p.id.empty() || p.status.empty()) return std::nullopt; if (const Json::Value* amount = doc->Find("amount"); amount && amount->IsObject()) { // Only euro amounts are ever created, so anything else failing to // parse to zero is the safe outcome — a zero amount never satisfies // an order total. if (amount->Str("currency") == "EUR") { if (auto minor = ParseAmountToMinor(amount->Str("value"))) { p.amountMinor = *minor; } } } if (const Json::Value* links = doc->Find("_links"); links && links->IsObject()) { if (const Json::Value* checkout = links->Find("checkout"); checkout && checkout->IsObject()) { p.checkoutUrl = std::string(checkout->Str("href")); } } return p; } 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 without inventing a second // test double: 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. 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; return PaidStatus{ std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending, "fake" }; } 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_; }; class MollieRail final : public PaymentRail { public: explicit MollieRail(RailConfig cfg) : cfg_(std::move(cfg)) {} std::optional CreateLink(std::int64_t amountMinor, const std::string& description, const std::string& redirectUrl) override { std::lock_guard lock(mutex_); const std::string body = std::format( R"({{"amount":{{"currency":"EUR","value":"{}"}},)" R"("description":"{}","redirectUrl":"{}"}})", Money::FormatMinor(amountMinor), JsonEscapeM(description), JsonEscapeM(redirectUrl)); const std::optional res = Call("POST", "/v2/payments", body); if (!res) return std::nullopt; const auto payment = ParseMolliePayment(*res); if (!payment || payment->checkoutUrl.empty()) { std::println(std::cerr, "mollie: create returned no checkout url"); return std::nullopt; } PaymentLink link; link.payId = payment->id; link.payUrl = payment->checkoutUrl; return link; } std::optional CheckPaid(const std::string& payId, std::int64_t expectedMinor) override { std::lock_guard lock(mutex_); // The id came from Mollie, but it travels through our ledger — keep // the path composition strict anyway. for (const char c : payId) { const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; if (!ok) return PaidStatus{ PayState::Dead, {} }; } const std::optional res = Call("GET", "/v2/payments/" + payId, {}); if (!res) return std::nullopt; const auto payment = ParseMolliePayment(*res); if (!payment) return std::nullopt; PaidStatus out; out.method = payment->method; if (payment->status == "paid" && payment->amountMinor >= expectedMinor) { out.state = PayState::Paid; } else if (payment->status == "canceled" || payment->status == "expired" || payment->status == "failed") { out.state = PayState::Dead; } else { // open / pending / authorized — still in flight. out.state = PayState::Pending; } return out; } std::string_view Name() const override { return "mollie"; } std::chrono::seconds PollInterval() const override { return std::chrono::seconds(10); } private: // One HTTPS call; nullopt on transport failure or a non-2xx answer. The // reconciler treats nullopt as "unknown, retry" — never as unpaid or dead. std::optional Call(std::string_view method, const std::string& path, const std::string& body) { try { if (!client_) { client_ = std::make_unique( "api.mollie.com", static_cast(443), Crafter::TLSClientCredentials{}); } Crafter::HTTPRequest req; req.method = std::string(method); req.path = path; req.authority = "api.mollie.com"; req.body = body; req.headers["authorization"] = "Bearer " + cfg_.apiKey; req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)"; if (!body.empty()) req.headers["content-type"] = "application/json"; const Crafter::HTTPResponse res = client_->Send(req); if (res.status.size() != 3 || res.status[0] != '2') { std::println(std::cerr, "mollie: {} {} -> {} {}", method, path, res.status, res.body.substr(0, 200)); return std::nullopt; } return res.body; } catch (const std::exception& e) { std::println(std::cerr, "mollie: {} {} failed: {}", method, path, e.what()); client_.reset(); // dial fresh next time return std::nullopt; } } RailConfig cfg_; std::mutex mutex_; std::unique_ptr client_; }; } // 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"); } if (config.mode == "mollie") return std::make_unique(config); // "eurc" is the one mode that can fail to construct for a reason other than // a typo: its chains file or address pool may not load. It returns nullptr // there, which main reports as an unknown rail — see the note in main about // why that message names the files. if (config.mode == "eurc") return MakeEurcRail(config); return nullptr; // "off" } } // namespace Catcrafts::Server