financial page with bank data
All checks were successful
Deploy / build-deploy (push) Successful in 1m48s
All checks were successful
Deploy / build-deploy (push) Successful in 1m48s
This commit is contained in:
parent
e68d2c245c
commit
33c68c2f44
11 changed files with 394 additions and 87 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue