catcrafts.net/tests/ShouldMatchBankTransfers/main.cpp

212 lines
9.8 KiB
C++
Raw Normal View History

2026-08-20 20:15:47 +02:00
/*
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.
*/
// MatchCredits: the decision that releases goods on the bank-transfer rail.
//
// Everything here is a case a real payer or a real bank actually produces. The
// reference travels through a human retyping it and a bank reformatting the
// field, so the interesting failures are all cosmetic-looking: a lower-case
// reference, a space inserted every four characters, the structured RF form
// quoted instead of the short one. Each of those arriving as "unpaid" would be
// money sitting in the account against an order the shop thinks was abandoned.
//
// The other half is refusing to over-match. A matcher that credits an order
// from money that was not for it is worse than one that misses: it ships goods
// nobody paid for.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
using Server::BankCredit;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
BankCredit Credit(std::string reference, std::int64_t amountMinor,
std::string method = "sepa", std::string id = "p1") {
BankCredit c;
c.id = std::move(id);
c.reference = std::move(reference);
c.amountMinor = amountMinor;
c.method = std::move(method);
return c;
}
std::int64_t PaidFor(std::vector<BankCredit> credits, std::string_view reference) {
return Server::MatchCredits(credits, reference).paidMinor;
}
} // namespace
int main() {
const std::string ref = "CC-2B6457";
// ── the forms a payer actually quotes ─────────────────────────────
Check(PaidFor({ Credit("CC-2B6457 catcrafts.net", 5000) }, ref) == 5000,
"the reference exactly as we printed it");
Check(PaidFor({ Credit("cc2b6457", 5000) }, ref) == 5000,
"lower case and no hyphen");
Check(PaidFor({ Credit("RF70CC2B6457", 5000) }, ref) == 5000,
"the structured RF form, whose body IS the reference");
Check(PaidFor({ Credit("CC 2B 64 57", 5000) }, ref) == 5000,
"a bank grouping the field into pairs");
Check(PaidFor({ Credit("Payment for order CC-2B6457, thanks!", 5000) }, ref) == 5000,
"the reference buried in a sentence");
Check(PaidFor({ Credit("betaling cc/2b/6457", 5000) }, ref) == 5000,
"separators a payer invented");
// ── refusing to over-match ────────────────────────────────────────
Check(PaidFor({ Credit("CC-2B6458", 5000) }, ref) == 0,
"one character different is a different order");
Check(PaidFor({ Credit("no reference at all", 5000) }, ref) == 0,
"a transfer with no reference pays for nothing");
Check(PaidFor({ Credit("", 5000) }, ref) == 0,
"an empty remittance field pays for nothing");
// The needle guard: an empty or near-empty reference must not match every
// credit on the account. This is the difference between one order settling
// and the whole ledger settling from a single payment.
Check(PaidFor({ Credit("anything", 5000), Credit("something", 900) }, "") == 0,
"an empty reference matches nothing");
Check(PaidFor({ Credit("anything", 5000) }, "CC") == 0,
"a too-short reference matches nothing");
// Outgoing money must never pay for an order. A refund we sent quotes the
// very reference of the order it refunds, so counting signed amounts
// blindly would let a refund settle the thing it reversed.
Check(PaidFor({ Credit("CC-2B6457 refund", -5000) }, ref) == 0,
"an outgoing payment quoting the reference is not income");
Check(PaidFor({ Credit("CC-2B6457", 5000), Credit("CC-2B6457 refund", -2000) },
ref) == 5000,
"a later refund does not reduce what arrived");
// ── partials accumulate ───────────────────────────────────────────
// The buyer is told to send the difference to the same IBAN with the same
// reference, so two credits for one order is a supported path and not an
// anomaly. Summing is what makes that instruction true.
Check(PaidFor({ Credit("CC-2B6457", 3000, "sepa", "a"),
Credit("CC-2B6457", 2000, "sepa", "b") }, ref) == 5000,
"two credits for one order are summed");
{
const Server::TransferMatch m = Server::MatchCredits(
{ Credit("CC-2B6457", 3000, "sepa", "a"),
Credit("CC-2B6457", 2000, "sepa", "b") }, ref);
Check(m.count == 2, "the count reports how many credits carried it",
std::format("{}", m.count));
}
// ── the method reaches the ledger ─────────────────────────────────
// The `via` column decides whether an order is safe to ship: a plain SEPA
// transfer is final, and anything with a dispute window is not.
{
const Server::TransferMatch m =
Server::MatchCredits({ Credit("CC-2B6457", 5000, "ideal") }, ref);
Check(m.method == "ideal", "the settling method is carried out", m.method);
}
// ── other orders' money is left alone ────────────────────────────
Check(PaidFor({ Credit("CC-AAAAAA", 90000), Credit("CC-2B6457", 5000),
Credit("CC-BBBBBB", 12345) }, ref) == 5000,
"only the credits quoting THIS reference are counted");
// ── the ambiguity tripwire ───────────────────────────────────────
// One transfer quoting two orders cannot be attributed by a per-order
// matcher: both orders would see the full amount and both would settle on
// the same money. The matcher cannot fix it, so it flags it for a human.
{
const Server::TransferMatch m =
Server::MatchCredits({ Credit("CC-2B6457 and CC-AAAAAA", 10000) }, ref);
Check(m.ambiguous,
"a credit quoting a second order reference is flagged ambiguous");
const Server::TransferMatch clean =
Server::MatchCredits({ Credit("CC-2B6457 thanks", 10000) }, ref);
Check(!clean.ambiguous, "an ordinary credit is not flagged");
}
// An empty account is a clean zero rather than anything alarming: it is
// simply the state of every order between checkout and payment.
Check(PaidFor({}, ref) == 0, "no credits at all is zero, not an error");
// ── pulling credits into the file the rail reads ──────────────────
//
// Every pull re-reads an overlapping window of the account, so the SAME
// credit arrives on every run. Appending it twice would double a payment
// and settle an order nobody paid twice for, which makes deduplication by
// the bank's own payment id the load-bearing property here. A file source
// stands in for the bank so this needs no network.
{
const std::filesystem::path dir =
std::filesystem::temp_directory_path() / "cc-transfer-pull-test";
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir, ec);
const std::filesystem::path bank = dir / "bank.jsonl";
const std::filesystem::path target = dir / "credits.jsonl";
{
std::ofstream out(bank);
out << R"({"id":"p1","reference":"CC-2B6457","amount_minor":2500,)"
R"("method":"sepa"})" << "\n";
out << R"({"id":"p2","reference":"CC-AAAAAA","amount_minor":900,)"
R"("method":"ideal"})" << "\n";
}
auto pull = [&] {
auto src = Server::MakeFileCreditSource(bank);
return Server::PullCreditsInto(*src, target);
};
const std::optional<int> first = pull();
Check(first.has_value() && *first == 2, "the first pull appends both credits",
first ? std::format("{}", *first) : "nullopt");
const std::optional<int> second = pull();
Check(second.has_value() && *second == 0,
"pulling the same window again appends nothing",
second ? std::format("{}", *second) : "nullopt");
// A new payment arrives at the bank; only it should be appended.
{
std::ofstream out(bank, std::ios::app);
out << R"({"id":"p3","reference":"CC-2B6457","amount_minor":100,)"
R"("method":"sepa"})" << "\n";
}
const std::optional<int> third = pull();
Check(third.has_value() && *third == 1, "only the new credit is appended",
third ? std::format("{}", *third) : "nullopt");
// And what the rail now reads settles correctly: 25.00 + 1.00.
{
auto reader = Server::MakeFileCreditSource(target);
const auto all = reader->Recent();
Check(all.has_value() && all->size() == 3,
"the credits file holds exactly the three distinct credits",
all ? std::format("{}", all->size()) : "nullopt");
if (all) {
Check(Server::MatchCredits(*all, ref).paidMinor == 2600,
"the deduplicated file sums to the real total");
}
}
std::filesystem::remove_all(dir, ec);
}
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
std::println("ShouldMatchBankTransfers: all checks passed");
return 0;
}