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

@ -544,7 +544,7 @@ of the fields the seven-year fiscal retention does not cover.
## The open financials page and the bunq mutation callback ## The open financials page and the bunq mutation callback
`/financials` publishes running totals only: sales, donations, and expenses `/financials` publishes running totals only: sales, donations, and expenses
grouped into recurring and one-off. Sales fold out of `orders.jsonl` on every as one flat list of categories. Sales fold out of `orders.jsonl` on every
request and need no setup at all — that half works the moment the page ships. request and need no setup at all — that half works the moment the page ships.
This section is about the other half. This section is about the other half.
@ -593,14 +593,14 @@ page and logged for you to write a rule for. It is never published as "other".
```json ```json
{"donation_accounts": [9911], {"donation_accounts": [9911],
"rules": [ "rules": [
{"description_contains": "hetzner", "group": "recurring", "label": "Hosting"}, {"description_contains": "hetzner", "group": "expense", "label": "Hosting"},
{"iban": "NL00INSURER0000000", "group": "recurring", "label": "Insurance"}, {"iban": "NL00INSURER0000000", "group": "expense", "label": "Insurance"},
{"iban": "DE00SUPPLIER000000", "group": "single", "label": "Inventory"}, {"iban": "DE00SUPPLIER000000", "group": "expense", "label": "Inventory"},
{"iban": "NL00MOLLIE00000000", "group": "ignore"}, {"iban": "NL00MOLLIE00000000", "group": "ignore"},
{"iban": "NL00OWNSELF0000000", "group": "ignore"}]} {"iban": "NL00OWNSELF0000000", "group": "ignore"}]}
``` ```
`group` is `donations`, `recurring`, `single` or `ignore`; first match wins, `group` is `donations`, `expense` or `ignore`; first match wins,
and explicit rules beat the donation-account default (which is how your own and explicit rules beat the donation-account default (which is how your own
transfer between accounts stays out of the donation total). **Ignore your transfer between accounts stays out of the donation total). **Ignore your
Mollie and CoinGate payouts** — those are sales, already counted from the Mollie and CoinGate payouts** — those are sales, already counted from the

View file

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

View file

@ -1426,24 +1426,23 @@ void RunMoneySelfTest() {
const Financials fin = LoadFinancials( const Financials fin = LoadFinancials(
R"({"as_of":"2026-08-14",)" R"({"as_of":"2026-08-14",)"
R"("donations":{"count":3,"total_minor":4500},)" R"("donations":{"count":3,"total_minor":4500},)"
R"("recurring":[{"label":"Hosting","total_minor":1200},)" R"("expenses":[{"label":"Hosting","total_minor":1200},)"
R"({"label":"Insurance","total_minor":3600}],)" R"({"label":"Insurance","total_minor":3600},)"
R"("single":[{"label":"Inventory","total_minor":230000}]})"); R"({"label":"Inventory","total_minor":230000}]})");
Check(fin.Loaded(), "financials: loads"); Check(fin.Loaded(), "financials: loads");
Check(fin.donationCount == 3 && fin.donationsMinor == 4500, Check(fin.donationCount == 3 && fin.donationsMinor == 4500,
"financials: donations aggregate"); "financials: donations aggregate");
Check(fin.recurring.size() == 2 && fin.recurring[0].label == "Hosting" Check(fin.expenses.size() == 3 && fin.expenses[0].label == "Hosting"
&& fin.recurring[1].totalMinor == 3600, && fin.expenses[1].totalMinor == 3600
"financials: recurring categories in order"); && fin.expenses[2].label == "Inventory",
Check(fin.single.size() == 1 && fin.single[0].label == "Inventory", "financials: expense categories in file order");
"financials: one-off categories");
Check(fin.ExpensesMinor() == 234800, "financials: expense total"); Check(fin.ExpensesMinor() == 234800, "financials: expense total");
Check(!LoadFinancials("garbage").Loaded(), Check(!LoadFinancials("garbage").Loaded(),
"financials: malformed input yields none"); "financials: malformed input yields none");
Check(!LoadFinancials(R"({"donations":{"count":1,"total_minor":1}})").Loaded(), Check(!LoadFinancials(R"({"donations":{"count":1,"total_minor":1}})").Loaded(),
"financials: undated figures stay unpublished"); "financials: undated figures stay unpublished");
Check(LoadFinancials(R"({"as_of":"2026-08-14","recurring":[{"total_minor":5}]})") Check(LoadFinancials(R"({"as_of":"2026-08-14","expenses":[{"total_minor":5}]})")
.recurring.empty(), .expenses.empty(),
"financials: a category without a label is dropped"); "financials: a category without a label is dropped");
Check(ParseRoute("/financials").kind == RouteKind::Financials, Check(ParseRoute("/financials").kind == RouteKind::Financials,
@ -1458,8 +1457,19 @@ void RunMoneySelfTest() {
Check(notes.slug == "financials" && !notes.lede.empty() Check(notes.slug == "financials" && !notes.lede.empty()
&& notes.sections.size() >= 2, && notes.sections.size() >= 2,
"content: financials notes present"); "content: financials notes present");
Check(notes.lede.find("never published") != std::string::npos, // The PROMISE, not the wording that happens to carry it. Pinning a
"content: financials lede states the privacy promise"); // 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 // The rendered page: live sales plus the bank aggregates, with the
// machine-readable copy the e2e suite reads. // machine-readable copy the e2e suite reads.
@ -1475,6 +1485,15 @@ void RunMoneySelfTest() {
Check(fp.main.View().find("Hosting") != std::string_view::npos Check(fp.main.View().find("Hosting") != std::string_view::npos
&& fp.main.View().find("€2348") != std::string_view::npos, && fp.main.View().find("€2348") != std::string_view::npos,
"financials: expense categories and their total render"); "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 // Before the bank figures exist the page says so instead of lying
// with zeros — and publishes no donation figures at all. // 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("not been published yet") != std::string_view::npos
&& bare.main.View().find("data-fin-donations-count") == 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"); "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 // Lifetime sales: ever-paid counts, awaiting doesn't, a refund after
// payment stays counted, a hand-shipped legacy order counts too. // payment stays counted, a hand-shipped legacy order counts too.
@ -1554,11 +1579,11 @@ void RunMoneySelfTest() {
R"({"donation_accounts":[9911],)" R"({"donation_accounts":[9911],)"
R"("rules":[)" R"("rules":[)"
R"({"iban":"NL01OWNSELF0000000","group":"ignore"},)" R"({"iban":"NL01OWNSELF0000000","group":"ignore"},)"
R"({"description_contains":"hetzner","group":"recurring","label":"Hosting"},)" R"({"description_contains":"hetzner","group":"expense","label":"Hosting"},)"
R"({"iban":"DE02SUPPLIER000000","group":"single","label":"Inventory"},)" R"({"iban":"DE02SUPPLIER000000","group":"expense","label":"Inventory"},)"
R"({"group":"single","label":"Claims everything"},)" R"({"group":"expense","label":"Claims everything"},)"
R"({"iban":"NL03TYPO0000000000","group":"nonsense","label":"X"},)" 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", Check(rules.donationAccounts.size() == 1 && rules.donationAccounts[0] == "9911",
"bunq: numeric donation account loads as text"); "bunq: numeric donation account loads as text");
// Three of the six survive: the criterion-less rule would claim every // 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.description = "HETZNER ONLINE GMBH invoice";
bill.created = "2026-08-15"; bill.created = "2026-08-15";
const Server::MutationClass billClass = Server::ClassifyMutation(bill, rules); 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"); "bunq: description matching, case-insensitively");
// Folding into the aggregates. // Folding into the aggregates.
@ -1611,8 +1636,8 @@ void RunMoneySelfTest() {
"bunq: a donation moves the count and the total"); "bunq: a donation moves the count and the total");
Check(fin.asOf == "2026-08-14", "bunq: as-of follows the mutation date"); Check(fin.asOf == "2026-08-14", "bunq: as-of follows the mutation date");
Server::ApplyMutation(fin, billClass, bill); Server::ApplyMutation(fin, billClass, bill);
Check(fin.recurring.size() == 1 && fin.recurring[0].label == "Hosting" Check(fin.expenses.size() == 1 && fin.expenses[0].label == "Hosting"
&& fin.recurring[0].totalMinor == 1200, && fin.expenses[0].totalMinor == 1200,
"bunq: an outgoing bill becomes a positive expense"); "bunq: an outgoing bill becomes a positive expense");
Check(fin.asOf == "2026-08-15", "bunq: as-of advances"); Check(fin.asOf == "2026-08-15", "bunq: as-of advances");
// A supplier refund reduces the category rather than appearing as // A supplier refund reduces the category rather than appearing as
@ -1621,7 +1646,7 @@ void RunMoneySelfTest() {
refund.amountMinor = 500; refund.amountMinor = 500;
refund.created = "2026-08-01"; refund.created = "2026-08-01";
Server::ApplyMutation(fin, billClass, refund); 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"); Check(fin.asOf == "2026-08-15", "bunq: as-of never moves backwards");
// An unclassified mutation touches nothing at all. // An unclassified mutation touches nothing at all.
const Financials before = fin; 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 // One classification rule. A rule matches when every criterion it states
// matches; the first matching rule wins. `group` is "donations", // matches; the first matching rule wins. `group` is "donations",
// "recurring", "single" or "ignore" — anything else is a typo and the // "expense" or "ignore" — anything else is a typo and the rule is dropped
// rule is dropped at load rather than inventing a category. // at load rather than inventing a category. (Expenses were once split
// into recurring/one-off; see Financials::expenses for why that went.)
struct FinancialRule { struct FinancialRule {
std::string iban; // exact, case-insensitive std::string iban; // exact, case-insensitive
std::string descriptionContains; // substring, case-insensitive std::string descriptionContains; // substring, case-insensitive

View file

@ -207,12 +207,12 @@ export const LegalPage& FinancialsPage() {
.slug = "financials", .slug = "financials",
.title = "Financials", .title = "Financials",
.updated = "2026-08-14", .updated = "2026-08-14",
.lede = "Catcrafts' money, in the open: running totals of what the company earns and spends, live from its own records. Aggregates only — individual transactions are never published.", .lede = "Catcrafts believes in openness, that's why its financials are open as well. As a supporter you deserve to know where your money is going.",
.sections = { .sections = {
{ "How this page works", { "How this page works",
{ {
"Sales come straight from the shop's order ledger and update the moment an order is paid. Donations and expenses are aggregated from the business bank account by category and carry the date they were last brought up to date. Anything the categoriser does not recognise is held back until it has been classified, never published as a guess.", "Sales come straight from the shop's order ledger and update the moment an order is paid. Donations and expenses are aggregated from the business bank account by category and carry the date they were last brought up to date. Anything the categoriser does not recognise is held back until it has been classified, never published as a guess.",
"Everything is a running total in euros on a cash basis: money counts when it moves, not when an invoice says it should. Amounts include VAT where VAT was charged. These are the company's own live numbers, not audited statements; the tax filings are the authoritative record.", "Everything is a running total in euros on a cash basis: money counts when it moves, not when an invoice says it should. Amounts include VAT where VAT was charged.",
} }, } },
{ "What is never published", { "What is never published",
{ {

View file

@ -298,14 +298,18 @@ export struct Financials {
// empty = nothing published yet // empty = nothing published yet
std::int64_t donationCount = 0; std::int64_t donationCount = 0;
std::int64_t donationsMinor = 0; std::int64_t donationsMinor = 0;
std::vector<FinCategory> recurring; // insurance, hosting, … // ONE flat list of expense categories. This carried a recurring/one-off
std::vector<FinCategory> single; // inventory, fees, tax, … // split until it was removed deliberately: the page publishes LIFETIME
// running totals, and a lifetime total cannot express a rate. "Telecom
// €158.18" says nothing about whether that is monthly or once ever, so
// the grouping conveyed no information while inviting the reader to infer
// one. Rate belongs to a cash-flow view this page is not.
std::vector<FinCategory> expenses;
bool Loaded() const { return !asOf.empty(); } bool Loaded() const { return !asOf.empty(); }
std::int64_t ExpensesMinor() const { std::int64_t ExpensesMinor() const {
std::int64_t sum = 0; std::int64_t sum = 0;
for (const FinCategory& c : recurring) sum += c.totalMinor; for (const FinCategory& c : expenses) sum += c.totalMinor;
for (const FinCategory& c : single) sum += c.totalMinor;
return sum; return sum;
} }
}; };
@ -322,9 +326,7 @@ export Financials LoadFinancials(std::string_view json) {
out.donationCount = d->Int("count"); out.donationCount = d->Int("count");
out.donationsMinor = d->Int("total_minor"); out.donationsMinor = d->Int("total_minor");
} }
auto categories = [](const Json::Value* arr) { if (const Json::Value* arr = doc->Find("expenses"); arr && arr->IsArray()) {
std::vector<FinCategory> cats;
if (!arr || !arr->IsArray()) return cats;
for (const Json::Value& v : arr->array) { for (const Json::Value& v : arr->array) {
if (!v.IsObject()) continue; if (!v.IsObject()) continue;
FinCategory c; FinCategory c;
@ -333,12 +335,9 @@ export Financials LoadFinancials(std::string_view json) {
// A category with no label has nothing to render as; dropping it // A category with no label has nothing to render as; dropping it
// beats an anonymous row that looks like a redaction. // beats an anonymous row that looks like a redaction.
if (c.label.empty()) continue; if (c.label.empty()) continue;
cats.push_back(std::move(c)); out.expenses.push_back(std::move(c));
} }
return cats; }
};
out.recurring = categories(doc->Find("recurring"));
out.single = categories(doc->Find("single"));
return out; return out;
} }

View file

@ -1473,32 +1473,22 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
std::vector<SafeHtml> incomeRows; std::vector<SafeHtml> incomeRows;
if (fin.Loaded()) { if (fin.Loaded()) {
incomeRows.push_back(MoneyRow( incomeRows.push_back(MoneyRow(
fin.donationCount == 1 ? std::string("Donations (1)") std::format("Donations ({})", fin.donationCount),
: std::format("Donations ({})", fin.donationCount),
fin.donationsMinor)); fin.donationsMinor));
} }
incomeRows.push_back(MoneyRow( incomeRows.push_back(MoneyRow(
salesCount == 1 ? std::string("Sales (1 order)") std::format("Sales ({})", salesCount),
: std::format("Sales ({} orders)", salesCount),
salesTotalMinor)); salesTotalMinor));
if (fin.Loaded()) { if (fin.Loaded()) {
incomeRows.push_back(totalRow("Income", fin.donationsMinor + salesTotalMinor)); incomeRows.push_back(totalRow("Income", fin.donationsMinor + salesTotalMinor));
} }
// Expenses: one table, group-header rows for the recurring/one-off split, // Expenses: one flat table. No recurring/one-off grouping — see the note
// so the amounts stay in a single aligned column. // on Financials::expenses for why a lifetime total cannot carry a rate.
SafeHtml expenses; SafeHtml expenses;
if (fin.Loaded()) { if (fin.Loaded()) {
std::vector<SafeHtml> rows; std::vector<SafeHtml> rows;
auto group = [&](std::string_view heading, std::span<const FinCategory> cats) { for (const FinCategory& c : fin.expenses) rows.push_back(MoneyRow(c.label, c.totalMinor));
if (cats.empty()) return;
rows.push_back(Format(
R"(<tr class="fin-group"><th colspan="2">{}</th></tr>)",
Escape(heading)));
for (const FinCategory& c : cats) rows.push_back(MoneyRow(c.label, c.totalMinor));
};
group("Recurring", fin.recurring);
group("One-off", fin.single);
rows.push_back(totalRow("Expenses", fin.ExpensesMinor())); rows.push_back(totalRow("Expenses", fin.ExpensesMinor()));
expenses = Format(R"(<table class="spec-table"><tbody>{}</tbody></table>)", expenses = Format(R"(<table class="spec-table"><tbody>{}</tbody></table>)",
Join(rows)); Join(rows));
@ -1509,6 +1499,33 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
R"(figures above are already live.</p>)"); R"(figures above are already live.</p>)");
} }
// Net: what is actually left. Shown only when the bank figures exist —
// income minus an unknown expense side is not a net of anything, and a
// figure equal to sales while expenses are unpublished would read as a
// company with no costs.
//
// Deliberately NOT called profit. On a cash basis this ignores stock
// still on the shelf, anything owed in either direction, and tax not yet
// paid, so calling it profit would be a claim the arithmetic cannot
// support. FormatEuro renders a negative as "€-12.34", which is the
// honest thing to show in a month that bought inventory.
const std::int64_t netMinor =
fin.donationsMinor + salesTotalMinor - fin.ExpensesMinor();
SafeHtml netBlock;
if (fin.Loaded()) {
netBlock = Format(
R"(<section class="section"><h2 class="section__title">Net</h2>)"
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
R"(<p class="section__lede">On the same cash basis as everything above. )"
R"(This is not profit: it counts no stock still on the shelf, nothing )"
R"(owed in either direction, and no tax.</p>)"
R"(</section>)",
// The row says the arithmetic rather than repeating the heading —
// a section titled "Net" whose only row is also "Net" reads as a
// rendering fault, and the sum is worth spelling out anyway.
totalRow("Income expenses", netMinor));
}
// The freshness line keeps the page honest about its two cadences. // The freshness line keeps the page honest about its two cadences.
const SafeHtml freshness = fin.Loaded() const SafeHtml freshness = fin.Loaded()
? Format(R"(<p class="legal__updated">Sales are live from the order ledger &middot; )" ? Format(R"(<p class="legal__updated">Sales are live from the order ledger &middot; )"
@ -1539,10 +1556,11 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
R"(<p class="page-header__lede">{}</p>)" R"(<p class="page-header__lede">{}</p>)"
R"({})" R"({})"
R"(</header>)" R"(</header>)"
R"(<div class="fin"{}{}{}{}{}>)" R"(<div class="fin"{}{}{}{}{}{}>)"
R"(<section class="section"><h2 class="section__title">Income</h2>)" R"(<section class="section"><h2 class="section__title">Income</h2>)"
R"(<table class="spec-table"><tbody>{}</tbody></table></section>)" R"(<table class="spec-table"><tbody>{}</tbody></table></section>)"
R"(<section class="section"><h2 class="section__title">Expenses</h2>{}</section>)" R"(<section class="section"><h2 class="section__title">Expenses</h2>{}</section>)"
R"({})"
R"(</div>)" R"(</div>)"
R"(<div class="legal">{}</div>)", R"(<div class="legal">{}</div>)",
Escape(notes.title), Escape(notes.lede), freshness, Escape(notes.title), Escape(notes.lede), freshness,
@ -1554,7 +1572,8 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
: SafeHtml{}, : SafeHtml{},
fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor())) fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor()))
: SafeHtml{}, : SafeHtml{},
Join(incomeRows), expenses, Join(sections)); fin.Loaded() ? Attr("data-fin-net-minor", std::to_string(netMinor)) : SafeHtml{},
Join(incomeRows), expenses, netBlock, Join(sections));
return page; return page;
} }

View file

@ -939,15 +939,8 @@ treatment it replaces, which read as the wrong category for the work.
/* ── the financials page ──────────────────────────────────────────── */ /* ── the financials page ──────────────────────────────────────────── */
/* The running-total tables are .spec-table; these two row types are the /* The running-total tables are .spec-table; this row type is the one ledger
ledger idioms on top of it: a group label heading the rows beneath it, idiom on top of it: a sum ruled off from what it sums. */
and a sum ruled off from what it sums. */
.fin-group th {
padding-top: var(--s0);
color: var(--text);
font-weight: 650;
border-bottom: 1px solid var(--border-strong);
}
.fin-total th, .fin-total th,
.fin-total td { .fin-total td {
border-top: 2px solid var(--border-strong); border-top: 2px solid var(--border-strong);

View file

@ -185,8 +185,51 @@ for item in d.get("Response",[]):
aid=acc.get("id"); st=acc.get("status",""); desc=acc.get("description","") aid=acc.get("id"); st=acc.get("status",""); desc=acc.get("description","")
print(f" id={aid:<10} {st:<8} {kind:<22} {desc} {iban} balance {bal}")' print(f" id={aid:<10} {st:<8} {kind:<22} {desc} {iban} balance {bal}")'
echo echo
echo "current MUTATION filters:" # Filters registered PER ACCOUNT do not appear in the user-level list, so
api GET "/v1/user/$USER_ID/notification-filter-url" "" "$SESSION" | python3 -m json.tool # showing only that one reads as "nothing is registered" when in fact
# everything is. Both are printed, per account, or this command lies.
echo "MUTATION filters, per account:"
api GET "/v1/user/$USER_ID/monetary-account?count=50" "" "$SESSION" | python3 -c 'import json,sys
d=json.load(sys.stdin)
ids=[]
for item in d.get("Response",[]):
for acc in item.values():
if isinstance(acc,dict) and "id" in acc: ids.append(str(acc["id"]))
print(" ".join(ids))' > /tmp/.cc-accts.$$
for _a in $(cat /tmp/.cc-accts.$$); do
_f=$(api GET "/v1/user/$USER_ID/monetary-account/$_a/notification-filter-url" "" "$SESSION" \
| python3 -c 'import json,sys
d=json.load(sys.stdin)
out=[]
for item in d.get("Response",[]):
f=item.get("NotificationFilterUrl") or {}
if not f: continue
t=f.get("notification_target") or ""
# never print the callback secret: the last path segment is masked
parts=t.rsplit("/",1)
masked=parts[0]+"/"+(parts[1][:4]+"…" if len(parts)>1 and parts[1] else "")
out.append(f.get("category","?")+" -> "+masked)
print("; ".join(out) if out else "(none)")')
printf ' account %-10s %s\n' "$_a" "$_f"
done
rm -f /tmp/.cc-accts.$$
echo
echo "user-level filters (apply to ALL accounts):"
api GET "/v1/user/$USER_ID/notification-filter-url" "" "$SESSION" | python3 -c 'import json,sys
d=json.load(sys.stdin)
r=d.get("Response") or []
if not r:
print(" (none)"); raise SystemExit
for item in r:
f=item.get("NotificationFilterUrl") or {}
t=f.get("notification_target") or ""
# The callback URL ENDS IN A SHARED SECRET. Never print it whole: this
# output gets pasted into issues and terminals that keep scrollback.
parts=t.rsplit("/",1)
masked=parts[0]+"/"+(parts[1][:4]+"…" if len(parts)>1 and parts[1] else "")
accts=f.get("all_monetary_account_id") or []
cat=f.get("category","?")
print(f" {cat} -> {masked} accounts={sorted(set(accts))}")'
;; ;;
set) set)
ACCOUNT="${2:?usage: tools/bunq-callback.sh set <account-id> <callback-url>}" ACCOUNT="${2:?usage: tools/bunq-callback.sh set <account-id> <callback-url>}"
@ -207,6 +250,21 @@ set)
echo " (A wrong secret answers 404 by design — check the secret too.)" >&2 echo " (A wrong secret answers 404 by design — check the secret too.)" >&2
exit 1 exit 1
fi fi
# "all" registers at USER level, covering every account including ones
# created later. Prefer it: bunq treats even per-account registrations as
# user-scoped entries anyway, and repeated per-account POSTs accumulate
# duplicate account ids rather than replacing cleanly. A user-level POST
# replaces the ENTIRE set, which is also how you clear stale URLs.
if [ "$ACCOUNT" = all ]; then
echo "bunq: installing the MUTATION filter for ALL accounts"
_resp=$(api POST "/v1/user/$USER_ID/notification-filter-url" \
"$(python3 -c 'import json,sys
print(json.dumps({"notification_filters":[
{"category":"MUTATION","notification_target":sys.argv[1]}]}))' "$URL")" "$SESSION")
die_on_error "$_resp"
echo " done (previous filters replaced)"
exit 0
fi
echo "bunq: installing the MUTATION filter on account $ACCOUNT" echo "bunq: installing the MUTATION filter on account $ACCOUNT"
# POST REPLACES the whole filter set for this account, so this is also how # POST REPLACES the whole filter set for this account, so this is also how
# you change or clear one. # you change or clear one.
@ -220,8 +278,165 @@ print(json.dumps({"notification_filters":[
echo "bunq: done. Send yourself €0.01 and watch:" echo "bunq: done. Send yourself €0.01 and watch:"
echo " ssh hetzner journalctl -u catcrafts-server -f" echo " ssh hetzner journalctl -u catcrafts-server -f"
;; ;;
payments)
# What a real mutation actually looks like, WITHOUT printing what a real
# mutation actually says. This exists to answer "which field tells me a
# payment came from bunq.me?" empirically rather than from memory — so it
# prints the SHAPE (key names, and the values of fields that classify
# rather than identify) and masks everything that names a human.
ACCOUNT="${2:?usage: tools/bunq-callback.sh payments <account-id> [count]}"
api GET "/v1/user/$USER_ID/monetary-account/$ACCOUNT/payment?count=${3:-25}" \
"" "$SESSION" | python3 -c 'import json,sys
SAFE={"id","created","type","sub_type","amount","payment_auto_allocate_instance",
"bunqme_fundraiser_result","request_reference_split_the_bill",
"payment_arrival_expected","merchant_reference","batch_id","scheduled_id"}
d=json.load(sys.stdin)
rows=[item["Payment"] for item in d.get("Response",[]) if "Payment" in item]
if not rows:
print(" (no payments on this account)"); raise SystemExit
allkeys=set()
for p in rows: allkeys.update(p.keys())
print(f" {len(rows)} payment(s). Union of keys present:")
for k in sorted(allkeys):
mark=" <- SAFE to classify on" if k in SAFE else ""
print(f" {k}{mark}")
print()
print(" per payment (identifying fields masked):")
for p in rows:
amt=(p.get("amount") or {})
val=amt.get("value","?"); cur=amt.get("currency","?")
bm=p.get("bunqme_fundraiser_result")
bmk="yes" if bm else "no"
extra=""
if isinstance(bm,dict):
extra=" bunqme_keys=" + ",".join(sorted(bm.keys()))
pid=p.get("id"); ptype=p.get("type"); psub=p.get("sub_type")
print(f" id={pid} type={ptype} sub_type={psub} "
f"amount={val} {cur} bunqme_fundraiser_result={bmk}{extra}")'
;;
backfill)
# The categorisation worklist for a date window. READ ONLY — it publishes
# nothing and writes nothing to the server. Incoming and outgoing are
# separated because they ask different questions ("is this a donation?" vs
# "which expense category?"), and outgoing is grouped by counterparty
# because that is the unit a rule matches on.
SINCE="${2:?usage: tools/bunq-callback.sh backfill <YYYY-MM-DD> [YYYY-MM-DD]}"
UNTIL="${3:-9999-12-31}"
_tmp=$(mktemp -d)
api GET "/v1/user/$USER_ID/monetary-account?count=50" "" "$SESSION" | python3 -c 'import json,sys
d=json.load(sys.stdin)
for item in d.get("Response",[]):
for acc in item.values():
if isinstance(acc,dict) and "id" in acc:
print(acc["id"], (acc.get("description") or "?").replace(" ","_"))' > "$_tmp/accts"
while read -r _id _nm; do
api GET "/v1/user/$USER_ID/monetary-account/$_id/payment?count=200" "" "$SESSION" \
> "$_tmp/pay-$_id-$_nm.json"
done < "$_tmp/accts"
python3 - "$SINCE" "$UNTIL" "$_tmp" <<'PY'
import glob, json, os, sys
since, until, tmp = sys.argv[1], sys.argv[2], sys.argv[3]
rows=[]
for path in sorted(glob.glob(os.path.join(tmp,"pay-*.json"))):
label=os.path.basename(path)[4:-5]
try: d=json.load(open(path))
except Exception: continue
for item in d.get("Response",[]):
p=item.get("Payment")
if not isinstance(p,dict): continue
created=(p.get("created") or "")[:10]
if not (since <= created <= until): continue
amt=(p.get("amount") or {})
if amt.get("currency")!="EUR": continue
try: cents=int(round(float(amt.get("value","0"))*100))
except Exception: continue
cp=(p.get("counterparty_alias") or {})
rows.append({"acct":label,"date":created,"cents":cents,
"type":p.get("type"),"sub":p.get("sub_type"),
"name":cp.get("display_name") or "?",
"iban":cp.get("iban") or "",
"desc":(p.get("description") or "").strip()[:44]})
if not rows:
print(" no EUR payments in", since, "..", until); raise SystemExit
print(f" window {since} .. {until} {len(rows)} payment(s)\n")
ins=[r for r in rows if r["cents"]>0]
outs=[r for r in rows if r["cents"]<0]
print(f" INCOMING ({len(ins)}) — decide donation / sale payout / other:")
for r in sorted(ins,key=lambda r:r["date"]):
eur=r["cents"]/100
print(f" {r['date']} {eur:>9.2f} {r['type']:<11}{r['sub']:<9} {r['name'][:22]:<22} {r['desc']}")
print(f" ---- incoming total: {sum(r['cents'] for r in ins)/100:.2f}\n")
print(f" OUTGOING ({len(outs)}) — grouped by counterparty; each group is one rule:")
groups={}
for r in outs:
key=(r["name"],r["iban"])
g=groups.setdefault(key,{"cents":0,"n":0,"descs":set()})
g["cents"]+=r["cents"]; g["n"]+=1
if r["desc"]: g["descs"].add(r["desc"])
for (name,iban),g in sorted(groups.items(),key=lambda kv:kv[1]["cents"]):
eur=-g["cents"]/100
sample=sorted(g["descs"])[0] if g["descs"] else ""
print(f" {eur:>10.2f} x{g['n']:<3} {name[:26]:<26} {iban:<20} {sample[:30]}")
print(f" ---- outgoing total: {-sum(r['cents'] for r in outs)/100:.2f}")
PY
rm -rf "$_tmp"
;;
payment)
# One payment by id, classification fields only. Used to answer "what does
# a payment that AROSE FROM a bunq.me tab actually look like on the wire?"
ACCOUNT="${2:?usage: tools/bunq-callback.sh payment <account-id> <payment-id>...}"
shift 2
for pid in "$@"; do
api GET "/v1/user/$USER_ID/monetary-account/$ACCOUNT/payment/$pid" "" "$SESSION" \
| python3 -c 'import json,sys
d=json.load(sys.stdin)
rows=[i["Payment"] for i in d.get("Response",[]) if "Payment" in i]
for p in rows:
amt=(p.get("amount") or {})
val=amt.get("value","?")
pid=p.get("id"); ptype=p.get("type"); psub=p.get("sub_type")
has_bm="bunqme_fundraiser_result" in p
print(f" id={pid} type={ptype} sub_type={psub} amount={val} "
f"has_bunqme_field={has_bm}")'
done
;;
bunqme)
# The bunq.me side. A tab records which payments fulfilled it, so this is
# the only authoritative "did this money come from a bunq.me link?" join —
# and note it takes an API CALL WITH THE KEY, which is exactly what the
# server does not have. Prints payment ids so they can be matched against
# `payments` output; no payer names or links are shown in full.
ACCOUNT="${2:?usage: tools/bunq-callback.sh bunqme <account-id> [count]}"
api GET "/v1/user/$USER_ID/monetary-account/$ACCOUNT/bunqme-tab?count=${3:-25}" \
"" "$SESSION" | python3 -c 'import json,sys
d=json.load(sys.stdin)
tabs=[i["BunqMeTab"] for i in d.get("Response",[]) if "BunqMeTab" in i]
if not tabs:
print(" (no bunq.me tabs on this account)"); raise SystemExit
for t in tabs:
url=t.get("bunqme_tab_share_url") or ""
slug=url.rsplit("/",1)[-1]
masked=slug[:3]+"…" if slug else "(none)"
entry=t.get("bunqme_tab_entry") or {}
inq=t.get("result_inquiries") or []
pids=[]
for r in inq:
node=r.get("BunqMeTabResultInquiry") or r
pay=(node.get("payment") or {})
pay=pay.get("Payment") or pay
if isinstance(pay,dict) and pay.get("id") is not None:
pids.append(str(pay["id"]))
tid=t.get("id"); st=t.get("status")
amt=(entry.get("amount_inquired") or {}).get("value","open")
joined=",".join(pids)
print(f" tab={tid} status={st} link=bunq.me/{masked} asked={amt} "
f"fulfilled_by_payment_ids=[{joined}]")'
;;
*) *)
echo "usage: tools/bunq-callback.sh [list | set <account-id> <callback-url>]" >&2 echo "usage: tools/bunq-callback.sh [list | set <account-id> <callback-url> |" >&2
echo " payments <account-id> [count] | bunqme <account-id> [count]]" >&2
exit 2 exit 2
;; ;;
esac esac

View file

@ -158,6 +158,51 @@ if [ "$RAIL" = mollie ]; then
esac esac
fi fi
# /financials has two inputs and a fresh dev run has neither: sales fold out of
# the order ledger, donations and expenses come from the bank-aggregates file.
# So the page honestly renders its empty state — which is worth previewing too,
# and is why this is opt-in rather than always on.
#
# DEV_FINANCIALS=1 seeds both with obviously-sample figures, so the FILLED
# layout can be designed against without waiting for real money or touching
# production. The aggregates file is re-read on every request, so you can edit
# it while the server runs and just refresh.
#
# DEV_FINANCIALS=<path> instead previews a REAL aggregates file — the one
# staged for production, say. No sample orders are seeded in that case: sales
# fold from the ledger, and inventing them would misrepresent the very figures
# you are checking. A closed shop showing €0 of sales is the truth.
if [ "${DEV_FINANCIALS:-0}" != 0 ] && [ "${DEV_FINANCIALS:-0}" != 1 ]; then
if [ ! -f "$DEV_FINANCIALS" ]; then
echo "dev: DEV_FINANCIALS='$DEV_FINANCIALS' is not a file" >&2
exit 1
fi
cp "$DEV_FINANCIALS" "$WORK/orders.jsonl.financials.json"
echo "dev: previewing REAL financials from $DEV_FINANCIALS (no sample orders seeded)"
elif [ "${DEV_FINANCIALS:-0}" = 1 ]; then
# Labels say SAMPLE and the amounts are flat round numbers on purpose:
# plausible-looking figures here were once mistaken for real bank data.
# Nothing in this block comes from anywhere — it exists to fill the layout.
cat > "$WORK/orders.jsonl.financials.json" <<'JSON'
{"as_of":"2026-01-01",
"donations":{"count":4,"total_minor":10000},
"expenses":[{"label":"SAMPLE hosting","total_minor":10000},
{"label":"SAMPLE insurance","total_minor":20000},
{"label":"SAMPLE inventory","total_minor":300000},
{"label":"SAMPLE payment fees","total_minor":5000}]}
JSON
# Two paid orders, written straight to the ledger: the product is
# coming-soon, so checkout refuses and there is no other way to make the
# sales row non-zero. Same event shapes Orders.cpp appends.
cat > "$WORK/orders.jsonl" <<'JSON'
{"type":"order","at":"2026-08-10T10:00:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","ref":"CC-111111","product":"fp6-pmos","color":"green","quantity":1,"unit_minor":56330,"email":"sample@example.org","name":"Sample Buyer","street":"1 Example St","postal":"1000AA","city":"Amsterdam","country":"NL","goods_minor":56330,"shipping_minor":713,"total_minor":57043,"vat_included":true,"status":"awaiting_payment","pay_choice":"bank","pay_url":"","pay_id":"dev-1"}
{"type":"status","at":"2026-08-10T10:04:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","status":"paid","via":"ideal"}
{"type":"order","at":"2026-08-12T14:30:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","ref":"CC-222222","product":"fp6-pmos","color":"white","quantity":1,"unit_minor":65488,"email":"other@example.org","name":"Other Buyer","street":"2 Example Rd","postal":"3000BB","city":"Rotterdam","country":"DE","goods_minor":65488,"shipping_minor":2500,"total_minor":67988,"vat_included":true,"status":"awaiting_payment","pay_choice":"crypto","pay_url":"","pay_id":"dev-2"}
{"type":"status","at":"2026-08-12T14:33:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","status":"paid","via":"bitcoin"}
JSON
echo "dev: seeded SAMPLE financials (DEV_FINANCIALS=1) — figures are invented"
fi
"$SRV/catcrafts-server" --serve "$BACKEND_PORT" \ "$SRV/catcrafts-server" --serve "$BACKEND_PORT" \
--orders="$WORK/orders.jsonl" --rail="$RAIL" \ --orders="$WORK/orders.jsonl" --rail="$RAIL" \
--redirect-base="http://localhost:$PORT" >"$WORK/server.log" 2>&1 & --redirect-base="http://localhost:$PORT" >"$WORK/server.log" 2>&1 &
@ -189,10 +234,17 @@ cat <<EOF
/posts fediverse posts /posts fediverse posts
/demos demo list; /demos/raytracer loads the wasm /demos demo list; /demos/raytracer loads the wasm
/legal/privacy privacy notice /legal/privacy privacy notice
/financials open financials$(case "${DEV_FINANCIALS:-0}" in
0) printf '%s' " — empty; DEV_FINANCIALS=1 seeds sample figures,
DEV_FINANCIALS=<file> previews a real one";;
1) printf '%s' " (SAMPLE data — invented figures)";;
*) printf '%s' " (real figures from $DEV_FINANCIALS)";; esac)
/feed.xml Atom feed /feed.xml Atom feed
/media/* mirrored post media (run tools/fetch-media.sh to populate) /media/* mirrored post media (run tools/fetch-media.sh to populate)
Orders from this session go to a temp file and are discarded on exit. Orders from this session go to a temp file and are discarded on exit.$([ "${DEV_FINANCIALS:-0}" = 1 ] && printf '%s' "
Edit the aggregates and just refresh — they are re-read every request:
\$EDITOR $WORK/orders.jsonl.financials.json")
Payment rail: $RAIL$([ "$RAIL" = fake ] && printf '%s' " — simulate a customer paying with: Payment rail: $RAIL$([ "$RAIL" = fake ] && printf '%s' " — simulate a customer paying with:
touch $WORK/orders.jsonl.fake-paid") touch $WORK/orders.jsonl.fake-paid")
Ctrl-C to stop. Ctrl-C to stop.

View file

@ -654,13 +654,18 @@ body_lacks /financials 'data-fin-donations-count' "no donation figures before th
cat >"$ORDERS.financials.json" <<'JSON' cat >"$ORDERS.financials.json" <<'JSON'
{"as_of":"2026-08-14", {"as_of":"2026-08-14",
"donations":{"count":3,"total_minor":4500}, "donations":{"count":3,"total_minor":4500},
"recurring":[{"label":"Hosting","total_minor":1200}], "expenses":[{"label":"Hosting","total_minor":1200},
"single":[{"label":"Inventory","total_minor":230000}]} {"label":"Inventory","total_minor":230000}]}
JSON JSON
body_has /financials 'data-fin-donations-count="3"' "donation count picked up live" body_has /financials 'data-fin-donations-count="3"' "donation count picked up live"
body_has /financials 'data-fin-expenses-minor="231200"' "expense total picked up live" body_has /financials 'data-fin-expenses-minor="231200"' "expense total picked up live"
body_has /financials 'Hosting' "recurring category renders" # Net = (donations 4500 + sales 0) - expenses 231200. Negative on purpose:
body_has /financials 'Inventory' "one-off category renders" # a shop that has bought stock but not sold it is exactly this shape, and the
# figure has to survive going below zero.
body_has /financials 'data-fin-net-minor="-226700"' "net is published and may be negative"
body_has /financials '€-2267' "a negative net renders with its sign"
body_has /financials 'Hosting' "an expense category renders"
body_has /financials 'Inventory' "a second expense category renders"
body_has /financials '2026-08-14' "bank figures carry their as-of date" body_has /financials '2026-08-14' "bank figures carry their as-of date"
echo "== the bunq mutation callback ==" echo "== the bunq mutation callback =="
@ -672,7 +677,7 @@ echo "== the bunq mutation callback =="
# re-read per callback, so a new rule takes effect without a restart. # re-read per callback, so a new rule takes effect without a restart.
cat >"$ORDERS.financial-rules.json" <<'JSON' cat >"$ORDERS.financial-rules.json" <<'JSON'
{"donation_accounts":[9911], {"donation_accounts":[9911],
"rules":[{"description_contains":"hetzner","group":"recurring","label":"Hosting"}, "rules":[{"description_contains":"hetzner","group":"expense","label":"Hosting"},
{"iban":"NL01OWNSELF0000000","group":"ignore"}]} {"iban":"NL01OWNSELF0000000","group":"ignore"}]}
JSON JSON