catcrafts.net/tests/ShouldMatchBankTransfers/main.cpp
Jorijn van der Graaf 0bfa715e6a
Some checks failed
Deploy / build-deploy (push) Failing after 6m19s
CI: build with the Crafter.Build Flatpak on a repo-scoped privileged runner
Crafter.Build's CI publishes only the Flatpak since 2026-10-02, so the
rolling `latest` tarball the Install crafter-build step downloaded is gone
and the 2026-10-12 deploy failed with a 404 before building anything.

Take the route imsd's package job takes. The job runs on `flatpak`, a
privileged runner registered to this repository alone
(forgejo-runner-catcrafts on the box, same arrangement as imsd's), with the
installed Flatpak and its Sdk kept on a docker volume between runs. The
container installs nothing that compiles any more: clang, lld, libc++, the
WASI sysroot, cmake, perl, gpg and OpenSSL all come with the Flatpak and its
Sdk. `crafter-build` is a wrapper around `flatpak run`, `crafter-run` runs a
built binary inside the sandbox, cast is installed under /opt where the
sandbox can see it, and the cache path is the Flatpak's own.

The server binary links the Flatpak's libc++, libc++abi and libunwind, which
are newer than the host's, so the deploy ships them next to the binary the
way it already ships libmsquic; the rpath finds them there. They need at
most GLIBC_2.38, the host has 2.41.

ShouldMatchBankTransfers passed braced lists straight into a std::span
parameter. That compiled only because libc++ 22 had the C++26
span(initializer_list) constructor; the libc++ 23 in the Flatpak does not,
so the lists now go through a vector like the rest of the file.

deploy/README.md documents the runner, the sandbox's private /tmp and how
to run a built binary where the host lacks a matching libc++.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 16:32:57 +02:00

220 lines
10 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;
}
// The braced lists below land in a vector on purpose. MatchCredits takes a
// span, and the libc++ 23 inside the Crafter.Build 2.1.0 Flatpak (what CI
// builds with) has no span(initializer_list) constructor; libc++ 22 had one
// under C++26, which is the only reason the lists ever compiled.
Server::TransferMatch Match(std::vector<BankCredit> credits, std::string_view reference) {
return Server::MatchCredits(credits, reference);
}
std::int64_t PaidFor(std::vector<BankCredit> credits, std::string_view reference) {
return Match(std::move(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 = Match(
{ 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 =
Match({ 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 =
Match({ 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 =
Match({ 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;
}