2026-08-15 00:54:05 +02:00
|
|
|
/*
|
|
|
|
|
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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-08-17 11:36:54 +02:00
|
|
|
// 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.
|
2026-08-15 00:54:05 +02:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
// 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";
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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 };
|
2026-08-15 00:54:05 +02:00
|
|
|
const Server::SalesSummary sum = Server::SummarizeSales(orders);
|
|
|
|
|
Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200,
|
2026-08-17 11:04:03 +02:00
|
|
|
"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");
|
2026-08-15 00:54:05 +02:00
|
|
|
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;
|
|
|
|
|
}
|