catcrafts.net/server/implementations/Catcrafts.Server-Transfer.cpp
Jorijn van der Graaf aaa7a8ce99
All checks were successful
Deploy / build-deploy (push) Successful in 3m11s
bank tranfer fix
2026-08-20 23:33:50 +02:00

479 lines
21 KiB
C++

/*
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));
}
bool AppendCreditTo(const std::filesystem::path& creditsPath,
const BankCredit& credit) {
if (credit.id.empty()) return false;
// Deduplicate on the bank's own payment id. bunq retries a callback about
// six times, and a periodic --pull-credits reads an overlapping window, so
// the SAME payment arrives more than once by design. Appending it twice
// would double the money against one reference — enough, on a part-paid
// order, to settle it without the balance ever arriving.
{
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 (doc->Str("id") == credit.id) return false;
}
}
std::ofstream out(creditsPath, std::ios::app | std::ios::binary);
if (!out) {
std::println(std::cerr, "transfer: cannot append to {}", creditsPath.string());
return false;
}
out << std::format(
R"({{"id":"{}","reference":"{}","amount_minor":{},"method":"{}"}})" "\n",
EscT(credit.id), EscT(credit.reference), credit.amountMinor,
EscT(credit.method));
out.flush();
return static_cast<bool>(out);
}
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