/* 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(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(c) < 0x20) { out += std::format("\\u{:04x}", static_cast(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 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> 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{}; } 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 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 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 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 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> 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 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::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(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 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> Fetch() { std::lock_guard lock(mutex_); const auto now = std::chrono::steady_clock::now(); if (cached_ && now - fetchedAt_ < CacheWindow()) return cached_; std::optional> 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 credits_; std::mutex mutex_; std::optional> cached_; std::chrono::steady_clock::time_point fetchedAt_{}; }; } // namespace std::unique_ptr MakeFileCreditSource(std::filesystem::path path) { return std::make_unique(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(out); } std::optional PullCreditsInto(CreditSource& source, const std::filesystem::path& creditsPath) { const std::optional> 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 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 MakeTransferRail(const RailConfig& config, std::unique_ptr 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(config, std::move(credits)); } } // namespace Catcrafts::Server