272 lines
13 KiB
C++
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();
|
||
|
|
}
|