212 lines
9.8 KiB
C++
212 lines
9.8 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.
|
||
|
|
*/
|
||
|
|
|
||
|
|
// 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;
|
||
|
|
}
|