retire the bunq integration
All checks were successful
Deploy / build-deploy (push) Successful in 3m32s

The webhook is deregistered at bunq and the callback endpoint, its parser,
default-deny classifier, dedup ledger and signature check are removed; the
code is in git history if a bank feed ever comes back. /financials keeps
reading the hand-maintained aggregates file, and sales + shop donations
stay live from the order ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-17 11:36:54 +02:00
commit c31797bd9a
13 changed files with 70 additions and 1694 deletions

View file

@ -139,36 +139,17 @@ export namespace Catcrafts::Server {
// ── the bank side of /financials ──────────────────────────────────
//
// Donations and expenses come from a bank mutation callback rather than
// from an API key on this box: a bunq key can INITIATE PAYMENTS and has
// no read-only scope, so the key stays on the owner's machine (IP-bound)
// and is used there once to register a notification filter. From then on
// bunq pushes mutations here, and the server can learn that money moved
// without being able to move any.
//
// What crosses this boundary is deliberately small. A mutation is
// classified, its amount lands in a category total, its opaque id goes in
// a dedup ledger, and the name, IBAN and description are dropped before
// anything is written. Classification is default-deny: money no rule
// claims is withheld from the page and logged, never published as a guess.
// The owner's weekly pull recomputes every total from the full bunq
// history and overwrites the aggregates file, which is what makes this
// path safe to be lossy.
// Bank-side donations and expenses come from an aggregates file written
// by the owner's own tooling, off this box — no bank credential and no
// bank callback exists here (the bunq mutation callback was retired
// 2026-08-17; its code is in git history). Aggregates by construction:
// category totals and an as-of date are all the file can carry, which is
// the page's privacy design.
struct FinancialsConfig {
std::filesystem::path publicPath; // <orders>.financials.json — what
// the page reads; also written by
// the owner's reconciliation
std::filesystem::path seenPath; // <orders>.financials-seen.json —
// ingested mutation ids, so a
// redelivery cannot double-count
std::filesystem::path rulesPath; // <orders>.financial-rules.json —
// the classifier, authored by hand
std::string callbackSecret; // BUNQ_CALLBACK_SECRET; the last
// path segment of the callback
// URL. Empty = endpoint disabled
std::filesystem::path publicKeyPem; // BUNQ_CALLBACK_PUBKEY; empty =
// signature checking off
// the page reads; written by the
// owner's reconciliation
};
void ConfigureFinancials(FinancialsConfig config);
@ -177,72 +158,6 @@ export namespace Catcrafts::Server {
// published yet, which the page states rather than showing zeros.
Financials CurrentFinancials();
// One bank mutation, reduced to what a total needs. Everything
// identifying is dropped by the classifier and never persisted.
struct BankMutation {
std::string id; // bunq's opaque id — the dedup key
std::int64_t amountMinor = 0; // SIGNED: negative is money leaving
std::string currency; // only EUR is ever counted
std::string counterpartyIban; // classifier input; never stored
std::string description; // classifier input; never stored
std::string account; // monetary account id
std::string created; // ISO date only — the time is
// dropped at the parser
};
// One classification rule. A rule matches when every criterion it states
// matches; the first matching rule wins. `group` is "donations",
// "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
std::string account; // monetary account id
std::string group;
std::string label; // the page's category name
};
struct FinancialRules {
// Accounts whose INCOMING money is a donation by default. Keyed on
// the account because donors are strangers: no IBAN list can know
// them in advance.
std::vector<std::string> donationAccounts;
std::vector<FinancialRule> rules;
};
// Empty group = no rule claimed it. That is the default-deny answer, and
// the caller must withhold rather than guess.
struct MutationClass {
std::string group;
std::string label;
};
// Pure and exported for the self-test.
std::optional<std::int64_t> ParseSignedAmountToMinor(std::string_view s);
std::optional<BankMutation> ParseBunqMutation(std::string_view json);
FinancialRules LoadFinancialRules(std::string_view json);
MutationClass ClassifyMutation(const BankMutation& m, const FinancialRules& rules);
void ApplyMutation(Financials& fin, const MutationClass& cls, const BankMutation& m);
// Whether the callback endpoint exists at all. Unconfigured, the path is
// a plain 404 — an endpoint that is off should not announce itself.
bool BunqCallbackConfigured();
// Constant-time secret check, plus the RSA-SHA256 body signature when a
// public key is configured. False means 404, for the same reason an
// unknown order token is: a probe learns nothing from the shape.
bool BunqCallbackAuthorised(std::string_view pathSecret, std::string_view body,
std::string_view signatureB64);
enum class BunqIngestResult { Applied, Duplicate, Withheld, Ignored, Failed };
// Parse, classify, and fold one notification into the aggregates. Never
// reports a transport-level error for an unparseable body: bunq retries
// non-2xx, so an unknown payload shape would redeliver forever. It is
// logged and left to the weekly reconciliation instead.
BunqIngestResult IngestBunqNotification(std::string_view body);
// ── invoices ──────────────────────────────────────────────────────
//
// A paid order's invoice: plain markdown, clearsigned with the shop's