financial page with bank data
All checks were successful
Deploy / build-deploy (push) Successful in 1m48s

This commit is contained in:
Jorijn van der Graaf 2026-08-14 04:14:13 +02:00
commit 33c68c2f44
11 changed files with 394 additions and 87 deletions

View file

@ -179,9 +179,9 @@ std::string SerialiseFinancials(const Financials& f) {
return std::format(
R"({{"as_of":"{}",)"
R"("donations":{{"count":{},"total_minor":{}}},)"
R"("recurring":{},"single":{}}})",
R"("expenses":{}}})",
JsonEscapeF(f.asOf), f.donationCount, f.donationsMinor,
categories(f.recurring), categories(f.single));
categories(f.expenses));
}
// ── crypto ────────────────────────────────────────────────────────────
@ -378,12 +378,12 @@ FinancialRules LoadFinancialRules(std::string_view json) {
// silently become a published category.
const bool hasCriterion = !r.iban.empty() || !r.descriptionContains.empty()
|| !r.account.empty();
const bool knownGroup = r.group == "donations" || r.group == "recurring"
|| r.group == "single" || r.group == "ignore";
const bool knownGroup = r.group == "donations" || r.group == "expense"
|| r.group == "ignore";
if (!hasCriterion || !knownGroup) continue;
// Expense groups need a label to render under; donations and
// ignore do not have one.
if ((r.group == "recurring" || r.group == "single") && r.label.empty()) continue;
// An expense needs a label to render under; donations and ignore
// do not have one.
if (r.group == "expense" && r.label.empty()) continue;
out.rules.push_back(std::move(r));
}
}
@ -444,10 +444,8 @@ void ApplyMutation(Financials& fin, const MutationClass& cls, const BankMutation
// The count follows money in, not money out: a refunded donation
// reduces the total without pretending the gift never happened.
if (m.amountMinor > 0) ++fin.donationCount;
} else if (cls.group == "recurring") {
bump(fin.recurring);
} else if (cls.group == "single") {
bump(fin.single);
} else if (cls.group == "expense") {
bump(fin.expenses);
} else {
return; // "ignore" and unclassified touch nothing
}

View file

@ -1426,24 +1426,23 @@ void RunMoneySelfTest() {
const Financials fin = LoadFinancials(
R"({"as_of":"2026-08-14",)"
R"("donations":{"count":3,"total_minor":4500},)"
R"("recurring":[{"label":"Hosting","total_minor":1200},)"
R"({"label":"Insurance","total_minor":3600}],)"
R"("single":[{"label":"Inventory","total_minor":230000}]})");
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.recurring.size() == 2 && fin.recurring[0].label == "Hosting"
&& fin.recurring[1].totalMinor == 3600,
"financials: recurring categories in order");
Check(fin.single.size() == 1 && fin.single[0].label == "Inventory",
"financials: one-off categories");
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","recurring":[{"total_minor":5}]})")
.recurring.empty(),
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,
@ -1458,8 +1457,19 @@ void RunMoneySelfTest() {
Check(notes.slug == "financials" && !notes.lede.empty()
&& notes.sections.size() >= 2,
"content: financials notes present");
Check(notes.lede.find("never published") != std::string::npos,
"content: financials lede states the privacy promise");
// 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.
@ -1475,6 +1485,15 @@ void RunMoneySelfTest() {
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");
// Before the bank figures exist the page says so instead of lying
// with zeros — and publishes no donation figures at all.
@ -1483,6 +1502,12 @@ void RunMoneySelfTest() {
&& 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.
@ -1554,11 +1579,11 @@ void RunMoneySelfTest() {
R"({"donation_accounts":[9911],)"
R"("rules":[)"
R"({"iban":"NL01OWNSELF0000000","group":"ignore"},)"
R"({"description_contains":"hetzner","group":"recurring","label":"Hosting"},)"
R"({"iban":"DE02SUPPLIER000000","group":"single","label":"Inventory"},)"
R"({"group":"single","label":"Claims everything"},)"
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":"recurring"}]})");
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
@ -1601,7 +1626,7 @@ void RunMoneySelfTest() {
bill.description = "HETZNER ONLINE GMBH invoice";
bill.created = "2026-08-15";
const Server::MutationClass billClass = Server::ClassifyMutation(bill, rules);
Check(billClass.group == "recurring" && billClass.label == "Hosting",
Check(billClass.group == "expense" && billClass.label == "Hosting",
"bunq: description matching, case-insensitively");
// Folding into the aggregates.
@ -1611,8 +1636,8 @@ void RunMoneySelfTest() {
"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.recurring.size() == 1 && fin.recurring[0].label == "Hosting"
&& fin.recurring[0].totalMinor == 1200,
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
@ -1621,7 +1646,7 @@ void RunMoneySelfTest() {
refund.amountMinor = 500;
refund.created = "2026-08-01";
Server::ApplyMutation(fin, billClass, refund);
Check(fin.recurring[0].totalMinor == 700, "bunq: a refund reduces its category");
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;

View file

@ -180,8 +180,9 @@ export namespace Catcrafts::Server {
// One classification rule. A rule matches when every criterion it states
// matches; the first matching rule wins. `group` is "donations",
// "recurring", "single" or "ignore" — anything else is a typo and the
// rule is dropped at load rather than inventing a category.
// "expense" or "ignore" — anything else is a typo and the rule is dropped
// at load rather than inventing a category. (Expenses were once split
// into recurring/one-off; see Financials::expenses for why that went.)
struct FinancialRule {
std::string iban; // exact, case-insensitive
std::string descriptionContains; // substring, case-insensitive