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