This commit is contained in:
parent
7098ac75cb
commit
df91762271
29 changed files with 3079 additions and 838 deletions
216
server/implementations/Catcrafts.Server-Rails.cpp
Normal file
216
server/implementations/Catcrafts.Server-Rails.cpp
Normal 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
|
||||
Loading…
Reference in a new issue