/* 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 and the bunq mutation ingest behind it. The callback is // the only path by which a stranger's money reaches a public number on this // site, so its parser, its classifier and above all its default-deny // behaviour are pinned here. A rule that accidentally claims everything, or a // classifier that treats an unrecognised transfer as a donation, would // publish a figure that is simply untrue. 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 financials page ─────────────────────────────────────────────── void FinancialsPage() { const Financials fin = LoadFinancials( R"({"as_of":"2026-08-14",)" R"("donations":{"count":3,"total_minor":4500},)" R"("expenses":[{"label":"Hosting","total_minor":1200},)" R"({"label":"Insurance","total_minor":3600},)" R"({"label":"Inventory","total_minor":230000}]})"); Check(fin.Loaded(), "financials: loads"); Check(fin.donationCount == 3 && fin.donationsMinor == 4500, "financials: donations aggregate"); Check(fin.expenses.size() == 3 && fin.expenses[0].label == "Hosting" && fin.expenses[1].totalMinor == 3600 && fin.expenses[2].label == "Inventory", "financials: expense categories in file order"); Check(fin.ExpensesMinor() == 234800, "financials: expense total"); Check(!LoadFinancials("garbage").Loaded(), "financials: malformed input yields none"); Check(!LoadFinancials(R"({"donations":{"count":1,"total_minor":1}})").Loaded(), "financials: undated figures stay unpublished"); Check(LoadFinancials(R"({"as_of":"2026-08-14","expenses":[{"total_minor":5}]})") .expenses.empty(), "financials: a category without a label is dropped"); Check(ParseRoute("/financials").kind == RouteKind::Financials, "route: /financials"); Check(ParseRoute("/financials/").kind == RouteKind::Financials, "route: /financials/ normalises"); bool inSitemap = false; for (std::string_view p : SitemapPaths()) inSitemap = inSitemap || p == "/financials"; Check(inSitemap, "route: /financials is in the sitemap"); const LegalPage& notes = Content::FinancialsPage(); Check(notes.slug == "financials" && !notes.lede.empty() && notes.sections.size() >= 2, "content: financials notes present"); // The PROMISE, not the wording that happens to carry it. Pinning a // phrase in the lede made rewriting the page's opening sentence a // test failure, which is backwards: the lede is voice, the promise // below is the commitment that must survive every edit. bool statesPromise = false; for (const LegalSection& sec : notes.sections) { for (const std::string& para : sec.body) { if (para.find("No individual transactions") != std::string::npos) { statesPromise = true; } } } Check(statesPromise, "content: financials page states what it never publishes"); // The rendered page: live sales plus the bank aggregates, with the // machine-readable copy the e2e suite reads. const Views::RenderedPage fp = Views::RenderFinancials(2, 113745, fin); Check(fp.status == 200, "financials: renders"); Check(fp.main.View().find("data-fin-sales-minor=\"113745\"") != std::string_view::npos && fp.main.View().find("data-fin-expenses-minor=\"234800\"") != std::string_view::npos, "financials: machine-readable totals"); Check(fp.main.View().find("€1137.45") != std::string_view::npos && fp.main.View().find("€1182.45") != std::string_view::npos, "financials: income rows and their total render"); Check(fp.main.View().find("Hosting") != std::string_view::npos && fp.main.View().find("€2348") != std::string_view::npos, "financials: expense categories and their total render"); // Net = income - expenses = (4500 + 113745) - 234800 = -116555. // Deliberately a NEGATIVE case: a shop that has just bought stock is // the normal way for this figure to go below zero, and "€-1165.55" is // what must render rather than a mangled or unsigned number. Check(fp.main.View().find("data-fin-net-minor=\"-116555\"") != std::string_view::npos && fp.main.View().find("€-1165.55") != std::string_view::npos, "financials: net renders, and renders negative honestly"); Check(Money::FormatEuro(-26260) == "€-262.60" && Money::FormatEuro(-500) == "€-5.00", "financials: negative euro formatting"); // Donations paid through the shop join the bank-side ones in ONE row — // the reader has no use for a split by collection channel. The income // total and the net move with them. { const Views::RenderedPage both = Views::RenderFinancials(2, 113745, fin, 2, 5000); Check(both.main.View().find("data-fin-donations-count=\"5\"") != std::string_view::npos && both.main.View().find("data-fin-donations-minor=\"9500\"") != std::string_view::npos, "financials: shop and bank donations sum into one row"); Check(both.main.View().find("Donations (5)") != std::string_view::npos, "financials: the donation row counts both sources"); // Net = (4500 + 5000 + 113745) - 234800 = -111555. Check(both.main.View().find("data-fin-net-minor=\"-111555\"") != std::string_view::npos, "financials: the net includes shop donations"); } // A shop donation shows the moment it is paid, even before any bank // figures exist — it is live from the order ledger, like sales. The // income total still waits for the bank side: a total missing half its // inputs is not a total. { const Views::RenderedPage shopOnly = Views::RenderFinancials(0, 0, Financials{}, 1, 2500); Check(shopOnly.main.View().find("data-fin-donations-count=\"1\"") != std::string_view::npos && shopOnly.main.View().find("data-fin-donations-minor=\"2500\"") != std::string_view::npos, "financials: a shop donation publishes without bank figures"); Check(shopOnly.main.View().find("Donations (1)") != std::string_view::npos, "financials: and renders its row"); // The Income SECTION heading always renders; what must wait for the // bank side is the ruled-off total row (and the net). Check(shopOnly.main.View().find(R"(Income)") == std::string_view::npos && shopOnly.main.View().find("data-fin-net-minor") == std::string_view::npos, "financials: no income total or net while the bank side is unpublished"); } // Before the bank figures exist the page says so instead of lying // with zeros — and publishes no donation figures at all. const Views::RenderedPage bare = Views::RenderFinancials(0, 0, Financials{}); Check(bare.main.View().find("data-fin-sales-count=\"0\"") != std::string_view::npos && bare.main.View().find("not been published yet") != std::string_view::npos && bare.main.View().find("data-fin-donations-count") == std::string_view::npos, "financials: unpublished bank figures say so and publish nothing"); // And no net either: income minus an unknown expense side is not a // net of anything, and printing sales there would read as a company // with no costs. Check(bare.main.View().find("data-fin-net-minor") == std::string_view::npos && bare.main.View().find(">Net<") == std::string_view::npos, "financials: no net figure while expenses are unpublished"); // Lifetime sales: ever-paid counts, awaiting doesn't, a refund after // payment stays counted, a hand-shipped legacy order counts too. Server::OrderRecord paid; paid.totalMinor = 56330; paid.paidAt = "2026-08-14T00:00:00Z"; paid.status = "paid"; Server::OrderRecord waiting; waiting.totalMinor = 99999; Server::OrderRecord refunded; refunded.totalMinor = 56930; refunded.paidAt = "2026-08-14T00:00:00Z"; refunded.status = "cancelled"; Server::OrderRecord shipped; shipped.totalMinor = 200; shipped.status = "shipped"; // A paid donation is income but not a sale: it must land in the donation // pair, or the page would book the same euro as a sale. Server::OrderRecord gift; gift.totalMinor = 2500; gift.donation = true; gift.paidAt = "2026-08-17T00:00:00Z"; gift.status = "paid"; const std::array orders{ paid, waiting, refunded, shipped, gift }; const Server::SalesSummary sum = Server::SummarizeSales(orders); Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200, "financials: sales count ever-paid orders only, donations excluded"); Check(sum.donationCount == 1 && sum.donationsMinor == 2500, "financials: a paid shop donation folds into the donation pair"); Check(Server::SummarizeSales({}).count == 0, "financials: empty ledger sums to zero"); } // ── the bunq mutation callback ──────────────────────────────────────── void BunqIngest() { using Server::ParseSignedAmountToMinor; Check(ParseSignedAmountToMinor("25.00") == 2500, "bunq: positive amount"); Check(ParseSignedAmountToMinor("-12.50") == -1250, "bunq: outgoing is negative"); Check(ParseSignedAmountToMinor("+5") == 500, "bunq: explicit plus"); Check(!ParseSignedAmountToMinor("1.234").has_value(), "bunq: too many decimals"); Check(!ParseSignedAmountToMinor("nonsense").has_value(), "bunq: non-numeric"); Check(!ParseSignedAmountToMinor("").has_value(), "bunq: empty amount"); // A realistic payload: the mutation is nested two wrappers deep, and // the parser finds it by SHAPE so a wrapper rename cannot silently // turn every callback into a no-op. constexpr std::string_view kPayload = R"({"NotificationUrl":{"target_url":"https://catcrafts.net/api/bunq/s",)" R"("category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{)" R"("id":4823,"created":"2026-08-14 09:31:02.123456","monetary_account_id":9911,)" R"("amount":{"currency":"EUR","value":"25.00"},)" R"("description":"Thanks for imsd!",)" R"("counterparty_alias":{"iban":"NL55BUNQ2025123456","display_name":"A Donor"}}}}})"; const auto m = Server::ParseBunqMutation(kPayload); Check(m.has_value(), "bunq: nested payload parses"); if (m) { Check(m->id == "4823", "bunq: numeric id travels as text"); Check(m->amountMinor == 2500 && m->currency == "EUR", "bunq: amount and currency"); Check(m->account == "9911", "bunq: monetary account"); Check(m->counterpartyIban == "NL55BUNQ2025123456", "bunq: counterparty iban"); // The time of day never survives the parser: an exact timestamp // is the one field that would let a watcher pin a donation to a // person who mentioned donating. Check(m->created == "2026-08-14", "bunq: only the date is kept"); } Check(!Server::ParseBunqMutation("garbage").has_value(), "bunq: malformed payload"); Check(!Server::ParseBunqMutation(R"({"NotificationUrl":{"category":"MUTATION"}})") .has_value(), "bunq: a notification with no mutation yields nothing"); // The same payload with one field swapped, so every case below differs // from the parsing case above by exactly the thing under test. auto payloadWith = [](std::string_view amountObject, std::string_view alias) { std::string out; out += R"({"NotificationUrl":{"category":"MUTATION","object":{"Payment":{)"; out += R"("id":4823,"created":"2026-08-14 09:31:02.123456",)"; out += R"("monetary_account_id":9911,"amount":)"; out += amountObject; out += R"(,"description":"Thanks for imsd!","counterparty_alias":)"; out += alias; out += R"(}}}})"; return out; }; constexpr std::string_view kDonorAlias = R"({"iban":"NL55BUNQ2025123456","display_name":"A Donor"})"; // FindPaymentObject matches on the SHAPE — an amount object carrying a // value, plus an id — and never looks at what the value SAYS. So a // locale-mangled or hostile amount reaches the parser inside an otherwise // perfectly well-formed mutation, and the refusal has to happen here. If // it ever softened to a zero fallback the mutation would be recorded as // seen, permanently deduped, with the money dropped from the totals and // nothing in the operator log to say so. Check(!Server::ParseBunqMutation( payloadWith(R"({"currency":"EUR","value":"25,00"})", kDonorAlias)) .has_value(), "bunq: a comma decimal is refused rather than read as zero"); Check(!Server::ParseBunqMutation( payloadWith(R"({"currency":"EUR","value":"1.234"})", kDonorAlias)) .has_value(), "bunq: a third fraction digit is refused rather than truncated"); Check(!Server::ParseBunqMutation( payloadWith(R"({"currency":"EUR","value":"abc"})", kDonorAlias)) .has_value(), "bunq: a non-numeric amount is refused"); const Server::FinancialRules rules = Server::LoadFinancialRules( R"({"donation_accounts":[9911],)" R"("rules":[)" R"({"iban":"NL01OWNSELF0000000","group":"ignore"},)" R"({"description_contains":"hetzner","group":"expense","label":"Hosting"},)" R"({"iban":"DE02SUPPLIER000000","group":"expense","label":"Inventory"},)" R"({"group":"expense","label":"Claims everything"},)" R"({"iban":"NL03TYPO0000000000","group":"nonsense","label":"X"},)" R"({"iban":"NL04NOLABEL0000000","group":"expense"}]})"); Check(rules.donationAccounts.size() == 1 && rules.donationAccounts[0] == "9911", "bunq: numeric donation account loads as text"); // Three of the six survive: the criterion-less rule would claim every // mutation, the typo'd group is not a category, and an expense with // no label has nothing to render as. Check(rules.rules.size() == 3, "bunq: unsafe rules are dropped at load"); // Incoming on the donation account, claimed by no explicit rule. Check(m && Server::ClassifyMutation(*m, rules).group == "donations", "bunq: incoming on the donation account is a donation"); Server::BankMutation x = *m; // Money LEAVING the donation account is not a gift to this company. x.amountMinor = -2500; Check(Server::ClassifyMutation(x, rules).group.empty(), "bunq: outgoing on the donation account is not a donation"); // An explicit ignore beats the donation-account default, which is how // the owner's own transfer between accounts stays out of the total. x = *m; x.counterpartyIban = "nl01ownself0000000"; Check(Server::ClassifyMutation(x, rules).group == "ignore", "bunq: an explicit rule beats the donation default, case-insensitively"); // Foreign currency is never folded into a euro total. x = *m; x.currency = "USD"; Check(Server::ClassifyMutation(x, rules).group.empty(), "bunq: non-euro is never counted"); // Default-deny: an ordinary transfer from a stranger, on an account // that is not the donation one, is withheld rather than guessed at. x = *m; x.account = "1234"; x.counterpartyIban = "NL99UNKNOWN0000000"; x.description = ""; Check(Server::ClassifyMutation(x, rules).group.empty(), "bunq: an unmatched mutation is withheld, not guessed"); // A payload with no "currency" key at all still has the shape the parser // needs, so it parses — and is then refused by the classifier, which is // where the euro-only rule lives. Nothing reaches a euro total on the // strength of a field that was never sent. const auto noCurrency = Server::ParseBunqMutation(payloadWith(R"({"value":"25.00"})", kDonorAlias)); Check(noCurrency && noCurrency->amountMinor == 2500 && noCurrency->currency.empty(), "bunq: an amount with no currency still parses"); Check(noCurrency && Server::ClassifyMutation(*noCurrency, rules).group.empty(), "bunq: an unstated currency is never assumed to be euro"); // bunq's other alias flavour nests the IBAN one level down, under // "labelMonetaryAccount". If that fallback broke, the IBAN would come // back empty, the owner's own transfer INTO the donation account would // stop matching its ignore rule, and the donation-account default would // publish the owner's own money as a stranger's gift — on the one page // whose entire promise is that the number is true. const auto nested = Server::ParseBunqMutation(payloadWith( R"({"currency":"EUR","value":"25.00"})", R"({"labelMonetaryAccount":{"iban":"NL01OWNSELF0000000","display_name":"Self"}})")); Check(nested && nested->counterpartyIban == "NL01OWNSELF0000000", "bunq: the nested alias flavour still yields an iban"); Check(nested && Server::ClassifyMutation(*nested, rules).group == "ignore", "bunq: the owner's own transfer in is ignored, whichever alias shape carries it"); Server::BankMutation bill; bill.currency = "EUR"; bill.amountMinor = -1200; bill.description = "HETZNER ONLINE GMBH invoice"; bill.created = "2026-08-15"; const Server::MutationClass billClass = Server::ClassifyMutation(bill, rules); Check(billClass.group == "expense" && billClass.label == "Hosting", "bunq: description matching, case-insensitively"); // Folding into the aggregates. Financials fin; Server::ApplyMutation(fin, Server::ClassifyMutation(*m, rules), *m); Check(fin.donationCount == 1 && fin.donationsMinor == 2500, "bunq: a donation moves the count and the total"); Check(fin.asOf == "2026-08-14", "bunq: as-of follows the mutation date"); Server::ApplyMutation(fin, billClass, bill); Check(fin.expenses.size() == 1 && fin.expenses[0].label == "Hosting" && fin.expenses[0].totalMinor == 1200, "bunq: an outgoing bill becomes a positive expense"); Check(fin.asOf == "2026-08-15", "bunq: as-of advances"); // A supplier refund reduces the category rather than appearing as // income, and never drags the as-of date backwards. Server::BankMutation refund = bill; refund.amountMinor = 500; refund.created = "2026-08-01"; Server::ApplyMutation(fin, billClass, refund); Check(fin.expenses[0].totalMinor == 700, "bunq: a refund reduces its category"); Check(fin.asOf == "2026-08-15", "bunq: as-of never moves backwards"); // An unclassified mutation touches nothing at all. const Financials before = fin; Server::ApplyMutation(fin, Server::MutationClass{}, *m); Check(fin.donationCount == before.donationCount && fin.ExpensesMinor() == before.ExpensesMinor(), "bunq: an unclassified mutation changes no total"); // A REFUNDED gift. Reachable because an explicit rule may name a group // outright, so "donations" is not the exclusive property of the // incoming-only account default tested above. const Server::FinancialRules donationRules = Server::LoadFinancialRules( R"({"rules":[{"iban":"NL55BUNQ2025123456","group":"donations"}]})"); Server::BankMutation giftBack = *m; giftBack.amountMinor = -1000; const Server::MutationClass backClass = Server::ClassifyMutation(giftBack, donationRules); Check(backClass.group == "donations", "bunq: an explicit rule can classify outgoing money as a donation"); // The count follows money IN, never money out: 2500 - 1000 = 1500, and // the one person who gave still gave. Decrementing here would put the // published donor count below the number of people who actually donated, // and the weekly reconciliation folds through this same function — it // would reproduce the wrong figure rather than correct it. Financials gifts; gifts.donationsMinor = 2500; gifts.donationCount = 1; Server::ApplyMutation(gifts, backClass, giftBack); Check(gifts.donationsMinor == 1500 && gifts.donationCount == 1, "bunq: a refunded gift reduces the total and leaves the count alone"); // Two expenses under different labels are two rows, in first-seen order. // Merging them would hide what the money went on behind one bigger // number, which is the opposite of what this page is for. Server::BankMutation supplier; supplier.currency = "EUR"; supplier.amountMinor = -5000; supplier.counterpartyIban = "DE02SUPPLIER000000"; supplier.created = "2026-08-16"; const Server::MutationClass supplierClass = Server::ClassifyMutation(supplier, rules); Check(supplierClass.group == "expense" && supplierClass.label == "Inventory", "bunq: iban matching picks the supplier's category"); Financials twoCats; Server::ApplyMutation(twoCats, billClass, bill); // -1200 out → +1200 Hosting Server::ApplyMutation(twoCats, supplierClass, supplier); // -5000 out → +5000 Inventory Check(twoCats.expenses.size() == 2 && twoCats.expenses[0].label == "Hosting" && twoCats.expenses[0].totalMinor == 1200 && twoCats.expenses[1].label == "Inventory" && twoCats.expenses[1].totalMinor == 5000, "bunq: distinct labels become distinct rows, in first-seen order"); Check(twoCats.ExpensesMinor() == 6200, "bunq: the expense total is the sum of its rows"); } // ── the callback gate ───────────────────────────────────────────────── // // The one endpoint that writes public money figures, and the only thing // standing in front of it. Driven through ConfigureFinancials because that is // how the real server reaches it; no key material and no network are needed // to pin the parts that matter. void CallbackGate() { // Unconfigured: the path is a plain 404 and nothing authorises. An // endpoint that is off should not announce itself by answering // differently to a well-formed guess than to an empty one. Server::ConfigureFinancials(Server::FinancialsConfig{}); Check(!Server::BunqCallbackConfigured(), "callback: with no secret the endpoint does not exist"); Check(!Server::BunqCallbackAuthorised("", "{}", ""), "callback: an empty secret authorises nothing while unconfigured"); Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""), "callback: even a well-formed secret is refused while unconfigured"); Server::FinancialsConfig cfg; cfg.callbackSecret = "s3cret-not-real"; // never a live one: the real // secret only ever comes from // the environment on the box Server::ConfigureFinancials(cfg); Check(Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""), "callback: the exact secret is authorised"); // SecretEqual folds a length mismatch into the same accumulator as the // byte differences, so neither a prefix nor an extension can return early // — a plain == would leak the secret one byte at a time through timing, // and the secret sits in the URL where it can be probed a request at a // time. Check(!Server::BunqCallbackAuthorised("s3cret-not-rea", "{}", ""), "callback: a prefix of the secret is refused"); Check(!Server::BunqCallbackAuthorised("s3cret-not-realX", "{}", ""), "callback: an extension of the secret is refused"); Check(!Server::BunqCallbackAuthorised("", "{}", ""), "callback: an empty secret never matches a configured one"); // A secret with nowhere to write the aggregates is still no endpoint: // this is what keeps the path a 404 on a box that has the env var but // not the storage. Check(!Server::BunqCallbackConfigured(), "callback: a secret without an aggregates path leaves the endpoint off"); cfg.publicPath = "/nonexistent-catcrafts/financials.json"; Server::ConfigureFinancials(cfg); Check(Server::BunqCallbackConfigured(), "callback: secret plus aggregates path is what turns the endpoint on"); // Turning signature checking ON must never become a no-op. With a key // path that cannot be read there is no way to verify anything, so the // CORRECT secret now fails too — closed, not open. cfg.publicKeyPem = "/nonexistent-catcrafts/bunq-public-key.pem"; Server::ConfigureFinancials(cfg); Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", "YWJj"), "callback: signature checking with an unreadable key fails closed"); Server::ConfigureFinancials(Server::FinancialsConfig{}); // leave no global behind } } // namespace int main() { FinancialsPage(); BunqIngest(); CallbackGate(); if (failures != 0) { std::println(std::cerr, "{} check(s) failed", failures); return 1; } return 0; }