coingate
All checks were successful
Deploy / build-deploy (push) Successful in 3m10s

This commit is contained in:
Jorijn van der Graaf 2026-08-13 23:34:19 +02:00
commit 70668af8f5
20 changed files with 2354 additions and 1048 deletions

View file

@ -6,7 +6,8 @@ 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 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
@ -14,22 +15,20 @@ No permission is granted to copy, modify, distribute, or create derivative works
// 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.
// 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 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.
// 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; unlike the
// bunq client this one need not ship on faith.
// Mollie account is created — verify with that before going live.
module;
module Catcrafts.Server;
@ -67,6 +66,33 @@ std::string JsonEscapeM(std::string_view s) {
} // 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;
@ -98,6 +124,50 @@ std::optional<MolliePayment> ParseMolliePayment(std::string_view json) {
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)) {}
@ -199,15 +269,23 @@ private:
} // 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);
// 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) {
if (config.mode == "fake") return MakeFakeRail(config);
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
if (config.mode == "bunq") return MakeBunqRail(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"
}