donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-17 11:04:03 +02:00
commit abbd616b40
23 changed files with 2898 additions and 209 deletions

View file

@ -104,6 +104,41 @@ void FinancialsPage() {
Check(Money::FormatEuro(-26260) == "€-262.60" && Money::FormatEuro(-500) == "€-5.00",
"financials: negative euro formatting");
// Donations paid through the shop join the bank-side ones in ONE row —
// the reader has no use for a split by collection channel. The income
// total and the net move with them.
{
const Views::RenderedPage both = Views::RenderFinancials(2, 113745, fin, 2, 5000);
Check(both.main.View().find("data-fin-donations-count=\"5\"") != std::string_view::npos
&& both.main.View().find("data-fin-donations-minor=\"9500\"")
!= std::string_view::npos,
"financials: shop and bank donations sum into one row");
Check(both.main.View().find("Donations (5)") != std::string_view::npos,
"financials: the donation row counts both sources");
// Net = (4500 + 5000 + 113745) - 234800 = -111555.
Check(both.main.View().find("data-fin-net-minor=\"-111555\"") != std::string_view::npos,
"financials: the net includes shop donations");
}
// A shop donation shows the moment it is paid, even before any bank
// figures exist — it is live from the order ledger, like sales. The
// income total still waits for the bank side: a total missing half its
// inputs is not a total.
{
const Views::RenderedPage shopOnly = Views::RenderFinancials(0, 0, Financials{}, 1, 2500);
Check(shopOnly.main.View().find("data-fin-donations-count=\"1\"") != std::string_view::npos
&& shopOnly.main.View().find("data-fin-donations-minor=\"2500\"")
!= std::string_view::npos,
"financials: a shop donation publishes without bank figures");
Check(shopOnly.main.View().find("Donations (1)") != std::string_view::npos,
"financials: and renders its row");
// The Income SECTION heading always renders; what must wait for the
// bank side is the ruled-off total row (and the net).
Check(shopOnly.main.View().find(R"(<tr class="fin-total"><th scope="row">Income</th>)")
== std::string_view::npos
&& shopOnly.main.View().find("data-fin-net-minor") == std::string_view::npos,
"financials: no income total or net while the bank side is unpublished");
}
// 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{});
@ -133,10 +168,19 @@ void FinancialsPage() {
Server::OrderRecord shipped;
shipped.totalMinor = 200;
shipped.status = "shipped";
const std::array<Server::OrderRecord, 4> orders{ paid, waiting, refunded, shipped };
// A paid donation is income but not a sale: it must land in the donation
// pair, or the page would book the same euro as a sale.
Server::OrderRecord gift;
gift.totalMinor = 2500;
gift.donation = true;
gift.paidAt = "2026-08-17T00:00:00Z";
gift.status = "paid";
const std::array<Server::OrderRecord, 5> orders{ paid, waiting, refunded, shipped, gift };
const Server::SalesSummary sum = Server::SummarizeSales(orders);
Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200,
"financials: sales count ever-paid orders only");
"financials: sales count ever-paid orders only, donations excluded");
Check(sum.donationCount == 1 && sum.donationsMinor == 2500,
"financials: a paid shop donation folds into the donation pair");
Check(Server::SummarizeSales({}).count == 0,
"financials: empty ledger sums to zero");
}
@ -178,6 +222,42 @@ void BunqIngest() {
.has_value(),
"bunq: a notification with no mutation yields nothing");
// The same payload with one field swapped, so every case below differs
// from the parsing case above by exactly the thing under test.
auto payloadWith = [](std::string_view amountObject, std::string_view alias) {
std::string out;
out += R"({"NotificationUrl":{"category":"MUTATION","object":{"Payment":{)";
out += R"("id":4823,"created":"2026-08-14 09:31:02.123456",)";
out += R"("monetary_account_id":9911,"amount":)";
out += amountObject;
out += R"(,"description":"Thanks for imsd!","counterparty_alias":)";
out += alias;
out += R"(}}}})";
return out;
};
constexpr std::string_view kDonorAlias =
R"({"iban":"NL55BUNQ2025123456","display_name":"A Donor"})";
// FindPaymentObject matches on the SHAPE — an amount object carrying a
// value, plus an id — and never looks at what the value SAYS. So a
// locale-mangled or hostile amount reaches the parser inside an otherwise
// perfectly well-formed mutation, and the refusal has to happen here. If
// it ever softened to a zero fallback the mutation would be recorded as
// seen, permanently deduped, with the money dropped from the totals and
// nothing in the operator log to say so.
Check(!Server::ParseBunqMutation(
payloadWith(R"({"currency":"EUR","value":"25,00"})", kDonorAlias))
.has_value(),
"bunq: a comma decimal is refused rather than read as zero");
Check(!Server::ParseBunqMutation(
payloadWith(R"({"currency":"EUR","value":"1.234"})", kDonorAlias))
.has_value(),
"bunq: a third fraction digit is refused rather than truncated");
Check(!Server::ParseBunqMutation(
payloadWith(R"({"currency":"EUR","value":"abc"})", kDonorAlias))
.has_value(),
"bunq: a non-numeric amount is refused");
const Server::FinancialRules rules = Server::LoadFinancialRules(
R"({"donation_accounts":[9911],)"
R"("rules":[)"
@ -223,6 +303,31 @@ void BunqIngest() {
Check(Server::ClassifyMutation(x, rules).group.empty(),
"bunq: an unmatched mutation is withheld, not guessed");
// A payload with no "currency" key at all still has the shape the parser
// needs, so it parses — and is then refused by the classifier, which is
// where the euro-only rule lives. Nothing reaches a euro total on the
// strength of a field that was never sent.
const auto noCurrency =
Server::ParseBunqMutation(payloadWith(R"({"value":"25.00"})", kDonorAlias));
Check(noCurrency && noCurrency->amountMinor == 2500 && noCurrency->currency.empty(),
"bunq: an amount with no currency still parses");
Check(noCurrency && Server::ClassifyMutation(*noCurrency, rules).group.empty(),
"bunq: an unstated currency is never assumed to be euro");
// bunq's other alias flavour nests the IBAN one level down, under
// "labelMonetaryAccount". If that fallback broke, the IBAN would come
// back empty, the owner's own transfer INTO the donation account would
// stop matching its ignore rule, and the donation-account default would
// publish the owner's own money as a stranger's gift — on the one page
// whose entire promise is that the number is true.
const auto nested = Server::ParseBunqMutation(payloadWith(
R"({"currency":"EUR","value":"25.00"})",
R"({"labelMonetaryAccount":{"iban":"NL01OWNSELF0000000","display_name":"Self"}})"));
Check(nested && nested->counterpartyIban == "NL01OWNSELF0000000",
"bunq: the nested alias flavour still yields an iban");
Check(nested && Server::ClassifyMutation(*nested, rules).group == "ignore",
"bunq: the owner's own transfer in is ignored, whichever alias shape carries it");
Server::BankMutation bill;
bill.currency = "EUR";
bill.amountMinor = -1200;
@ -257,6 +362,109 @@ void BunqIngest() {
Check(fin.donationCount == before.donationCount
&& fin.ExpensesMinor() == before.ExpensesMinor(),
"bunq: an unclassified mutation changes no total");
// A REFUNDED gift. Reachable because an explicit rule may name a group
// outright, so "donations" is not the exclusive property of the
// incoming-only account default tested above.
const Server::FinancialRules donationRules = Server::LoadFinancialRules(
R"({"rules":[{"iban":"NL55BUNQ2025123456","group":"donations"}]})");
Server::BankMutation giftBack = *m;
giftBack.amountMinor = -1000;
const Server::MutationClass backClass =
Server::ClassifyMutation(giftBack, donationRules);
Check(backClass.group == "donations",
"bunq: an explicit rule can classify outgoing money as a donation");
// The count follows money IN, never money out: 2500 - 1000 = 1500, and
// the one person who gave still gave. Decrementing here would put the
// published donor count below the number of people who actually donated,
// and the weekly reconciliation folds through this same function — it
// would reproduce the wrong figure rather than correct it.
Financials gifts;
gifts.donationsMinor = 2500;
gifts.donationCount = 1;
Server::ApplyMutation(gifts, backClass, giftBack);
Check(gifts.donationsMinor == 1500 && gifts.donationCount == 1,
"bunq: a refunded gift reduces the total and leaves the count alone");
// Two expenses under different labels are two rows, in first-seen order.
// Merging them would hide what the money went on behind one bigger
// number, which is the opposite of what this page is for.
Server::BankMutation supplier;
supplier.currency = "EUR";
supplier.amountMinor = -5000;
supplier.counterpartyIban = "DE02SUPPLIER000000";
supplier.created = "2026-08-16";
const Server::MutationClass supplierClass = Server::ClassifyMutation(supplier, rules);
Check(supplierClass.group == "expense" && supplierClass.label == "Inventory",
"bunq: iban matching picks the supplier's category");
Financials twoCats;
Server::ApplyMutation(twoCats, billClass, bill); // -1200 out → +1200 Hosting
Server::ApplyMutation(twoCats, supplierClass, supplier); // -5000 out → +5000 Inventory
Check(twoCats.expenses.size() == 2
&& twoCats.expenses[0].label == "Hosting"
&& twoCats.expenses[0].totalMinor == 1200
&& twoCats.expenses[1].label == "Inventory"
&& twoCats.expenses[1].totalMinor == 5000,
"bunq: distinct labels become distinct rows, in first-seen order");
Check(twoCats.ExpensesMinor() == 6200, "bunq: the expense total is the sum of its rows");
}
// ── the callback gate ─────────────────────────────────────────────────
//
// The one endpoint that writes public money figures, and the only thing
// standing in front of it. Driven through ConfigureFinancials because that is
// how the real server reaches it; no key material and no network are needed
// to pin the parts that matter.
void CallbackGate() {
// Unconfigured: the path is a plain 404 and nothing authorises. An
// endpoint that is off should not announce itself by answering
// differently to a well-formed guess than to an empty one.
Server::ConfigureFinancials(Server::FinancialsConfig{});
Check(!Server::BunqCallbackConfigured(),
"callback: with no secret the endpoint does not exist");
Check(!Server::BunqCallbackAuthorised("", "{}", ""),
"callback: an empty secret authorises nothing while unconfigured");
Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""),
"callback: even a well-formed secret is refused while unconfigured");
Server::FinancialsConfig cfg;
cfg.callbackSecret = "s3cret-not-real"; // never a live one: the real
// secret only ever comes from
// the environment on the box
Server::ConfigureFinancials(cfg);
Check(Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""),
"callback: the exact secret is authorised");
// SecretEqual folds a length mismatch into the same accumulator as the
// byte differences, so neither a prefix nor an extension can return early
// — a plain == would leak the secret one byte at a time through timing,
// and the secret sits in the URL where it can be probed a request at a
// time.
Check(!Server::BunqCallbackAuthorised("s3cret-not-rea", "{}", ""),
"callback: a prefix of the secret is refused");
Check(!Server::BunqCallbackAuthorised("s3cret-not-realX", "{}", ""),
"callback: an extension of the secret is refused");
Check(!Server::BunqCallbackAuthorised("", "{}", ""),
"callback: an empty secret never matches a configured one");
// A secret with nowhere to write the aggregates is still no endpoint:
// this is what keeps the path a 404 on a box that has the env var but
// not the storage.
Check(!Server::BunqCallbackConfigured(),
"callback: a secret without an aggregates path leaves the endpoint off");
cfg.publicPath = "/nonexistent-catcrafts/financials.json";
Server::ConfigureFinancials(cfg);
Check(Server::BunqCallbackConfigured(),
"callback: secret plus aggregates path is what turns the endpoint on");
// Turning signature checking ON must never become a no-op. With a key
// path that cannot be read there is no way to verify anything, so the
// CORRECT secret now fails too — closed, not open.
cfg.publicKeyPem = "/nonexistent-catcrafts/bunq-public-key.pem";
Server::ConfigureFinancials(cfg);
Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", "YWJj"),
"callback: signature checking with an unreadable key fails closed");
Server::ConfigureFinancials(Server::FinancialsConfig{}); // leave no global behind
}
} // namespace
@ -264,6 +472,7 @@ void BunqIngest() {
int main() {
FinancialsPage();
BunqIngest();
CallbackGate();
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);