This commit is contained in:
parent
70668af8f5
commit
e68d2c245c
17 changed files with 1801 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue