finacial page
All checks were successful
Deploy / build-deploy (push) Successful in 2m20s

This commit is contained in:
Jorijn van der Graaf 2026-08-14 02:50:58 +02:00
commit e68d2c245c
17 changed files with 1801 additions and 15 deletions

View file

@ -75,6 +75,12 @@ std::string gCssHref = "/styles.css";
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.
// 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
// loop — a shared sweep that ran at the slower rail's pace would make the
@ -92,6 +98,12 @@ 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 {};
@ -316,6 +328,27 @@ HTTPResponse RenderPage(std::string_view target) {
return res;
}
// The financials page: lifetime sales folded live from the order ledger,
// donations and expenses from the bank-aggregates file. Server-rendered
// here because both inputs are runtime state; the shared dispatch's case
// is the backend-down fallback, like orders. Refolding the ledger per
// request is what every order lookup already does, and the page's whole
// promise is that a refresh shows the current totals — so no caching.
if (route.kind == RouteKind::Financials) {
const std::vector<OrderRecord> orders = ListOrders();
const SalesSummary sales = SummarizeSales(orders);
const Financials fin = CurrentFinancials();
const Views::RenderedPage page =
Views::RenderFinancials(sales.count, sales.totalMinor, fin);
HTTPResponse res;
res.status = std::to_string(page.status);
ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/false,
page.meta.noindex);
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Financials),
Views::RenderFooter(), {}, gCssHref);
return res;
}
const Views::RenderedPage page = Views::RenderRoute(route, gContent);
HTTPResponse res;
@ -1027,6 +1060,48 @@ 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));