/* 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. // // 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 refreshingly small next to bunq's: one bearer-token key, no // RSA signing, no session dance. // // POST /v2/payments {amount, description, redirectUrl} -> id + checkout URL // GET /v2/payments/{id} -> status, method // // Trust direction is unchanged from 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. One deliberate difference from // the bunq tab model: 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; unlike the // bunq client this one need not ship on faith. 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 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 { 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 // Defined here rather than in the bunq unit so the rail roster has one home; // the bunq and fake constructors are declared by their own units. std::unique_ptr MakeBunqRail(const RailConfig& config); std::unique_ptr MakeFakeRail(const RailConfig& config); std::unique_ptr MakeRail(const RailConfig& config) { if (config.mode == "fake") return MakeFakeRail(config); if (config.mode == "mollie") return std::make_unique(config); if (config.mode == "bunq") return MakeBunqRail(config); return nullptr; // "off" } } // namespace Catcrafts::Server