This commit is contained in:
parent
70668af8f5
commit
e68d2c245c
17 changed files with 1801 additions and 15 deletions
562
server/implementations/Catcrafts.Server-Financials.cpp
Normal file
562
server/implementations/Catcrafts.Server-Financials.cpp
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
/*
|
||||
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 <openssl/bio.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
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<unsigned char>(c) < 0x20) {
|
||||
out += std::format("\\u{:04x}", static_cast<unsigned char>(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<char>(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<std::string> 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<FinCategory>& 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"("recurring":{},"single":{}}})",
|
||||
JsonEscapeF(f.asOf), f.donationCount, f.donationsMinor,
|
||||
categories(f.recurring), categories(f.single));
|
||||
}
|
||||
|
||||
// ── crypto ────────────────────────────────────────────────────────────
|
||||
|
||||
struct PkeyDeleter {
|
||||
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
|
||||
};
|
||||
|
||||
std::optional<std::vector<unsigned char>> 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<unsigned char> 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<std::uint32_t>(v);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push_back(static_cast<unsigned char>((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<int>(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<EVP_PKEY, PkeyDeleter> 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<const unsigned char*>(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<unsigned char>(a[i]) : 0;
|
||||
const unsigned char cb = i < b.size() ? static_cast<unsigned char>(b[i]) : 0;
|
||||
diff |= static_cast<unsigned char>(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<std::int64_t> 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<BankMutation> 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<std::int64_t>(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<std::int64_t>(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 == "recurring"
|
||||
|| r.group == "single" || 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;
|
||||
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<FinCategory>& 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 == "recurring") {
|
||||
bump(fin.recurring);
|
||||
} else if (cls.group == "single") {
|
||||
bump(fin.single);
|
||||
} 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
|
||||
|
|
@ -75,6 +75,12 @@ std::string gCssHref = "/styles.css";
|
|||
PaymentRails gRails;
|
||||
std::string gRedirectBase = "https://catcrafts.net";
|
||||
|
||||
// The bank-derived aggregates for /financials live in Catcrafts.Server-
|
||||
// Financials.cpp, which owns their file and the bunq callback that updates
|
||||
// them. They are read through CurrentFinancials() per request rather than
|
||||
// cached: unlike the content files they CAN change under a running process,
|
||||
// and live is the page's whole promise.
|
||||
|
||||
// The reconciler's sweep cadence: the shortest interval any configured rail
|
||||
// asks for. Each order is still paced by ITS OWN rail's interval inside the
|
||||
// loop — a shared sweep that ran at the slower rail's pace would make the
|
||||
|
|
@ -92,6 +98,12 @@ std::chrono::seconds SweepInterval() {
|
|||
return out;
|
||||
}
|
||||
|
||||
// The callback URL's fixed prefix; everything after it is the shared secret.
|
||||
// Under /api because Caddy proxies that prefix straight through and the
|
||||
// analytics ingest censors it out of the public report (deploy/README.md) —
|
||||
// a URL carrying a secret must not end up on a page anyone can read.
|
||||
inline constexpr std::string_view kBunqCallbackPrefix = "/api/bunq/";
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& p) {
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
if (!in) return {};
|
||||
|
|
@ -316,6 +328,27 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
return res;
|
||||
}
|
||||
|
||||
// The financials page: lifetime sales folded live from the order ledger,
|
||||
// donations and expenses from the bank-aggregates file. Server-rendered
|
||||
// here because both inputs are runtime state; the shared dispatch's case
|
||||
// is the backend-down fallback, like orders. Refolding the ledger per
|
||||
// request is what every order lookup already does, and the page's whole
|
||||
// promise is that a refresh shows the current totals — so no caching.
|
||||
if (route.kind == RouteKind::Financials) {
|
||||
const std::vector<OrderRecord> orders = ListOrders();
|
||||
const SalesSummary sales = SummarizeSales(orders);
|
||||
const Financials fin = CurrentFinancials();
|
||||
const Views::RenderedPage page =
|
||||
Views::RenderFinancials(sales.count, sales.totalMinor, fin);
|
||||
HTTPResponse res;
|
||||
res.status = std::to_string(page.status);
|
||||
ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/false,
|
||||
page.meta.noindex);
|
||||
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Financials),
|
||||
Views::RenderFooter(), {}, gCssHref);
|
||||
return res;
|
||||
}
|
||||
|
||||
const Views::RenderedPage page = Views::RenderRoute(route, gContent);
|
||||
|
||||
HTTPResponse res;
|
||||
|
|
@ -1027,6 +1060,48 @@ int Serve(std::uint16_t port) {
|
|||
};
|
||||
|
||||
auto fallback = [](const HTTPRequest& req) -> HTTPResponse {
|
||||
// The bunq mutation callback. Handled here rather than through
|
||||
// ParseRoute because the path carries a SECRET — the shared route
|
||||
// table is compiled into the wasm bundle that ships to every browser,
|
||||
// and a secret has no business being in it.
|
||||
//
|
||||
// Everything unauthorised answers 404, never 401: the endpoint should
|
||||
// not confirm its own existence to a prober, exactly as an unknown
|
||||
// order token does not confirm the shape of a real one.
|
||||
if (const std::string_view path = PathWithoutQueryHTTP(req.path);
|
||||
path.starts_with(kBunqCallbackPrefix)) {
|
||||
HTTPResponse res;
|
||||
res.headers["content-type"] = "text/plain; charset=utf-8";
|
||||
res.headers["cache-control"] = "no-store";
|
||||
res.headers["x-robots-tag"] = "noindex, nofollow";
|
||||
const std::string_view secret = path.substr(kBunqCallbackPrefix.size());
|
||||
if (!BunqCallbackConfigured() || req.method != "POST"
|
||||
|| req.body.size() > Form::kMaxBodyBytes) {
|
||||
res.status = "404";
|
||||
res.body = "Not found\n";
|
||||
return res;
|
||||
}
|
||||
std::string_view signature;
|
||||
if (const auto h = req.headers.find("x-bunq-server-signature");
|
||||
h != req.headers.end()) {
|
||||
signature = h->second;
|
||||
}
|
||||
if (!BunqCallbackAuthorised(secret, req.body, signature)) {
|
||||
res.status = "404";
|
||||
res.body = "Not found\n";
|
||||
return res;
|
||||
}
|
||||
// 200 for everything the endpoint understood, including a
|
||||
// withheld or duplicate mutation: those are correct outcomes, and
|
||||
// a non-2xx would make bunq redeliver a callback that was already
|
||||
// handled exactly as intended. Only a failed WRITE earns a 500,
|
||||
// because a retry of that genuinely could succeed.
|
||||
const BunqIngestResult result = IngestBunqNotification(req.body);
|
||||
res.status = result == BunqIngestResult::Failed ? "500" : "200";
|
||||
res.body = result == BunqIngestResult::Failed ? "Could not record\n" : "OK\n";
|
||||
return res;
|
||||
}
|
||||
|
||||
// A POST to a product page is a checkout submission.
|
||||
if (req.method == "POST") {
|
||||
const Route route = ParseRoute(PathWithoutQueryHTTP(req.path));
|
||||
|
|
|
|||
|
|
@ -154,6 +154,13 @@ std::vector<OrderRecord> FoldLocked() {
|
|||
if (const std::string_view via = doc->Str("via"); !via.empty()) {
|
||||
r->paidVia = std::string(via);
|
||||
}
|
||||
// The FIRST paid event is the sale, whatever happens later — a
|
||||
// refund folds the status onward but never unhappens the payment
|
||||
// (see SummarizeSales). First, not last, so a replayed line
|
||||
// cannot move the recorded moment.
|
||||
if (status == "paid" && r->paidAt.empty()) {
|
||||
r->paidAt = std::string(doc->Str("at"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
|
@ -296,6 +303,20 @@ std::vector<OrderRecord> ListOrders() {
|
|||
return FoldLocked();
|
||||
}
|
||||
|
||||
SalesSummary SummarizeSales(std::span<const OrderRecord> orders) {
|
||||
SalesSummary out;
|
||||
for (const OrderRecord& r : orders) {
|
||||
// paidAt is the signal. The status check keeps faith with a ledger
|
||||
// whose order was marked shipped by hand without a paid event ever
|
||||
// being written — the same paid-or-shipped idiom the invoice
|
||||
// download uses.
|
||||
if (r.paidAt.empty() && r.status != "paid" && r.status != "shipped") continue;
|
||||
++out.count;
|
||||
out.totalMinor += r.totalMinor;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string NewOrderToken() {
|
||||
// std::random_device on this platform reads the kernel CSPRNG. The token
|
||||
// gates access to a name and address, so 128 bits — the same order of
|
||||
|
|
|
|||
|
|
@ -1420,6 +1420,216 @@ void RunMoneySelfTest() {
|
|||
Check(r.Find("USD") == 1'083'400, "rates: lookup");
|
||||
Check(r.Find("XXX") == 0, "rates: absent is zero");
|
||||
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
|
||||
|
||||
// ── the financials page ───────────────────────────────────────────
|
||||
{
|
||||
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}]})");
|
||||
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.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(),
|
||||
"financials: a category without a label is dropped");
|
||||
|
||||
Check(ParseRoute("/financials").kind == RouteKind::Financials,
|
||||
"route: /financials");
|
||||
Check(ParseRoute("/financials/").kind == RouteKind::Financials,
|
||||
"route: /financials/ normalises");
|
||||
bool inSitemap = false;
|
||||
for (std::string_view p : SitemapPaths()) inSitemap = inSitemap || p == "/financials";
|
||||
Check(inSitemap, "route: /financials is in the sitemap");
|
||||
|
||||
const LegalPage& notes = Content::FinancialsPage();
|
||||
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 rendered page: live sales plus the bank aggregates, with the
|
||||
// machine-readable copy the e2e suite reads.
|
||||
const Views::RenderedPage fp = Views::RenderFinancials(2, 113745, fin);
|
||||
Check(fp.status == 200, "financials: renders");
|
||||
Check(fp.main.View().find("data-fin-sales-minor=\"113745\"") != std::string_view::npos
|
||||
&& fp.main.View().find("data-fin-expenses-minor=\"234800\"")
|
||||
!= std::string_view::npos,
|
||||
"financials: machine-readable totals");
|
||||
Check(fp.main.View().find("€1137.45") != std::string_view::npos
|
||||
&& fp.main.View().find("€1182.45") != std::string_view::npos,
|
||||
"financials: income rows and their total render");
|
||||
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");
|
||||
|
||||
// Before the bank figures exist the page says so instead of lying
|
||||
// with zeros — and publishes no donation figures at all.
|
||||
const Views::RenderedPage bare = Views::RenderFinancials(0, 0, Financials{});
|
||||
Check(bare.main.View().find("data-fin-sales-count=\"0\"") != 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,
|
||||
"financials: unpublished bank figures say so and publish nothing");
|
||||
|
||||
// Lifetime sales: ever-paid counts, awaiting doesn't, a refund after
|
||||
// payment stays counted, a hand-shipped legacy order counts too.
|
||||
Server::OrderRecord paid;
|
||||
paid.totalMinor = 56330;
|
||||
paid.paidAt = "2026-08-14T00:00:00Z";
|
||||
paid.status = "paid";
|
||||
Server::OrderRecord waiting;
|
||||
waiting.totalMinor = 99999;
|
||||
Server::OrderRecord refunded;
|
||||
refunded.totalMinor = 56930;
|
||||
refunded.paidAt = "2026-08-14T00:00:00Z";
|
||||
refunded.status = "cancelled";
|
||||
Server::OrderRecord shipped;
|
||||
shipped.totalMinor = 200;
|
||||
shipped.status = "shipped";
|
||||
const std::array<Server::OrderRecord, 4> orders{ paid, waiting, refunded, shipped };
|
||||
const Server::SalesSummary sum = Server::SummarizeSales(orders);
|
||||
Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200,
|
||||
"financials: sales count ever-paid orders only");
|
||||
Check(Server::SummarizeSales({}).count == 0,
|
||||
"financials: empty ledger sums to zero");
|
||||
}
|
||||
|
||||
// ── the bunq mutation callback ────────────────────────────────────
|
||||
//
|
||||
// The callback is the only path by which a stranger's money reaches a
|
||||
// public number on this site, so its parser, its classifier and above all
|
||||
// its default-deny behaviour are pinned here. A rule that accidentally
|
||||
// claims everything, or a classifier that treats an unrecognised transfer
|
||||
// as a donation, would publish a figure that is simply untrue.
|
||||
{
|
||||
using Server::ParseSignedAmountToMinor;
|
||||
Check(ParseSignedAmountToMinor("25.00") == 2500, "bunq: positive amount");
|
||||
Check(ParseSignedAmountToMinor("-12.50") == -1250, "bunq: outgoing is negative");
|
||||
Check(ParseSignedAmountToMinor("+5") == 500, "bunq: explicit plus");
|
||||
Check(!ParseSignedAmountToMinor("1.234").has_value(), "bunq: too many decimals");
|
||||
Check(!ParseSignedAmountToMinor("nonsense").has_value(), "bunq: non-numeric");
|
||||
Check(!ParseSignedAmountToMinor("").has_value(), "bunq: empty amount");
|
||||
|
||||
// A realistic payload: the mutation is nested two wrappers deep, and
|
||||
// the parser finds it by SHAPE so a wrapper rename cannot silently
|
||||
// turn every callback into a no-op.
|
||||
constexpr std::string_view kPayload =
|
||||
R"({"NotificationUrl":{"target_url":"https://catcrafts.net/api/bunq/s",)"
|
||||
R"("category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{)"
|
||||
R"("id":4823,"created":"2026-08-14 09:31:02.123456","monetary_account_id":9911,)"
|
||||
R"("amount":{"currency":"EUR","value":"25.00"},)"
|
||||
R"("description":"Thanks for imsd!",)"
|
||||
R"("counterparty_alias":{"iban":"NL55BUNQ2025123456","display_name":"A Donor"}}}}})";
|
||||
const auto m = Server::ParseBunqMutation(kPayload);
|
||||
Check(m.has_value(), "bunq: nested payload parses");
|
||||
if (m) {
|
||||
Check(m->id == "4823", "bunq: numeric id travels as text");
|
||||
Check(m->amountMinor == 2500 && m->currency == "EUR", "bunq: amount and currency");
|
||||
Check(m->account == "9911", "bunq: monetary account");
|
||||
Check(m->counterpartyIban == "NL55BUNQ2025123456", "bunq: counterparty iban");
|
||||
// The time of day never survives the parser: an exact timestamp
|
||||
// is the one field that would let a watcher pin a donation to a
|
||||
// person who mentioned donating.
|
||||
Check(m->created == "2026-08-14", "bunq: only the date is kept");
|
||||
}
|
||||
Check(!Server::ParseBunqMutation("garbage").has_value(), "bunq: malformed payload");
|
||||
Check(!Server::ParseBunqMutation(R"({"NotificationUrl":{"category":"MUTATION"}})")
|
||||
.has_value(),
|
||||
"bunq: a notification with no mutation yields nothing");
|
||||
|
||||
const Server::FinancialRules rules = Server::LoadFinancialRules(
|
||||
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"({"iban":"NL03TYPO0000000000","group":"nonsense","label":"X"},)"
|
||||
R"({"iban":"NL04NOLABEL0000000","group":"recurring"}]})");
|
||||
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
|
||||
// mutation, the typo'd group is not a category, and an expense with
|
||||
// no label has nothing to render as.
|
||||
Check(rules.rules.size() == 3, "bunq: unsafe rules are dropped at load");
|
||||
|
||||
// Incoming on the donation account, claimed by no explicit rule.
|
||||
Check(m && Server::ClassifyMutation(*m, rules).group == "donations",
|
||||
"bunq: incoming on the donation account is a donation");
|
||||
|
||||
Server::BankMutation x = *m;
|
||||
// Money LEAVING the donation account is not a gift to this company.
|
||||
x.amountMinor = -2500;
|
||||
Check(Server::ClassifyMutation(x, rules).group.empty(),
|
||||
"bunq: outgoing on the donation account is not a donation");
|
||||
// An explicit ignore beats the donation-account default, which is how
|
||||
// the owner's own transfer between accounts stays out of the total.
|
||||
x = *m;
|
||||
x.counterpartyIban = "nl01ownself0000000";
|
||||
Check(Server::ClassifyMutation(x, rules).group == "ignore",
|
||||
"bunq: an explicit rule beats the donation default, case-insensitively");
|
||||
// Foreign currency is never folded into a euro total.
|
||||
x = *m;
|
||||
x.currency = "USD";
|
||||
Check(Server::ClassifyMutation(x, rules).group.empty(),
|
||||
"bunq: non-euro is never counted");
|
||||
// Default-deny: an ordinary transfer from a stranger, on an account
|
||||
// that is not the donation one, is withheld rather than guessed at.
|
||||
x = *m;
|
||||
x.account = "1234";
|
||||
x.counterpartyIban = "NL99UNKNOWN0000000";
|
||||
x.description = "";
|
||||
Check(Server::ClassifyMutation(x, rules).group.empty(),
|
||||
"bunq: an unmatched mutation is withheld, not guessed");
|
||||
|
||||
Server::BankMutation bill;
|
||||
bill.currency = "EUR";
|
||||
bill.amountMinor = -1200;
|
||||
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",
|
||||
"bunq: description matching, case-insensitively");
|
||||
|
||||
// Folding into the aggregates.
|
||||
Financials fin;
|
||||
Server::ApplyMutation(fin, Server::ClassifyMutation(*m, rules), *m);
|
||||
Check(fin.donationCount == 1 && fin.donationsMinor == 2500,
|
||||
"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,
|
||||
"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
|
||||
// income, and never drags the as-of date backwards.
|
||||
Server::BankMutation refund = bill;
|
||||
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.asOf == "2026-08-15", "bunq: as-of never moves backwards");
|
||||
// An unclassified mutation touches nothing at all.
|
||||
const Financials before = fin;
|
||||
Server::ApplyMutation(fin, Server::MutationClass{}, *m);
|
||||
Check(fin.donationCount == before.donationCount
|
||||
&& fin.ExpensesMinor() == before.ExpensesMinor(),
|
||||
"bunq: an unclassified mutation changes no total");
|
||||
}
|
||||
}
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& p) {
|
||||
|
|
@ -1538,6 +1748,7 @@ int main(int argc, char** argv) {
|
|||
if (has("--routes")) {
|
||||
const Views::SiteContent content = LoadContent("content");
|
||||
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope",
|
||||
"/financials",
|
||||
"/order/0123456789abcdef0123456789abcdef",
|
||||
"/order/not-a-token",
|
||||
"/legal/privacy", "/legal/imprint",
|
||||
|
|
@ -1654,6 +1865,28 @@ int main(int argc, char** argv) {
|
|||
}
|
||||
|
||||
Server::SetOrdersPath(ordersPath);
|
||||
// The /financials aggregates and the bunq callback that feeds them.
|
||||
// Same derivation convention as the rail marker and the shipping
|
||||
// cache: state hangs off the orders path. The secret is the last
|
||||
// segment of the callback URL and is what enables the endpoint at
|
||||
// all; unset means /api/bunq/* is a plain 404. No bunq API KEY is
|
||||
// ever read here — see Catcrafts.Server-Financials.cpp for why.
|
||||
{
|
||||
Server::FinancialsConfig finCfg;
|
||||
finCfg.publicPath = ordersPath;
|
||||
finCfg.publicPath += ".financials.json";
|
||||
finCfg.seenPath = ordersPath;
|
||||
finCfg.seenPath += ".financials-seen.json";
|
||||
finCfg.rulesPath = ordersPath;
|
||||
finCfg.rulesPath += ".financial-rules.json";
|
||||
if (const char* v = std::getenv("BUNQ_CALLBACK_SECRET"); v && *v) {
|
||||
finCfg.callbackSecret = v;
|
||||
}
|
||||
if (const char* v = std::getenv("BUNQ_CALLBACK_PUBKEY"); v && *v) {
|
||||
finCfg.publicKeyPem = v;
|
||||
}
|
||||
Server::ConfigureFinancials(std::move(finCfg));
|
||||
}
|
||||
Server::LoadContent(contentDir, bundleIndex);
|
||||
// Refuse to serve an empty catalogue: it almost always means the
|
||||
// content path is wrong or a JSON file is malformed, and a silently
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue