catcrafts.net/tests/ShouldPublishFinancials/main.cpp
Jorijn van der Graaf c31797bd9a
All checks were successful
Deploy / build-deploy (push) Successful in 3m32s
retire the bunq integration
The webhook is deregistered at bunq and the callback endpoint, its parser,
default-deny classifier, dedup ledger and signature check are removed; the
code is in git history if a bank feed ever comes back. /financials keeps
reading the hand-maintained aggregates file, and sales + shop donations
stay live from the order ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:43:09 +02:00

196 lines
9.7 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 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"(<tr class="fin-total"><th scope="row">Income</th>)")
== 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<Server::OrderRecord, 5> 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");
}
} // namespace
int main() {
FinancialsPage();
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}