catcrafts.net/tests/ShouldSettleBankTransfers/main.cpp
Jorijn van der Graaf df91762271
All checks were successful
Deploy / build-deploy (push) Successful in 3m47s
Replaced mollie
2026-08-20 20:15:47 +02:00

272 lines
13 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.
*/
// 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();
}