/* 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 financials page over real HTTP, and the bunq mutation callback that // feeds it. Liveness is the page's promise: the aggregates file appears and // the very next request reflects it — no restart, no cache, no delay. The // callback is the only path by which a stranger's money reaches a public // number, so idempotency, default-deny, and the no-leak guarantees are pinned // against the real endpoint here. import std; import Catcrafts.E2eHarness; using namespace Catcrafts::E2e; namespace { // fin_attr -> its value on the live page std::string FinAttr(TestServer& srv, std::string_view attr) { const std::string body = srv.Body("/financials"); std::smatch m; if (std::regex_search(body, m, std::regex(std::format(R"lit({}="([0-9]*)")lit", attr)))) { return m[1].str(); } return {}; } std::string BunqPayload(std::string_view id, std::string_view account, std::string_view value, std::string_view iban, std::string_view description) { std::string p = R"({"NotificationUrl":{"category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{"id":)"; p += id; p += R"(,"created":"2026-08-15 09:31:02.000000","monetary_account_id":)"; p += account; p += R"(,"amount":{"currency":"EUR","value":")"; p += value; p += R"("},"description":")"; p += description; p += R"(","counterparty_alias":{"iban":")"; p += iban; p += R"(","display_name":"Someone"}}}}})"; return p; } } // namespace int main(int argc, char** argv) { // The secret IS the last segment of the callback URL, and setting it is // what brings the endpoint into existence — unset, the path is an // ordinary 404. Note what is NOT here: a bunq API key. One could initiate // payments, so no such key ever reaches the server; it only receives. constexpr std::string_view kSecret = "e2e-callback-secret-not-a-real-one"; ServerOptions options; options.env.emplace_back("BUNQ_CALLBACK_SECRET", std::string(kSecret)); TestServer srv(argv[1], 8215, options); // ── the financials page ─────────────────────────────────────────── // Aggregate-only by construction: totals and counts, machine-readable via // the data-fin-* attributes. Live is the page's promise, so it must never // sit in a shared cache. srv.HeaderHas("/financials", "cache-control", "no-store", "financials are never cached"); srv.BodyHas("/financials", "data-fin-sales-count=\"0\"", "financials start at zero sales"); // Before the bank-aggregates file exists the page says so, and publishes // no donation figures at all — an unknowable €0 would be a lie. srv.BodyHas("/financials", "not been published yet", "unpublished bank figures say so"); srv.BodyLacks("/financials", "data-fin-donations-count", "no donation figures before the file exists"); // The aggregates file appears, exactly as the owner's tooling will write // it, and the very next request reflects it — this is the liveness the // donation counter depends on. WriteFile(std::filesystem::path(srv.Orders().string() + ".financials.json"), R"({"as_of":"2026-08-14",)" "\n" R"( "donations":{"count":3,"total_minor":4500},)" "\n" R"( "expenses":[{"label":"Hosting","total_minor":1200},)" "\n" R"( {"label":"Inventory","total_minor":230000}]})" "\n"); srv.BodyHas("/financials", "data-fin-donations-count=\"3\"", "donation count picked up live"); srv.BodyHas("/financials", "data-fin-expenses-minor=\"231200\"", "expense total picked up live"); // Net = (donations 4500 + sales 0) - expenses 231200. Negative on // purpose: a shop that has bought stock but not sold it is exactly this // shape, and the figure has to survive going below zero. srv.BodyHas("/financials", "data-fin-net-minor=\"-226700\"", "net is published and may be negative"); srv.BodyHas("/financials", "€-2267", "a negative net renders with its sign"); srv.BodyHas("/financials", "Hosting", "an expense category renders"); srv.BodyHas("/financials", "Inventory", "a second expense category renders"); srv.BodyHas("/financials", "2026-08-14", "bank figures carry their as-of date"); // ── the bunq mutation callback ──────────────────────────────────── // The rules are written here rather than at startup on purpose — they are // re-read per callback, so a new rule takes effect without a restart. WriteFile(std::filesystem::path(srv.Orders().string() + ".financial-rules.json"), R"({"donation_accounts":[9911],)" "\n" R"( "rules":[{"description_contains":"hetzner","group":"expense","label":"Hosting"},)" "\n" R"( {"iban":"NL01OWNSELF0000000","group":"ignore"}]})" "\n"); const std::string cb = std::format("/api/bunq/{}", kSecret); auto bunqPost = [&](std::string_view id, std::string_view account, std::string_view value, std::string_view iban, std::string_view description) { return srv.Post(cb, BunqPayload(id, account, value, iban, description), "application/json").status; }; // An endpoint guarded by a secret must not confirm its own existence: // every unauthorised shape is the same 404 an unknown order token gets. { const auto wrong = srv.Post("/api/bunq/wrong-secret", "{}", "application/json"); Check(wrong.status == "404", "POST /api/bunq/wrong-secret -> 404", wrong.status); } srv.CheckStatus(cb, "404"); // GET on the right URL is still not a callback srv.CheckStatus(cb, "404", "HEAD"); // A donation arrives on the donation account. No rule names the sender — // donors are strangers, which is exactly why the account is what // classifies. Check(bunqPost("4823", "9911", "25.00", "NL55BUNQ2025123456", "Thanks for imsd") == "200", "the callback accepts a mutation"); Check(FinAttr(srv, "data-fin-donations-count") == "4" && FinAttr(srv, "data-fin-donations-minor") == "7000", "a donation ticks the public counter immediately"); // bunq redelivers a callback it did not see a 2xx for, and can redeliver // one it did. Counting that twice would publish money that never arrived. bunqPost("4823", "9911", "25.00", "NL55BUNQ2025123456", "Thanks for imsd"); Check(FinAttr(srv, "data-fin-donations-count") == "4" && FinAttr(srv, "data-fin-donations-minor") == "7000", "a redelivered mutation is not counted twice"); // Default-deny: money no rule claims is WITHHELD from the page. It is // logged for classification, never published as a guess. Check(bunqPost("4824", "1234", "90.00", "NL99UNKNOWN00000000", "unlabelled transfer") == "200", "an unclassifiable mutation is still accepted (no redelivery loop)"); Check(FinAttr(srv, "data-fin-donations-count") == "4" && FinAttr(srv, "data-fin-expenses-minor") == "231200", "an unclassified mutation is withheld from every total"); // An outgoing bill matched by description becomes a positive expense. bunqPost("4825", "9911", "-12.00", "DE00HETZNER00000000", "HETZNER ONLINE GMBH"); Check(FinAttr(srv, "data-fin-expenses-minor") == "232400", "an outgoing bill lands in its expense category"); srv.BodyHas("/financials", "2026-08-15", "the as-of date advances with the mutations"); // The page still publishes nothing but aggregates: no counterparty, no // description, no id, no timestamp. This is the assertion that would // catch a well-meant future edit adding a "recent activity" list. for (std::string_view leak : { "NL55BUNQ2025123456", "Someone", "Thanks for imsd", "4823", "09:31" }) { srv.BodyLacks("/financials", std::string(leak), std::format("financials leak no transaction detail ({})", leak)); } // And nothing identifying was written to disk either — the ingest ledger // holds opaque ids and counters, and no other file learned the donor // exists. { bool persisted = false; for (const auto& entry : std::filesystem::recursive_directory_iterator(srv.Work())) { if (!entry.is_regular_file()) continue; if (ReadFile(entry.path()).find("NL55BUNQ2025123456") != std::string::npos) { persisted = true; std::println(std::cerr, " IBAN found in {}", entry.path().string()); } } Check(!persisted, "no counterparty IBAN is persisted anywhere"); } return Finish(); }