retire the bunq integration
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:
Jorijn van der Graaf 2026-08-17 11:36:54 +02:00
commit c31797bd9a
13 changed files with 70 additions and 1694 deletions

View file

@ -76,10 +76,10 @@ 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.
// Financials.cpp, which owns their file. 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
@ -98,12 +98,6 @@ 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 {};
@ -1132,48 +1126,6 @@ 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));