diff --git a/deploy/README.md b/deploy/README.md index d077980..af22327 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -544,7 +544,7 @@ of the fields the seven-year fiscal retention does not cover. ## The open financials page and the bunq mutation callback `/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. 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 {"donation_accounts": [9911], "rules": [ - {"description_contains": "hetzner", "group": "recurring", "label": "Hosting"}, - {"iban": "NL00INSURER0000000", "group": "recurring", "label": "Insurance"}, - {"iban": "DE00SUPPLIER000000", "group": "single", "label": "Inventory"}, + {"description_contains": "hetzner", "group": "expense", "label": "Hosting"}, + {"iban": "NL00INSURER0000000", "group": "expense", "label": "Insurance"}, + {"iban": "DE00SUPPLIER000000", "group": "expense", "label": "Inventory"}, {"iban": "NL00MOLLIE00000000", "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 transfer between accounts stays out of the donation total). **Ignore your Mollie and CoinGate payouts** — those are sales, already counted from the diff --git a/server/implementations/Catcrafts.Server-Financials.cpp b/server/implementations/Catcrafts.Server-Financials.cpp index db2572e..92bc201 100644 --- a/server/implementations/Catcrafts.Server-Financials.cpp +++ b/server/implementations/Catcrafts.Server-Financials.cpp @@ -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 } diff --git a/server/implementations/main.cpp b/server/implementations/main.cpp index 230deb1..fa2ea5a 100644 --- a/server/implementations/main.cpp +++ b/server/implementations/main.cpp @@ -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; diff --git a/server/interfaces/Catcrafts.Server.cppm b/server/interfaces/Catcrafts.Server.cppm index 491353f..c3fde20 100644 --- a/server/interfaces/Catcrafts.Server.cppm +++ b/server/interfaces/Catcrafts.Server.cppm @@ -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 diff --git a/shared/interfaces/Catcrafts.Shared-Content.cppm b/shared/interfaces/Catcrafts.Shared-Content.cppm index 8078a93..d2ef37e 100644 --- a/shared/interfaces/Catcrafts.Shared-Content.cppm +++ b/shared/interfaces/Catcrafts.Shared-Content.cppm @@ -207,12 +207,12 @@ export const LegalPage& FinancialsPage() { .slug = "financials", .title = "Financials", .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 = { { "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.", - "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", { diff --git a/shared/interfaces/Catcrafts.Shared-Model.cppm b/shared/interfaces/Catcrafts.Shared-Model.cppm index 27ff237..e23ebc1 100644 --- a/shared/interfaces/Catcrafts.Shared-Model.cppm +++ b/shared/interfaces/Catcrafts.Shared-Model.cppm @@ -298,14 +298,18 @@ export struct Financials { // empty = nothing published yet std::int64_t donationCount = 0; std::int64_t donationsMinor = 0; - std::vector recurring; // insurance, hosting, … - std::vector single; // inventory, fees, tax, … + // ONE flat list of expense categories. This carried a recurring/one-off + // 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 expenses; bool Loaded() const { return !asOf.empty(); } std::int64_t ExpensesMinor() const { std::int64_t sum = 0; - for (const FinCategory& c : recurring) sum += c.totalMinor; - for (const FinCategory& c : single) sum += c.totalMinor; + for (const FinCategory& c : expenses) sum += c.totalMinor; return sum; } }; @@ -322,9 +326,7 @@ export Financials LoadFinancials(std::string_view json) { out.donationCount = d->Int("count"); out.donationsMinor = d->Int("total_minor"); } - auto categories = [](const Json::Value* arr) { - std::vector cats; - if (!arr || !arr->IsArray()) return cats; + if (const Json::Value* arr = doc->Find("expenses"); arr && arr->IsArray()) { for (const Json::Value& v : arr->array) { if (!v.IsObject()) continue; 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 // beats an anonymous row that looks like a redaction. 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; } diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm index 6f9d973..055e8de 100644 --- a/shared/interfaces/Catcrafts.Shared-Views.cppm +++ b/shared/interfaces/Catcrafts.Shared-Views.cppm @@ -1473,32 +1473,22 @@ export RenderedPage RenderFinancials(std::int64_t salesCount, std::vector incomeRows; if (fin.Loaded()) { incomeRows.push_back(MoneyRow( - fin.donationCount == 1 ? std::string("Donations (1)") - : std::format("Donations ({})", fin.donationCount), + std::format("Donations ({})", fin.donationCount), fin.donationsMinor)); } incomeRows.push_back(MoneyRow( - salesCount == 1 ? std::string("Sales (1 order)") - : std::format("Sales ({} orders)", salesCount), + std::format("Sales ({})", salesCount), salesTotalMinor)); if (fin.Loaded()) { incomeRows.push_back(totalRow("Income", fin.donationsMinor + salesTotalMinor)); } - // Expenses: one table, group-header rows for the recurring/one-off split, - // so the amounts stay in a single aligned column. + // Expenses: one flat table. No recurring/one-off grouping — see the note + // on Financials::expenses for why a lifetime total cannot carry a rate. SafeHtml expenses; if (fin.Loaded()) { std::vector rows; - auto group = [&](std::string_view heading, std::span cats) { - if (cats.empty()) return; - rows.push_back(Format( - R"({})", - Escape(heading))); - for (const FinCategory& c : cats) rows.push_back(MoneyRow(c.label, c.totalMinor)); - }; - group("Recurring", fin.recurring); - group("One-off", fin.single); + for (const FinCategory& c : fin.expenses) rows.push_back(MoneyRow(c.label, c.totalMinor)); rows.push_back(totalRow("Expenses", fin.ExpensesMinor())); expenses = Format(R"({}
)", Join(rows)); @@ -1509,6 +1499,33 @@ export RenderedPage RenderFinancials(std::int64_t salesCount, R"(figures above are already live.

)"); } + // 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"(

Net

)" + R"({}
)" + R"(

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.

)" + R"(
)", + // 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. const SafeHtml freshness = fin.Loaded() ? Format(R"(

{}

)" R"({})" R"()" - R"(
)" + R"(
)" R"(

Income

)" R"({}
)" R"(

Expenses

{}
)" + R"({})" R"(
)" R"()", Escape(notes.title), Escape(notes.lede), freshness, @@ -1554,7 +1572,8 @@ export RenderedPage RenderFinancials(std::int64_t salesCount, : SafeHtml{}, fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor())) : 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; } diff --git a/styles/styles.css b/styles/styles.css index c63645a..40a882c 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -939,15 +939,8 @@ treatment it replaces, which read as the wrong category for the work. /* ── the financials page ──────────────────────────────────────────── */ - /* The running-total tables are .spec-table; these two row types are the - ledger idioms on top of it: a group label heading the rows beneath it, - 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); - } + /* The running-total tables are .spec-table; this row type is the one ledger + idiom on top of it: a sum ruled off from what it sums. */ .fin-total th, .fin-total td { border-top: 2px solid var(--border-strong); diff --git a/tools/bunq-callback.sh b/tools/bunq-callback.sh index 37cfc49..5609fe9 100755 --- a/tools/bunq-callback.sh +++ b/tools/bunq-callback.sh @@ -185,8 +185,51 @@ for item in d.get("Response",[]): 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}")' echo - echo "current MUTATION filters:" - api GET "/v1/user/$USER_ID/notification-filter-url" "" "$SESSION" | python3 -m json.tool + # Filters registered PER ACCOUNT do not appear in the user-level list, so + # 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) ACCOUNT="${2:?usage: tools/bunq-callback.sh set }" @@ -207,6 +250,21 @@ set) echo " (A wrong secret answers 404 by design — check the secret too.)" >&2 exit 1 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" # POST REPLACES the whole filter set for this account, so this is also how # you change or clear one. @@ -220,8 +278,165 @@ print(json.dumps({"notification_filters":[ echo "bunq: done. Send yourself €0.01 and watch:" 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 [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]}" + 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 ...}" + 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 [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 ]" >&2 + echo "usage: tools/bunq-callback.sh [list | set |" >&2 + echo " payments [count] | bunqme [count]]" >&2 exit 2 ;; esac diff --git a/tools/dev.sh b/tools/dev.sh index dfa8b25..34ca882 100755 --- a/tools/dev.sh +++ b/tools/dev.sh @@ -158,6 +158,51 @@ if [ "$RAIL" = mollie ]; then esac 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= 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" \ --orders="$WORK/orders.jsonl" --rail="$RAIL" \ --redirect-base="http://localhost:$PORT" >"$WORK/server.log" 2>&1 & @@ -189,10 +234,17 @@ cat < previews a real one";; + 1) printf '%s' " (SAMPLE data — invented figures)";; + *) printf '%s' " (real figures from $DEV_FINANCIALS)";; esac) /feed.xml Atom feed /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: touch $WORK/orders.jsonl.fake-paid") Ctrl-C to stop. diff --git a/tools/e2e.sh b/tools/e2e.sh index 0fd92c2..e2ab41c 100755 --- a/tools/e2e.sh +++ b/tools/e2e.sh @@ -654,13 +654,18 @@ body_lacks /financials 'data-fin-donations-count' "no donation figures before th cat >"$ORDERS.financials.json" <<'JSON' {"as_of":"2026-08-14", "donations":{"count":3,"total_minor":4500}, - "recurring":[{"label":"Hosting","total_minor":1200}], - "single":[{"label":"Inventory","total_minor":230000}]} + "expenses":[{"label":"Hosting","total_minor":1200}, + {"label":"Inventory","total_minor":230000}]} JSON 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 'Hosting' "recurring category renders" -body_has /financials 'Inventory' "one-off category renders" +# Net = (donations 4500 + sales 0) - expenses 231200. Negative on purpose: +# 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" 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. cat >"$ORDERS.financial-rules.json" <<'JSON' {"donation_accounts":[9911], - "rules":[{"description_contains":"hetzner","group":"recurring","label":"Hosting"}, + "rules":[{"description_contains":"hetzner","group":"expense","label":"Hosting"}, {"iban":"NL01OWNSELF0000000","group":"ignore"}]} JSON