This commit is contained in:
parent
7098ac75cb
commit
df91762271
29 changed files with 3079 additions and 838 deletions
|
|
@ -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();
|
||||
}
|
||||
212
tests/ShouldMatchBankTransfers/main.cpp
Normal file
212
tests/ShouldMatchBankTransfers/main.cpp
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
157
tests/ShouldParseBunqPayments/main.cpp
Normal file
157
tests/ShouldParseBunqPayments/main.cpp
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
272
tests/ShouldSettleBankTransfers/main.cpp
Normal file
272
tests/ShouldSettleBankTransfers/main.cpp
Normal 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();
|
||||
}
|
||||
|
|
@ -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 } }) {
|
||||
|
|
|
|||
|
|
@ -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" }) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue