448 lines
19 KiB
C++
448 lines
19 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));
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|