This commit is contained in:
parent
fb2f6079cc
commit
934c94cb5c
50 changed files with 10464 additions and 758 deletions
214
server/implementations/Catcrafts.Server-Mollie.cpp
Normal file
214
server/implementations/Catcrafts.Server-Mollie.cpp
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
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
|
||||
Loading…
Reference in a new issue