/* 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 "); 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(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(); }