/* 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: the aggregates loader, the renderer's two donation // sources (bank file + shop ledger), and the sales/donations split over the // order fold. The bank-callback ingest this suite once pinned was retired // with the bunq integration — see git history. 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"(