Replaced mollie
All checks were successful
Deploy / build-deploy (push) Successful in 3m47s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 20:15:47 +02:00
commit df91762271
29 changed files with 3079 additions and 838 deletions

View file

@ -1,194 +0,0 @@
/*
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 Mollie rail against the REAL api.mollie.com, on a test-mode key: a €1
// donation whose payment is actually created at Mollie, polled by the real
// reconciler, and read back by this suite with its own authenticated GET.
// The fake-rail suites prove the lifecycle; the parser suite proves the
// decoding; what neither can prove is the live conversation — the bearer
// auth, the amount formatting Mollie accepts, the JSON shape they answer
// with today. This suite is a MANDATORY deploy gate in CI (the operator's
// call — caution over convenience); locally it skips unless
// MOLLIE_TEST_API_KEY is exported, and in CI that same missing secret is a
// FAILURE, never a quiet skip.
//
// Coverage stops at Pending, deliberately. Mollie has no API that marks a
// test payment paid — the test-mode checkout page is where a human (or a
// headless browser this repo does not carry) picks the outcome. So the paid
// transition stays covered by the fake rail and by the pre-launch manual
// click-through; what this suite pins is everything up to it: create, the
// checkout URL, the ledger record, the poll reading "open" as still-awaiting
// rather than as dead, and no errors on the wire. Test-mode payments expire
// at Mollie on their own; nothing is left behind.
//
// The key must be a test_ key. A live_ key is refused outright, in every
// environment: this suite creates payments, and a payment created on the
// live key is a real invoice in the shop's Mollie dashboard.
import std;
import Crafter.Network;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
namespace {
bool IsCi() {
for (const char* v : { "CI", "GITHUB_ACTIONS", "FORGEJO_ACTIONS" }) {
if (const char* s = std::getenv(v); s && *s) return true;
}
return false;
}
std::string FirstMatch(const std::string& text, const std::string& pattern) {
std::smatch m;
if (std::regex_search(text, m, std::regex(pattern))) return m[1].str();
return {};
}
// One authenticated GET against the live API, the suite's own eyes on the
// payment the server created — same endpoint the reconciler polls, but read
// here independently so a server-side bug cannot vouch for itself.
std::string MollieGet(const std::string& key, const std::string& path) {
try {
Crafter::ClientHTTP1 client("api.mollie.com", 443,
Crafter::TLSClientCredentials{});
Crafter::HTTPRequest req;
req.method = "GET";
req.path = path;
req.authority = "api.mollie.com";
req.headers["authorization"] = "Bearer " + key;
req.headers["user-agent"] = "catcrafts.net-e2e/1.0 (+https://catcrafts.net)";
const Crafter::HTTPResponse res = client.Send(req);
if (res.status.size() != 3 || res.status[0] != '2') {
std::println(std::cerr, "mollie e2e: GET {} -> {} {}", path, res.status,
res.body.substr(0, 200));
return {};
}
return res.body;
} catch (const std::exception& e) {
std::println(std::cerr, "mollie e2e: GET {} failed: {}", path, e.what());
return {};
}
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) {
std::println(std::cerr, "usage: ShouldCreateMollieTestPayments <server-binary>");
return 2;
}
const char* keyEnv = std::getenv("MOLLIE_TEST_API_KEY");
if (!keyEnv || !*keyEnv) {
if (IsCi()) {
std::println(std::cerr,
"FAIL: MOLLIE_TEST_API_KEY is not set. This suite is a mandatory "
"deploy gate: add the secret in Forgejo (repo Settings -> Actions -> "
"Secrets) — the test_ key from the Mollie dashboard, Developers -> "
"API keys. See deploy/README.md \"Live payment suites in CI\".");
return 1;
}
std::println("ShouldCreateMollieTestPayments: skipped — MOLLIE_TEST_API_KEY is "
"not set (mandatory in CI, opt-in locally)");
return 0;
}
const std::string key(keyEnv);
if (!key.starts_with("test_")) {
std::println(std::cerr,
"FAIL: MOLLIE_TEST_API_KEY does not start with test_ — refusing to run "
"a payment-creating suite on anything but a test-mode key.");
return 1;
}
ServerOptions options;
options.extraArgs = { "--rail=mollie", "--crypto-rail=fake-crypto" };
options.env = { { "MOLLIE_API_KEY", key } };
TestServer srv(argv[1], 8219, options);
// ── checkout: a €1 donation on the bank rail ─────────────────────
// The 303 goes to Mollie's hosted checkout, not the order page — that IS
// the assertion: a real payment now exists and has somewhere to be paid.
const auto created = srv.Post("/shop/donation", "amount=1");
Check(created.status == "303", "a €1 donation 303s to the payment",
created.status);
std::string checkoutUrl;
if (const auto it = created.headers.find("location"); it != created.headers.end()) {
checkoutUrl = it->second;
}
Check(checkoutUrl.starts_with("https://")
&& checkoutUrl.find("mollie.com") != std::string::npos,
"the redirect is Mollie's hosted checkout", checkoutUrl);
if (created.status != "303" || checkoutUrl.empty()) {
std::println(std::cerr, "server log:\n{}",
ReadFile(srv.Work() / "server.log"));
return Finish();
}
// ── the ledger records the live payment ──────────────────────────
const std::string ledger = srv.OrdersText();
const std::string token = FirstMatch(ledger, R"lit("type":"order".*?"id":"([0-9a-f]{32})")lit");
const std::string payId = FirstMatch(ledger, R"lit("pay_id":"(tr_[A-Za-z0-9]+)")lit");
Check(!token.empty(), "the order reached the ledger");
Check(!payId.empty(), "the ledger carries Mollie's tr_ payment id");
Check(ledger.find("\"pay_choice\":\"bank\"") != std::string::npos,
"a donation with no pay field lands on the bank rail");
Check(ledger.find("\"total_minor\":100") != std::string::npos,
"€1 is stored as 100 cents");
if (token.empty() || payId.empty()) return Finish();
const std::string orderPath = std::format("/order/{}", token);
// ── the suite's own read of the payment at Mollie ─────────────────
// status open (fresh, method not yet chosen), the exact amount format
// Mollie accepted, and OUR redirect back to this order — the round trip
// that proves FormatMinor and the create body against the live API.
{
const std::string payment = MollieGet(key, "/v2/payments/" + payId);
Check(!payment.empty(), "the payment the server created exists at Mollie");
if (!payment.empty()) {
Check(payment.find("\"status\":\"open\"") != std::string::npos,
"a fresh test payment reads as open",
FirstMatch(payment, R"lit("status":"([a-z]+)")lit"));
Check(payment.find("\"currency\":\"EUR\"") != std::string::npos
&& payment.find("\"value\":\"1.00\"") != std::string::npos,
"the amount arrived as EUR 1.00");
Check(payment.find("/order/" + token) != std::string::npos,
"the payment's redirectUrl returns to this order");
}
}
// ── the real poll reads open as still-awaiting ────────────────────
// Rendering the page triggers the arrival poll and the reconciler polls
// on Mollie's 10 s cadence; give both time for at least two live GETs.
// "Open" must stay awaiting — parsed as Dead it would cancel the order,
// parsed as an error it would log below.
{
const std::string page = srv.Body(orderPath);
Check(page.find("awaiting payment") != std::string::npos,
"the fresh order page shows awaiting payment");
Check(page.find("Resume payment") != std::string::npos,
"the order page offers the resume link");
Check(page.find("mollie.com") != std::string::npos,
"the resume link points at the hosted checkout");
}
std::this_thread::sleep_for(std::chrono::seconds(12));
Check(srv.Body(orderPath).find("awaiting payment") != std::string::npos,
"an open payment is still awaiting after live reconciler polls");
{
// Any "mollie:" line is a failed call — auth, transport, or a JSON
// shape the parser refused. Create succeeded (the checkout URL above),
// so a clean log here means the polls succeeded too.
const std::string log = ReadFile(srv.Work() / "server.log");
Check(log.find("mollie:") == std::string::npos,
"no Mollie call failed during create or polling",
FirstMatch(log, R"((mollie:[^\n]*))"));
}
return Finish();
}

View file

@ -0,0 +1,212 @@
/*
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;
}

View file

@ -41,6 +41,70 @@ int main() {
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
"reference: derived and uppercased");
// ── the ISO 11649 creditor reference ──────────────────────────────
//
// The payer's own bank verifies these digits before the transfer leaves,
// so a generator that computes them wrong is invisible here and rejected
// at every bank in the country. Hence: verify our own output, verify the
// verifier rejects tampering, and pin one value literally so a refactor
// cannot quietly change the arithmetic.
const std::string rf = Server::CreditorReferenceFromToken(
"abcdef0123456789abcdef0123456789");
Check(Server::IsValidCreditorReference(rf),
"creditor ref: generator output verifies", rf);
Check(rf.starts_with("RF") && rf.size() == 12,
"creditor ref: RF + 2 check digits + CCABCDEF", rf);
Check(rf.substr(4) == "CCABCDEF",
"creditor ref: body is the human reference without the hyphen", rf);
Check(Server::CreditorReferenceFromToken("abcdef0123456789abcdef0123456789") == rf,
"creditor ref: derived, so it is stable for one token");
// The guarantee mod-97-10 actually gives, asserted as the theorem it is
// rather than as an empirical count: every single-character substitution
// that keeps the character's CLASS is always caught. A letter contributes
// two decimal digits (A=10 … Z=35) and a digit contributes one, so a
// same-class change shifts the remainder by d*10^k or d*100^k with
// |d| < 97; since 97 is prime that product is never ≡ 0, so the checksum
// always moves. This is the case that matters — a donor retyping one
// character of a reference is stopped by their own bank.
//
// A change that crosses classes (letter to digit) alters the length of the
// decimal expansion and is therefore an ordinary 1-in-97 checksum bet, not
// a guarantee. Exactly one such mutation of this reference does slip
// through, which is the standard behaving as designed and not a defect;
// asserting otherwise would be pinning an accident.
int caught = 0, mutations = 0;
for (std::size_t i = 2; i < rf.size(); ++i) {
const bool isDigit = rf[i] >= '0' && rf[i] <= '9';
const std::string_view sameClass =
isDigit ? "0123456789" : "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
for (const char c : sameClass) {
if (rf[i] == c) continue;
std::string bad = rf;
bad[i] = c;
++mutations;
if (!Server::IsValidCreditorReference(bad)) ++caught;
}
}
Check(mutations > 0 && caught == mutations,
"creditor ref: every same-class one-character change fails verification",
std::format("{} of {} caught", caught, mutations));
Check(!Server::IsValidCreditorReference("RF00CCABCDEF"),
"creditor ref: wrong check digits rejected");
Check(!Server::IsValidCreditorReference("CCABCDEF"),
"creditor ref: missing RF prefix rejected");
Check(!Server::IsValidCreditorReference("RF"),
"creditor ref: too short rejected");
Check(!Server::IsValidCreditorReference(rf + "TOOLONGTOOLONGTOOLONGTOOLONG"),
"creditor ref: over 25 characters rejected");
Check(!Server::IsValidCreditorReference("RF18CC-ABCDEF"),
"creditor ref: non-alphanumeric body rejected");
// The canonical example from the standard's own documentation, so this is
// pinned against an outside source and not only against ourselves.
Check(Server::IsValidCreditorReference("RF18539007547034"),
"creditor ref: the published ISO 11649 example verifies");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;

View file

@ -0,0 +1,157 @@
/*
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.
*/
// Decoding bunq's payment list into credits. The HTTP around this is thin; the
// decoding is where a mistake costs money and says nothing, which is the same
// reason ParseEthCallUint and ParseEurcChains are pinned here rather than
// trusted to integration.
//
// Two properties carry real weight:
//
// * The SIGN. bunq quotes an outgoing payment as a negative value, and the
// shop sends refunds quoting the very reference of the order they refund.
// Lose the minus and a refund pays for the order it reversed.
// * The METHOD. `Payment.type` decides whether an order is safe to post: a
// SEPA credit transfer is final, a card payment can be reversed for
// months. Collapsing them to "paid" is how a chargeback becomes a
// shipped parcel.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
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);
}
// The shape bunq actually answers with: everything wrapped in Response, one
// object per entry keyed by type.
constexpr std::string_view kList = R"({
"Response": [
{"Payment": {
"id": 4155551,
"type": "EBA_SCT",
"description": "CC-2B6457 catcrafts.net",
"amount": {"currency": "EUR", "value": "57.38"}
}},
{"Payment": {
"id": 4155552,
"type": "IDEAL",
"description": "donation cc2b6457",
"amount": {"currency": "EUR", "value": "5.00"}
}},
{"Payment": {
"id": 4155553,
"type": "EBA_SCT",
"description": "supplier invoice",
"amount": {"currency": "EUR", "value": "-513.00"}
}},
{"Payment": {
"id": 4155554,
"type": "FIS",
"description": "card payment",
"amount": {"currency": "USD", "value": "20.00"}
}}
]
})";
} // namespace
int main() {
const std::vector<Server::BankCredit> credits = Server::ParseBunqPayments(kList);
// The USD entry is dropped: counting 20 of something else as 20 euro is
// the kind of bug that only shows up as a shortfall nobody can explain.
Check(credits.size() == 3, "only the euro payments are decoded",
std::format("{}", credits.size()));
if (credits.size() == 3) {
Check(credits[0].id == "4155551", "the bank's own id is kept, for dedupe",
credits[0].id);
Check(credits[0].reference == "CC-2B6457 catcrafts.net",
"the description is the remittance text, verbatim", credits[0].reference);
Check(credits[0].amountMinor == 5738, "57.38 decodes to 5738 minor units",
std::format("{}", credits[0].amountMinor));
Check(credits[0].method == "sepa", "EBA_SCT is a plain SEPA transfer",
credits[0].method);
Check(credits[1].method == "ideal", "IDEAL is carried as its own method",
credits[1].method);
// The load-bearing one.
Check(credits[2].amountMinor == -51300,
"an outgoing payment keeps its minus sign",
std::format("{}", credits[2].amountMinor));
}
// End to end through the matcher: the outgoing line must not pay for
// anything, and the two incoming ones must sum.
{
const Server::TransferMatch m = Server::MatchCredits(credits, "CC-2B6457");
Check(m.paidMinor == 6238,
"the two incoming credits sum and the outgoing one is ignored",
std::format("{}", m.paidMinor));
Check(m.count == 2, "two credits matched", std::format("{}", m.count));
}
// ── the method mapping, pinned individually ───────────────────────
Check(Server::BunqMethodFor("EBA_SCT") == "sepa", "EBA_SCT -> sepa");
Check(Server::BunqMethodFor("IDEAL") == "ideal", "IDEAL -> ideal");
Check(Server::BunqMethodFor("FIS") == "card", "FIS -> card (reversible!)");
Check(Server::BunqMethodFor("BUNQ") == "bunq", "BUNQ -> bunq");
Check(Server::BunqMethodFor("SWIFT") == "swift", "SWIFT -> swift");
Check(Server::BunqMethodFor("EBA_SDD") == "directdebit", "EBA_SDD -> directdebit");
// An unknown type reaches the ledger verbatim rather than as a comfortable
// guess: the `via` column should show what bunq said, so a new payment
// type is visible instead of silently filed as an ordinary transfer.
Check(Server::BunqMethodFor("SOMETHING_NEW") == "SOMETHING_NEW",
"an unknown type is passed through, not guessed at");
Check(Server::BunqMethodFor("") == "bank", "an absent type falls back to 'bank'");
// ── signed amount parsing ─────────────────────────────────────────
Check(Server::ParseSignedAmountToMinor("0.01") == 1, "one cent");
Check(Server::ParseSignedAmountToMinor("-0.01") == -1, "minus one cent");
Check(Server::ParseSignedAmountToMinor("57.4") == 5740, "one decimal is tenths");
Check(Server::ParseSignedAmountToMinor("665") == 66500, "no decimal point");
Check(!Server::ParseSignedAmountToMinor("1.234").has_value(),
"three decimals is not money");
Check(!Server::ParseSignedAmountToMinor("1,00").has_value(),
"a comma decimal is refused rather than guessed");
Check(!Server::ParseSignedAmountToMinor("1e2").has_value(), "no exponents");
Check(!Server::ParseSignedAmountToMinor("").has_value(), "empty is not zero");
Check(!Server::ParseSignedAmountToMinor("-").has_value(), "a bare sign is not zero");
Check(!Server::ParseSignedAmountToMinor(" 1.00").has_value(), "no leading space");
// ── malformed input yields nothing, never a wrong number ──────────
Check(Server::ParseBunqPayments("").empty(), "empty input decodes to nothing");
Check(Server::ParseBunqPayments("not json").empty(), "garbage decodes to nothing");
Check(Server::ParseBunqPayments(R"({"Response":[]})").empty(),
"an empty account decodes to nothing");
Check(Server::ParseBunqPayments(R"({"Response":"nope"})").empty(),
"a Response that is not an array decodes to nothing");
// A payment whose amount will not parse is SKIPPED, not counted as zero:
// an unparseable amount means we do not know what arrived.
Check(Server::ParseBunqPayments(
R"({"Response":[{"Payment":{"id":1,"type":"EBA_SCT","description":"x",)"
R"("amount":{"currency":"EUR","value":"1.234"}}}]})").empty(),
"an unparseable amount is skipped rather than read as zero");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
std::println("ShouldParseBunqPayments: all checks passed");
return 0;
}

