finacial page
All checks were successful
Deploy / build-deploy (push) Successful in 2m20s

This commit is contained in:
Jorijn van der Graaf 2026-08-14 02:50:58 +02:00
commit e68d2c245c
17 changed files with 1801 additions and 15 deletions

View file

@ -73,6 +73,10 @@ export namespace Catcrafts::Server {
std::string payId; // provider payment id ("tr_…" at Mollie,
// a decimal order id at CoinGate)
std::string paidVia; // method that settled it ("ideal", "bitcoin")
std::string paidAt; // ISO 8601 of the FIRST paid event; empty =
// never paid. A later cancel (a refund)
// does not clear it: "was this ever paid"
// is what the public sales totals count.
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
std::string invoicedAt; // ISO 8601 of the invoice event
std::string confirmationSentAt; // ISO 8601 of the confirmation-email
@ -109,6 +113,123 @@ export namespace Catcrafts::Server {
// apology — never toward a buyer who paid and heard nothing.
bool AppendOrderNotified(std::string_view token, std::string_view isoTimestamp);
// ── the public financials page ────────────────────────────────────
//
// /financials shows lifetime sales as two integers: how many orders were
// ever paid, and what they summed to. Ever-paid on purpose — a refund is
// an expense on that page, it does not un-happen the sale. Pure and
// exported for the self-test.
struct SalesSummary {
std::int64_t count = 0;
std::int64_t totalMinor = 0;
};
SalesSummary SummarizeSales(std::span<const OrderRecord> orders);
// ── 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.
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
};
void ConfigureFinancials(FinancialsConfig config);
// The published aggregates, re-read per request — live is the page's
// promise and the file is a few hundred bytes. Empty asOf = nothing
// 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",
// "recurring", "single" or "ignore" — anything else is a typo and the
// rule is dropped at load rather than inventing a category.
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