catcrafts.net/server/implementations/Catcrafts.Server-Mollie.cpp

214 lines
8.3 KiB
C++
Raw Normal View History

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.
*/
// 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<unsigned char>(c) < 0x20) {
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
} else {
out += c;
}
}
}
return out;
}
} // namespace
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 {
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
// 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<PaymentRail> MakeBunqRail(const RailConfig& config);
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config);
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
if (config.mode == "fake") return MakeFakeRail(config);
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
if (config.mode == "bunq") return MakeBunqRail(config);
return nullptr; // "off"
}
} // namespace Catcrafts::Server