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,598 @@
/*
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 bunq credit source: reads the shop's OWN bank account and reports the
// incoming credits, which is all the transfer rail needs to settle an order.
//
// This is a READER. It never moves money, and that asymmetry is the only
// reason it can exist at all — see the key-policy note below, which is the
// most important thing in this file.
//
// Descended from the bunq PAYMENT rail this repo carried until 2026-08-14
// (deleted in 70668af): the four-call onboarding dance, the RSA body signing
// and the context file are that code, kept because they were already proven
// against the live API. What changed is the question asked at the end. The old
// rail created bunq.me tabs and polled them; that route is closed to a webshop
// on volume grounds (a €1,500/week ceiling across all of bunq.me, and no way
// to disable its 2.5% card path), so this one reads the account's payment list
// instead and lets the reference do the matching.
//
// installation (once, ever) -> installation token
// device-server (once, ever) -> binds the API key to this "device"
// session-server (per session) -> session token + user id
// monetary-account (once) -> which account to read
// payment (per poll) -> the credits
//
// State persists in ONE json file so the first four never repeat: bunq allows
// as few as TEN calls PER DAY to the setup endpoints, so a client that
// re-onboarded on every start would lock itself out by lunchtime. Delete the
// file and it re-onboards from the API key.
//
// ── THE KEY POLICY, which this file cannot enforce on its own ─────────
//
// A bunq API key can INITIATE PAYMENTS. bunq offers no read-only scope, so
// there is no such thing as a key that can only do what this file does. The
// standing rule for this project is therefore that the bunq key does NOT live
// on the internet-facing box.
//
// That rule and this code are compatible, but only in one deployment shape:
// run this as `catcrafts-server --pull-credits` on a trusted machine, on a
// timer, and ship the resulting credits file to the server, which reads it
// with the file source and holds no key at all. The server then cannot be made
// to move money even if it is fully compromised.
//
// Configuring BUNQ_API_KEY on the server itself also works and is one less
// moving part, but it puts a payment-capable credential on a public host. The
// startup path says so out loud rather than letting it pass unnoticed.
module;
#include <openssl/bio.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
import Crafter.Network;
namespace Catcrafts::Server {
namespace {
std::string Base64B(std::span<const unsigned char> in) {
static constexpr char tbl[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve(((in.size() + 2) / 3) * 4);
std::size_t i = 0;
for (; i + 2 < in.size(); i += 3) {
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8) | in[i + 2];
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
}
if (i + 1 == in.size()) {
const std::uint32_t n = in[i] << 16;
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
out += "==";
} else if (i + 2 == in.size()) {
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8);
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
out += tbl[(n >> 6) & 63]; out += '=';
}
return out;
}
std::string EscB(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;
}
std::string RandomHexB(std::size_t words) {
std::random_device rd;
std::string out;
for (std::size_t i = 0; i < words; ++i) out += std::format("{:08x}", rd());
return out;
}
// bunq wraps everything: {"Response":[{"Id":{…}},{"Token":{…}}]}. Find the
// first object under `key` anywhere in that array.
const Json::Value* InResponse(const Json::Value& doc, std::string_view key) {
const Json::Value* resp = doc.Find("Response");
if (!resp || !resp->IsArray()) return nullptr;
for (const Json::Value& item : resp->array) {
if (!item.IsObject()) continue;
if (const Json::Value* v = item.Find(key)) return v;
}
return nullptr;
}
} // namespace
std::optional<std::int64_t> ParseSignedAmountToMinor(std::string_view s) {
// bunq quotes an OUTGOING payment as a negative value ("-25.00"), and the
// shared money parser rejects a sign by design — it guards amounts we
// choose, where a minus would be nonsense. Here the sign is information:
// it is what separates a customer paying us from us paying a supplier, and
// dropping it would let a refund look like income. So the sign is peeled
// off here and the magnitude goes through the strict parser unchanged.
bool negative = false;
if (!s.empty() && (s.front() == '-' || s.front() == '+')) {
negative = s.front() == '-';
s.remove_prefix(1);
}
const std::optional<std::int64_t> magnitude = ParseAmountToMinor(s);
if (!magnitude) return std::nullopt;
return negative ? -*magnitude : *magnitude;
}
std::string_view BunqMethodFor(std::string_view paymentType) {
// bunq's Payment.type, mapped to the ledger's `via` vocabulary. The
// distinction earns its keep at dispatch time: a SEPA credit transfer is
// final, while a card payment can be reversed for months, so these are not
// interchangeable labels for "money arrived".
if (paymentType == "EBA_SCT") return "sepa";
if (paymentType == "IDEAL") return "ideal";
if (paymentType == "FIS") return "card";
if (paymentType == "BUNQ") return "bunq";
if (paymentType == "SWIFT") return "swift";
if (paymentType == "EBA_SDD") return "directdebit";
// An unknown type still settles — the money is on the account either way —
// but it reaches the ledger verbatim so the `via` column shows what bunq
// actually said instead of a comfortable guess.
return paymentType.empty() ? std::string_view("bank") : paymentType;
}
std::vector<BankCredit> ParseBunqPayments(std::string_view json) {
std::vector<BankCredit> out;
const auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return out;
const Json::Value* resp = doc->Find("Response");
if (!resp || !resp->IsArray()) return out;
for (const Json::Value& item : resp->array) {
if (!item.IsObject()) continue;
const Json::Value* p = item.Find("Payment");
if (!p || !p->IsObject()) continue;
const Json::Value* amount = p->Find("amount");
if (!amount || !amount->IsObject()) continue;
// Only euro amounts can pay a euro order. A foreign-currency credit is
// skipped rather than counted at face value, which would silently
// treat 25 of something else as 25 euro.
if (amount->Str("currency") != "EUR") continue;
const std::optional<std::int64_t> minor =
ParseSignedAmountToMinor(amount->Str("value"));
if (!minor) continue;
BankCredit c;
c.id = std::format("{}", p->Int("id"));
c.reference = std::string(p->Str("description"));
c.amountMinor = *minor;
c.method = std::string(BunqMethodFor(p->Str("type")));
out.push_back(std::move(c));
}
return out;
}
namespace {
class BunqCreditSource final : public CreditSource {
public:
BunqCreditSource(std::string apiKey, std::filesystem::path statePath,
std::string permittedIps, int count, std::string wantIban)
: apiKey_(std::move(apiKey)), statePath_(std::move(statePath)),
permittedIps_(std::move(permittedIps)), count_(count),
wantIban_(std::move(wantIban)) {}
std::optional<std::vector<BankCredit>> Recent() override {
std::lock_guard lock(mutex_);
if (!EnsureSession()) return std::nullopt;
// One page is enough and more would be worse: the reconciler only ever
// asks about orders inside their payment window, so a credit old enough
// to fall off this page is old enough to be settled by hand anyway.
// Paging the whole account history every minute would also spend the
// per-method rate limit on data nothing reads.
const auto doc = Call("GET", std::format(
"/v1/user/{}/monetary-account/{}/payment?count={}", userId_, accountId_,
count_), {});
if (!doc) return std::nullopt;
return ParseBunqPayments(raw_);
}
std::string_view Name() const override { return "bunq"; }
private:
// ── state, so onboarding happens once ─────────────────────────────
void LoadState() {
std::ifstream in(statePath_, std::ios::binary);
if (!in) return;
std::ostringstream buf;
buf << in.rdbuf();
const auto doc = Json::Parse(buf.str());
if (!doc || !doc->IsObject()) return;
privateKeyPem_ = std::string(doc->Str("private_key_pem"));
installationToken_ = std::string(doc->Str("installation_token"));
deviceRegistered_ = doc->Bool("device_registered");
sessionToken_ = std::string(doc->Str("session_token"));
userId_ = doc->Int("user_id");
accountId_ = doc->Int("account_id");
}
bool SaveState() {
std::ofstream out(statePath_, std::ios::trunc | std::ios::binary);
if (!out) {
std::println(std::cerr, "bunq: cannot write context {}", statePath_.string());
return false;
}
out << std::format(
R"({{"private_key_pem":"{}","installation_token":"{}",)"
R"("device_registered":{},"session_token":"{}","user_id":{},)"
R"("account_id":{}}})",
EscB(privateKeyPem_), EscB(installationToken_), deviceRegistered_,
EscB(sessionToken_), userId_, accountId_);
out.flush();
// The file holds a private key. Narrow it even though the directory
// should already be private: defence in depth costs one syscall.
std::error_code ec;
std::filesystem::permissions(statePath_,
std::filesystem::perms::owner_read
| std::filesystem::perms::owner_write,
ec);
return static_cast<bool>(out);
}
// ── the keypair bunq's installation call demands ──────────────────
bool EnsureKeypair() {
if (!privateKeyPem_.empty()) return LoadKey();
EVP_PKEY* raw = EVP_RSA_gen(2048);
if (!raw) return false;
key_.reset(raw);
BIO* bio = BIO_new(BIO_s_mem());
if (!bio) return false;
if (PEM_write_bio_PrivateKey(bio, key_.get(), nullptr, nullptr, 0,
nullptr, nullptr) != 1) {
BIO_free(bio);
return false;
}
char* data = nullptr;
const long len = BIO_get_mem_data(bio, &data);
privateKeyPem_.assign(data, static_cast<std::size_t>(len));
BIO_free(bio);
return SaveState();
}
bool LoadKey() {
if (key_) return true;
BIO* bio = BIO_new_mem_buf(privateKeyPem_.data(),
static_cast<int>(privateKeyPem_.size()));
if (!bio) return false;
EVP_PKEY* raw = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
BIO_free(bio);
if (!raw) return false;
key_.reset(raw);
return true;
}
std::string PublicKeyPem() {
if (!LoadKey()) return {};
BIO* bio = BIO_new(BIO_s_mem());
if (!bio) return {};
if (PEM_write_bio_PUBKEY(bio, key_.get()) != 1) {
BIO_free(bio);
return {};
}
char* data = nullptr;
const long len = BIO_get_mem_data(bio, &data);
std::string pem(data, static_cast<std::size_t>(len));
BIO_free(bio);
return pem;
}
// bunq stopped REQUIRING body signatures years ago, but the keypair has to
// exist for installation anyway and a signed request is valid whether or
// not the server checks, so every body is signed.
std::string SignBody(std::string_view body) {
if (!LoadKey()) return {};
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
if (!ctx) return {};
std::string out;
do {
if (EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr,
key_.get()) != 1) break;
std::size_t len = 0;
if (EVP_DigestSign(ctx, nullptr, &len,
reinterpret_cast<const unsigned char*>(body.data()),
body.size()) != 1) break;
std::vector<unsigned char> sig(len);
if (EVP_DigestSign(ctx, sig.data(), &len,
reinterpret_cast<const unsigned char*>(body.data()),
body.size()) != 1) break;
sig.resize(len);
out = Base64B(sig);
} while (false);
EVP_MD_CTX_free(ctx);
return out;
}
// ── transport ─────────────────────────────────────────────────────
std::optional<Json::Value> DoCall(std::string_view method, const std::string& path,
const std::string& body,
const std::string& authToken,
std::string* statusOut = nullptr) {
try {
if (!client_) {
client_ = std::make_unique<Crafter::ClientHTTP1>(
kHost, static_cast<std::uint16_t>(443),
Crafter::TLSClientCredentials{});
}
Crafter::HTTPRequest req;
req.method = std::string(method);
req.path = path;
req.authority = kHost;
req.body = body;
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
req.headers["cache-control"] = "no-cache";
req.headers["x-bunq-client-request-id"] = RandomHexB(4);
req.headers["x-bunq-geolocation"] = "0 0 0 0 000";
req.headers["x-bunq-language"] = "en_US";
req.headers["x-bunq-region"] = "nl_NL";
if (!body.empty()) {
req.headers["content-type"] = "application/json";
const std::string sig = SignBody(body);
if (!sig.empty()) req.headers["x-bunq-client-signature"] = sig;
}
if (!authToken.empty()) {
req.headers["x-bunq-client-authentication"] = authToken;
}
const Crafter::HTTPResponse res = client_->Send(req);
if (statusOut) *statusOut = res.status;
if (res.status.size() != 3 || res.status[0] != '2') {
// 429 gets named, because the cure is different from every
// other failure: bunq allows only a few reads per second per
// method, and setup endpoints as few as ten per DAY.
if (res.status == "429") {
std::println(std::cerr,
"bunq: {} {} -> 429 rate limited; reads are capped at "
"a few per second and setup calls at ~10/day, so back "
"off rather than retrying in a loop", method, path);
} else {
std::println(std::cerr, "bunq: {} {} -> {} {}", method, path,
res.status, res.body.substr(0, 200));
}
return std::nullopt;
}
// Kept so the caller can re-parse into domain types without this
// layer knowing about them.
raw_ = res.body;
auto doc = Json::Parse(res.body);
if (!doc) return std::nullopt;
return std::move(*doc);
} catch (const std::exception& e) {
std::println(std::cerr, "bunq: {} {} failed: {}", method, path, e.what());
client_.reset(); // dial fresh next time
return std::nullopt;
}
}
// A session call, with ONE automatic re-session on 401. Sessions expire
// server-side on a schedule the bunq app controls, so expiry is routine
// and must not surface as a payment failure.
std::optional<Json::Value> Call(std::string_view method, const std::string& path,
const std::string& body) {
std::string status;
auto doc = DoCall(method, path, body, sessionToken_, &status);
if (!doc && status == "401") {
sessionToken_.clear();
if (!EnsureSession()) return std::nullopt;
doc = DoCall(method, path, body, sessionToken_, &status);
}
return doc;
}
// ── onboarding, each step at most once ────────────────────────────
bool EnsureSession() {
if (!loaded_) { LoadState(); loaded_ = true; }
if (apiKey_.empty()) {
std::println(std::cerr, "bunq: no API key configured");
return false;
}
if (!EnsureKeypair()) return false;
if (installationToken_.empty()) {
const std::string body = std::format(R"({{"client_public_key":"{}"}})",
EscB(PublicKeyPem()));
const auto doc = DoCall("POST", "/v1/installation", body, {});
if (!doc) return false;
const Json::Value* token = InResponse(*doc, "Token");
if (!token) return false;
installationToken_ = std::string(token->Str("token"));
if (installationToken_.empty()) return false;
SaveState();
}
if (!deviceRegistered_) {
// permitted_ips decides what a leaked key is worth. bunq has no
// read-only scope, so this key can move money: pinning it to the
// one address that should ever use it is the difference between a
// leak being survivable and being catastrophic. "*" is accepted
// but announced, because silently unpinning a payment-capable
// credential is exactly the kind of default nobody revisits.
if (permittedIps_ == "*") {
std::println(std::cerr,
"bunq: registering this device with permitted_ips=* — a "
"leaked key would then work from anywhere. Set "
"BUNQ_PERMITTED_IPS to this machine's egress address to "
"pin it (the registration is once-only, so changing it "
"later means deleting the context file).");
}
std::string ipList;
for (const auto part : std::views::split(permittedIps_, ',')) {
const std::string_view ip(part.begin(), part.end());
if (ip.empty()) continue;
if (!ipList.empty()) ipList += ",";
ipList += std::format("\"{}\"", EscB(ip));
}
if (ipList.empty()) ipList = "\"*\"";
const std::string body = std::format(
R"({{"description":"catcrafts.net credit reader","secret":"{}",)"
R"("permitted_ips":[{}]}})", EscB(apiKey_), ipList);
const auto doc = DoCall("POST", "/v1/device-server", body,
installationToken_);
if (!doc) return false;
deviceRegistered_ = true;
SaveState();
}
if (sessionToken_.empty() || userId_ == 0) {
const std::string body = std::format(R"({{"secret":"{}"}})", EscB(apiKey_));
const auto doc = DoCall("POST", "/v1/session-server", body,
installationToken_);
if (!doc) return false;
const Json::Value* token = InResponse(*doc, "Token");
if (!token) return false;
sessionToken_ = std::string(token->Str("token"));
// Which user object comes back depends on the account type, so try
// each rather than assuming this is a company account.
for (const std::string_view k : { "UserPerson", "UserCompany", "UserApiKey" }) {
if (const Json::Value* u = InResponse(*doc, k)) {
userId_ = u->Int("id");
break;
}
}
if (sessionToken_.empty() || userId_ == 0) return false;
SaveState();
}
if (accountId_ == 0) {
const auto doc = Call("GET", std::format(
"/v1/user/{}/monetary-account?count=25", userId_), {});
if (!doc) return false;
const Json::Value* resp = doc->Find("Response");
if (!resp || !resp->IsArray()) return false;
// With more than one account, guessing is how the shop ends up
// reconciling against savings. Name the wanted one by IBAN when
// there is a choice, and refuse rather than pick.
std::vector<std::pair<std::int64_t, std::string>> active;
for (const Json::Value& item : resp->array) {
const Json::Value* acc = item.Find("MonetaryAccountBank");
if (!acc || !acc->IsObject()) continue;
if (acc->Str("status") != "ACTIVE") continue;
std::string iban;
if (const Json::Value* aliases = acc->Find("alias");
aliases && aliases->IsArray()) {
for (const Json::Value& a : aliases->array) {
if (a.IsObject() && a.Str("type") == "IBAN") {
iban = std::string(a.Str("value"));
break;
}
}
}
active.emplace_back(acc->Int("id"), std::move(iban));
}
if (active.empty()) {
std::println(std::cerr, "bunq: no active account found");
return false;
}
if (!wantIban_.empty()) {
for (const auto& [id, iban] : active) {
if (Matches(iban, wantIban_)) { accountId_ = id; break; }
}
if (accountId_ == 0) {
std::println(std::cerr,
"bunq: no active account matches TRANSFER_IBAN; the "
"key sees {} account(s)", active.size());
return false;
}
} else if (active.size() == 1) {
accountId_ = active.front().first;
} else {
std::println(std::cerr,
"bunq: this key sees {} active accounts and no "
"TRANSFER_IBAN was given to choose between them — "
"refusing to guess which one the shop is paid into",
active.size());
return false;
}
SaveState();
}
return true;
}
// IBANs are compared ignoring spacing and case: what a human pastes into
// configuration and what the API returns differ cosmetically far more
// often than they differ in substance.
static bool Matches(std::string_view a, std::string_view b) {
const auto fold = [](std::string_view s) {
std::string out;
for (const char c : s) {
if (c == ' ' || c == '\t') continue;
out += static_cast<char>(c >= 'a' && c <= 'z' ? c - 'a' + 'A' : c);
}
return out;
};
return !a.empty() && fold(a) == fold(b);
}
struct PkeyDeleter {
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
};
static constexpr const char* kHost = "api.bunq.com";
std::string apiKey_;
std::filesystem::path statePath_;
std::string permittedIps_;
int count_ = 50;
std::string wantIban_;
std::mutex mutex_;
std::unique_ptr<Crafter::ClientHTTP1> client_;
std::unique_ptr<EVP_PKEY, PkeyDeleter> key_;
std::string raw_;
bool loaded_ = false;
std::string privateKeyPem_;
std::string installationToken_;
bool deviceRegistered_ = false;
std::string sessionToken_;
std::int64_t userId_ = 0;
std::int64_t accountId_ = 0;
};
} // namespace
std::unique_ptr<CreditSource> MakeBunqCreditSource(const BunqConfig& config) {
if (config.apiKey.empty()) return nullptr;
if (config.statePath.empty()) {
std::println(std::cerr, "bunq: no context path given");
return nullptr;
}
return std::make_unique<BunqCreditSource>(
config.apiKey, config.statePath,
config.permittedIps.empty() ? std::string("*") : config.permittedIps,
config.count > 0 ? config.count : 50, config.iban);
}
} // namespace Catcrafts::Server

View file

@ -203,7 +203,7 @@ std::optional<std::int64_t> Pow10(int n) {
// so anything bigger is a broken or hostile node rather than a large balance,
// and the one thing it must not do is satisfy the covering comparison.
// Exported so the self-test can drive it with canned RPC bodies, the same way
// ParseMolliePayment is driven — the HTTP around it is thin, the decoding is
// ParseBunqPayments is driven — the HTTP around it is thin, the decoding is
// where a mistake would cost money.
// True when the reply carries exactly the numeric id we sent. Absent or
// non-numeric is false: an answer that will not say which question it belongs
@ -610,7 +610,7 @@ public:
// chain — and the reconciler walks EVERY awaiting order per sweep,
// each taking the same lock, while a real buyer's CreateLink (which
// needs the mutex only to hand out a pool address, no network at all)
// queued behind the whole procession. The Mollie side of this file's
// queued behind the whole procession. The bank side of this file's
// sibling had the identical incident; see the arrival-poll note in
// Catcrafts.Server-Http.cpp.
const std::optional<PayIdParts> parts = SplitPayId(payId);

View file

@ -164,7 +164,8 @@ HTTPResponse RenderPage(std::string_view target) {
const ShippingTable ship = CurrentShippingTable();
const Views::RenderedPage page =
Views::RenderProduct(*product, gContent.rates, ship.perCountry,
{}, {}, CryptoPaymentAvailable());
{}, {}, CryptoPaymentAvailable(),
BankPaymentAvailable());
HTTPResponse res;
res.status = std::to_string(page.status);
ApplyPageHeaders(res, "text/html; charset=utf-8",
@ -266,7 +267,7 @@ HTTPResponse RenderPage(std::string_view target) {
// ArrivalPollAllowed. A reload past that renders from the ledger and
// lets the reconciler do its job, which is the whole point of having
// one. The interval is the ORDER'S rail's, so a crypto order is not
// paced by Mollie's cadence or the other way round.
// paced by the bank rail's cadence or the other way round.
if (order->status == "awaiting_payment") {
if (const PaymentRail* rail = gRails.For(order->payChoice);
rail && ArrivalPollAllowed(order->token, rail->PollInterval())) {
@ -314,6 +315,15 @@ HTTPResponse RenderPage(std::string_view target) {
OrderCryptoPay pay;
pay.address = instr->address;
pay.amount = instr->amount;
// Set only by bank-transfer rails, and what the renderer
// switches on. The structured reference is derived from the
// order token rather than carried by the rail, so the two
// forms the page prints cannot disagree with each other.
pay.beneficiary = instr->beneficiary;
pay.bic = instr->bic;
if (!instr->beneficiary.empty()) {
pay.structuredReference = CreditorReferenceFromToken(order->token);
}
const std::int64_t now =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
@ -650,7 +660,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
ApplyPageHeaders(res, "text/html; charset=utf-8", false, true);
const Views::RenderedPage page = Views::RenderProduct(
*product, gContent.rates, shipTable.perCountry, errors, prev,
CryptoPaymentAvailable());
CryptoPaymentAvailable(), BankPaymentAvailable());
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product),
Views::RenderFooter(), {}, gCssHref);
return res;
@ -861,7 +871,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
// Straight to the payment page — the buyer clicked "buy", not "read an
// interim status page". The order page stays the receipt/status URL that
// Mollie redirects back to afterwards.
// a hosted provider would redirect back to afterwards.
res.status = "303";
res.headers["location"] = order.payUrl;
res.headers["cache-control"] = "no-store";
@ -874,7 +884,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
// The gate on the order page's arrival poll.
//
// Rendering /order/<token> asks the provider whether the payment landed, so a
// buyer redirected back from Mollie sees "paid" immediately instead of an
// buyer returning to this page sees "paid" immediately instead of an
// alarming "awaiting payment" that flips ten seconds later. That is a good
// thing to do once. The problem was that it happened on EVERY render: an
// outbound HTTPS round trip, on the request thread, holding the rail's mutex,
@ -882,7 +892,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
//
// The hole that closes: an attacker places one order — their own, so no token
// guessing is involved — and then reloads it in a loop. Every reload spent a
// live Mollie API call against the shop's key, and because CreateLink shares
// live call against the shop's account, and because CreateLink shares
// that same mutex, real buyers' checkouts queued behind the flood. The
// listener is thread-per-connection with no cap, so the blocked threads piled
// up as well.
@ -1056,6 +1066,8 @@ void ConfigurePayments(PaymentRails rails, std::string redirectBase) {
bool CryptoPaymentAvailable() { return gRails.crypto != nullptr; }
bool BankPaymentAvailable() { return gRails.bank != nullptr; }
namespace {
// The reconciler: the ONLY thing that moves an order to paid.

View file

@ -1,326 +0,0 @@
/*
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 — 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
// 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 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 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.
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<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;
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 {
// ── 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;
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) rather than the hosted button
// that slot never shows in production. The bank fake keeps the button,
// mirroring Mollie. 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_;
};
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
// 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");
}
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
// "eurc" is the one mode that can fail to construct for a reason other than
// a typo: its chains file or address pool may not load. It returns nullptr
// there, which main reports as an unknown rail — see the note in main about
// why that message names the files.
if (config.mode == "eurc") return MakeEurcRail(config);
return nullptr; // "off"
}
} // namespace Catcrafts::Server

View file

@ -359,4 +359,66 @@ std::string ReferenceFromToken(std::string_view token) {
return out;
}
namespace {
// ISO 7064 mod-97-10 over an alphanumeric string, the same arithmetic that
// checks an IBAN: letters become two digits (A=10 … Z=35), everything is read
// as one long decimal number, and the remainder mod 97 is taken. Folded
// incrementally so no big-integer type is needed — the running value never
// exceeds 97*100+35, which fits an int comfortably.
//
// Returns nullopt on any character that is not [0-9A-Z], because silently
// skipping one would make two different references check out identically.
std::optional<int> Mod97(std::string_view s) {
int rem = 0;
for (const char c : s) {
if (c >= '0' && c <= '9') {
rem = (rem * 10 + (c - '0')) % 97;
} else if (c >= 'A' && c <= 'Z') {
const int v = c - 'A' + 10;
rem = (rem * 100 + v) % 97;
} else {
return std::nullopt;
}
}
return rem;
}
// The body an RF reference carries: the CC- reference with its hyphen dropped,
// because ISO 11649 permits only alphanumerics. "CC-2B6457" -> "CC2B6457".
std::string ReferenceBody(std::string_view token) {
const std::string human = ReferenceFromToken(token);
std::string body;
body.reserve(human.size());
for (const char c : human) {
if (c != '-') body += c;
}
return body;
}
} // namespace
std::string CreditorReferenceFromToken(std::string_view token) {
const std::string body = ReferenceBody(token);
// The check digits are computed over the body followed by "RF00" — the
// standard's rearrangement, prefix and placeholder moved to the end.
const std::optional<int> rem = Mod97(body + "RF00");
if (!rem) return {}; // unreachable for our own token alphabet
const int check = 98 - *rem;
return std::format("RF{:02}{}", check, body);
}
bool IsValidCreditorReference(std::string_view s) {
// "RF" + 2 check digits + 1..21 body characters.
if (s.size() < 5 || s.size() > 25) return false;
if (s[0] != 'R' || s[1] != 'F') return false;
if (s[2] < '0' || s[2] > '9' || s[3] < '0' || s[3] > '9') return false;
// Rearranged the same way the generator does it, then the whole thing must
// leave a remainder of exactly 1 — that is what mod-97-10 verification is.
std::string rearranged(s.substr(4));
rearranged += s.substr(0, 4);
const std::optional<int> rem = Mod97(rearranged);
return rem && *rem == 1;
}
} // namespace Catcrafts::Server

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

View file

@ -0,0 +1,448 @@
/*
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 bank-transfer rail: the buyer sends a plain SEPA transfer to our own
// IBAN quoting the order's creditor reference, and the order settles when a
// credit carrying that reference shows up on the account.
//
// Why this rail exists at all: after the bank payment provider closed the
// shop's account on 2026-08-20 with no appeal, the lesson taken was not "find
// a better provider" but "stop putting a party with unilateral offboarding
// power in the payment path". A transfer to our own account has no such party.
// The bank can still close the account — that is unavoidable, every euro has
// to land somewhere — but it cannot decline a payment method while leaving the
// business running, which is what actually happened.
//
// Shape: self-hosted, like the EURC rail and unlike the hosted ones. There is
// no checkout to redirect to, so CreateLink makes no network call at all and
// Instructions() is what the buyer actually acts on. Three facts go on the
// order page — IBAN, amount, reference — and the beneficiary name, which is
// load-bearing rather than decorative (see PayInstructions).
//
// Trust direction is the same rule as every other rail: an order becomes paid
// only when the rail's own authenticated read of the account says a covering
// credit arrived. Nothing the buyer tells us is evidence, including "I paid".
//
// The bank is behind a CreditSource so it is swappable. That is a deliberate
// hedge and not speculative generality: the account being reconciled is also
// the shop's own bank account, so the day that relationship ends, the rail
// must survive with one new adapter rather than a rewrite.
module;
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
namespace Catcrafts::Server {
namespace {
// Reduce remittance text to the alphabet a reference lives in: upper-case
// letters and digits, everything else dropped. Banks are free to reformat the
// field — spacing, punctuation, case — and a payer types it by hand, so
// comparing raw strings would fail on cosmetics. Dropping separators is also
// what makes one needle match every form the payer might have used.
std::string Fold(std::string_view s) {
std::string out;
out.reserve(s.size());
for (const char c : s) {
if (c >= '0' && c <= '9') out += c;
else if (c >= 'A' && c <= 'Z') out += c;
else if (c >= 'a' && c <= 'z') out += static_cast<char>(c - 'a' + 'A');
}
return out;
}
// Minimal JSON string escaping for the credits file. The reference field is
// text a stranger typed into their banking app, so a raw quote or newline in
// it would corrupt the line-per-record format and silently truncate the
// evidence a settlement decision reads.
std::string EscT(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;
}
// Does the folded remittance text carry a SECOND thing shaped like one of our
// references, beyond the one at `foundAt`? "CC" followed by six characters of
// the token alphabet is the shape. Used only to raise the advisory ambiguous
// flag: one transfer quoting two orders cannot be attributed per-order, and a
// human should look rather than two orders settling on the same money.
bool HasOtherReferenceShape(std::string_view folded, std::size_t foundAt,
std::size_t needleLen) {
for (std::size_t i = 0; i + 8 <= folded.size(); ++i) {
if (i >= foundAt && i < foundAt + needleLen) continue; // the known one
if (folded[i] != 'C' || folded[i + 1] != 'C') continue;
bool hex = true;
for (std::size_t j = i + 2; j < i + 8; ++j) {
const char c = folded[j];
const bool isHex = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F');
if (!isHex) { hex = false; break; }
}
if (hex) return true;
}
return false;
}
} // namespace
TransferMatch MatchCredits(std::span<const BankCredit> credits,
std::string_view reference) {
TransferMatch out;
const std::string needle = Fold(reference);
// An empty needle would match every credit on the account. Refuse rather
// than settle the whole ledger from one payment.
if (needle.size() < 4) return out;
for (const BankCredit& c : credits) {
// Only money coming IN can pay for something. A negative amount is an
// outgoing payment that happens to quote the reference — a refund we
// sent, most likely — and counting it would be a refund paying for
// the order it refunded.
if (c.amountMinor <= 0) continue;
const std::string folded = Fold(c.reference);
const std::size_t at = folded.find(needle);
if (at == std::string::npos) continue;
out.paidMinor += c.amountMinor;
++out.count;
out.method = c.method;
if (HasOtherReferenceShape(folded, at, needle.size())) out.ambiguous = true;
}
return out;
}
namespace {
class FileCreditSource final : public CreditSource {
public:
explicit FileCreditSource(std::filesystem::path path) : path_(std::move(path)) {}
std::optional<std::vector<BankCredit>> Recent() override {
std::error_code ec;
if (!std::filesystem::exists(path_, ec)) {
// Not an error: a shop that has taken no transfers yet has no
// file. Distinct from a read failure below, which IS unknown.
return std::vector<BankCredit>{};
}
std::ifstream in(path_, std::ios::binary);
if (!in) {
std::println(std::cerr, "transfer: cannot read credits file {}",
path_.string());
return std::nullopt;
}
std::vector<BankCredit> out;
std::string line;
int lineNo = 0;
while (std::getline(in, line)) {
++lineNo;
if (line.empty()) continue;
const auto doc = Json::Parse(line);
if (!doc || !doc->IsObject()) {
// One malformed line must not silently shrink the evidence a
// settlement decision rests on, so say so and keep the rest:
// dropping the whole file would strand every paid order.
std::println(std::cerr, "transfer: {}:{} is not a JSON object, skipped",
path_.string(), lineNo);
continue;
}
BankCredit c;
c.id = std::string(doc->Str("id"));
c.reference = std::string(doc->Str("reference"));
c.method = std::string(doc->Str("method"));
if (c.method.empty()) c.method = "sepa";
c.amountMinor = doc->Int("amount_minor");
out.push_back(std::move(c));
}
return out;
}
std::string_view Name() const override { return "file"; }
private:
std::filesystem::path path_;
};
class TransferRail final : public PaymentRail {
public:
TransferRail(RailConfig cfg, std::unique_ptr<CreditSource> credits)
: cfg_(std::move(cfg)), credits_(std::move(credits)) {}
// No network call, and nothing to reserve: unlike the EURC rail, which
// burns a receiving address per order, a transfer reuses one IBAN forever
// and the reference is what separates orders. So this cannot fail, which
// is worth noticing — checkout can never lose a sale to a provider being
// down, because there is no provider.
std::optional<PaymentLink> CreateLink(std::int64_t,
const std::string& description,
const std::string& redirectUrl) override {
PaymentLink link;
// payId carries the reference AND the deadline, the same trick the
// EURC rail uses: CheckPaid is given only the id and the amount, and
// this rail has to know its own window. Both halves are wanted in the
// ledger anyway.
const std::int64_t deadline = NowUnix() + WindowSeconds();
link.payId = std::format("{}@{}", ReferenceOf(description), deadline);
// Self-hosted: there is nowhere to send the buyer but the order page,
// which is where Instructions() renders.
link.payUrl = redirectUrl;
return link;
}
std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) override {
const auto [reference, deadline] = SplitPayId(payId);
if (reference.empty()) {
// Not one of ours. This is the shape a ledger line from the OLD
// hosted provider has, and this rail genuinely cannot decide it:
// Pending lets the reconciler age it out instead of pretending to
// know it is dead.
return PaidStatus{ PayState::Pending, {} };
}
const std::optional<std::vector<BankCredit>> all = Fetch();
if (!all) return std::nullopt; // unknown, retry — never "unpaid"
const TransferMatch m = MatchCredits(*all, reference);
if (m.ambiguous) {
std::println(std::cerr,
"transfer: {} matched a credit that also quotes another "
"order reference — settle this one by hand", reference);
}
PaidStatus out;
out.method = m.method.empty() ? std::string("sepa") : m.method;
if (m.paidMinor >= expectedMinor) {
out.state = PayState::Paid;
return out;
}
// Money arrived but does not cover the order. The buyer is told to
// send the difference to the same IBAN with the same reference, which
// is why partials stay Pending and keep accumulating rather than
// failing: the next credit adds to this sum.
out.seen = m.paidMinor > 0;
if (deadline > 0 && NowUnix() > deadline) {
// The window closed. NOT bounced money: the IBAN is ours and a
// late transfer still lands there — say so in the log, because the
// operator settling it by hand is the one who needs to know.
std::println(std::cerr,
"transfer: {} lapsed with {} of {} received; the IBAN "
"remains ours, a late payment still arrives and is "
"settled with --mark-paid", reference,
Money::FormatMinor(m.paidMinor),
Money::FormatMinor(expectedMinor));
out.state = PayState::Dead;
return out;
}
out.state = PayState::Pending;
return out;
}
std::optional<PayInstructions> Instructions(const std::string& payId,
std::int64_t totalMinor) const override {
const auto [reference, deadline] = SplitPayId(payId);
if (reference.empty() || totalMinor <= 0) return std::nullopt;
PayInstructions out;
out.address = cfg_.transferIban;
out.beneficiary = cfg_.transferBeneficiary;
out.bic = cfg_.transferBic;
out.reference = reference;
out.amount = Money::FormatMinor(totalMinor);
out.deadlineUnix = deadline;
return out;
}
std::string_view Name() const override { return "transfer"; }
// A transfer does not arrive in seconds even under instant payments, and
// the bank's API is rate limited per method — the shared cache below is
// what keeps a sweep of many orders down to one request, but a slow
// cadence is the other half of being a good citizen there. Configurable so
// a suite reading a local file can run at a speed a test can wait for.
std::chrono::seconds PollInterval() const override {
return std::chrono::seconds(cfg_.transferPollSeconds > 0
? cfg_.transferPollSeconds : 60);
}
private:
static std::int64_t NowUnix() {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
std::int64_t WindowSeconds() const {
const int hours = cfg_.transferWindowHours > 0 ? cfg_.transferWindowHours
: 14 * 24;
return static_cast<std::int64_t>(hours) * 3600;
}
// "CC-2B6457 catcrafts.net" -> "CC-2B6457". The description checkout
// builds starts with the reference; take the first token so the payId
// stays short and the '@' split below cannot be confused by a space.
static std::string ReferenceOf(std::string_view description) {
const std::size_t sp = description.find(' ');
return std::string(sp == std::string_view::npos ? description
: description.substr(0, sp));
}
static std::pair<std::string, std::int64_t> SplitPayId(std::string_view payId) {
const std::size_t at = payId.rfind('@');
if (at == std::string_view::npos) return { {}, 0 };
const std::string_view ref = payId.substr(0, at);
// The reference must look like ours before this rail claims the order.
if (ref.size() < 4 || !ref.starts_with("CC")) return { {}, 0 };
std::int64_t deadline = 0;
const std::string_view tail = payId.substr(at + 1);
const auto [ptr, ec] = std::from_chars(tail.data(), tail.data() + tail.size(),
deadline);
if (ec != std::errc{} || ptr != tail.data() + tail.size()) return { {}, 0 };
return { std::string(ref), deadline };
}
// ONE bank read per sweep, shared by every order in it. Not an
// optimisation: the bank rate-limits reads to a few per second, and the
// reconciler asks per order, so without this a shop with a dozen open
// orders would throttle itself and the answers would start coming back as
// "unknown" — which is indistinguishable, from the outside, from a shop
// whose payments have stopped working.
//
// The window only has to span ONE sweep, not one poll interval. Setting it
// to PollInterval() is the tempting mistake and it doubles the worst-case
// wait: a sweep that lands just after a fetch would answer every order
// from data already a minute old, so money could sit visible at the bank
// for two minutes before any order noticed. A few seconds is enough to
// collapse a sweep into a single request, and at one request per window
// the rate limit is nowhere in sight.
// Never longer than the poll interval itself: at the default 60 s cadence
// five seconds comfortably spans one sweep, but a rail polling every
// second would otherwise answer from data older than its own interval.
std::chrono::seconds CacheWindow() const {
return std::min(std::chrono::seconds(5), PollInterval());
}
std::optional<std::vector<BankCredit>> Fetch() {
std::lock_guard lock(mutex_);
const auto now = std::chrono::steady_clock::now();
if (cached_ && now - fetchedAt_ < CacheWindow()) return cached_;
std::optional<std::vector<BankCredit>> fresh = credits_->Recent();
if (!fresh) {
// Keep serving the last good answer rather than turning a blip
// into "unknown" for every order at once. Only when there has
// never been one does the caller get nullopt.
return cached_ ? cached_ : std::nullopt;
}
cached_ = std::move(fresh);
fetchedAt_ = now;
return cached_;
}
RailConfig cfg_;
std::unique_ptr<CreditSource> credits_;
std::mutex mutex_;
std::optional<std::vector<BankCredit>> cached_;
std::chrono::steady_clock::time_point fetchedAt_{};
};
} // namespace
std::unique_ptr<CreditSource> MakeFileCreditSource(std::filesystem::path path) {
return std::make_unique<FileCreditSource>(std::move(path));
}
std::optional<int> PullCreditsInto(CreditSource& source,
const std::filesystem::path& creditsPath) {
const std::optional<std::vector<BankCredit>> fresh = source.Recent();
if (!fresh) return std::nullopt;
// Which ids the file already holds. Append-only and deduplicated by the
// bank's own payment id, so running this twice — or on an overlapping
// window, which every run does — adds nothing the second time. That
// matters more than it sounds: the file is evidence for settling money,
// and a duplicated credit would double a payment and settle an order
// nobody paid twice for.
std::set<std::string> known;
{
std::ifstream in(creditsPath, std::ios::binary);
std::string line;
while (std::getline(in, line)) {
if (line.empty()) continue;
const auto doc = Json::Parse(line);
if (!doc || !doc->IsObject()) continue;
if (const std::string_view id = doc->Str("id"); !id.empty()) {
known.emplace(id);
}
}
}
std::string add;
int appended = 0;
for (const BankCredit& c : *fresh) {
if (c.id.empty() || known.contains(c.id)) continue;
add += std::format(
R"({{"id":"{}","reference":"{}","amount_minor":{},"method":"{}"}})"
"\n",
EscT(c.id), EscT(c.reference), c.amountMinor, EscT(c.method));
++appended;
}
if (appended == 0) return 0;
// Append rather than rewrite: the file may also hold lines an operator
// added by hand to settle something, and a rewrite would lose them.
std::ofstream out(creditsPath, std::ios::app | std::ios::binary);
if (!out) {
std::println(std::cerr, "transfer: cannot append to {}", creditsPath.string());
return std::nullopt;
}
out << add;
out.flush();
if (!out) {
std::println(std::cerr, "transfer: write to {} failed", creditsPath.string());
return std::nullopt;
}
return appended;
}
std::unique_ptr<PaymentRail> MakeTransferRail(const RailConfig& config,
std::unique_ptr<CreditSource> credits) {
if (!credits) return nullptr;
// The two facts the buyer is told. Without them the order page would
// render an incomplete instruction, which loses the money rather than the
// sale — refuse to start instead, the same way the EURC rail refuses
// without its chains file.
if (config.transferIban.empty()) {
std::println(std::cerr,
"transfer: TRANSFER_IBAN is not set — the order page would "
"have no account to name");
return nullptr;
}
if (config.transferBeneficiary.empty()) {
std::println(std::cerr,
"transfer: TRANSFER_BENEFICIARY is not set — Verification of "
"Payee shows the payer a mismatch warning without the exact "
"account-holder name");
return nullptr;
}
return std::make_unique<TransferRail>(config, std::move(credits));
}
} // namespace Catcrafts::Server

View file

@ -162,20 +162,24 @@ int main(int argc, char** argv) {
// directory is publicly served and wiped by rsync --delete each deploy.
std::filesystem::path ordersPath = "orders.jsonl";
// Payment rail selection, one slot per payment choice the buyer gets.
// Flags beat environment beats default, and the default for each slot
// is "the provider whose key is set, off otherwise" — so a box with no
// credentials serves the whole site minus checkout instead of refusing
// to start, and a box with only one key offers only that one method.
// Flags beat environment beats default, and a slot whose configuration
// is absent is simply off — so a box with nothing configured serves the
// whole site minus checkout instead of refusing to start, and a box
// with one rail configured offers only that one method.
//
// bank MOLLIE_API_KEY iDEAL, cards, transfer
// crypto EURC_CHAINS self-hosted EURC, no processor, no key
// bank TRANSFER_IBAN SEPA transfer to our own account
// crypto EURC_CHAINS self-hosted EURC, on our own addresses
//
// The crypto slot is selected by the presence of a chains FILE rather
// than a credential: the self-hosted rail has no credential, which is
// the feature.
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
// NEITHER slot is selected by a credential, and that is the point
// rather than an accident. Both rails are self-hosted, so each is
// selected by naming where the money lands: there is no provider to
// authenticate to, and therefore no key anyone can revoke. The shop
// ran on a hosted provider until 2026-08-20, when it closed the
// account after a risk review with no appeal and took every payment
// method with it. These two rails are the answer to that.
const char* transferIban = std::getenv("TRANSFER_IBAN");
const char* eurcChains = std::getenv("EURC_CHAINS");
std::string railMode = mollieKey && *mollieKey ? "mollie" : "off";
std::string railMode = transferIban && *transferIban ? "transfer" : "off";
std::string cryptoMode = eurcChains && *eurcChains ? "eurc" : "off";
std::filesystem::path railState;
std::string redirectBase = [] {
@ -306,24 +310,66 @@ int main(int argc, char** argv) {
}
}
auto build = [&](const std::string& mode, const char* key, const char* keyName,
// The bank-transfer rail's configuration. Credential-free like the EURC
// rail — there is no provider to authenticate to, only our own account
// to name — so TRANSFER_IBAN is what SELECTS it, for the same reason
// EURC_CHAINS selects the crypto slot: a value that appeared by
// convention rather than by intent must not switch a payment method on.
// The credits file is derived, though, because it is state rather than
// intent, and it hangs off the orders path like everything else.
std::filesystem::path transferCreditsPath;
if (const char* v = std::getenv("TRANSFER_CREDITS"); v && *v) {
transferCreditsPath = v;
} else {
transferCreditsPath = ordersPath;
transferCreditsPath += ".transfer-credits.jsonl";
}
int transferPollSeconds = 60;
if (const char* v = std::getenv("TRANSFER_POLL_SECONDS"); v && *v) {
const std::string_view sv(v);
int parsed = 0;
if (std::from_chars(sv.data(), sv.data() + sv.size(), parsed).ec == std::errc{}
&& parsed > 0 && parsed <= 3600) {
transferPollSeconds = parsed;
} else {
std::println(std::cerr,
"catcrafts-server: TRANSFER_POLL_SECONDS='{}' is not a "
"sane second count — refusing to start", sv);
return 2;
}
}
int transferWindowHours = 14 * 24;
if (const char* v = std::getenv("TRANSFER_WINDOW_HOURS"); v && *v) {
const std::string_view s(v);
int parsed = 0;
if (std::from_chars(s.data(), s.data() + s.size(), parsed).ec == std::errc{}
&& parsed > 0 && parsed <= 24 * 90) {
transferWindowHours = parsed;
} else {
std::println(std::cerr,
"catcrafts-server: TRANSFER_WINDOW_HOURS='{}' is not a "
"sane hour count — refusing to start", s);
return 2;
}
}
auto build = [&](const std::string& mode,
std::unique_ptr<Server::PaymentRail>& out) -> bool {
Server::RailConfig cfg;
cfg.mode = mode;
cfg.apiKey = key ? key : "";
cfg.statePath = railState;
cfg.redirectBase = redirectBase;
cfg.eurcChainsPath = eurcChainsPath;
cfg.eurcPoolPath = eurcPoolPath;
cfg.eurcWindowHours = eurcWindowHours;
const bool needsKey = mode == "mollie";
if (needsKey && cfg.apiKey.empty()) {
std::println(std::cerr,
"catcrafts-server: rail '{}' selected but {} is not set — "
"refusing to start with a rail that cannot work",
mode, keyName);
return false;
if (const char* v = std::getenv("TRANSFER_IBAN"); v) cfg.transferIban = v;
if (const char* v = std::getenv("TRANSFER_BENEFICIARY"); v) {
cfg.transferBeneficiary = v;
}
if (const char* v = std::getenv("TRANSFER_BIC"); v) cfg.transferBic = v;
cfg.transferCreditsPath = transferCreditsPath;
cfg.transferPollSeconds = transferPollSeconds;
cfg.transferWindowHours = transferWindowHours;
out = Server::MakeRail(cfg);
// "off" is a legitimate choice and yields no rail; a mode nobody
// recognises silently would too, which is how a typo becomes a
@ -333,7 +379,7 @@ int main(int argc, char** argv) {
// and must not be answered the same way (see below).
if (!out && mode != "off") {
static constexpr std::string_view kKnown[] = {
"mollie", "eurc", "fake", "fake-crypto"
"eurc", "transfer", "fake", "fake-crypto"
};
const bool known = std::ranges::find(kKnown, mode) != std::end(kKnown);
if (!known) {
@ -373,10 +419,10 @@ int main(int argc, char** argv) {
};
Server::PaymentRails rails;
if (!build(railMode, mollieKey, "MOLLIE_API_KEY", rails.bank)) return 2;
if (!build(railMode, rails.bank)) return 2;
// The crypto slot carries no credential at all; what it needs instead
// rode in on cfg.eurc* above.
if (!build(cryptoMode, nullptr, "", rails.crypto)) return 2;
if (!build(cryptoMode, rails.crypto)) return 2;
Server::ConfigurePayments(std::move(rails), redirectBase);
@ -413,6 +459,78 @@ int main(int argc, char** argv) {
return Server::Serve(port);
}
// --pull-credits: read the bank account once and append anything new to the
// credits file the transfer rail settles from. Prints how many arrived.
//
// A SEPARATE ENTRY POINT ON PURPOSE, and the reason is the whole point of
// the design. A bunq API key can initiate payments — bunq has no read-only
// scope — so the project's rule is that it never lives on the public host.
// Run this on a trusted machine on a timer, ship the credits file over, and
// the server settles orders while holding no credential that can move a
// cent. Configuring BUNQ_API_KEY on the server works too and is simpler,
// but it is strictly worse and this program will say so when it starts.
//
// catcrafts-server --pull-credits [--orders FILE] [--credits FILE]
if (!args.empty() && args[0] == "--pull-credits") {
std::filesystem::path ordersPath = "orders.jsonl";
std::filesystem::path creditsPath;
std::filesystem::path statePath;
for (std::size_t i = 1; i < args.size(); ++i) {
const std::string_view a = args[i];
auto next = [&]() -> std::string {
return (i + 1 < args.size()) ? std::string(args[++i]) : std::string{};
};
if (a == "--orders") ordersPath = next();
else if (a == "--credits") creditsPath = next();
else if (a == "--state") statePath = next();
}
if (creditsPath.empty()) {
if (const char* v = std::getenv("TRANSFER_CREDITS"); v && *v) {
creditsPath = v;
} else {
creditsPath = ordersPath;
creditsPath += ".transfer-credits.jsonl";
}
}
if (statePath.empty()) {
if (const char* v = std::getenv("BUNQ_STATE"); v && *v) {
statePath = v;
} else {
statePath = ordersPath;
statePath += ".bunq-context.json";
}
}
Server::BunqConfig bunq;
if (const char* v = std::getenv("BUNQ_API_KEY"); v) bunq.apiKey = v;
if (const char* v = std::getenv("TRANSFER_IBAN"); v) bunq.iban = v;
if (const char* v = std::getenv("BUNQ_PERMITTED_IPS"); v) bunq.permittedIps = v;
if (bunq.apiKey.empty()) {
std::println(std::cerr,
"catcrafts-server: BUNQ_API_KEY is not set — nothing to pull "
"with. This command reads the bank account; it never pays "
"anyone.");
return 2;
}
bunq.statePath = statePath;
std::unique_ptr<Server::CreditSource> source = Server::MakeBunqCreditSource(bunq);
if (!source) return 2;
const std::optional<int> added =
Server::PullCreditsInto(*source, creditsPath);
if (!added) {
// Distinct from "nothing new": a timer that cannot tell these
// apart will report success while the shop silently stops
// noticing payments.
std::println(std::cerr,
"catcrafts-server: could not read the account — nothing was "
"written; the credits file still holds what it did");
return 1;
}
std::println("pulled {} new credit(s) into {}", *added, creditsPath.string());
return 0;
}
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
// the escape hatch for a payment confirmed out-of-band (or a refund):
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
@ -478,15 +596,24 @@ int main(int argc, char** argv) {
std::println("catcrafts-server: --render <path> | --routes | --sitemap | --feed\n"
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
" [--rail=off|fake|mollie]\n"
" [--rail=off|fake|transfer]\n"
" [--crypto-rail=off|fake-crypto|eurc]\n"
" [--rail-state=FILE] [--redirect-base=URL]\n"
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
" --pull-credits [--orders FILE] [--credits FILE] [--state FILE]\n"
"\n"
"environment: MOLLIE_API_KEY (test_… or live_…) selects the bank rail.\n"
" EURC_CHAINS=FILE selects the self-hosted crypto rail (no key:\n"
" that is the point); EURC_POOL=FILE of receiving addresses,\n"
" default <orders>.eurc-addresses, EURC_WINDOW_HOURS (24).\n"
"environment: TRANSFER_IBAN selects the bank-transfer rail (no key: that is\n"
" the point), with TRANSFER_BENEFICIARY the account-holder name\n"
" EXACTLY as the bank holds it — payers' banks name-check it —\n"
" TRANSFER_CREDITS=FILE (default <orders>.transfer-credits.jsonl)\n"
" and TRANSFER_WINDOW_HOURS (336).\n"
" EURC_CHAINS=FILE selects the self-hosted crypto rail (also no\n"
" key); EURC_POOL=FILE of receiving addresses, default\n"
" <orders>.eurc-addresses, EURC_WINDOW_HOURS (24).\n"
" BUNQ_API_KEY + BUNQ_PERMITTED_IPS, BUNQ_STATE are for\n"
" --pull-credits. That key CAN MOVE MONEY (bunq has no read-only\n"
" scope), so run --pull-credits on a trusted machine and ship the\n"
" credits file here, rather than setting it on this host.\n"
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
return 0;

View file

@ -78,8 +78,9 @@ export namespace Catcrafts::Server {
// normalises before writing.
std::string payUrl; // the provider's hosted checkout link;
// for the EURC rail, the order page itself
std::string payId; // provider payment id ("tr_…" at Mollie,
// "<address>@<deadline>" at the EURC rail)
std::string payId; // the rail's own handle on the payment:
// "<reference>@<deadline>" for a bank
// transfer, "<address>@<deadline>" for EURC
std::string paidVia; // method that settled it ("ideal", "bitcoin")
std::string paidAt; // ISO 8601 of the FIRST paid event; empty =
// never paid. A later cancel (a refund)
@ -198,6 +199,28 @@ export namespace Catcrafts::Server {
std::string NewOrderToken();
std::string ReferenceFromToken(std::string_view token);
// The same reference again, as an ISO 11649 structured creditor reference:
// "RF" + two ISO 7064 mod-97-10 check digits + the CC-style body. Derived
// from the same token for the same reason, so the two can never disagree.
//
// Why it is worth the arithmetic: Dutch consumer banking gives this a
// dedicated payment-reference field, and the PAYER'S OWN BANK verifies the
// check digits before the transfer leaves. A correct reference travels as
// structured remittance information; a mistyped one is caught at the other
// end rather than arriving here as money nobody can match to an order.
// That is what makes an unattended bank-transfer reconciler trustworthy —
// exact match on a validated key instead of a substring hunt through
// free text a human retyped.
std::string CreditorReferenceFromToken(std::string_view token);
// Whether `s` is a well-formed ISO 11649 reference: the RF prefix, a length
// within the standard, an alphanumeric body and check digits that verify.
// Exported because this is where a mistake is invisible — a generator that
// computes the digits wrong still produces something that LOOKS like a
// reference, and every payer's bank would then reject it while our own
// logs showed nothing wrong at all.
bool IsValidCreditorReference(std::string_view s);
// ── the order confirmation email ──────────────────────────────────
//
// A paid order gets ONE email: the confirmation, with the clearsigned
@ -254,9 +277,11 @@ export namespace Catcrafts::Server {
// What a poll learned about one payment. Pending and Dead are different
// answers on purpose: an unpaid order does not stay payable forever —
// Mollie expires its payments after its own window, and the EURC rail
// closes its own (24 hours by default) — and an order whose payment can
// never arrive should lapse rather than sit "awaiting" forever.
// each rail closes its own window (14 days for a bank transfer, 24 hours
// for EURC) — and an order whose payment can never arrive should lapse
// rather than sit "awaiting" forever. Neither window means bounced money:
// the account and the address stay ours, so a late payment still lands and
// is settled by hand.
enum class PayState { Pending, Paid, Dead };
struct PaidStatus {
PayState state = PayState::Pending;
@ -282,10 +307,27 @@ export namespace Catcrafts::Server {
std::string note; // optional display hint ("lowest fees")
};
struct PayInstructions {
std::string address; // where the money goes
std::string amount; // decimal token amount ("570.43")
std::string address; // where the money goes: a token address, or an IBAN
std::string amount; // decimal amount ("570.43")
std::int64_t deadlineUnix = 0;
std::vector<PayChainOption> chains;
// ── bank-transfer rails only; empty for on-chain ones ──────────
//
// The beneficiary name is NOT decoration. Since 2025-10-09 every
// euro-area transfer is checked by Verification of Payee, and the
// payer sees a mismatch warning at the moment of payment if the name
// they were given does not match the one holding the IBAN. So this
// must be the name the BANK holds, character for character, not the
// trading name — a well-meaning "Catcrafts" where the bank says
// something else scares buyers off at the last step.
std::string beneficiary;
// The structured creditor reference the payer must quote, which is
// what makes the incoming money matchable to this order.
std::string reference;
// Empty unless configured; only a payer sending from outside SEPA
// needs it. See RailConfig::transferBic.
std::string bic;
};
class PaymentRail {
@ -317,8 +359,8 @@ export namespace Catcrafts::Server {
};
struct RailConfig {
std::string mode; // "off" | "fake" | "mollie" | "eurc"
std::string apiKey; // mollie: live_… or test_…
std::string mode; // "off" | "fake" | "fake-crypto"
// | "transfer" | "eurc"
std::filesystem::path statePath; // fake: the paid marker
std::string redirectBase = "https://catcrafts.net";
@ -330,6 +372,33 @@ export namespace Catcrafts::Server {
std::filesystem::path eurcChainsPath;
std::filesystem::path eurcPoolPath;
int eurcWindowHours = 24; // 0 or less means the 24h default
// transfer: also credential-free. What it needs is where the money
// goes and what the payer must be told — see PayInstructions on why
// the beneficiary name is load-bearing rather than cosmetic.
std::string transferIban;
std::string transferBeneficiary;
// Optional. Inside SEPA an IBAN is sufficient and has been since 2016,
// so this is shown only when set, and labelled for the case that
// actually needs it: a payer whose bank is outside SEPA and who is
// sending by SWIFT, where the form asks for a BIC and cannot proceed
// without one. Rendering it unconditionally would invite every Dutch
// buyer to type a field their bank does not want.
std::string transferBic;
std::filesystem::path transferCreditsPath;
// How often the reconciler asks this rail about an order. 60 s suits a
// real bank: money does not arrive faster than that even under instant
// payments, and the rail shares ONE account read across a whole sweep
// so the cadence is about politeness rather than cost. Configurable
// because a suite driving a local credits FILE has nobody to be polite
// to, and a 60 s wait per assertion makes a test unusable.
int transferPollSeconds = 60;
// A bank transfer has no provider-side expiry, so this window is
// purely ours: how long an order waits before it is treated as
// abandoned. Generous on purpose, and lapsing is NOT bounced money —
// the IBAN stays ours and a late payment still arrives, to be settled
// by hand. Same semantics as the EURC window.
int transferWindowHours = 14 * 24;
};
// nullptr for mode "off" — that slot then offers no payment choice.
@ -340,7 +409,7 @@ export namespace Catcrafts::Server {
// checkout form renders the choices that exist, so a page can never
// advertise a way to pay the server would then refuse.
struct PaymentRails {
std::unique_ptr<PaymentRail> bank; // Mollie: iDEAL, cards, transfer
std::unique_ptr<PaymentRail> bank; // SEPA transfer to our own account
std::unique_ptr<PaymentRail> crypto; // EURC: self-hosted, on-chain
bool Any() const { return bank != nullptr || crypto != nullptr; }
@ -356,22 +425,13 @@ export namespace Catcrafts::Server {
}
};
// Parsed essentials of a Mollie /v2/payments object. Exported so the
// self-test can drive the parser with canned responses — the HTTP around
// it is thin.
struct MolliePayment {
std::string id;
std::string status; // open|pending|authorized|paid|canceled|expired|failed
std::string method; // may be empty until the payer picks one
std::string checkoutUrl; // present while payable
std::int64_t amountMinor = 0;
};
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
// Exact decimal-string-to-minor-units parser for the amounts Mollie's API
// quotes as strings ("614.00" -> 61400). Rejects anything that is not
// a plain non-negative decimal with at most two fraction digits — no
// floats touch money on the way in either. Exported for the self-test.
// Exact decimal-string-to-minor-units parser for amounts that arrive as
// strings ("614.00" -> 61400), which is how every external source quotes
// them. Rejects anything that is not a plain non-negative decimal with at
// most two fraction digits — no floats touch money on the way in, and a
// sign is refused here so it cannot quietly halve a total (see
// ParseSignedAmountToMinor, which handles the one case where the sign is
// meaningful). Exported for the self-test.
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
// One chain the EURC rail watches. Every field is configuration because
@ -431,6 +491,125 @@ export namespace Catcrafts::Server {
// was given.
std::unique_ptr<PaymentRail> MakeEurcRail(const RailConfig& config);
// ── the bank-transfer rail ────────────────────────────────────────
//
// The other self-hosted rail: the buyer sends a plain SEPA transfer to our
// own IBAN quoting the order's creditor reference, and the rail settles the
// order when a matching credit shows up on the account. No provider stands
// in the payment path, which is the entire point — the only third party is
// the bank the money was always going to land in anyway.
// One incoming credit on the account, reduced to what matching needs.
struct BankCredit {
std::string id; // the bank's own payment id; dedupe and logs
std::string reference; // remittance information, as the bank has it
std::int64_t amountMinor = 0;
std::string method; // ledger `via`: "sepa", "ideal", …
};
// What the credits say about one order.
struct TransferMatch {
std::int64_t paidMinor = 0; // summed over every credit carrying the reference
int count = 0; // how many credits carried it
std::string method; // the method of the last matching credit
// Set when a matching credit's remittance text ALSO carries something
// shaped like a second order reference. One transfer quoting two
// references cannot be attributed by a per-order matcher, and at that
// point a human should look rather than two orders both settling on the
// same money. Advisory: the caller logs it, it does not block.
bool ambiguous = false;
};
// Sum the credits that quote `reference`, in whatever form the payer typed
// it. Pure, and exported for the self-test, because this is the decision
// that releases goods — the same reason ParseEthCallUint is exported.
//
// Matching is on the reference BODY ("CC2B6457") after reducing both sides
// to upper-case alphanumerics. That one choice covers every form a payer
// might quote — "CC-2B6457", "cc2b6457", or the full structured
// "RF70CC2B6457" — because the body is a substring of all of them, and it
// survives whatever spacing a bank puts in the field. The RF check digits
// deliberately do NO work here: their job was done at the payer's own bank,
// which refuses a mistyped reference before the transfer ever leaves.
TransferMatch MatchCredits(std::span<const BankCredit> credits,
std::string_view reference);
// Where a transfer rail gets its incoming credits. One implementation talks
// to the bank; the suites use a file-backed one so the whole rail — payment
// instructions, matching, settlement, the window — runs with no network.
class CreditSource {
public:
virtual ~CreditSource() = default;
// nullopt = the bank could not be reached. NEVER an empty vector for
// that case: "no credits yet" and "cannot ask" must not look alike, or
// an outage would read as a shop full of unpaid orders.
virtual std::optional<std::vector<BankCredit>> Recent() = 0;
virtual std::string_view Name() const = 0;
};
// The rail itself. Takes its credit source so the bank is swappable — a
// deliberate hedge, since the account that reconciles the shop is also the
// shop's bank account, and replacing one adapter must not mean rewriting
// the rail.
std::unique_ptr<PaymentRail> MakeTransferRail(const RailConfig& config,
std::unique_ptr<CreditSource> credits);
// A CreditSource reading newline-delimited JSON from a file, one credit per
// line: {"id":…,"reference":…,"amount_minor":…,"method":…}. This is how the
// suites drive real settlement, and how an operator can settle a transfer
// by hand without touching the ledger. A missing file is an EMPTY list, not
// a failure: no transfers yet is a normal state.
std::unique_ptr<CreditSource> MakeFileCreditSource(std::filesystem::path path);
// ── the bunq credit source ────────────────────────────────────────
struct BunqConfig {
std::string apiKey; // BUNQ_API_KEY; can MOVE MONEY, see below
std::filesystem::path statePath; // keypair + tokens, 0600
// Which account, when the key can see more than one. Compared to the
// account's IBAN ignoring spacing and case; without it a key that sees
// several active accounts is a refusal rather than a guess, because
// guessing means reconciling the shop against its savings.
std::string iban;
// Registered with bunq ONCE, at device-server time. bunq has no
// read-only key scope, so this is the only thing standing between a
// leaked key and someone spending the balance. "*" works and is
// announced loudly; an explicit egress address is what should be used.
std::string permittedIps;
int count = 50; // payments per read
};
// nullptr when no key is configured. NOTE the deployment rule that goes
// with this: a bunq key can initiate payments and bunq offers no read-only
// scope, so the project's standing policy is that it does NOT live on the
// internet-facing host. Run `--pull-credits` on a trusted machine and ship
// the credits file to the server, which then holds no credential at all.
std::unique_ptr<CreditSource> MakeBunqCreditSource(const BunqConfig& config);
// bunq's payment list -> credits. Exported for the self-test: the money
// decisions downstream are only as good as this decoding, and the HTTP
// around it is thin. Same reasoning as ParseEthCallUint.
std::vector<BankCredit> ParseBunqPayments(std::string_view json);
// Amount strings as bunq quotes them, INCLUDING the leading minus an
// outgoing payment carries. Exported because the sign is the difference
// between income and a refund, and getting it wrong would let a refund pay
// for the order it reversed.
std::optional<std::int64_t> ParseSignedAmountToMinor(std::string_view s);
// bunq's Payment.type -> the ledger's `via` vocabulary. Exported so the
// suite can pin the mapping that decides whether an order is safe to ship:
// "sepa" is final, "card" can be reversed for months.
std::string_view BunqMethodFor(std::string_view paymentType);
// One pull: read the account and append every credit not already in the
// file to it, newest last. Returns the number appended, or nullopt if the
// bank could not be reached. This is what `--pull-credits` runs, and it is
// deliberately a separate entry point from the rail so the machine holding
// the key need not be the machine serving the shop.
std::optional<int> PullCreditsInto(CreditSource& source,
const std::filesystem::path& creditsPath);
// ── shipping rates ────────────────────────────────────────────────
//
// Live per-country, per-weight-bracket rates from Sendcloud's
@ -508,6 +687,12 @@ export namespace Catcrafts::Server {
// offers the crypto choice only when something can actually serve it.
bool CryptoPaymentAvailable();
// The same question for the bank slot. It exists because the asymmetry
// was an outage: when the bank rail went away the form kept rendering a
// pre-selected "Bank or card" option that checkout could only refuse with
// a 503. Every renderer that knows must pass both.
bool BankPaymentAvailable();
// ── request provenance ────────────────────────────────────────────
//
// Two questions a reverse-proxied process has to answer carefully, both