Replaced mollie
All checks were successful
Deploy / build-deploy (push) Successful in 3m47s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 20:15:47 +02:00
commit df91762271
29 changed files with 3079 additions and 838 deletions

View file

@ -0,0 +1,216 @@
/*
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 payment-rail roster, the fake rail the suites run on, and the one money
// parser both real rails share.
//
// Every rail this shop has is SELF-HOSTED: a bank transfer to its own account,
// or EURC to an address it generated itself. There is no hosted provider in the
// payment path and no credential that a third party can revoke.
//
// That is a scar, not a philosophy. The shop ran on a hosted payment provider
// until 2026-08-20, when that provider closed the account after a risk review,
// with no appeal and no reason beyond "outside our acceptance criteria". Every
// payment method died in one email: iDEAL, cards, the lot. Its rail
// implementation was removed once the decision proved final — keeping a dead
// integration alive costs a CI gate, a secret, and a steady trickle of
// confusion about which rail is actually serving.
//
// What that history is worth remembering FOR: a hosted rail can be switched
// off by someone else, and a self-hosted one cannot. The bank can still close
// the account, because every euro has to land somewhere, but it cannot decline
// a payment method while leaving the business running. That is the property
// the two current rails were chosen for, and the reason not to trade it away
// for convenience later.
module;
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
namespace Catcrafts::Server {
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.
//
// Shared because every amount that arrives from outside comes as a decimal
// string: the bank quotes "57.38", and the crypto rail's own decoding
// reduces to the same question. The sign is deliberately NOT accepted here
// (see ParseSignedAmountToMinor, which peels it off first) so that a stray
// minus can never quietly halve a total.
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;
}
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: 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.
//
// Note what it is NOT for any more. Both live rails are self-hosted and render
// instructions rather than a button, and each has its own black-box coverage
// against the real implementation (ShouldSettleBankTransfers,
// ShouldSettleEurcOnTestnet). So the fake rail's remaining job is the parts
// that are about the SHOP rather than about a provider: the choice, the
// ledger, the reconciler, and the hosted-button branch that no live rail takes
// but the renderer still has to be able to draw.
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;
if (std::filesystem::exists(marker_, ec)) {
return PaidStatus{ PayState::Paid, "fake" };
}
PaidStatus out;
out.state = PayState::Pending;
out.method = "fake";
// "<marker>.seen" is the in-flight state: money visible on the
// network, finality still pending. It exists so the e2e suite can
// drive the order page's "your payment is on its way" notice the
// same way the marker itself drives "paid".
std::filesystem::path seenMarker = marker_;
seenMarker += ".seen";
out.seen = std::filesystem::exists(seenMarker, ec);
return out;
}
// The crypto slot's fake renders payment INSTRUCTIONS, like the real EURC
// rail, so the suites exercise the order page's self-hosted branch
// (address, window, the in-flight notice). The bank slot's fake keeps the
// BUTTON on purpose: no live rail takes that branch any more, and without
// one fake still drawing it, the renderer's hosted-payment path would go
// completely uncovered. Fixed values, so assertions can pin them.
std::optional<PayInstructions> Instructions(const std::string&,
std::int64_t totalMinor) const override {
if (name_ != "fake-crypto" || totalMinor <= 0) return std::nullopt;
PayInstructions out;
out.address = "0x" + std::string(40, 'f');
out.amount = Money::FormatMinor(totalMinor);
out.deadlineUnix =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count()
+ 24 * 3600;
PayChainOption chain;
chain.name = "fake-chain";
chain.contract = "0x" + std::string(40, 'f');
out.chains.push_back(std::move(chain));
return out;
}
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_;
};
} // namespace
// 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.
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& 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");
}
// The two live rails. Both can fail to construct for a reason other than a
// typo — a missing IBAN or beneficiary name, a chains file or address pool
// that will not load — and both return nullptr there, which main reports
// as a rail that could not load rather than as an unknown one.
if (config.mode == "transfer") {
// Which credit source depends on where the operator chose to keep the
// bank key. With BUNQ_API_KEY set here, this process reads the account
// itself — simpler, and strictly worse, because that key can move
// money and this process is reachable from the internet. Without it,
// the rail reads the credits file that `--pull-credits` fills from a
// trusted machine, and this host holds nothing that can spend.
std::unique_ptr<CreditSource> credits;
if (const char* key = std::getenv("BUNQ_API_KEY"); key && *key) {
std::println(std::cerr,
"transfer: WARNING — reading the bank directly with "
"BUNQ_API_KEY present in this process. A bunq key can "
"initiate payments and bunq has no read-only scope, so this "
"host now holds a credential that can spend the account. The "
"intended shape is `--pull-credits` on a trusted machine "
"writing the credits file this rail reads.");
BunqConfig bunq;
bunq.apiKey = key;
bunq.iban = config.transferIban;
if (const char* v = std::getenv("BUNQ_PERMITTED_IPS"); v) {
bunq.permittedIps = v;
}
if (const char* v = std::getenv("BUNQ_STATE"); v && *v) {
bunq.statePath = v;
} else {
bunq.statePath = config.transferCreditsPath;
bunq.statePath += ".bunq-context.json";
}
credits = MakeBunqCreditSource(bunq);
if (!credits) return nullptr;
} else {
credits = MakeFileCreditSource(config.transferCreditsPath);
}
return MakeTransferRail(config, std::move(credits));
}
if (config.mode == "eurc") return MakeEurcRail(config);
return nullptr; // "off"
}
} // namespace Catcrafts::Server