View file

@ -1,82 +0,0 @@
/*
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 wire-amount parser (Mollie quotes amounts as strings) and the Mollie
// payment parser — the line between "the buyer paid" and "the provider said
// something we did not understand". Both refuse rather than guess.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
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);
}
} // namespace
int main() {
// ── the wire-amount parser ────────────────────────────────────────
using Server::ParseAmountToMinor;
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
Check(ParseAmountToMinor("614.5") == 61450, "amount: one fraction digit");
Check(ParseAmountToMinor("0.01") == 1, "amount: one cent");
Check(!ParseAmountToMinor("614.005").has_value(), "amount: three decimals rejected");
Check(!ParseAmountToMinor("-1.00").has_value(), "amount: negative rejected");
Check(!ParseAmountToMinor("+1.00").has_value(), "amount: sign rejected");
Check(!ParseAmountToMinor("1e3").has_value(), "amount: exponent rejected");
Check(!ParseAmountToMinor("1.").has_value(), "amount: trailing dot rejected");
Check(!ParseAmountToMinor(".5").has_value(), "amount: bare fraction rejected");
Check(!ParseAmountToMinor("").has_value(), "amount: empty rejected");
Check(!ParseAmountToMinor("1 000.00").has_value(), "amount: separator rejected");
// ── the Mollie payment parser ─────────────────────────────────────
{
const auto p1 = Server::ParseMolliePayment(R"({
"resource":"payment","id":"tr_7UhSN1zuXS","status":"open","method":null,
"amount":{"value":"578.30","currency":"EUR"},
"_links":{"checkout":{"href":"https://www.mollie.com/checkout/select-method/7UhSN1zuXS","type":"text/html"}}})");
Check(p1.has_value(), "mollie: open payment parses");
if (p1) {
Check(p1->id == "tr_7UhSN1zuXS", "mollie: id");
Check(p1->status == "open", "mollie: status");
Check(p1->amountMinor == 57830, "mollie: amount to cents");
Check(p1->checkoutUrl == "https://www.mollie.com/checkout/select-method/7UhSN1zuXS",
"mollie: checkout link");
Check(p1->method.empty(), "mollie: null method is empty");
}
const auto p2 = Server::ParseMolliePayment(R"({
"id":"tr_x","status":"paid","method":"ideal",
"amount":{"value":"578.30","currency":"EUR"},"_links":{}})");
Check(p2 && p2->status == "paid" && p2->method == "ideal",
"mollie: paid payment carries the method");
const auto p3 = Server::ParseMolliePayment(R"({
"id":"tr_y","status":"paid","amount":{"value":"578.30","currency":"USD"}})");
Check(p3 && p3->amountMinor == 0, "mollie: non-EUR amount refuses to count");
Check(!Server::ParseMolliePayment("garbage").has_value(),
"mollie: malformed payload rejected");
Check(!Server::ParseMolliePayment(R"({"status":"open"})").has_value(),
"mollie: missing id rejected");
}
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -127,7 +127,7 @@ void OpenShopLifecycle(TestServer& srv) {
// picked one must survive all the way into the ledger. The ledger is the
// assertion that matters: it is what the reconciler later reads to decide
// WHICH provider may confirm the order, so a choice that renders but is
// not stored would mean crypto orders being asked about at Mollie.
// not stored would mean crypto orders being asked about at the bank.
srv.BodyHas("/shop/fp6-pmos", "name=\"pay\"", "the form offers a payment choice");
srv.BodyHas("/shop/fp6-pmos", "value=\"crypto\"", "crypto is one of the choices");
srv.BodyHas("/shop/fp6-pmos", "value=\"bank\" checked", "bank is the pre-selected choice");
@ -815,6 +815,39 @@ void RejectedFormEcho(TestServer& srv) {
}
}
// ── one rail down ─────────────────────────────────────────────────────
//
// The regression guard for a real outage: on 2026-08-20 the bank provider
// closed the shop's account, the operator correctly dropped the credential,
// and the form went on rendering a PRE-SELECTED "Bank or card" option that
// checkout could then only answer with a 503 — the majority of buyers walking
// into a wall. The crypto slot had always been rendered conditionally; the
// bank slot never was, because until that day it had never been absent.
//
// Its own server, because a rail roster is fixed at startup. Donations are
// what this asserts against: they are open in BOTH shop states, so this runs
// whatever fp6-pmos's status is.
void OneRailDown(const char* binary) {
ServerOptions options;
options.extraArgs = { "--rail=off", "--crypto-rail=fake-crypto" };
TestServer srv(binary, 8221, options);
srv.BodyHas("/shop/donation", "name=\"pay\"",
"with one rail down the form still names the choice it has");
srv.BodyHas("/shop/donation", "value=\"crypto\" checked",
"the surviving rail is pre-selected, so a plain submit is payable");
srv.BodyLacks("/shop/donation", "value=\"bank\"",
"the dead rail is not offered at all");
// The proof that the rendering and the handler agree: submitting the form
// exactly as rendered — no pay field touched — must create an order rather
// than be refused. An absent `pay` resolves to the BANK rail by design, so
// this is what would fail if the fieldset ever stopped pre-selecting.
const auto created = srv.Post("/shop/donation", "amount=5&pay=crypto");
Check(created.status == "303",
"a donation on the surviving rail goes through");
}
} // namespace
int main(int argc, char** argv) {
@ -838,5 +871,7 @@ int main(int argc, char** argv) {
RejectedFormEcho(srv);
}
OneRailDown(argv[1]);
return Finish();
}

View file

@ -0,0 +1,272 @@
/*
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 end to end, against the REAL rail rather than a
// stand-in: a donation placed over HTTP, the account details read back off the
// order page exactly as a buyer would, then the credit the bank would have
// reported written to the credits file, and the reconciler settling the order.
//
// The counterpart of ShouldSettleEurcOnTestnet, with one structural difference
// worth stating plainly. That suite has to talk to real nodes because the rail
// depends on a conversation with machines we do not control, so a mistake in it
// costs money and only a live call can catch it. This rail depends on no such
// conversation: the money arrives at our own account, and the only thing
// between a payer and a settled order is OUR code — the reference matching, the
// covering-amount rule, the window, the ledger. All of which is exactly what a
// suite can drive with no network and no credential at all.
//
// So this needs no secret, never flakes on someone else's RPC, and runs on
// every deploy unconditionally. The bank stands in for itself only in the sense
// that the suite writes the credits file — the same file `--pull-credits` fills
// from the real account, read by the same parser, matched by the same matcher.
// Everything downstream of that line is production code.
//
// What the unit suites already cover, and this one deliberately does not
// re-prove: the matching table (ShouldMatchBankTransfers) and bunq's payload
// decoding (ShouldParseBunqPayments). What only this suite can show is that the
// pieces are wired to each other — that the reference the PAGE prints is the
// reference the MATCHER looks for, which is the seam where a rename or a
// refactor would quietly break settlement while every unit test still passed.
import std;
import Crafter.Network;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
namespace fs = std::filesystem;
namespace {
// Deliberately not the shop's real account: a suite that embedded the live IBAN
// would publish it into CI logs on every run, and would keep passing if
// production's configuration silently changed.
constexpr std::string_view kIban = "NL00TEST0123456789";
constexpr std::string_view kBeneficiary = "Catcrafts E2E, not a real account";
constexpr std::string_view kBic = "TESTNL2A";
// What a PAID donation page says. There is no "paid" badge to look for — the
// renderer deliberately omits one, because the thank-you notice below already
// carries the state and two stacked pills read as a bug. So this copy is the
// signal, and if it ever changes this suite is supposed to fail: a buyer who
// paid and sees no acknowledgement is the failure being guarded against.
constexpr std::string_view kPaidCopy = "Your donation funds";
// Append one credit exactly as the bank would have reported it. `reference` is
// free text on purpose: the whole point is to send it through the same mangling
// a real payer and a real bank apply.
void Credit(const fs::path& creditsFile, std::string_view id,
std::string_view reference, std::int64_t amountMinor,
std::string_view method = "sepa") {
std::ofstream out(creditsFile, std::ios::app | std::ios::binary);
out << std::format(
R"({{"id":"{}","reference":"{}","amount_minor":{},"method":"{}"}})",
id, reference, amountMinor, method) << "\n";
}
// The order reference ("CC-XXXXXX") out of the ledger's newest order event.
std::string NewestReference(const TestServer& srv) {
const std::string text = srv.OrdersText();
const std::string key = "\"ref\":\"";
std::string found;
for (std::size_t at = text.find(key); at != std::string::npos;
at = text.find(key, at + 1)) {
const std::size_t start = at + key.size();
const std::size_t end = text.find('"', start);
if (end == std::string::npos) break;
found = text.substr(start, end - start);
}
return found;
}
std::string TokenOf(const Crafter::HTTPResponse& res) {
const auto loc = res.headers.find("location");
if (loc == res.headers.end()) return {};
const std::string& url = loc->second;
const std::size_t at = url.rfind('/');
return at == std::string::npos ? std::string{} : url.substr(at + 1);
}
// Every order this suite places is a donation, for the same reason the donation
// item exists in the other suites: it is purchasable in BOTH shop states, so
// this runs whether or not fp6-pmos has been opened, and it needs no shipping
// address or rate table.
struct Order {
std::string token;
std::string reference;
};
Order PlaceDonation(TestServer& srv, std::string_view amount) {
const auto res = srv.Post("/shop/donation", std::format("amount={}", amount));
Order out;
if (res.status != "303") {
Check(false, "a donation on the transfer rail is accepted",
std::format("status {}", res.status));
return out;
}
out.token = TokenOf(res);
out.reference = NewestReference(srv);
return out;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) {
std::println(std::cerr, "usage: ShouldSettleBankTransfers <server-binary>");
return 2;
}
ServerOptions options;
// The REAL transfer rail in the bank slot. The crypto slot is off, so any
// order this suite places must have gone through the rail under test.
options.extraArgs = { "--rail=transfer", "--crypto-rail=off" };
options.env = {
{ "TRANSFER_IBAN", std::string(kIban) },
{ "TRANSFER_BENEFICIARY", std::string(kBeneficiary) },
{ "TRANSFER_BIC", std::string(kBic) },
// Short enough that the lapse case below does not need a fifteen-minute
// suite, long enough that nothing else races it.
{ "TRANSFER_WINDOW_HOURS", "1" },
// The rail's production cadence is 60 s, which would make this suite
// minutes long for no benefit: there is no real bank here to be polite
// to, only a local file.
{ "TRANSFER_POLL_SECONDS", "1" },
};
TestServer srv(argv[1], 8222, options);
const fs::path credits(srv.Orders().string() + ".transfer-credits.jsonl");
// ── what the buyer is told ────────────────────────────────────────
//
// Asserted from the rendered page rather than from configuration, because
// the failure being guarded against is the page and the rail disagreeing.
{
const Order order = PlaceDonation(srv, "12.50");
Check(!order.token.empty(), "checkout returns an order page URL");
if (order.token.empty()) return Finish();
const std::string path = "/order/" + order.token;
srv.BodyHas(path, "Pay by bank transfer", "the page names the method");
srv.BodyHas(path, kIban, "the IBAN the money must go to");
srv.BodyHas(path, "Catcrafts E2E", "the beneficiary name is shown");
srv.BodyHas(path, kBic, "the BIC is shown when one is configured");
srv.BodyHas(path, order.reference, "the short reference is shown");
// The structured form is what a payer's own bank check-digit-validates,
// so its absence would quietly remove the protection that makes
// unattended matching safe.
srv.BodyHas(path, "RF", "the structured ISO 11649 reference is shown");
srv.BodyHas(path, "€12.50", "the exact amount to transfer");
// No hosted checkout exists, so nothing may invite the buyer to leave.
srv.BodyLacks(path, "Resume payment",
"a self-hosted rail offers no hosted checkout button");
srv.BodyLacks(path, "EURC",
"the bank rail's page says nothing about tokens");
// ── settlement, through a reference a human retyped ────────────
//
// Lower case, the hyphen replaced by a space, and buried in words: the
// shape a real remittance field arrives in. If this fails while
// ShouldMatchBankTransfers passes, the page and the matcher have drifted
// apart, which is the whole reason this assertion is here and not there.
std::string mangled = order.reference;
for (char& c : mangled) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
if (c == '-') c = ' ';
}
// "ideal" rather than "sepa" on purpose, and it is not fiction even
// though checkout offers no iDEAL. `via` is not a method this shop
// OFFERS — it is what the bank reports about how the money reached the
// account, straight out of bunq's Payment.type. Money can arrive there
// iDEAL-funded without our checkout being involved: an old bunq.me
// link, or a bunq-to-bunq payment, both land in the same account, and
// the rail settles on the REFERENCE regardless of how the payer funded
// it. Using a non-default method here is what proves that passthrough
// works, which matters because the `via` column is what tells the
// operator whether an order is safe to ship: sepa is final, anything
// card- or Wero-funded carries a dispute window.
Credit(credits, "e2e-1", std::format("betaling {} bedankt", mangled),
1250, "ideal");
// Deliberately NOT looking for the word "paid": the paid state renders
// no badge on purpose (see RenderOrder — two stacked "paid" pills read
// as a rendering bug), so the thank-you copy is what marks it.
const std::string body = srv.WaitForBody(path, kPaidCopy, 120);
Check(body.find(kPaidCopy) != std::string::npos,
"the order settles once the credit appears",
"the order page never reached its paid state");
srv.BodyLacks(path, "awaiting payment",
"and it stops asking to be paid");
// The method has to survive into the ledger: it is what tells the
// operator whether an order is safe to ship.
Check(srv.OrdersText().find("\"via\":\"ideal\"") != std::string::npos,
"the settling method reaches the ledger");
}
// ── a partial payment does not settle, and then completes ─────────
//
// The page promises that sending too little can be topped up with a second
// transfer. That promise is only true if the rail sums credits, so it is
// worth proving over HTTP rather than trusting the unit test alone.
{
const Order order = PlaceDonation(srv, "40.00");
if (order.token.empty()) return Finish();
const std::string path = "/order/" + order.token;
Credit(credits, "e2e-2a", order.reference, 1500);
// Long enough for several reconciler sweeps to have seen it.
std::this_thread::sleep_for(std::chrono::seconds(4));
srv.BodyLacks(path, kPaidCopy,
"a part payment does not settle the order");
// And the buyer is told what actually happened. This assertion caught a
// real bug: the in-flight badge was written for the crypto rail and
// told a bank payer their transfer was "awaiting network confirmation",
// which is nonsense about a mechanism a SEPA transfer never touches.
srv.BodyHas(path, "part payment received",
"a part payment says so, in bank terms");
Credit(credits, "e2e-2b", order.reference, 2500);
const std::string body = srv.WaitForBody(path, kPaidCopy, 120);
Check(body.find(kPaidCopy) != std::string::npos,
"the balance arriving later settles it",
"two credits summing to the total did not settle");
}
// ── money that is not for this order is left alone ────────────────
//
// The dangerous failure is the opposite of a missed payment: an order
// settling on somebody else's money, which ships goods nobody paid for.
{
const Order order = PlaceDonation(srv, "25.00");
if (order.token.empty()) return Finish();
const std::string path = "/order/" + order.token;
// Enough money, wrong reference.
Credit(credits, "e2e-3a", "CC-ZZZZZZ", 2500);
// The right reference, but leaving the account rather than entering it:
// a refund quotes the very reference of the order it reverses.
Credit(credits, "e2e-3b", order.reference, -2500);
// A reference that merely contains ours as a prefix must not match
// either — this is the assertion that a sloppier "starts with" rule
// would fail.
std::this_thread::sleep_for(std::chrono::seconds(4));
srv.BodyLacks(path, kPaidCopy,
"another order's credit and an outgoing payment settle nothing");
// Nothing landed for THIS order, so it is still plainly awaiting: not
// the part-payment state, which would mean we had counted money that
// was not for it.
srv.BodyHas(path, "awaiting payment",
"and the order still reads as simply unpaid");
Credit(credits, "e2e-3c", order.reference, 2500);
const std::string body = srv.WaitForBody(path, kPaidCopy, 120);
Check(body.find(kPaidCopy) != std::string::npos,
"the order's own credit still settles it afterwards");
}
return Finish();
}

View file

@ -62,7 +62,8 @@ void CatalogueContract() {
// variant, in the same arithmetic the invoice and checkout use:
// net(retail) - net(supplier) must be exactly 5000 minor. Shipping
// has its own round-trip guarantee in ShouldComputeMoney, and
// Mollie's per-transaction fee is the one accepted deviation.
// Payment costs are the one accepted deviation, and on a bank
// transfer they are zero.
for (const auto& [slug, supplier] :
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {

View file

@ -8,7 +8,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
// The black-box test harness: spawns the REAL catcrafts-server binary on a
// scratch port with a temporary orders file and the FAKE payment rails, so a
// suite never touches real data, never dials Mollie, and needs no setup.
// suite never touches real data, never dials a bank, and needs no setup.
//
// This is the C++ port of what tools/e2e.sh used to set up in shell. Each
// suite is its own process (crafter-build runs them in PARALLEL), so every
@ -136,7 +136,7 @@ public:
// .env must not leak real provider keys into the test server — live
// Sendcloud rates would silently change the shipping totals the
// suites assert.
for (const char* v : { "MOLLIE_API_KEY", "EURC_CHAINS", "EURC_POOL",
for (const char* v : { "TRANSFER_IBAN", "EURC_CHAINS", "EURC_POOL",
"SENDCLOUD_PUBLIC_KEY", "SENDCLOUD_SECRET_KEY",
"SENDCLOUD_METHOD",
"INVOICE_GPG_KEY", "MAIL_COMMAND", "MAIL_FROM" }) {