2026-08-05 04:18:37 +02:00
|
|
|
/*
|
|
|
|
|
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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
//
|
|
|
|
|
// 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.
|
|
|
|
|
//
|
2026-08-13 23:34:19 +02:00
|
|
|
// The API is small: one bearer-token key, no signing, no session dance.
|
2026-08-05 04:18:37 +02:00
|
|
|
//
|
|
|
|
|
// POST /v2/payments {amount, description, redirectUrl} -> id + checkout URL
|
|
|
|
|
// GET /v2/payments/{id} -> status, method
|
|
|
|
|
//
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
//
|
|
|
|
|
// A test API key (test_…) works against the real endpoints from the moment a
|
2026-08-13 23:34:19 +02:00
|
|
|
// Mollie account is created — verify with that before going live.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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 {
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
// ── 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_;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
// 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
|
2026-08-13 23:34:19 +02:00
|
|
|
// 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");
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
if (config.mode == "mollie") return std::make_unique<MollieRail>(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);
|
2026-08-05 04:18:37 +02:00
|
|
|
return nullptr; // "off"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace Catcrafts::Server
|