This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue