All checks were successful
Deploy / build-deploy (push) Successful in 2m20s
562 lines
23 KiB
C++
562 lines
23 KiB
C++
/*
|
|
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
|