/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ // The public financials aggregates, and the bunq callback that keeps them live. // // The page at /financials shows running totals only. Sales fold out of the // order ledger on every request (Orders.cpp); donations and expenses come from // the aggregates file this unit owns, and bunq's mutation callback is what // moves those numbers the moment money does. // // THE PRIVACY RULE, which is the reason this unit is shaped the way it is: a // bank mutation arrives here carrying a counterparty name, an IBAN and a // description. NONE of that is ever written down. A mutation is classified, // its amount is added to a category total, and its opaque id goes in a // dedup ledger so a retry cannot double-count it. Everything else is dropped // on the floor before anything is persisted. There is therefore no file on // this box that the callback path could leak a donor's identity from, because // no such file is ever written. // // CLASSIFICATION IS DEFAULT-DENY. A mutation that no rule claims is NOT // published — not as "other", not as a guess. It is counted and logged for the // operator, and stays out of the totals until a rule exists for it. Getting a // number wrong on this page is worse than the number being late. // // WHY THE KEY IS NOT HERE. A bunq API key can initiate payments; there is no // read-only scope. So this box never holds one. The notification filter is // registered once from the owner's own machine (which is where the key lives, // IP-bound), and from then on bunq PUSHES here. The server can receive money // news without being able to move money — which is the whole point. // // THE CALLBACK IS PROVISIONAL, THE WEEKLY PULL IS AUTHORITATIVE. Callbacks can // be missed, replayed or arrive out of order, and the classifier can be wrong // until a rule is added. The owner's home tooling recomputes every total from // the full bunq mutation history and overwrites the aggregates file wholesale. // That is the correction mechanism, and it is why this path is allowed to be // lossy but never wrong: it may withhold, it may not invent. module; #include #include #include module Catcrafts.Server; import std; import Catcrafts.Shared; namespace Catcrafts::Server { namespace { std::mutex gFinMutex; FinancialsConfig gFinCfg; std::string ReadStateFile(const std::filesystem::path& p) { if (p.empty()) return {}; std::ifstream in(p, std::ios::binary); if (!in) return {}; std::ostringstream buf; buf << in.rdbuf(); return buf.str(); } // Write via a temp file and rename, so a reader (the /financials handler, or // the owner's tooling) never observes a half-written document. The aggregates // file is read on every page request, so a torn write would be a visible // wrong number rather than a transient. bool WriteStateFileAtomic(const std::filesystem::path& p, std::string_view data) { if (p.empty()) return false; std::filesystem::path tmp = p; tmp += ".tmp"; { std::ofstream out(tmp, std::ios::binary | std::ios::trunc); if (!out) return false; out << data; out.flush(); if (!out) return false; } std::error_code ec; std::filesystem::permissions(tmp, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, ec); std::filesystem::rename(tmp, p, ec); if (ec) { std::filesystem::remove(tmp, ec); return false; } return true; } std::string JsonEscapeF(std::string_view s) { std::string out; out.reserve(s.size() + 8); for (const char c : s) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: if (static_cast(c) < 0x20) { out += std::format("\\u{:04x}", static_cast(c)); } else { out += c; } } } return out; } std::string LowerF(std::string_view s) { std::string out(s); for (char& c : out) { if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); } return out; } // ── the ingest ledger ───────────────────────────────────────────────── // // Opaque bunq mutation ids and two counters. No amounts against ids, no // names, nothing that reconstructs a transaction — this file exists purely so // a redelivered callback is recognised as one already counted. struct SeenLedger { std::vector ids; std::int64_t pendingCount = 0; // mutations no rule claimed std::int64_t pendingMinor = 0; // and what they summed to, for the operator bool Has(std::string_view id) const { return std::find(ids.begin(), ids.end(), id) != ids.end(); } }; SeenLedger LoadSeen(std::string_view json) { SeenLedger out; auto doc = Json::Parse(json); if (!doc || !doc->IsObject()) return out; if (const Json::Value* a = doc->Find("ids"); a && a->IsArray()) { for (const Json::Value& v : a->array) { if (v.type == Json::Type::String && !v.string.empty()) out.ids.push_back(v.string); } } out.pendingCount = doc->Int("pending_count"); out.pendingMinor = doc->Int("pending_minor"); return out; } std::string SerialiseSeen(const SeenLedger& s) { std::string out = "{\"ids\":["; for (std::size_t i = 0; i < s.ids.size(); ++i) { if (i) out += ','; out += std::format("\"{}\"", JsonEscapeF(s.ids[i])); } out += std::format("],\"pending_count\":{},\"pending_minor\":{}}}", s.pendingCount, s.pendingMinor); return out; } // The aggregates file, in exactly the shape Shared's LoadFinancials reads and // the owner's tooling writes. One format, three writers, no translation layer. std::string SerialiseFinancials(const Financials& f) { auto categories = [](const std::vector& cats) { std::string out = "["; for (std::size_t i = 0; i < cats.size(); ++i) { if (i) out += ','; out += std::format(R"({{"label":"{}","total_minor":{}}})", JsonEscapeF(cats[i].label), cats[i].totalMinor); } out += ']'; return out; }; return std::format( R"({{"as_of":"{}",)" R"("donations":{{"count":{},"total_minor":{}}},)" R"("expenses":{}}})", JsonEscapeF(f.asOf), f.donationCount, f.donationsMinor, categories(f.expenses)); } // ── crypto ──────────────────────────────────────────────────────────── struct PkeyDeleter { void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); } }; std::optional> Base64Decode(std::string_view in) { auto sextet = [](char c) -> int { if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') return c - 'a' + 26; if (c >= '0' && c <= '9') return c - '0' + 52; if (c == '+') return 62; if (c == '/') return 63; return -1; }; std::vector out; std::uint32_t acc = 0; int bits = 0; for (const char c : in) { if (c == '\n' || c == '\r' || c == ' ' || c == '\t') continue; if (c == '=') break; const int v = sextet(c); if (v < 0) return std::nullopt; // not base64: refuse rather than guess acc = (acc << 6) | static_cast(v); bits += 6; if (bits >= 8) { bits -= 8; out.push_back(static_cast((acc >> bits) & 0xff)); } } return out; } // RSA-SHA256 over the raw request body against bunq's server public key. // // Optional and off unless a key file is configured, for an honest reason: the // header bunq signs callbacks with has changed across API generations, and a // verifier that is wrong about the header name rejects every real callback // while looking like it is working. Enable it once a real callback has been // observed carrying a signature — see deploy/README.md. When it IS enabled a // failure is fatal to the request: no signature, no ingest. bool SignatureValid(std::string_view body, std::string_view signatureB64, const std::filesystem::path& pubkeyPath) { const std::string pem = ReadStateFile(pubkeyPath); if (pem.empty() || signatureB64.empty()) return false; const auto sig = Base64Decode(signatureB64); if (!sig || sig->empty()) return false; BIO* bio = BIO_new_mem_buf(pem.data(), static_cast(pem.size())); if (!bio) return false; EVP_PKEY* raw = PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr); BIO_free(bio); if (!raw) return false; const std::unique_ptr key(raw); EVP_MD_CTX* ctx = EVP_MD_CTX_new(); if (!ctx) return false; bool ok = false; do { if (EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key.get()) != 1) break; ok = EVP_DigestVerify(ctx, sig->data(), sig->size(), reinterpret_cast(body.data()), body.size()) == 1; } while (false); EVP_MD_CTX_free(ctx); return ok; } // Length-independent, content-independent comparison. The secret sits in the // callback URL, so an attacker can probe it one request at a time; a plain == // would leak the matching prefix through timing. bool SecretEqual(std::string_view a, std::string_view b) { if (a.empty() || b.empty()) return false; unsigned char diff = a.size() == b.size() ? 0 : 1; const std::size_t n = std::max(a.size(), b.size()); for (std::size_t i = 0; i < n; ++i) { const unsigned char ca = i < a.size() ? static_cast(a[i]) : 0; const unsigned char cb = i < b.size() ? static_cast(b[i]) : 0; diff |= static_cast(ca ^ cb); } return diff == 0; } // Recursively find the object that describes the payment. bunq wraps the // payload differently across API generations and event types // (NotificationUrl -> object -> Payment | MutationCreated | …), so this looks // for the SHAPE rather than a fixed path: an object carrying an "amount" // object and an "id". Matching on shape is what keeps a wrapper rename from // silently turning every callback into a no-op. const Json::Value* FindPaymentObject(const Json::Value& v) { if (v.IsObject()) { const Json::Value* amount = v.Find("amount"); if (amount && amount->IsObject() && amount->Find("value") && v.Find("id")) return &v; for (const auto& [k, child] : v.object) { if (const Json::Value* hit = FindPaymentObject(child)) return hit; } } else if (v.IsArray()) { for (const Json::Value& child : v.array) { if (const Json::Value* hit = FindPaymentObject(child)) return hit; } } return nullptr; } } // namespace // ── pure parsing and classification (exported for the self-test) ────── std::optional ParseSignedAmountToMinor(std::string_view s) { bool negative = false; if (s.starts_with('-')) { negative = true; s.remove_prefix(1); } else if (s.starts_with('+')) { s.remove_prefix(1); } const auto magnitude = ParseAmountToMinor(s); if (!magnitude) return std::nullopt; return negative ? -*magnitude : *magnitude; } std::optional ParseBunqMutation(std::string_view json) { auto doc = Json::Parse(json); if (!doc) return std::nullopt; const Json::Value* pay = FindPaymentObject(*doc); if (!pay) return std::nullopt; BankMutation m; // bunq sends the id as a JSON number; it travels as text from here, like // every other provider id in this codebase. if (const Json::Value* id = pay->Find("id")) { if (id->type == Json::Type::Number) { m.id = std::format("{}", static_cast(id->number)); } else if (id->type == Json::Type::String) { m.id = id->string; } } if (m.id.empty()) return std::nullopt; const Json::Value* amount = pay->Find("amount"); if (!amount) return std::nullopt; m.currency = std::string(amount->Str("currency")); const auto minor = ParseSignedAmountToMinor(amount->Str("value")); if (!minor) return std::nullopt; m.amountMinor = *minor; if (const Json::Value* cp = pay->Find("counterparty_alias"); cp && cp->IsObject()) { // The IBAN sits either directly on the alias or under its // "labelMonetaryAccount"/"iban", depending on the payload flavour. m.counterpartyIban = std::string(cp->Str("iban")); if (m.counterpartyIban.empty()) { if (const Json::Value* lma = cp->Find("labelMonetaryAccount"); lma && lma->IsObject()) { m.counterpartyIban = std::string(lma->Str("iban")); } } } m.description = std::string(pay->Str("description")); if (const std::int64_t acct = pay->Int("monetary_account_id"); acct != 0) { m.account = std::format("{}", acct); } // "2026-08-14 09:31:02.123456" -> "2026-08-14". Only the date is kept, and // only to stamp the page's as-of line; the time is dropped here, at the // parser, so no later code can publish it by accident. if (const std::string_view created = pay->Str("created"); created.size() >= 10) { m.created = std::string(created.substr(0, 10)); } return m; } FinancialRules LoadFinancialRules(std::string_view json) { FinancialRules out; auto doc = Json::Parse(json); if (!doc || !doc->IsObject()) return out; if (const Json::Value* a = doc->Find("donation_accounts"); a && a->IsArray()) { for (const Json::Value& v : a->array) { if (v.type == Json::Type::String) out.donationAccounts.push_back(v.string); else if (v.type == Json::Type::Number) { out.donationAccounts.push_back( std::format("{}", static_cast(v.number))); } } } if (const Json::Value* a = doc->Find("rules"); a && a->IsArray()) { for (const Json::Value& v : a->array) { if (!v.IsObject()) continue; FinancialRule r; r.iban = LowerF(v.Str("iban")); r.descriptionContains = LowerF(v.Str("description_contains")); r.account = std::string(v.Str("account")); r.group = std::string(v.Str("group")); r.label = std::string(v.Str("label")); // A rule with no criterion would claim every mutation, which is // the exact opposite of default-deny. A rule whose group is not // one this code understands is a typo, and a typo must not // 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 == "expense" || r.group == "ignore"; if (!hasCriterion || !knownGroup) 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)); } } return out; } MutationClass ClassifyMutation(const BankMutation& m, const FinancialRules& rules) { MutationClass out; // Only euro. A foreign-currency mutation has no place in a euro total and // converting one here would invent a rate. if (m.currency != "EUR") return out; const std::string iban = LowerF(m.counterpartyIban); const std::string description = LowerF(m.description); // Explicit rules first, so an "ignore" can carve an exception out of a // donation account — the owner moving money between their own accounts // must not read as a gift. for (const FinancialRule& r : rules.rules) { if (!r.iban.empty() && r.iban != iban) continue; if (!r.account.empty() && r.account != m.account) continue; if (!r.descriptionContains.empty() && description.find(r.descriptionContains) == std::string::npos) { continue; } out.group = r.group; out.label = r.label; return out; } // The donation-account default: money ARRIVING on an account dedicated to // donations is a donation. Keyed on the account rather than the sender // because donors are strangers — an IBAN allowlist cannot know them, and // this is the one category that must work for someone who has never paid // this company before. if (m.amountMinor > 0 && std::find(rules.donationAccounts.begin(), rules.donationAccounts.end(), m.account) != rules.donationAccounts.end()) { out.group = "donations"; } return out; } void ApplyMutation(Financials& fin, const MutationClass& cls, const BankMutation& m) { auto bump = [&](std::vector& cats) { for (FinCategory& c : cats) { if (c.label == cls.label) { // Outgoing money is negative on the wire and an expense is a // positive total, so the sign flips here. A refund from a // supplier arrives positive and correctly REDUCES the // category rather than appearing as income. c.totalMinor += -m.amountMinor; return; } } cats.push_back(FinCategory{ cls.label, -m.amountMinor }); }; if (cls.group == "donations") { fin.donationsMinor += m.amountMinor; // 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 == "expense") { bump(fin.expenses); } else { return; // "ignore" and unclassified touch nothing } // The page's freshness line. Never moves backwards: callbacks can arrive // out of order, and an as-of date that jumped back a week would read as a // stall that never happened. if (!m.created.empty() && m.created > fin.asOf) fin.asOf = m.created; } // ── configuration and the live aggregates ───────────────────────────── void ConfigureFinancials(FinancialsConfig config) { std::lock_guard lock(gFinMutex); gFinCfg = std::move(config); } Financials CurrentFinancials() { std::lock_guard lock(gFinMutex); return LoadFinancials(ReadStateFile(gFinCfg.publicPath)); } bool BunqCallbackConfigured() { std::lock_guard lock(gFinMutex); return !gFinCfg.callbackSecret.empty() && !gFinCfg.publicPath.empty(); } bool BunqCallbackAuthorised(std::string_view pathSecret, std::string_view body, std::string_view signatureB64) { std::filesystem::path pubkey; { std::lock_guard lock(gFinMutex); if (gFinCfg.callbackSecret.empty()) return false; if (!SecretEqual(pathSecret, gFinCfg.callbackSecret)) return false; pubkey = gFinCfg.publicKeyPem; } if (pubkey.empty()) return true; // signature checking not enabled return SignatureValid(body, signatureB64, pubkey); } BunqIngestResult IngestBunqNotification(std::string_view body) { std::lock_guard lock(gFinMutex); if (gFinCfg.publicPath.empty()) return BunqIngestResult::Ignored; const auto mutation = ParseBunqMutation(body); if (!mutation) { // Deliberately NOT an error status: bunq retries a non-2xx, so a // payload shape this parser does not understand would become an // endless redelivery loop. It is logged instead, and the weekly pull // is what recovers the money. The log names no field VALUES — the // point of the log is which shape arrived, not what it said. std::println(std::cerr, "catcrafts-server: bunq callback carried no recognisable " "mutation ({} bytes); the weekly reconciliation will pick " "it up", body.size()); return BunqIngestResult::Ignored; } SeenLedger seen = LoadSeen(ReadStateFile(gFinCfg.seenPath)); if (seen.Has(mutation->id)) return BunqIngestResult::Duplicate; const FinancialRules rules = LoadFinancialRules(ReadStateFile(gFinCfg.rulesPath)); const MutationClass cls = ClassifyMutation(*mutation, rules); // Recorded as seen either way: an unclassified mutation must not be // re-counted as pending on every redelivery, and once a rule exists for // it the weekly pull is what brings the money in. seen.ids.push_back(mutation->id); BunqIngestResult result = BunqIngestResult::Withheld; if (cls.group.empty()) { ++seen.pendingCount; seen.pendingMinor += mutation->amountMinor; std::println(std::cerr, "catcrafts-server: bunq mutation {} matched no rule and is " "WITHHELD from /financials ({} awaiting classification, " "{} cents net). Add a rule to {}.", mutation->id, seen.pendingCount, seen.pendingMinor, gFinCfg.rulesPath.string()); } else if (cls.group == "ignore") { result = BunqIngestResult::Ignored; } else { Financials fin = LoadFinancials(ReadStateFile(gFinCfg.publicPath)); ApplyMutation(fin, cls, *mutation); // First publication: a file that has never been written has no as-of // date, and a mutation with no usable created stamp still has to // produce one or the page would keep saying nothing is published. if (fin.asOf.empty()) { fin.asOf = mutation->created.empty() ? std::string("1970-01-01") : mutation->created; } if (!WriteStateFileAtomic(gFinCfg.publicPath, SerialiseFinancials(fin))) { std::println(std::cerr, "catcrafts-server: could not write the financials " "aggregates to {}", gFinCfg.publicPath.string()); return BunqIngestResult::Failed; } result = BunqIngestResult::Applied; } if (!WriteStateFileAtomic(gFinCfg.seenPath, SerialiseSeen(seen))) { // The money is already published; losing the dedup entry only risks a // double count on redelivery, so say so loudly rather than fail the // request and guarantee that redelivery. std::println(std::cerr, "catcrafts-server: could not write the financials ingest " "ledger to {} — a redelivered callback may double-count", gFinCfg.seenPath.string()); } return result; } } // namespace Catcrafts::Server