retire the bunq integration
All checks were successful
Deploy / build-deploy (push) Successful in 3m32s
All checks were successful
Deploy / build-deploy (push) Successful in 3m32s
The webhook is deregistered at bunq and the callback endpoint, its parser, default-deny classifier, dedup ledger and signature check are removed; the code is in git history if a bank feed ever comes back. /financials keeps reading the hand-maintained aggregates file, and sales + shop donations stay live from the order ledger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4c49000d46
commit
c31797bd9a
13 changed files with 70 additions and 1694 deletions
|
|
@ -6,12 +6,10 @@ 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 financials page and the bunq mutation ingest behind it. 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.
|
||||
// The financials page: the aggregates loader, the renderer's two donation
|
||||
// sources (bank file + shop ledger), and the sales/donations split over the
|
||||
// order fold. The bank-callback ingest this suite once pinned was retired
|
||||
// with the bunq integration — see git history.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
|
|
@ -185,294 +183,10 @@ void FinancialsPage() {
|
|||
"financials: empty ledger sums to zero");
|
||||
}
|
||||
|
||||
// ── the bunq mutation callback ────────────────────────────────────────
|
||||
void BunqIngest() {
|
||||
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");
|
||||
|
||||
// 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":[)"
|
||||
R"({"iban":"NL01OWNSELF0000000","group":"ignore"},)"
|
||||
R"({"description_contains":"hetzner","group":"expense","label":"Hosting"},)"
|
||||
R"({"iban":"DE02SUPPLIER000000","group":"expense","label":"Inventory"},)"
|
||||
R"({"group":"expense","label":"Claims everything"},)"
|
||||
R"({"iban":"NL03TYPO0000000000","group":"nonsense","label":"X"},)"
|
||||
R"({"iban":"NL04NOLABEL0000000","group":"expense"}]})");
|
||||
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");
|
||||
|
||||
// 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;
|
||||
bill.description = "HETZNER ONLINE GMBH invoice";
|
||||
bill.created = "2026-08-15";
|
||||
const Server::MutationClass billClass = Server::ClassifyMutation(bill, rules);
|
||||
Check(billClass.group == "expense" && 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.expenses.size() == 1 && fin.expenses[0].label == "Hosting"
|
||||
&& fin.expenses[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.expenses[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");
|
||||
|
||||
// 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
|
||||
|
||||
int main() {
|
||||
FinancialsPage();
|
||||
BunqIngest();
|
||||
CallbackGate();
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
|
|
|
|||
|
|
@ -6,58 +6,18 @@ 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 financials page over real HTTP, and the bunq mutation callback that
|
||||
// feeds it. Liveness is the page's promise: the aggregates file appears and
|
||||
// the very next request reflects it — no restart, no cache, no delay. The
|
||||
// callback is the only path by which a stranger's money reaches a public
|
||||
// number, so idempotency, default-deny, and the no-leak guarantees are pinned
|
||||
// against the real endpoint here.
|
||||
// The financials page over real HTTP. Liveness is the page's promise: the
|
||||
// aggregates file appears (written by the owner's tooling — the retired bunq
|
||||
// callback used to do this; see git history) and the very next request
|
||||
// reflects it — no restart, no cache, no delay.
|
||||
|
||||
import std;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
namespace {
|
||||
|
||||
// fin_attr <attribute> -> its value on the live page
|
||||
std::string FinAttr(TestServer& srv, std::string_view attr) {
|
||||
const std::string body = srv.Body("/financials");
|
||||
std::smatch m;
|
||||
if (std::regex_search(body, m, std::regex(std::format(R"lit({}="([0-9]*)")lit", attr)))) {
|
||||
return m[1].str();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string BunqPayload(std::string_view id, std::string_view account,
|
||||
std::string_view value, std::string_view iban,
|
||||
std::string_view description) {
|
||||
std::string p = R"({"NotificationUrl":{"category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{"id":)";
|
||||
p += id;
|
||||
p += R"(,"created":"2026-08-15 09:31:02.000000","monetary_account_id":)";
|
||||
p += account;
|
||||
p += R"(,"amount":{"currency":"EUR","value":")";
|
||||
p += value;
|
||||
p += R"("},"description":")";
|
||||
p += description;
|
||||
p += R"(","counterparty_alias":{"iban":")";
|
||||
p += iban;
|
||||
p += R"(","display_name":"Someone"}}}}})";
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// The secret IS the last segment of the callback URL, and setting it is
|
||||
// what brings the endpoint into existence — unset, the path is an
|
||||
// ordinary 404. Note what is NOT here: a bunq API key. One could initiate
|
||||
// payments, so no such key ever reaches the server; it only receives.
|
||||
constexpr std::string_view kSecret = "e2e-callback-secret-not-a-real-one";
|
||||
ServerOptions options;
|
||||
options.env.emplace_back("BUNQ_CALLBACK_SECRET", std::string(kSecret));
|
||||
TestServer srv(argv[1], 8215, options);
|
||||
TestServer srv(argv[1], 8215);
|
||||
|
||||
// ── the financials page ───────────────────────────────────────────
|
||||
// Aggregate-only by construction: totals and counts, machine-readable via
|
||||
|
|
@ -71,8 +31,8 @@ int main(int argc, char** argv) {
|
|||
srv.BodyLacks("/financials", "data-fin-donations-count",
|
||||
"no donation figures before the file exists");
|
||||
|
||||
// The aggregates file appears, exactly as the owner's tooling will write
|
||||
// it, and the very next request reflects it — this is the liveness the
|
||||
// The aggregates file appears, exactly as the owner's tooling writes it,
|
||||
// and the very next request reflects it — this is the liveness the
|
||||
// donation counter depends on.
|
||||
WriteFile(std::filesystem::path(srv.Orders().string() + ".financials.json"),
|
||||
R"({"as_of":"2026-08-14",)"
|
||||
|
|
@ -96,88 +56,5 @@ int main(int argc, char** argv) {
|
|||
srv.BodyHas("/financials", "Inventory", "a second expense category renders");
|
||||
srv.BodyHas("/financials", "2026-08-14", "bank figures carry their as-of date");
|
||||
|
||||
// ── the bunq mutation callback ────────────────────────────────────
|
||||
// The rules are written here rather than at startup on purpose — they are
|
||||
// re-read per callback, so a new rule takes effect without a restart.
|
||||
WriteFile(std::filesystem::path(srv.Orders().string() + ".financial-rules.json"),
|
||||
R"({"donation_accounts":[9911],)"
|
||||
"\n"
|
||||
R"( "rules":[{"description_contains":"hetzner","group":"expense","label":"Hosting"},)"
|
||||
"\n"
|
||||
R"( {"iban":"NL01OWNSELF0000000","group":"ignore"}]})"
|
||||
"\n");
|
||||
|
||||
const std::string cb = std::format("/api/bunq/{}", kSecret);
|
||||
auto bunqPost = [&](std::string_view id, std::string_view account,
|
||||
std::string_view value, std::string_view iban,
|
||||
std::string_view description) {
|
||||
return srv.Post(cb, BunqPayload(id, account, value, iban, description),
|
||||
"application/json").status;
|
||||
};
|
||||
|
||||
// An endpoint guarded by a secret must not confirm its own existence:
|
||||
// every unauthorised shape is the same 404 an unknown order token gets.
|
||||
{
|
||||
const auto wrong = srv.Post("/api/bunq/wrong-secret", "{}", "application/json");
|
||||
Check(wrong.status == "404", "POST /api/bunq/wrong-secret -> 404", wrong.status);
|
||||
}
|
||||
srv.CheckStatus(cb, "404"); // GET on the right URL is still not a callback
|
||||
srv.CheckStatus(cb, "404", "HEAD");
|
||||
|
||||
// A donation arrives on the donation account. No rule names the sender —
|
||||
// donors are strangers, which is exactly why the account is what
|
||||
// classifies.
|
||||
Check(bunqPost("4823", "9911", "25.00", "NL55BUNQ2025123456", "Thanks for imsd") == "200",
|
||||
"the callback accepts a mutation");
|
||||
Check(FinAttr(srv, "data-fin-donations-count") == "4"
|
||||
&& FinAttr(srv, "data-fin-donations-minor") == "7000",
|
||||
"a donation ticks the public counter immediately");
|
||||
|
||||
// bunq redelivers a callback it did not see a 2xx for, and can redeliver
|
||||
// one it did. Counting that twice would publish money that never arrived.
|
||||
bunqPost("4823", "9911", "25.00", "NL55BUNQ2025123456", "Thanks for imsd");
|
||||
Check(FinAttr(srv, "data-fin-donations-count") == "4"
|
||||
&& FinAttr(srv, "data-fin-donations-minor") == "7000",
|
||||
"a redelivered mutation is not counted twice");
|
||||
|
||||
// Default-deny: money no rule claims is WITHHELD from the page. It is
|
||||
// logged for classification, never published as a guess.
|
||||
Check(bunqPost("4824", "1234", "90.00", "NL99UNKNOWN00000000", "unlabelled transfer") == "200",
|
||||
"an unclassifiable mutation is still accepted (no redelivery loop)");
|
||||
Check(FinAttr(srv, "data-fin-donations-count") == "4"
|
||||
&& FinAttr(srv, "data-fin-expenses-minor") == "231200",
|
||||
"an unclassified mutation is withheld from every total");
|
||||
|
||||
// An outgoing bill matched by description becomes a positive expense.
|
||||
bunqPost("4825", "9911", "-12.00", "DE00HETZNER00000000", "HETZNER ONLINE GMBH");
|
||||
Check(FinAttr(srv, "data-fin-expenses-minor") == "232400",
|
||||
"an outgoing bill lands in its expense category");
|
||||
srv.BodyHas("/financials", "2026-08-15", "the as-of date advances with the mutations");
|
||||
|
||||
// The page still publishes nothing but aggregates: no counterparty, no
|
||||
// description, no id, no timestamp. This is the assertion that would
|
||||
// catch a well-meant future edit adding a "recent activity" list.
|
||||
for (std::string_view leak : { "NL55BUNQ2025123456", "Someone", "Thanks for imsd",
|
||||
"4823", "09:31" }) {
|
||||
srv.BodyLacks("/financials", std::string(leak),
|
||||
std::format("financials leak no transaction detail ({})", leak));
|
||||
}
|
||||
|
||||
// And nothing identifying was written to disk either — the ingest ledger
|
||||
// holds opaque ids and counters, and no other file learned the donor
|
||||
// exists.
|
||||
{
|
||||
bool persisted = false;
|
||||
for (const auto& entry :
|
||||
std::filesystem::recursive_directory_iterator(srv.Work())) {
|
||||
if (!entry.is_regular_file()) continue;
|
||||
if (ReadFile(entry.path()).find("NL55BUNQ2025123456") != std::string::npos) {
|
||||
persisted = true;
|
||||
std::println(std::cerr, " IBAN found in {}", entry.path().string());
|
||||
}
|
||||
}
|
||||
Check(!persisted, "no counterparty IBAN is persisted anywhere");
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ struct ServerOptions {
|
|||
// attach the signed invoice, shell out — runs with zero network. Each
|
||||
// accepted message lands as its own mail-<n>.eml.
|
||||
bool mailer = false;
|
||||
// Extra environment for the server (e.g. BUNQ_CALLBACK_SECRET).
|
||||
// Extra environment for the server.
|
||||
std::vector<std::pair<std::string, std::string>> env;
|
||||
};
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ public:
|
|||
// suites assert.
|
||||
for (const char* v : { "MOLLIE_API_KEY", "EURC_CHAINS", "EURC_POOL",
|
||||
"SENDCLOUD_PUBLIC_KEY", "SENDCLOUD_SECRET_KEY",
|
||||
"SENDCLOUD_METHOD", "BUNQ_CALLBACK_SECRET",
|
||||
"SENDCLOUD_METHOD",
|
||||
"INVOICE_GPG_KEY", "MAIL_COMMAND", "MAIL_FROM" }) {
|
||||
::unsetenv(v);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue