catcrafts.net/server/implementations/Catcrafts.Server-Mollie.cpp
Jorijn van der Graaf 70668af8f5
All checks were successful
Deploy / build-deploy (push) Successful in 3m10s
coingate
2026-08-13 23:34:19 +02:00

292 lines
12 KiB
C++

/*
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<unsigned char>(c) < 0x20) {
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
} else {
out += c;
}
}
}
return out;
}
} // namespace
std::optional<std::int64_t> 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<MolliePayment> 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<PaymentLink> CreateLink(std::int64_t, const std::string&,
const std::string& redirectUrl) override {
static std::atomic<std::int64_t> 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<PaidStatus> 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<PaymentLink> 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<std::string> 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<PaidStatus> 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<std::string> 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<std::string> Call(std::string_view method, const std::string& path,
const std::string& body) {
try {
if (!client_) {
client_ = std::make_unique<Crafter::ClientHTTP1>(
"api.mollie.com", static_cast<std::uint16_t>(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<Crafter::ClientHTTP1> client_;
};
} // namespace
// The roster has one home, here; the CoinGate constructor is declared by its
// own unit. 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<PaymentRail> MakeCoingateRail(const RailConfig& config);
std::unique_ptr<PaymentRail> 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<FakeRail>(config.statePath, "fake");
}
if (config.mode == "fake-crypto") {
return std::make_unique<FakeRail>(config.statePath, "fake-crypto");
}
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
if (config.mode == "coingate") return MakeCoingateRail(config);
return nullptr; // "off"
}
} // namespace Catcrafts::Server