This commit is contained in:
parent
70668af8f5
commit
e68d2c245c
17 changed files with 1801 additions and 15 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -20,7 +20,14 @@ media/
|
||||||
# temp dir under dev.sh/e2e; never in the repo, never in the web root.
|
# temp dir under dev.sh/e2e; never in the repo, never in the web root.
|
||||||
orders.jsonl
|
orders.jsonl
|
||||||
*.fake-paid
|
*.fake-paid
|
||||||
|
*.financials.json
|
||||||
|
*.financials-seen.json
|
||||||
|
*.financial-rules.json
|
||||||
bunq-state.json
|
bunq-state.json
|
||||||
|
# tools/bunq-callback.sh's client keypair (a PRIVATE key) and the bunq server
|
||||||
|
# public key it saves for BUNQ_CALLBACK_PUBKEY. Home-machine credentials.
|
||||||
|
bunq-client-key.pem
|
||||||
|
bunq-server-public-key.pem
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|
|
||||||
137
deploy/README.md
137
deploy/README.md
|
|
@ -14,10 +14,14 @@ now for the content files and matters a great deal once the shop has a database
|
||||||
and bank credentials.
|
and bank credentials.
|
||||||
|
|
||||||
Runtime state goes in a third place, `/var/lib/catcrafts`, created by the
|
Runtime state goes in a third place, `/var/lib/catcrafts`, created by the
|
||||||
service's `StateDirectory=`. Today that is `orders.jsonl` (the order event log)
|
service's `StateDirectory=`. Today that is `orders.jsonl` (the order event log),
|
||||||
and `orders.jsonl.shipping.json` (the cached carrier rate table). Neither
|
`orders.jsonl.shipping.json` (the cached carrier rate table) and three files
|
||||||
payment provider needs stored state — both authenticate with a bearer token per
|
behind the public /financials page — `orders.jsonl.financials.json` (the
|
||||||
request.
|
published running totals), `orders.jsonl.financials-seen.json` (ingested bunq
|
||||||
|
mutation ids, so a redelivered callback cannot double-count) and
|
||||||
|
`orders.jsonl.financial-rules.json` (the classifier). See "The open financials
|
||||||
|
page and the bunq mutation callback". Neither payment provider needs stored
|
||||||
|
state — both authenticate with a bearer token per request.
|
||||||
|
|
||||||
**Two things on this box cannot be regenerated.** Everything else — the wasm
|
**Two things on this box cannot be regenerated.** Everything else — the wasm
|
||||||
bundle, the content, the binary — comes back from a rebuild.
|
bundle, the content, the binary — comes back from a rebuild.
|
||||||
|
|
@ -35,7 +39,9 @@ cp /var/lib/catcrafts/orders.jsonl \
|
||||||
It contains names, addresses and email addresses, so it is personal data: keep
|
It contains names, addresses and email addresses, so it is personal data: keep
|
||||||
it 0600, keep it off the web root, and encrypt it before it leaves the machine.
|
it 0600, keep it off the web root, and encrypt it before it leaves the machine.
|
||||||
(The cached shipping table is deliberately NOT worth backing up: delete it and
|
(The cached shipping table is deliberately NOT worth backing up: delete it and
|
||||||
the next Sendcloud refresh rebuilds it.)
|
the next Sendcloud refresh rebuilds it. Neither are the financials files: the
|
||||||
|
weekly reconciliation regenerates the totals from the bank history, and the
|
||||||
|
rules file is a handful of lines you can rewrite.)
|
||||||
|
|
||||||
The second is `/srv/catcrafts-app/media` — the mirrored post images and screen
|
The second is `/srv/catcrafts-app/media` — the mirrored post images and screen
|
||||||
recordings. Usually reproducible from `content/posts.json`, but **not if a source
|
recordings. Usually reproducible from `content/posts.json`, but **not if a source
|
||||||
|
|
@ -535,6 +541,127 @@ Each appends a status event to the log — nothing is ever rewritten, so the
|
||||||
file remains its own audit trail. Deleting personal data on request is an edit
|
file remains its own audit trail. Deleting personal data on request is an edit
|
||||||
of the fields the seven-year fiscal retention does not cover.
|
of the fields the seven-year fiscal retention does not cover.
|
||||||
|
|
||||||
|
## The open financials page and the bunq mutation callback
|
||||||
|
|
||||||
|
`/financials` publishes running totals only: sales, donations, and expenses
|
||||||
|
grouped into recurring and one-off. Sales fold out of `orders.jsonl` on every
|
||||||
|
request and need no setup at all — that half works the moment the page ships.
|
||||||
|
This section is about the other half.
|
||||||
|
|
||||||
|
**No bunq API key belongs on this box.** A bunq key can initiate payments and
|
||||||
|
there is no read-only scope, so a compromised server would be a compromised
|
||||||
|
bank account. Instead the key stays on your own machine, is used there once to
|
||||||
|
register a notification filter, and from then on bunq PUSHES mutations here.
|
||||||
|
The server can learn that money moved without being able to move any.
|
||||||
|
|
||||||
|
### What reaches the page, and what never does
|
||||||
|
|
||||||
|
A callback arrives carrying a counterparty name, an IBAN and a description.
|
||||||
|
None of it is written down. The mutation is classified, its amount is added to
|
||||||
|
a category total, its opaque id goes in a dedup ledger, and everything else is
|
||||||
|
dropped before anything touches the disk. There is no file here that could leak
|
||||||
|
a donor's identity, because no such file is ever written. `tools/e2e.sh` asserts
|
||||||
|
exactly that, by grepping the whole state directory for a test IBAN afterwards.
|
||||||
|
|
||||||
|
Classification is **default-deny**: money no rule claims is withheld from the
|
||||||
|
page and logged for you to write a rule for. It is never published as "other".
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **A dedicated bunq account.** Point donations at a monetary account used
|
||||||
|
for nothing else. That account id is what classifies a donation, because
|
||||||
|
donors are strangers and no IBAN list can know them in advance.
|
||||||
|
|
||||||
|
2. **The secret.** It is the last segment of the callback URL, and setting it
|
||||||
|
is what brings the endpoint into existence — unset, `/api/bunq/*` is an
|
||||||
|
ordinary 404.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl rand -hex 32 # into BUNQ_CALLBACK_SECRET in payments.env
|
||||||
|
systemctl restart catcrafts-server
|
||||||
|
```
|
||||||
|
|
||||||
|
The URL is `https://catcrafts.net/api/bunq/<secret>`. Caddy proxies `/api/*`
|
||||||
|
straight through, so no Caddyfile change is needed, and the analytics ingest
|
||||||
|
censors `/api` out of the **public** report. It is NOT censored from the
|
||||||
|
private tier or from Caddy's own access log, so treat the secret the way you
|
||||||
|
treat the analytics password: rotate it if logs are ever shared.
|
||||||
|
|
||||||
|
3. **The rules**, at `/var/lib/catcrafts/orders.jsonl.financial-rules.json`.
|
||||||
|
Re-read on every callback, so a new rule takes effect without a restart:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"donation_accounts": [9911],
|
||||||
|
"rules": [
|
||||||
|
{"description_contains": "hetzner", "group": "recurring", "label": "Hosting"},
|
||||||
|
{"iban": "NL00INSURER0000000", "group": "recurring", "label": "Insurance"},
|
||||||
|
{"iban": "DE00SUPPLIER000000", "group": "single", "label": "Inventory"},
|
||||||
|
{"iban": "NL00MOLLIE00000000", "group": "ignore"},
|
||||||
|
{"iban": "NL00OWNSELF0000000", "group": "ignore"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`group` is `donations`, `recurring`, `single` or `ignore`; first match wins,
|
||||||
|
and explicit rules beat the donation-account default (which is how your own
|
||||||
|
transfer between accounts stays out of the donation total). **Ignore your
|
||||||
|
Mollie and CoinGate payouts** — those are sales, already counted from the
|
||||||
|
ledger, and letting them through would publish that money twice. A rule with
|
||||||
|
no criterion, an unknown group, or an expense with no label is dropped at
|
||||||
|
load rather than allowed to claim everything.
|
||||||
|
|
||||||
|
4. **Register the filter from your own machine**, with the key that lives
|
||||||
|
there — `tools/bunq-callback.sh` does the whole handshake (installation,
|
||||||
|
device-server, session) and installs a `MUTATION` `NotificationFilterUrl`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/bunq-callback.sh list # accounts + current filters
|
||||||
|
tools/bunq-callback.sh set <account-id> https://catcrafts.net/api/bunq/<secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
It reads `BUNQ_KEY` from the repo-root `.env`, binds the key to **this
|
||||||
|
machine's** address only (never the server's — `permitted_ips` governs who
|
||||||
|
may CALL the bunq API, not where callbacks are delivered), and refuses to
|
||||||
|
register a URL that is not already answering 200. Run `list` first and put
|
||||||
|
the donations account id in `donation_accounts` in the rules file above.
|
||||||
|
|
||||||
|
The device registration is permanent for that key, and your home address is
|
||||||
|
probably dynamic: when it rotates, this script stops working from here
|
||||||
|
(add the new address to the device in the bunq app). **The callback keeps
|
||||||
|
working regardless** — bunq delivers outbound, so nothing about `permitted_ips`
|
||||||
|
affects it.
|
||||||
|
|
||||||
|
5. **Optional but recommended: signature checking.** Set
|
||||||
|
`BUNQ_CALLBACK_PUBKEY` to a PEM file holding bunq's server public key and
|
||||||
|
every callback must then carry a valid RSA-SHA256 signature over its body.
|
||||||
|
It is off by default deliberately: the header bunq signs with has changed
|
||||||
|
across API generations, and a verifier wrong about the header name rejects
|
||||||
|
every real callback while looking like it works. Turn it on **after** you
|
||||||
|
have seen a real callback arrive carrying `X-Bunq-Server-Signature`, and
|
||||||
|
confirm afterwards that donations still land.
|
||||||
|
|
||||||
|
### Operating it
|
||||||
|
|
||||||
|
* Watch it work: `journalctl -u catcrafts-server -f`. A mutation no rule
|
||||||
|
claimed logs its id, the running count of withheld mutations and their net
|
||||||
|
total — that log line is your to-do list.
|
||||||
|
* Every unauthorised request answers 404, never 401: the endpoint does not
|
||||||
|
confirm its own existence to a prober.
|
||||||
|
* A duplicate, a withheld and an ignored mutation all answer 200. A non-2xx
|
||||||
|
makes bunq redeliver, so only a failed write earns a 500 — the one case
|
||||||
|
where a retry could actually help.
|
||||||
|
* State files, all under `/var/lib/catcrafts` and none worth backing up:
|
||||||
|
`orders.jsonl.financials.json` (the published totals),
|
||||||
|
`orders.jsonl.financials-seen.json` (ingested ids + withheld counters), and
|
||||||
|
the rules file above.
|
||||||
|
|
||||||
|
### The weekly reconciliation is the authority
|
||||||
|
|
||||||
|
Callbacks can be missed, replayed or arrive before a rule exists for them, so
|
||||||
|
this path is allowed to be **lossy but never wrong**: it may withhold, it may
|
||||||
|
not invent. Your home tooling recomputes every total from the full bunq
|
||||||
|
mutation history and overwrites `orders.jsonl.financials.json` wholesale — same
|
||||||
|
format, same file. That is the correction mechanism, and it is what makes it
|
||||||
|
safe for the live path to publish provisionally.
|
||||||
|
|
||||||
## Verifying a deploy
|
## Verifying a deploy
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,14 @@ ReadOnlyPaths=/srv/catcrafts-app /srv/catcrafts.net
|
||||||
# INVOICE_GPG_KEY=... invoice signing (see deploy/README.md)
|
# INVOICE_GPG_KEY=... invoice signing (see deploy/README.md)
|
||||||
# MAIL_COMMAND=msmtp -t order confirmation email (see deploy/README.md,
|
# MAIL_COMMAND=msmtp -t order confirmation email (see deploy/README.md,
|
||||||
# MAIL_FROM=... "Order email"); unset = no email is sent
|
# MAIL_FROM=... "Order email"); unset = no email is sent
|
||||||
|
# BUNQ_CALLBACK_SECRET=... the last segment of the bunq mutation callback
|
||||||
|
# URL, and what brings that endpoint into being:
|
||||||
|
# unset, /api/bunq/* is a plain 404. NOT an API
|
||||||
|
# key — no bunq key belongs on this box, because
|
||||||
|
# one can initiate payments (deploy/README.md,
|
||||||
|
# "The bunq mutation callback")
|
||||||
|
# BUNQ_CALLBACK_PUBKEY=... path to bunq's server public key in PEM; set it
|
||||||
|
# to REQUIRE a valid RSA-SHA256 body signature
|
||||||
# The '-' prefix makes the file optional: without it the server starts with
|
# The '-' prefix makes the file optional: without it the server starts with
|
||||||
# payments off and the shop renders but refuses checkout — degraded, not down.
|
# payments off and the shop renders but refuses checkout — degraded, not down.
|
||||||
EnvironmentFile=-/etc/catcrafts/payments.env
|
EnvironmentFile=-/etc/catcrafts/payments.env
|
||||||
|
|
|
||||||
11
project.cpp
11
project.cpp
|
|
@ -119,7 +119,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
std::array<fs::path, 1> ifaces = {
|
std::array<fs::path, 1> ifaces = {
|
||||||
"server/interfaces/Catcrafts.Server",
|
"server/interfaces/Catcrafts.Server",
|
||||||
};
|
};
|
||||||
std::array<fs::path, 8> impls = {
|
std::array<fs::path, 9> impls = {
|
||||||
"server/implementations/main",
|
"server/implementations/main",
|
||||||
"server/implementations/Catcrafts.Server-Http",
|
"server/implementations/Catcrafts.Server-Http",
|
||||||
"server/implementations/Catcrafts.Server-Orders",
|
"server/implementations/Catcrafts.Server-Orders",
|
||||||
|
|
@ -128,14 +128,17 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
"server/implementations/Catcrafts.Server-Coingate",
|
"server/implementations/Catcrafts.Server-Coingate",
|
||||||
"server/implementations/Catcrafts.Server-Shipping",
|
"server/implementations/Catcrafts.Server-Shipping",
|
||||||
"server/implementations/Catcrafts.Server-Mail",
|
"server/implementations/Catcrafts.Server-Mail",
|
||||||
|
"server/implementations/Catcrafts.Server-Financials",
|
||||||
};
|
};
|
||||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
|
||||||
// Both rails reach their provider over TLS, which is what libssl is
|
// Both rails reach their provider over TLS, which is what libssl is
|
||||||
// for here. No code in this product calls libcrypto directly any more
|
// for here. libcrypto is named again on its own account: the bunq
|
||||||
// — the RSA request signing that did went with the bunq rail — so it
|
// mutation callback verifies an RSA-SHA256 body signature through
|
||||||
// is left to libssl's own dependency rather than named again.
|
// EVP, so this product calls libcrypto directly rather than only
|
||||||
|
// inheriting it as libssl's dependency.
|
||||||
cfg.linkFlags.push_back("-lssl");
|
cfg.linkFlags.push_back("-lssl");
|
||||||
|
cfg.linkFlags.push_back("-lcrypto");
|
||||||
|
|
||||||
// libmsquic.so.2 is built in crafter-build's external cache, and the
|
// libmsquic.so.2 is built in crafter-build's external cache, and the
|
||||||
// RUNPATH pointing there only exists on the build machine — the first
|
// RUNPATH pointing there only exists on the build machine — the first
|
||||||
|
|
|
||||||
562
server/implementations/Catcrafts.Server-Financials.cpp
Normal file
562
server/implementations/Catcrafts.Server-Financials.cpp
Normal file
|
|
@ -0,0 +1,562 @@
|
||||||
|
/*
|
||||||
|
catcrafts.net
|
||||||
|
Copyright (C) 2026 Catcrafts
|
||||||
|
|
||||||
|
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 public financials aggregates, and the bunq callback that keeps them live.
|
||||||
|
//
|
||||||
|
// The page at /financials shows running totals only. Sales fold out of the
|
||||||
|
// order ledger on every request (Orders.cpp); donations and expenses come from
|
||||||
|
// the aggregates file this unit owns, and bunq's mutation callback is what
|
||||||
|
// moves those numbers the moment money does.
|
||||||
|
//
|
||||||
|
// THE PRIVACY RULE, which is the reason this unit is shaped the way it is: a
|
||||||
|
// bank mutation arrives here carrying a counterparty name, an IBAN and a
|
||||||
|
// description. NONE of that is ever written down. A mutation is classified,
|
||||||
|
// its amount is added to a category total, and its opaque id goes in a
|
||||||
|
// dedup ledger so a retry cannot double-count it. Everything else is dropped
|
||||||
|
// on the floor before anything is persisted. There is therefore no file on
|
||||||
|
// this box that the callback path could leak a donor's identity from, because
|
||||||
|
// no such file is ever written.
|
||||||
|
//
|
||||||
|
// CLASSIFICATION IS DEFAULT-DENY. A mutation that no rule claims is NOT
|
||||||
|
// published — not as "other", not as a guess. It is counted and logged for the
|
||||||
|
// operator, and stays out of the totals until a rule exists for it. Getting a
|
||||||
|
// number wrong on this page is worse than the number being late.
|
||||||
|
//
|
||||||
|
// WHY THE KEY IS NOT HERE. A bunq API key can initiate payments; there is no
|
||||||
|
// read-only scope. So this box never holds one. The notification filter is
|
||||||
|
// registered once from the owner's own machine (which is where the key lives,
|
||||||
|
// IP-bound), and from then on bunq PUSHES here. The server can receive money
|
||||||
|
// news without being able to move money — which is the whole point.
|
||||||
|
//
|
||||||
|
// THE CALLBACK IS PROVISIONAL, THE WEEKLY PULL IS AUTHORITATIVE. Callbacks can
|
||||||
|
// be missed, replayed or arrive out of order, and the classifier can be wrong
|
||||||
|
// until a rule is added. The owner's home tooling recomputes every total from
|
||||||
|
// the full bunq mutation history and overwrites the aggregates file wholesale.
|
||||||
|
// That is the correction mechanism, and it is why this path is allowed to be
|
||||||
|
// lossy but never wrong: it may withhold, it may not invent.
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <openssl/bio.h>
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
#include <openssl/pem.h>
|
||||||
|
module Catcrafts.Server;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
|
|
||||||
|
namespace Catcrafts::Server {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::mutex gFinMutex;
|
||||||
|
FinancialsConfig gFinCfg;
|
||||||
|
|
||||||
|
std::string ReadStateFile(const std::filesystem::path& p) {
|
||||||
|
if (p.empty()) return {};
|
||||||
|
std::ifstream in(p, std::ios::binary);
|
||||||
|
if (!in) return {};
|
||||||
|
std::ostringstream buf;
|
||||||
|
buf << in.rdbuf();
|
||||||
|
return buf.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write via a temp file and rename, so a reader (the /financials handler, or
|
||||||
|
// the owner's tooling) never observes a half-written document. The aggregates
|
||||||
|
// file is read on every page request, so a torn write would be a visible
|
||||||
|
// wrong number rather than a transient.
|
||||||
|
bool WriteStateFileAtomic(const std::filesystem::path& p, std::string_view data) {
|
||||||
|
if (p.empty()) return false;
|
||||||
|
std::filesystem::path tmp = p;
|
||||||
|
tmp += ".tmp";
|
||||||
|
{
|
||||||
|
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!out) return false;
|
||||||
|
out << data;
|
||||||
|
out.flush();
|
||||||
|
if (!out) return false;
|
||||||
|
}
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::permissions(tmp,
|
||||||
|
std::filesystem::perms::owner_read
|
||||||
|
| std::filesystem::perms::owner_write,
|
||||||
|
ec);
|
||||||
|
std::filesystem::rename(tmp, p, ec);
|
||||||
|
if (ec) {
|
||||||
|
std::filesystem::remove(tmp, ec);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string JsonEscapeF(std::string_view s) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(s.size() + 8);
|
||||||
|
for (const char c : s) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': out += "\\\""; break;
|
||||||
|
case '\\': out += "\\\\"; break;
|
||||||
|
case '\n': out += "\\n"; break;
|
||||||
|
case '\r': out += "\\r"; break;
|
||||||
|
case '\t': out += "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
|
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||||||
|
} else {
|
||||||
|
out += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string LowerF(std::string_view s) {
|
||||||
|
std::string out(s);
|
||||||
|
for (char& c : out) {
|
||||||
|
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the ingest ledger ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Opaque bunq mutation ids and two counters. No amounts against ids, no
|
||||||
|
// names, nothing that reconstructs a transaction — this file exists purely so
|
||||||
|
// a redelivered callback is recognised as one already counted.
|
||||||
|
|
||||||
|
struct SeenLedger {
|
||||||
|
std::vector<std::string> ids;
|
||||||
|
std::int64_t pendingCount = 0; // mutations no rule claimed
|
||||||
|
std::int64_t pendingMinor = 0; // and what they summed to, for the operator
|
||||||
|
|
||||||
|
bool Has(std::string_view id) const {
|
||||||
|
return std::find(ids.begin(), ids.end(), id) != ids.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SeenLedger LoadSeen(std::string_view json) {
|
||||||
|
SeenLedger out;
|
||||||
|
auto doc = Json::Parse(json);
|
||||||
|
if (!doc || !doc->IsObject()) return out;
|
||||||
|
if (const Json::Value* a = doc->Find("ids"); a && a->IsArray()) {
|
||||||
|
for (const Json::Value& v : a->array) {
|
||||||
|
if (v.type == Json::Type::String && !v.string.empty()) out.ids.push_back(v.string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.pendingCount = doc->Int("pending_count");
|
||||||
|
out.pendingMinor = doc->Int("pending_minor");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SerialiseSeen(const SeenLedger& s) {
|
||||||
|
std::string out = "{\"ids\":[";
|
||||||
|
for (std::size_t i = 0; i < s.ids.size(); ++i) {
|
||||||
|
if (i) out += ',';
|
||||||
|
out += std::format("\"{}\"", JsonEscapeF(s.ids[i]));
|
||||||
|
}
|
||||||
|
out += std::format("],\"pending_count\":{},\"pending_minor\":{}}}",
|
||||||
|
s.pendingCount, s.pendingMinor);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The aggregates file, in exactly the shape Shared's LoadFinancials reads and
|
||||||
|
// the owner's tooling writes. One format, three writers, no translation layer.
|
||||||
|
std::string SerialiseFinancials(const Financials& f) {
|
||||||
|
auto categories = [](const std::vector<FinCategory>& cats) {
|
||||||
|
std::string out = "[";
|
||||||
|
for (std::size_t i = 0; i < cats.size(); ++i) {
|
||||||
|
if (i) out += ',';
|
||||||
|
out += std::format(R"({{"label":"{}","total_minor":{}}})",
|
||||||
|
JsonEscapeF(cats[i].label), cats[i].totalMinor);
|
||||||
|
}
|
||||||
|
out += ']';
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
return std::format(
|
||||||
|
R"({{"as_of":"{}",)"
|
||||||
|
R"("donations":{{"count":{},"total_minor":{}}},)"
|
||||||
|
R"("recurring":{},"single":{}}})",
|
||||||
|
JsonEscapeF(f.asOf), f.donationCount, f.donationsMinor,
|
||||||
|
categories(f.recurring), categories(f.single));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── crypto ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct PkeyDeleter {
|
||||||
|
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
|
||||||
|
};
|
||||||
|
|
||||||
|
std::optional<std::vector<unsigned char>> Base64Decode(std::string_view in) {
|
||||||
|
auto sextet = [](char c) -> int {
|
||||||
|
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||||
|
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||||
|
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||||
|
if (c == '+') return 62;
|
||||||
|
if (c == '/') return 63;
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
std::vector<unsigned char> out;
|
||||||
|
std::uint32_t acc = 0;
|
||||||
|
int bits = 0;
|
||||||
|
for (const char c : in) {
|
||||||
|
if (c == '\n' || c == '\r' || c == ' ' || c == '\t') continue;
|
||||||
|
if (c == '=') break;
|
||||||
|
const int v = sextet(c);
|
||||||
|
if (v < 0) return std::nullopt; // not base64: refuse rather than guess
|
||||||
|
acc = (acc << 6) | static_cast<std::uint32_t>(v);
|
||||||
|
bits += 6;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
out.push_back(static_cast<unsigned char>((acc >> bits) & 0xff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSA-SHA256 over the raw request body against bunq's server public key.
|
||||||
|
//
|
||||||
|
// Optional and off unless a key file is configured, for an honest reason: the
|
||||||
|
// header bunq signs callbacks with has changed across API generations, and a
|
||||||
|
// verifier that is wrong about the header name rejects every real callback
|
||||||
|
// while looking like it is working. Enable it once a real callback has been
|
||||||
|
// observed carrying a signature — see deploy/README.md. When it IS enabled a
|
||||||
|
// failure is fatal to the request: no signature, no ingest.
|
||||||
|
bool SignatureValid(std::string_view body, std::string_view signatureB64,
|
||||||
|
const std::filesystem::path& pubkeyPath) {
|
||||||
|
const std::string pem = ReadStateFile(pubkeyPath);
|
||||||
|
if (pem.empty() || signatureB64.empty()) return false;
|
||||||
|
const auto sig = Base64Decode(signatureB64);
|
||||||
|
if (!sig || sig->empty()) return false;
|
||||||
|
|
||||||
|
BIO* bio = BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size()));
|
||||||
|
if (!bio) return false;
|
||||||
|
EVP_PKEY* raw = PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr);
|
||||||
|
BIO_free(bio);
|
||||||
|
if (!raw) return false;
|
||||||
|
const std::unique_ptr<EVP_PKEY, PkeyDeleter> key(raw);
|
||||||
|
|
||||||
|
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||||
|
if (!ctx) return false;
|
||||||
|
bool ok = false;
|
||||||
|
do {
|
||||||
|
if (EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key.get()) != 1) break;
|
||||||
|
ok = EVP_DigestVerify(ctx, sig->data(), sig->size(),
|
||||||
|
reinterpret_cast<const unsigned char*>(body.data()),
|
||||||
|
body.size()) == 1;
|
||||||
|
} while (false);
|
||||||
|
EVP_MD_CTX_free(ctx);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length-independent, content-independent comparison. The secret sits in the
|
||||||
|
// callback URL, so an attacker can probe it one request at a time; a plain ==
|
||||||
|
// would leak the matching prefix through timing.
|
||||||
|
bool SecretEqual(std::string_view a, std::string_view b) {
|
||||||
|
if (a.empty() || b.empty()) return false;
|
||||||
|
unsigned char diff = a.size() == b.size() ? 0 : 1;
|
||||||
|
const std::size_t n = std::max(a.size(), b.size());
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
const unsigned char ca = i < a.size() ? static_cast<unsigned char>(a[i]) : 0;
|
||||||
|
const unsigned char cb = i < b.size() ? static_cast<unsigned char>(b[i]) : 0;
|
||||||
|
diff |= static_cast<unsigned char>(ca ^ cb);
|
||||||
|
}
|
||||||
|
return diff == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively find the object that describes the payment. bunq wraps the
|
||||||
|
// payload differently across API generations and event types
|
||||||
|
// (NotificationUrl -> object -> Payment | MutationCreated | …), so this looks
|
||||||
|
// for the SHAPE rather than a fixed path: an object carrying an "amount"
|
||||||
|
// object and an "id". Matching on shape is what keeps a wrapper rename from
|
||||||
|
// silently turning every callback into a no-op.
|
||||||
|
const Json::Value* FindPaymentObject(const Json::Value& v) {
|
||||||
|
if (v.IsObject()) {
|
||||||
|
const Json::Value* amount = v.Find("amount");
|
||||||
|
if (amount && amount->IsObject() && amount->Find("value") && v.Find("id")) return &v;
|
||||||
|
for (const auto& [k, child] : v.object) {
|
||||||
|
if (const Json::Value* hit = FindPaymentObject(child)) return hit;
|
||||||
|
}
|
||||||
|
} else if (v.IsArray()) {
|
||||||
|
for (const Json::Value& child : v.array) {
|
||||||
|
if (const Json::Value* hit = FindPaymentObject(child)) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ── pure parsing and classification (exported for the self-test) ──────
|
||||||
|
|
||||||
|
std::optional<std::int64_t> ParseSignedAmountToMinor(std::string_view s) {
|
||||||
|
bool negative = false;
|
||||||
|
if (s.starts_with('-')) { negative = true; s.remove_prefix(1); }
|
||||||
|
else if (s.starts_with('+')) { s.remove_prefix(1); }
|
||||||
|
const auto magnitude = ParseAmountToMinor(s);
|
||||||
|
if (!magnitude) return std::nullopt;
|
||||||
|
return negative ? -*magnitude : *magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<BankMutation> ParseBunqMutation(std::string_view json) {
|
||||||
|
auto doc = Json::Parse(json);
|
||||||
|
if (!doc) return std::nullopt;
|
||||||
|
const Json::Value* pay = FindPaymentObject(*doc);
|
||||||
|
if (!pay) return std::nullopt;
|
||||||
|
|
||||||
|
BankMutation m;
|
||||||
|
// bunq sends the id as a JSON number; it travels as text from here, like
|
||||||
|
// every other provider id in this codebase.
|
||||||
|
if (const Json::Value* id = pay->Find("id")) {
|
||||||
|
if (id->type == Json::Type::Number) {
|
||||||
|
m.id = std::format("{}", static_cast<std::int64_t>(id->number));
|
||||||
|
} else if (id->type == Json::Type::String) {
|
||||||
|
m.id = id->string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (m.id.empty()) return std::nullopt;
|
||||||
|
|
||||||
|
const Json::Value* amount = pay->Find("amount");
|
||||||
|
if (!amount) return std::nullopt;
|
||||||
|
m.currency = std::string(amount->Str("currency"));
|
||||||
|
const auto minor = ParseSignedAmountToMinor(amount->Str("value"));
|
||||||
|
if (!minor) return std::nullopt;
|
||||||
|
m.amountMinor = *minor;
|
||||||
|
|
||||||
|
if (const Json::Value* cp = pay->Find("counterparty_alias"); cp && cp->IsObject()) {
|
||||||
|
// The IBAN sits either directly on the alias or under its
|
||||||
|
// "labelMonetaryAccount"/"iban", depending on the payload flavour.
|
||||||
|
m.counterpartyIban = std::string(cp->Str("iban"));
|
||||||
|
if (m.counterpartyIban.empty()) {
|
||||||
|
if (const Json::Value* lma = cp->Find("labelMonetaryAccount");
|
||||||
|
lma && lma->IsObject()) {
|
||||||
|
m.counterpartyIban = std::string(lma->Str("iban"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.description = std::string(pay->Str("description"));
|
||||||
|
if (const std::int64_t acct = pay->Int("monetary_account_id"); acct != 0) {
|
||||||
|
m.account = std::format("{}", acct);
|
||||||
|
}
|
||||||
|
// "2026-08-14 09:31:02.123456" -> "2026-08-14". Only the date is kept, and
|
||||||
|
// only to stamp the page's as-of line; the time is dropped here, at the
|
||||||
|
// parser, so no later code can publish it by accident.
|
||||||
|
if (const std::string_view created = pay->Str("created"); created.size() >= 10) {
|
||||||
|
m.created = std::string(created.substr(0, 10));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
FinancialRules LoadFinancialRules(std::string_view json) {
|
||||||
|
FinancialRules out;
|
||||||
|
auto doc = Json::Parse(json);
|
||||||
|
if (!doc || !doc->IsObject()) return out;
|
||||||
|
if (const Json::Value* a = doc->Find("donation_accounts"); a && a->IsArray()) {
|
||||||
|
for (const Json::Value& v : a->array) {
|
||||||
|
if (v.type == Json::Type::String) out.donationAccounts.push_back(v.string);
|
||||||
|
else if (v.type == Json::Type::Number) {
|
||||||
|
out.donationAccounts.push_back(
|
||||||
|
std::format("{}", static_cast<std::int64_t>(v.number)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (const Json::Value* a = doc->Find("rules"); a && a->IsArray()) {
|
||||||
|
for (const Json::Value& v : a->array) {
|
||||||
|
if (!v.IsObject()) continue;
|
||||||
|
FinancialRule r;
|
||||||
|
r.iban = LowerF(v.Str("iban"));
|
||||||
|
r.descriptionContains = LowerF(v.Str("description_contains"));
|
||||||
|
r.account = std::string(v.Str("account"));
|
||||||
|
r.group = std::string(v.Str("group"));
|
||||||
|
r.label = std::string(v.Str("label"));
|
||||||
|
// A rule with no criterion would claim every mutation, which is
|
||||||
|
// the exact opposite of default-deny. A rule whose group is not
|
||||||
|
// one this code understands is a typo, and a typo must not
|
||||||
|
// silently become a published category.
|
||||||
|
const bool hasCriterion = !r.iban.empty() || !r.descriptionContains.empty()
|
||||||
|
|| !r.account.empty();
|
||||||
|
const bool knownGroup = r.group == "donations" || r.group == "recurring"
|
||||||
|
|| r.group == "single" || r.group == "ignore";
|
||||||
|
if (!hasCriterion || !knownGroup) continue;
|
||||||
|
// Expense groups need a label to render under; donations and
|
||||||
|
// ignore do not have one.
|
||||||
|
if ((r.group == "recurring" || r.group == "single") && r.label.empty()) continue;
|
||||||
|
out.rules.push_back(std::move(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
MutationClass ClassifyMutation(const BankMutation& m, const FinancialRules& rules) {
|
||||||
|
MutationClass out;
|
||||||
|
// Only euro. A foreign-currency mutation has no place in a euro total and
|
||||||
|
// converting one here would invent a rate.
|
||||||
|
if (m.currency != "EUR") return out;
|
||||||
|
|
||||||
|
const std::string iban = LowerF(m.counterpartyIban);
|
||||||
|
const std::string description = LowerF(m.description);
|
||||||
|
// Explicit rules first, so an "ignore" can carve an exception out of a
|
||||||
|
// donation account — the owner moving money between their own accounts
|
||||||
|
// must not read as a gift.
|
||||||
|
for (const FinancialRule& r : rules.rules) {
|
||||||
|
if (!r.iban.empty() && r.iban != iban) continue;
|
||||||
|
if (!r.account.empty() && r.account != m.account) continue;
|
||||||
|
if (!r.descriptionContains.empty()
|
||||||
|
&& description.find(r.descriptionContains) == std::string::npos) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.group = r.group;
|
||||||
|
out.label = r.label;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// The donation-account default: money ARRIVING on an account dedicated to
|
||||||
|
// donations is a donation. Keyed on the account rather than the sender
|
||||||
|
// because donors are strangers — an IBAN allowlist cannot know them, and
|
||||||
|
// this is the one category that must work for someone who has never paid
|
||||||
|
// this company before.
|
||||||
|
if (m.amountMinor > 0
|
||||||
|
&& std::find(rules.donationAccounts.begin(), rules.donationAccounts.end(), m.account)
|
||||||
|
!= rules.donationAccounts.end()) {
|
||||||
|
out.group = "donations";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplyMutation(Financials& fin, const MutationClass& cls, const BankMutation& m) {
|
||||||
|
auto bump = [&](std::vector<FinCategory>& cats) {
|
||||||
|
for (FinCategory& c : cats) {
|
||||||
|
if (c.label == cls.label) {
|
||||||
|
// Outgoing money is negative on the wire and an expense is a
|
||||||
|
// positive total, so the sign flips here. A refund from a
|
||||||
|
// supplier arrives positive and correctly REDUCES the
|
||||||
|
// category rather than appearing as income.
|
||||||
|
c.totalMinor += -m.amountMinor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cats.push_back(FinCategory{ cls.label, -m.amountMinor });
|
||||||
|
};
|
||||||
|
if (cls.group == "donations") {
|
||||||
|
fin.donationsMinor += m.amountMinor;
|
||||||
|
// The count follows money in, not money out: a refunded donation
|
||||||
|
// reduces the total without pretending the gift never happened.
|
||||||
|
if (m.amountMinor > 0) ++fin.donationCount;
|
||||||
|
} else if (cls.group == "recurring") {
|
||||||
|
bump(fin.recurring);
|
||||||
|
} else if (cls.group == "single") {
|
||||||
|
bump(fin.single);
|
||||||
|
} else {
|
||||||
|
return; // "ignore" and unclassified touch nothing
|
||||||
|
}
|
||||||
|
// The page's freshness line. Never moves backwards: callbacks can arrive
|
||||||
|
// out of order, and an as-of date that jumped back a week would read as a
|
||||||
|
// stall that never happened.
|
||||||
|
if (!m.created.empty() && m.created > fin.asOf) fin.asOf = m.created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── configuration and the live aggregates ─────────────────────────────
|
||||||
|
|
||||||
|
void ConfigureFinancials(FinancialsConfig config) {
|
||||||
|
std::lock_guard lock(gFinMutex);
|
||||||
|
gFinCfg = std::move(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
Financials CurrentFinancials() {
|
||||||
|
std::lock_guard lock(gFinMutex);
|
||||||
|
return LoadFinancials(ReadStateFile(gFinCfg.publicPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BunqCallbackConfigured() {
|
||||||
|
std::lock_guard lock(gFinMutex);
|
||||||
|
return !gFinCfg.callbackSecret.empty() && !gFinCfg.publicPath.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BunqCallbackAuthorised(std::string_view pathSecret, std::string_view body,
|
||||||
|
std::string_view signatureB64) {
|
||||||
|
std::filesystem::path pubkey;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(gFinMutex);
|
||||||
|
if (gFinCfg.callbackSecret.empty()) return false;
|
||||||
|
if (!SecretEqual(pathSecret, gFinCfg.callbackSecret)) return false;
|
||||||
|
pubkey = gFinCfg.publicKeyPem;
|
||||||
|
}
|
||||||
|
if (pubkey.empty()) return true; // signature checking not enabled
|
||||||
|
return SignatureValid(body, signatureB64, pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
BunqIngestResult IngestBunqNotification(std::string_view body) {
|
||||||
|
std::lock_guard lock(gFinMutex);
|
||||||
|
if (gFinCfg.publicPath.empty()) return BunqIngestResult::Ignored;
|
||||||
|
|
||||||
|
const auto mutation = ParseBunqMutation(body);
|
||||||
|
if (!mutation) {
|
||||||
|
// Deliberately NOT an error status: bunq retries a non-2xx, so a
|
||||||
|
// payload shape this parser does not understand would become an
|
||||||
|
// endless redelivery loop. It is logged instead, and the weekly pull
|
||||||
|
// is what recovers the money. The log names no field VALUES — the
|
||||||
|
// point of the log is which shape arrived, not what it said.
|
||||||
|
std::println(std::cerr,
|
||||||
|
"catcrafts-server: bunq callback carried no recognisable "
|
||||||
|
"mutation ({} bytes); the weekly reconciliation will pick "
|
||||||
|
"it up", body.size());
|
||||||
|
return BunqIngestResult::Ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
SeenLedger seen = LoadSeen(ReadStateFile(gFinCfg.seenPath));
|
||||||
|
if (seen.Has(mutation->id)) return BunqIngestResult::Duplicate;
|
||||||
|
|
||||||
|
const FinancialRules rules = LoadFinancialRules(ReadStateFile(gFinCfg.rulesPath));
|
||||||
|
const MutationClass cls = ClassifyMutation(*mutation, rules);
|
||||||
|
|
||||||
|
// Recorded as seen either way: an unclassified mutation must not be
|
||||||
|
// re-counted as pending on every redelivery, and once a rule exists for
|
||||||
|
// it the weekly pull is what brings the money in.
|
||||||
|
seen.ids.push_back(mutation->id);
|
||||||
|
|
||||||
|
BunqIngestResult result = BunqIngestResult::Withheld;
|
||||||
|
if (cls.group.empty()) {
|
||||||
|
++seen.pendingCount;
|
||||||
|
seen.pendingMinor += mutation->amountMinor;
|
||||||
|
std::println(std::cerr,
|
||||||
|
"catcrafts-server: bunq mutation {} matched no rule and is "
|
||||||
|
"WITHHELD from /financials ({} awaiting classification, "
|
||||||
|
"{} cents net). Add a rule to {}.",
|
||||||
|
mutation->id, seen.pendingCount, seen.pendingMinor,
|
||||||
|
gFinCfg.rulesPath.string());
|
||||||
|
} else if (cls.group == "ignore") {
|
||||||
|
result = BunqIngestResult::Ignored;
|
||||||
|
} else {
|
||||||
|
Financials fin = LoadFinancials(ReadStateFile(gFinCfg.publicPath));
|
||||||
|
ApplyMutation(fin, cls, *mutation);
|
||||||
|
// First publication: a file that has never been written has no as-of
|
||||||
|
// date, and a mutation with no usable created stamp still has to
|
||||||
|
// produce one or the page would keep saying nothing is published.
|
||||||
|
if (fin.asOf.empty()) {
|
||||||
|
fin.asOf = mutation->created.empty() ? std::string("1970-01-01")
|
||||||
|
: mutation->created;
|
||||||
|
}
|
||||||
|
if (!WriteStateFileAtomic(gFinCfg.publicPath, SerialiseFinancials(fin))) {
|
||||||
|
std::println(std::cerr,
|
||||||
|
"catcrafts-server: could not write the financials "
|
||||||
|
"aggregates to {}", gFinCfg.publicPath.string());
|
||||||
|
return BunqIngestResult::Failed;
|
||||||
|
}
|
||||||
|
result = BunqIngestResult::Applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WriteStateFileAtomic(gFinCfg.seenPath, SerialiseSeen(seen))) {
|
||||||
|
// The money is already published; losing the dedup entry only risks a
|
||||||
|
// double count on redelivery, so say so loudly rather than fail the
|
||||||
|
// request and guarantee that redelivery.
|
||||||
|
std::println(std::cerr,
|
||||||
|
"catcrafts-server: could not write the financials ingest "
|
||||||
|
"ledger to {} — a redelivered callback may double-count",
|
||||||
|
gFinCfg.seenPath.string());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Catcrafts::Server
|
||||||
|
|
@ -75,6 +75,12 @@ std::string gCssHref = "/styles.css";
|
||||||
PaymentRails gRails;
|
PaymentRails gRails;
|
||||||
std::string gRedirectBase = "https://catcrafts.net";
|
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
|
// 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
|
// 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
|
// 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;
|
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::string ReadFile(const std::filesystem::path& p) {
|
||||||
std::ifstream in(p, std::ios::binary);
|
std::ifstream in(p, std::ios::binary);
|
||||||
if (!in) return {};
|
if (!in) return {};
|
||||||
|
|
@ -316,6 +328,27 @@ HTTPResponse RenderPage(std::string_view target) {
|
||||||
return res;
|
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);
|
const Views::RenderedPage page = Views::RenderRoute(route, gContent);
|
||||||
|
|
||||||
HTTPResponse res;
|
HTTPResponse res;
|
||||||
|
|
@ -1027,6 +1060,48 @@ int Serve(std::uint16_t port) {
|
||||||
};
|
};
|
||||||
|
|
||||||
auto fallback = [](const HTTPRequest& req) -> HTTPResponse {
|
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.
|
// A POST to a product page is a checkout submission.
|
||||||
if (req.method == "POST") {
|
if (req.method == "POST") {
|
||||||
const Route route = ParseRoute(PathWithoutQueryHTTP(req.path));
|
const Route route = ParseRoute(PathWithoutQueryHTTP(req.path));
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,13 @@ std::vector<OrderRecord> FoldLocked() {
|
||||||
if (const std::string_view via = doc->Str("via"); !via.empty()) {
|
if (const std::string_view via = doc->Str("via"); !via.empty()) {
|
||||||
r->paidVia = std::string(via);
|
r->paidVia = std::string(via);
|
||||||
}
|
}
|
||||||
|
// The FIRST paid event is the sale, whatever happens later — a
|
||||||
|
// refund folds the status onward but never unhappens the payment
|
||||||
|
// (see SummarizeSales). First, not last, so a replayed line
|
||||||
|
// cannot move the recorded moment.
|
||||||
|
if (status == "paid" && r->paidAt.empty()) {
|
||||||
|
r->paidAt = std::string(doc->Str("at"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|
@ -296,6 +303,20 @@ std::vector<OrderRecord> ListOrders() {
|
||||||
return FoldLocked();
|
return FoldLocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SalesSummary SummarizeSales(std::span<const OrderRecord> orders) {
|
||||||
|
SalesSummary out;
|
||||||
|
for (const OrderRecord& r : orders) {
|
||||||
|
// paidAt is the signal. The status check keeps faith with a ledger
|
||||||
|
// whose order was marked shipped by hand without a paid event ever
|
||||||
|
// being written — the same paid-or-shipped idiom the invoice
|
||||||
|
// download uses.
|
||||||
|
if (r.paidAt.empty() && r.status != "paid" && r.status != "shipped") continue;
|
||||||
|
++out.count;
|
||||||
|
out.totalMinor += r.totalMinor;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
std::string NewOrderToken() {
|
std::string NewOrderToken() {
|
||||||
// std::random_device on this platform reads the kernel CSPRNG. The token
|
// std::random_device on this platform reads the kernel CSPRNG. The token
|
||||||
// gates access to a name and address, so 128 bits — the same order of
|
// gates access to a name and address, so 128 bits — the same order of
|
||||||
|
|
|
||||||
|
|
@ -1420,6 +1420,216 @@ void RunMoneySelfTest() {
|
||||||
Check(r.Find("USD") == 1'083'400, "rates: lookup");
|
Check(r.Find("USD") == 1'083'400, "rates: lookup");
|
||||||
Check(r.Find("XXX") == 0, "rates: absent is zero");
|
Check(r.Find("XXX") == 0, "rates: absent is zero");
|
||||||
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
|
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) {
|
std::string ReadFile(const std::filesystem::path& p) {
|
||||||
|
|
@ -1538,6 +1748,7 @@ int main(int argc, char** argv) {
|
||||||
if (has("--routes")) {
|
if (has("--routes")) {
|
||||||
const Views::SiteContent content = LoadContent("content");
|
const Views::SiteContent content = LoadContent("content");
|
||||||
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope",
|
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope",
|
||||||
|
"/financials",
|
||||||
"/order/0123456789abcdef0123456789abcdef",
|
"/order/0123456789abcdef0123456789abcdef",
|
||||||
"/order/not-a-token",
|
"/order/not-a-token",
|
||||||
"/legal/privacy", "/legal/imprint",
|
"/legal/privacy", "/legal/imprint",
|
||||||
|
|
@ -1654,6 +1865,28 @@ int main(int argc, char** argv) {
|
||||||
}
|
}
|
||||||
|
|
||||||
Server::SetOrdersPath(ordersPath);
|
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);
|
Server::LoadContent(contentDir, bundleIndex);
|
||||||
// Refuse to serve an empty catalogue: it almost always means the
|
// Refuse to serve an empty catalogue: it almost always means the
|
||||||
// content path is wrong or a JSON file is malformed, and a silently
|
// content path is wrong or a JSON file is malformed, and a silently
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ export namespace Catcrafts::Server {
|
||||||
std::string payId; // provider payment id ("tr_…" at Mollie,
|
std::string payId; // provider payment id ("tr_…" at Mollie,
|
||||||
// a decimal order id at CoinGate)
|
// a decimal order id at CoinGate)
|
||||||
std::string paidVia; // method that settled it ("ideal", "bitcoin")
|
std::string paidVia; // method that settled it ("ideal", "bitcoin")
|
||||||
|
std::string paidAt; // ISO 8601 of the FIRST paid event; empty =
|
||||||
|
// never paid. A later cancel (a refund)
|
||||||
|
// does not clear it: "was this ever paid"
|
||||||
|
// is what the public sales totals count.
|
||||||
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
|
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
|
||||||
std::string invoicedAt; // ISO 8601 of the invoice event
|
std::string invoicedAt; // ISO 8601 of the invoice event
|
||||||
std::string confirmationSentAt; // ISO 8601 of the confirmation-email
|
std::string confirmationSentAt; // ISO 8601 of the confirmation-email
|
||||||
|
|
@ -109,6 +113,123 @@ export namespace Catcrafts::Server {
|
||||||
// apology — never toward a buyer who paid and heard nothing.
|
// apology — never toward a buyer who paid and heard nothing.
|
||||||
bool AppendOrderNotified(std::string_view token, std::string_view isoTimestamp);
|
bool AppendOrderNotified(std::string_view token, std::string_view isoTimestamp);
|
||||||
|
|
||||||
|
// ── the public financials page ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// /financials shows lifetime sales as two integers: how many orders were
|
||||||
|
// ever paid, and what they summed to. Ever-paid on purpose — a refund is
|
||||||
|
// an expense on that page, it does not un-happen the sale. Pure and
|
||||||
|
// exported for the self-test.
|
||||||
|
struct SalesSummary {
|
||||||
|
std::int64_t count = 0;
|
||||||
|
std::int64_t totalMinor = 0;
|
||||||
|
};
|
||||||
|
SalesSummary SummarizeSales(std::span<const OrderRecord> orders);
|
||||||
|
|
||||||
|
// ── the bank side of /financials ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// Donations and expenses come from a bank mutation callback rather than
|
||||||
|
// from an API key on this box: a bunq key can INITIATE PAYMENTS and has
|
||||||
|
// no read-only scope, so the key stays on the owner's machine (IP-bound)
|
||||||
|
// and is used there once to register a notification filter. From then on
|
||||||
|
// bunq pushes mutations here, and the server can learn that money moved
|
||||||
|
// without being able to move any.
|
||||||
|
//
|
||||||
|
// What crosses this boundary is deliberately small. A mutation is
|
||||||
|
// classified, its amount lands in a category total, its opaque id goes in
|
||||||
|
// a dedup ledger, and the name, IBAN and description are dropped before
|
||||||
|
// anything is written. Classification is default-deny: money no rule
|
||||||
|
// claims is withheld from the page and logged, never published as a guess.
|
||||||
|
// The owner's weekly pull recomputes every total from the full bunq
|
||||||
|
// history and overwrites the aggregates file, which is what makes this
|
||||||
|
// path safe to be lossy.
|
||||||
|
|
||||||
|
struct FinancialsConfig {
|
||||||
|
std::filesystem::path publicPath; // <orders>.financials.json — what
|
||||||
|
// the page reads; also written by
|
||||||
|
// the owner's reconciliation
|
||||||
|
std::filesystem::path seenPath; // <orders>.financials-seen.json —
|
||||||
|
// ingested mutation ids, so a
|
||||||
|
// redelivery cannot double-count
|
||||||
|
std::filesystem::path rulesPath; // <orders>.financial-rules.json —
|
||||||
|
// the classifier, authored by hand
|
||||||
|
std::string callbackSecret; // BUNQ_CALLBACK_SECRET; the last
|
||||||
|
// path segment of the callback
|
||||||
|
// URL. Empty = endpoint disabled
|
||||||
|
std::filesystem::path publicKeyPem; // BUNQ_CALLBACK_PUBKEY; empty =
|
||||||
|
// signature checking off
|
||||||
|
};
|
||||||
|
void ConfigureFinancials(FinancialsConfig config);
|
||||||
|
|
||||||
|
// The published aggregates, re-read per request — live is the page's
|
||||||
|
// promise and the file is a few hundred bytes. Empty asOf = nothing
|
||||||
|
// published yet, which the page states rather than showing zeros.
|
||||||
|
Financials CurrentFinancials();
|
||||||
|
|
||||||
|
// One bank mutation, reduced to what a total needs. Everything
|
||||||
|
// identifying is dropped by the classifier and never persisted.
|
||||||
|
struct BankMutation {
|
||||||
|
std::string id; // bunq's opaque id — the dedup key
|
||||||
|
std::int64_t amountMinor = 0; // SIGNED: negative is money leaving
|
||||||
|
std::string currency; // only EUR is ever counted
|
||||||
|
std::string counterpartyIban; // classifier input; never stored
|
||||||
|
std::string description; // classifier input; never stored
|
||||||
|
std::string account; // monetary account id
|
||||||
|
std::string created; // ISO date only — the time is
|
||||||
|
// dropped at the parser
|
||||||
|
};
|
||||||
|
|
||||||
|
// One classification rule. A rule matches when every criterion it states
|
||||||
|
// matches; the first matching rule wins. `group` is "donations",
|
||||||
|
// "recurring", "single" or "ignore" — anything else is a typo and the
|
||||||
|
// rule is dropped at load rather than inventing a category.
|
||||||
|
struct FinancialRule {
|
||||||
|
std::string iban; // exact, case-insensitive
|
||||||
|
std::string descriptionContains; // substring, case-insensitive
|
||||||
|
std::string account; // monetary account id
|
||||||
|
std::string group;
|
||||||
|
std::string label; // the page's category name
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FinancialRules {
|
||||||
|
// Accounts whose INCOMING money is a donation by default. Keyed on
|
||||||
|
// the account because donors are strangers: no IBAN list can know
|
||||||
|
// them in advance.
|
||||||
|
std::vector<std::string> donationAccounts;
|
||||||
|
std::vector<FinancialRule> rules;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty group = no rule claimed it. That is the default-deny answer, and
|
||||||
|
// the caller must withhold rather than guess.
|
||||||
|
struct MutationClass {
|
||||||
|
std::string group;
|
||||||
|
std::string label;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure and exported for the self-test.
|
||||||
|
std::optional<std::int64_t> ParseSignedAmountToMinor(std::string_view s);
|
||||||
|
std::optional<BankMutation> ParseBunqMutation(std::string_view json);
|
||||||
|
FinancialRules LoadFinancialRules(std::string_view json);
|
||||||
|
MutationClass ClassifyMutation(const BankMutation& m, const FinancialRules& rules);
|
||||||
|
void ApplyMutation(Financials& fin, const MutationClass& cls, const BankMutation& m);
|
||||||
|
|
||||||
|
// Whether the callback endpoint exists at all. Unconfigured, the path is
|
||||||
|
// a plain 404 — an endpoint that is off should not announce itself.
|
||||||
|
bool BunqCallbackConfigured();
|
||||||
|
|
||||||
|
// Constant-time secret check, plus the RSA-SHA256 body signature when a
|
||||||
|
// public key is configured. False means 404, for the same reason an
|
||||||
|
// unknown order token is: a probe learns nothing from the shape.
|
||||||
|
bool BunqCallbackAuthorised(std::string_view pathSecret, std::string_view body,
|
||||||
|
std::string_view signatureB64);
|
||||||
|
|
||||||
|
enum class BunqIngestResult { Applied, Duplicate, Withheld, Ignored, Failed };
|
||||||
|
|
||||||
|
// Parse, classify, and fold one notification into the aggregates. Never
|
||||||
|
// reports a transport-level error for an unparseable body: bunq retries
|
||||||
|
// non-2xx, so an unknown payload shape would redeliver forever. It is
|
||||||
|
// logged and left to the weekly reconciliation instead.
|
||||||
|
BunqIngestResult IngestBunqNotification(std::string_view body);
|
||||||
|
|
||||||
// ── invoices ──────────────────────────────────────────────────────
|
// ── invoices ──────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// A paid order's invoice: plain markdown, clearsigned with the shop's
|
// A paid order's invoice: plain markdown, clearsigned with the shop's
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,32 @@ export const LegalPage& AboutPage() {
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The open-financials page's authored prose. The numbers themselves come from
|
||||||
|
// the order ledger and the bank-aggregates state file at render time; what is
|
||||||
|
// compiled in is the promise around them — what the page shows, what it never
|
||||||
|
// will, and how to read it. Kept in the LegalPage shape so it reuses that
|
||||||
|
// renderer's section markup and CSS, exactly as the about page does.
|
||||||
|
export const LegalPage& FinancialsPage() {
|
||||||
|
static const LegalPage page{
|
||||||
|
.slug = "financials",
|
||||||
|
.title = "Financials",
|
||||||
|
.updated = "2026-08-14",
|
||||||
|
.lede = "Catcrafts' money, in the open: running totals of what the company earns and spends, live from its own records. Aggregates only — individual transactions are never published.",
|
||||||
|
.sections = {
|
||||||
|
{ "How this page works",
|
||||||
|
{
|
||||||
|
"Sales come straight from the shop's order ledger and update the moment an order is paid. Donations and expenses are aggregated from the business bank account by category and carry the date they were last brought up to date. Anything the categoriser does not recognise is held back until it has been classified, never published as a guess.",
|
||||||
|
"Everything is a running total in euros on a cash basis: money counts when it moves, not when an invoice says it should. Amounts include VAT where VAT was charged. These are the company's own live numbers, not audited statements; the tax filings are the authoritative record.",
|
||||||
|
} },
|
||||||
|
{ "What is never published",
|
||||||
|
{
|
||||||
|
"No individual transactions, no timestamps, no counterparties, no account balances, and nothing about who paid or was paid. Totals only. One consequence is accepted openly: the totals update live, so someone watching the page closely could infer that a sale or a donation happened. That is as far as it goes.",
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
export const std::vector<LegalPage>& LegalPages() {
|
export const std::vector<LegalPage>& LegalPages() {
|
||||||
static const std::vector<LegalPage> pages = {
|
static const std::vector<LegalPage> pages = {
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,67 @@ export Rates LoadRates(std::string_view json) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The bank-derived aggregates for the public /financials page, read from a
|
||||||
|
// state file the owner's tooling writes (<orders>.financials.json on the
|
||||||
|
// server). Aggregates by construction: a category is a label and a running
|
||||||
|
// total, donations are a count and a running total, and nothing finer ever
|
||||||
|
// exists in this structure — that is the page's privacy design, not an
|
||||||
|
// implementation shortcut. Sales are not in here: they fold live out of the
|
||||||
|
// order ledger on the server and arrive at the renderer as two integers.
|
||||||
|
export struct FinCategory {
|
||||||
|
std::string label; // "Hosting" — shown verbatim
|
||||||
|
std::int64_t totalMinor = 0; // running total, EUR cents
|
||||||
|
};
|
||||||
|
|
||||||
|
export struct Financials {
|
||||||
|
std::string asOf; // ISO date the figures are current to;
|
||||||
|
// empty = nothing published yet
|
||||||
|
std::int64_t donationCount = 0;
|
||||||
|
std::int64_t donationsMinor = 0;
|
||||||
|
std::vector<FinCategory> recurring; // insurance, hosting, …
|
||||||
|
std::vector<FinCategory> single; // inventory, fees, tax, …
|
||||||
|
|
||||||
|
bool Loaded() const { return !asOf.empty(); }
|
||||||
|
std::int64_t ExpensesMinor() const {
|
||||||
|
std::int64_t sum = 0;
|
||||||
|
for (const FinCategory& c : recurring) sum += c.totalMinor;
|
||||||
|
for (const FinCategory& c : single) sum += c.totalMinor;
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export Financials LoadFinancials(std::string_view json) {
|
||||||
|
Financials out;
|
||||||
|
auto doc = Json::Parse(json);
|
||||||
|
if (!doc || !doc->IsObject()) return out;
|
||||||
|
out.asOf = std::string(doc->Str("as_of"));
|
||||||
|
// Undated figures stay unpublished: the page promises an honest
|
||||||
|
// freshness line, and numbers that cannot carry one are not shown.
|
||||||
|
if (out.asOf.empty()) return out;
|
||||||
|
if (const Json::Value* d = doc->Find("donations"); d && d->IsObject()) {
|
||||||
|
out.donationCount = d->Int("count");
|
||||||
|
out.donationsMinor = d->Int("total_minor");
|
||||||
|
}
|
||||||
|
auto categories = [](const Json::Value* arr) {
|
||||||
|
std::vector<FinCategory> cats;
|
||||||
|
if (!arr || !arr->IsArray()) return cats;
|
||||||
|
for (const Json::Value& v : arr->array) {
|
||||||
|
if (!v.IsObject()) continue;
|
||||||
|
FinCategory c;
|
||||||
|
c.label = std::string(v.Str("label"));
|
||||||
|
c.totalMinor = v.Int("total_minor");
|
||||||
|
// A category with no label has nothing to render as; dropping it
|
||||||
|
// beats an anonymous row that looks like a redaction.
|
||||||
|
if (c.label.empty()) continue;
|
||||||
|
cats.push_back(std::move(c));
|
||||||
|
}
|
||||||
|
return cats;
|
||||||
|
};
|
||||||
|
out.recurring = categories(doc->Find("recurring"));
|
||||||
|
out.single = categories(doc->Find("single"));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Everything the order status page needs to render — a projection of the
|
// Everything the order status page needs to render — a projection of the
|
||||||
// server's order record, not the record itself. The renderer stays a pure
|
// server's order record, not the record itself. The renderer stays a pure
|
||||||
// function in Shared; the server owns storage and fills this in.
|
// function in Shared; the server owns storage and fills this in.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export enum class RouteKind {
|
||||||
Order, // /order/<token> — an order's status page
|
Order, // /order/<token> — an order's status page
|
||||||
Invoice, // /order/<token>/invoice.md — the signed invoice download
|
Invoice, // /order/<token>/invoice.md — the signed invoice download
|
||||||
Legal, // /legal/<slug> — privacy, imprint, terms
|
Legal, // /legal/<slug> — privacy, imprint, terms
|
||||||
|
Financials, // /financials — the open money page: live aggregate totals
|
||||||
// The blog these routes replace. sitemap.xml advertised /blog and
|
// The blog these routes replace. sitemap.xml advertised /blog and
|
||||||
// /blog/<slug>, and those URLs are in the wild — in shared links and in
|
// /blog/<slug>, and those URLs are in the wild — in shared links and in
|
||||||
// whatever the crawlers already have. They resolve to Posts and carry a
|
// whatever the crawlers already have. They resolve to Posts and carry a
|
||||||
|
|
@ -100,6 +101,7 @@ export Route ParseRoute(std::string_view path, std::string_view query = {}) {
|
||||||
if (p == "/posts") { r.kind = RouteKind::Posts; return r; }
|
if (p == "/posts") { r.kind = RouteKind::Posts; return r; }
|
||||||
if (p == "/demos") { r.kind = RouteKind::Demos; return r; }
|
if (p == "/demos") { r.kind = RouteKind::Demos; return r; }
|
||||||
if (p == "/shop") { r.kind = RouteKind::Shop; return r; }
|
if (p == "/shop") { r.kind = RouteKind::Shop; return r; }
|
||||||
|
if (p == "/financials") { r.kind = RouteKind::Financials; return r; }
|
||||||
|
|
||||||
// /order/<token>. The token is validated structurally here for the same
|
// /order/<token>. The token is validated structurally here for the same
|
||||||
// reason slugs are: nothing downstream should ever see one it must
|
// reason slugs are: nothing downstream should ever see one it must
|
||||||
|
|
@ -232,8 +234,12 @@ export std::span<const std::string_view> SitemapPaths() {
|
||||||
// /shop/<slug> entries are appended by the caller from the loaded product
|
// /shop/<slug> entries are appended by the caller from the loaded product
|
||||||
// list — the sitemap has to reflect what actually exists, and a hardcoded
|
// list — the sitemap has to reflect what actually exists, and a hardcoded
|
||||||
// slug list here would be one more thing to forget to update.
|
// slug list here would be one more thing to forget to update.
|
||||||
static constexpr std::array<std::string_view, 9> paths{
|
static constexpr std::array<std::string_view, 10> paths{
|
||||||
"/", "/about", "/shop", "/projects", "/posts", "/demos",
|
"/", "/about", "/shop", "/projects", "/posts", "/demos",
|
||||||
|
// Indexable for the same reason the legal pages are: open financials
|
||||||
|
// are a trust signal, and someone checking the company out should be
|
||||||
|
// able to land on them from a search engine.
|
||||||
|
"/financials",
|
||||||
// Legal pages are indexable on purpose: they are trust signals, and a
|
// Legal pages are indexable on purpose: they are trust signals, and a
|
||||||
// buyer looking for the returns policy before purchasing should be able
|
// buyer looking for the returns policy before purchasing should be able
|
||||||
// to find it from a search engine.
|
// to find it from a search engine.
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ export SafeHtml RenderFooter() {
|
||||||
R"(<a{}>Forgejo</a><a{}>Source</a>)"
|
R"(<a{}>Forgejo</a><a{}>Source</a>)"
|
||||||
R"(</p>)"
|
R"(</p>)"
|
||||||
R"(<p class="footer-links">)"
|
R"(<p class="footer-links">)"
|
||||||
R"(<a{}>Privacy</a><a{}>Terms</a><a{}>Imprint & contact</a>)"
|
R"(<a{}>Privacy</a><a{}>Terms</a><a{}>Imprint & contact</a><a{}>Financials</a>)"
|
||||||
R"(</p>)"
|
R"(</p>)"
|
||||||
R"(<p class="footer-legal">© 2026 Catcrafts®. Crafter® and Catcrafts® )"
|
R"(<p class="footer-legal">© 2026 Catcrafts®. Crafter® and Catcrafts® )"
|
||||||
R"(are registered trademarks with the EUIPO.</p>)"
|
R"(are registered trademarks with the EUIPO.</p>)"
|
||||||
|
|
@ -129,7 +129,8 @@ export SafeHtml RenderFooter() {
|
||||||
Url("href", "https://forgejo.catcrafts.net/Catcrafts/catcrafts.net"),
|
Url("href", "https://forgejo.catcrafts.net/Catcrafts/catcrafts.net"),
|
||||||
Url("href", "/legal/privacy"),
|
Url("href", "/legal/privacy"),
|
||||||
Url("href", "/legal/terms"),
|
Url("href", "/legal/terms"),
|
||||||
Url("href", "/legal/imprint"));
|
Url("href", "/legal/imprint"),
|
||||||
|
Url("href", "/financials"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── schema.org JSON-LD ────────────────────────────────────────────────
|
// ── schema.org JSON-LD ────────────────────────────────────────────────
|
||||||
|
|
@ -1441,6 +1442,122 @@ export RenderedPage RenderAbout(const LegalPage& about) {
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── financials ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The open-financials page: the company's money as live running totals.
|
||||||
|
//
|
||||||
|
// The privacy design is structural, not editorial. This renderer can only
|
||||||
|
// ever see aggregates: sales arrive as two integers folded out of the order
|
||||||
|
// ledger, donations and expenses arrive as category totals from the
|
||||||
|
// bank-aggregates file. No transaction, timestamp or counterparty exists in
|
||||||
|
// either input, so no future edit here can accidentally publish one.
|
||||||
|
//
|
||||||
|
// The data-fin-* attributes are the machine-readable copy of the figures —
|
||||||
|
// what the e2e suite asserts against, and what anyone scraping the page in
|
||||||
|
// good faith should read instead of parsing euro signs.
|
||||||
|
export RenderedPage RenderFinancials(std::int64_t salesCount,
|
||||||
|
std::int64_t salesTotalMinor,
|
||||||
|
const Financials& fin) {
|
||||||
|
const LegalPage& notes = Content::FinancialsPage();
|
||||||
|
|
||||||
|
// A total row is ruled off from the rows it sums, the way a ledger is.
|
||||||
|
auto totalRow = [](std::string_view label, std::int64_t minor) {
|
||||||
|
return Format(
|
||||||
|
R"(<tr class="fin-total"><th scope="row">{}</th><td class="order__amount">{}</td></tr>)",
|
||||||
|
Escape(label), Escape(Money::FormatEuro(minor)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Income. Sales are always live; the donation row exists only once the
|
||||||
|
// bank figures do — a €0 the page cannot yet know would be a lie, and so
|
||||||
|
// would an income total missing half its inputs.
|
||||||
|
std::vector<SafeHtml> incomeRows;
|
||||||
|
if (fin.Loaded()) {
|
||||||
|
incomeRows.push_back(MoneyRow(
|
||||||
|
fin.donationCount == 1 ? std::string("Donations (1)")
|
||||||
|
: std::format("Donations ({})", fin.donationCount),
|
||||||
|
fin.donationsMinor));
|
||||||
|
}
|
||||||
|
incomeRows.push_back(MoneyRow(
|
||||||
|
salesCount == 1 ? std::string("Sales (1 order)")
|
||||||
|
: std::format("Sales ({} orders)", salesCount),
|
||||||
|
salesTotalMinor));
|
||||||
|
if (fin.Loaded()) {
|
||||||
|
incomeRows.push_back(totalRow("Income", fin.donationsMinor + salesTotalMinor));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expenses: one table, group-header rows for the recurring/one-off split,
|
||||||
|
// so the amounts stay in a single aligned column.
|
||||||
|
SafeHtml expenses;
|
||||||
|
if (fin.Loaded()) {
|
||||||
|
std::vector<SafeHtml> rows;
|
||||||
|
auto group = [&](std::string_view heading, std::span<const FinCategory> cats) {
|
||||||
|
if (cats.empty()) return;
|
||||||
|
rows.push_back(Format(
|
||||||
|
R"(<tr class="fin-group"><th colspan="2">{}</th></tr>)",
|
||||||
|
Escape(heading)));
|
||||||
|
for (const FinCategory& c : cats) rows.push_back(MoneyRow(c.label, c.totalMinor));
|
||||||
|
};
|
||||||
|
group("Recurring", fin.recurring);
|
||||||
|
group("One-off", fin.single);
|
||||||
|
rows.push_back(totalRow("Expenses", fin.ExpensesMinor()));
|
||||||
|
expenses = Format(R"(<table class="spec-table"><tbody>{}</tbody></table>)",
|
||||||
|
Join(rows));
|
||||||
|
} else {
|
||||||
|
expenses = Raw(
|
||||||
|
R"(<p class="notice">Donations and expenses are aggregated from the )"
|
||||||
|
R"(business bank account and have not been published yet. The sales )"
|
||||||
|
R"(figures above are already live.</p>)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The freshness line keeps the page honest about its two cadences.
|
||||||
|
const SafeHtml freshness = fin.Loaded()
|
||||||
|
? Format(R"(<p class="legal__updated">Sales are live from the order ledger · )"
|
||||||
|
R"(bank figures as of <time{}>{}</time></p>)",
|
||||||
|
Attr("datetime", fin.asOf), Escape(fin.asOf))
|
||||||
|
: Raw(R"(<p class="legal__updated">Sales are live from the order ledger</p>)");
|
||||||
|
|
||||||
|
// The methodology prose, in the legal pages' section shape and CSS.
|
||||||
|
std::vector<SafeHtml> sections;
|
||||||
|
for (const LegalSection& sec : notes.sections) {
|
||||||
|
std::vector<SafeHtml> paras;
|
||||||
|
for (const std::string& para : sec.body) {
|
||||||
|
paras.push_back(Format(R"(<p>{}</p>)", Autolink(para)));
|
||||||
|
}
|
||||||
|
sections.push_back(Format(
|
||||||
|
R"(<section class="legal__section">)"
|
||||||
|
R"(<h2 class="legal__heading">{}</h2>{}</section>)",
|
||||||
|
Escape(sec.heading), Join(paras)));
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderedPage page;
|
||||||
|
page.meta.title = notes.title + " — Catcrafts";
|
||||||
|
page.meta.description = notes.lede;
|
||||||
|
page.meta.canonical = "/financials";
|
||||||
|
page.main = Format(
|
||||||
|
R"(<header class="page-header">)"
|
||||||
|
R"(<h1 class="page-header__title">{}</h1>)"
|
||||||
|
R"(<p class="page-header__lede">{}</p>)"
|
||||||
|
R"({})"
|
||||||
|
R"(</header>)"
|
||||||
|
R"(<div class="fin"{}{}{}{}{}>)"
|
||||||
|
R"(<section class="section"><h2 class="section__title">Income</h2>)"
|
||||||
|
R"(<table class="spec-table"><tbody>{}</tbody></table></section>)"
|
||||||
|
R"(<section class="section"><h2 class="section__title">Expenses</h2>{}</section>)"
|
||||||
|
R"(</div>)"
|
||||||
|
R"(<div class="legal">{}</div>)",
|
||||||
|
Escape(notes.title), Escape(notes.lede), freshness,
|
||||||
|
Attr("data-fin-sales-count", std::to_string(salesCount)),
|
||||||
|
Attr("data-fin-sales-minor", std::to_string(salesTotalMinor)),
|
||||||
|
fin.Loaded() ? Attr("data-fin-donations-count", std::to_string(fin.donationCount))
|
||||||
|
: SafeHtml{},
|
||||||
|
fin.Loaded() ? Attr("data-fin-donations-minor", std::to_string(fin.donationsMinor))
|
||||||
|
: SafeHtml{},
|
||||||
|
fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor()))
|
||||||
|
: SafeHtml{},
|
||||||
|
Join(incomeRows), expenses, Join(sections));
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
// ── demos ─────────────────────────────────────────────────────────────
|
// ── demos ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export RenderedPage RenderDemos(std::span<const Demo> demos) {
|
export RenderedPage RenderDemos(std::span<const Demo> demos) {
|
||||||
|
|
@ -1632,6 +1749,24 @@ RenderedPage RenderRouteBody(const Route& route, const SiteContent& content) {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case RouteKind::Financials: {
|
||||||
|
// The live totals are server state, and the server intercepts this
|
||||||
|
// route before shared dispatch, exactly like orders. Reaching this
|
||||||
|
// case means the wasm app is rendering with the backend down — an
|
||||||
|
// honest notice beats a page of zeros posing as the company's
|
||||||
|
// finances.
|
||||||
|
RenderedPage page;
|
||||||
|
page.meta.title = "Financials — Catcrafts";
|
||||||
|
page.meta.canonical = "/financials";
|
||||||
|
page.meta.refreshSeconds = 30;
|
||||||
|
page.main = Raw(
|
||||||
|
R"(<header class="page-header">)"
|
||||||
|
R"(<h1 class="page-header__title">Financials unavailable</h1>)"
|
||||||
|
R"(<p class="page-header__lede">The live figures aren't reachable )"
|
||||||
|
R"(right now. This page retries automatically.</p>)"
|
||||||
|
R"(</header>)");
|
||||||
|
return page;
|
||||||
|
}
|
||||||
case RouteKind::Product: {
|
case RouteKind::Product: {
|
||||||
// A slug that parsed but names nothing is a 404, not an empty
|
// A slug that parsed but names nothing is a 404, not an empty
|
||||||
// product page — otherwise every typo becomes an indexable URL.
|
// product page — otherwise every typo becomes an indexable URL.
|
||||||
|
|
|
||||||
|
|
@ -937,6 +937,25 @@ treatment it replaces, which read as the wrong category for the work.
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── the financials page ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* The running-total tables are .spec-table; these two row types are the
|
||||||
|
ledger idioms on top of it: a group label heading the rows beneath it,
|
||||||
|
and a sum ruled off from what it sums. */
|
||||||
|
.fin-group th {
|
||||||
|
padding-top: var(--s0);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 650;
|
||||||
|
border-bottom: 1px solid var(--border-strong);
|
||||||
|
}
|
||||||
|
.fin-total th,
|
||||||
|
.fin-total td {
|
||||||
|
border-top: 2px solid var(--border-strong);
|
||||||
|
border-bottom: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── demos ────────────────────────────────────────────────────────── */
|
/* ── demos ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.demo-grid {
|
.demo-grid {
|
||||||
|
|
|
||||||
227
tools/bunq-callback.sh
Executable file
227
tools/bunq-callback.sh
Executable file
|
|
@ -0,0 +1,227 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Register the /financials mutation callback with bunq.
|
||||||
|
#
|
||||||
|
# RUN THIS FROM THE MACHINE THE KEY BELONGS TO — your own, not the server.
|
||||||
|
# That is the whole point: a bunq API key can initiate payments and has no
|
||||||
|
# read-only scope, so it never goes on the internet-facing box. This script
|
||||||
|
# uses it once, here, to tell bunq "push mutations to that URL". Afterwards
|
||||||
|
# bunq talks to the server and the key stays home.
|
||||||
|
#
|
||||||
|
# tools/bunq-callback.sh list # accounts + current filters
|
||||||
|
# tools/bunq-callback.sh set <account-id> <url> # install the filter
|
||||||
|
#
|
||||||
|
# The key comes from BUNQ_KEY in the repo-root .env (gitignored). Session
|
||||||
|
# state — the RSA keypair, the installation token, whether the device was
|
||||||
|
# registered — persists in ./bunq-state.json (gitignored, 0600), so re-runs
|
||||||
|
# reuse the registration instead of making a new one every time.
|
||||||
|
#
|
||||||
|
# PERMITTED IPS: the device registration binds the key to the addresses that
|
||||||
|
# may USE it. This defaults to this machine's current public address, NOT the
|
||||||
|
# server's — bunq delivers callbacks outbound to an HTTPS URL, which has
|
||||||
|
# nothing to do with this list, so whitelisting the server would grant
|
||||||
|
# bank-API access to the box most exposed to attack and buy nothing. Override
|
||||||
|
# with BUNQ_PERMITTED_IP if your address has moved.
|
||||||
|
#
|
||||||
|
# Note your home address is probably dynamic: when the ISP rotates it, API
|
||||||
|
# calls from here start failing with a permission error. Re-running is not
|
||||||
|
# enough — a device registration is per-key and cannot be re-pointed — so the
|
||||||
|
# recovery is to add the new address to the existing device via the bunq app,
|
||||||
|
# or to accept "*" and rely on the key secret alone. The callback itself keeps
|
||||||
|
# working throughout; only your ability to run this script from here breaks.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
API_HOST="${BUNQ_API_HOST:-api.bunq.com}"
|
||||||
|
STATE="${BUNQ_STATE:-bunq-state.json}"
|
||||||
|
UA='catcrafts.net-tools/1.0 (+https://catcrafts.net)'
|
||||||
|
|
||||||
|
[ -f .env ] || { echo "bunq: no .env in $(pwd) — run from the repo root" >&2; exit 1; }
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
BUNQ_KEY=$(sed -n 's/^BUNQ_KEY=//p' .env | head -n1 | tr -d '"'"'"'')
|
||||||
|
[ -n "$BUNQ_KEY" ] || { echo "bunq: BUNQ_KEY is empty in .env" >&2; exit 1; }
|
||||||
|
|
||||||
|
for bin in openssl curl python3; do
|
||||||
|
command -v "$bin" >/dev/null || { echo "bunq: $bin is required" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── state ─────────────────────────────────────────────────────────────
|
||||||
|
# One JSON file, 0600: it holds a private key.
|
||||||
|
|
||||||
|
state_get() {
|
||||||
|
[ -f "$STATE" ] || { echo ""; return; }
|
||||||
|
python3 -c 'import json,sys
|
||||||
|
try: print(json.load(open(sys.argv[1])).get(sys.argv[2],"") or "")
|
||||||
|
except Exception: print("")' "$STATE" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
state_set() {
|
||||||
|
python3 -c 'import json,os,sys
|
||||||
|
p=sys.argv[1]
|
||||||
|
try: d=json.load(open(p))
|
||||||
|
except Exception: d={}
|
||||||
|
d[sys.argv[2]]=sys.argv[3]
|
||||||
|
fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
||||||
|
with os.fdopen(fd,"w") as f: json.dump(d,f)' "$STATE" "$1" "$2"
|
||||||
|
chmod 600 "$STATE"
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
|
||||||
|
json_get() {
|
||||||
|
# json_get <dotted-ish key path through the bunq Response envelope>
|
||||||
|
python3 -c 'import json,sys
|
||||||
|
def walk(o,k):
|
||||||
|
if isinstance(o,dict):
|
||||||
|
if k in o: yield o[k]
|
||||||
|
for v in o.values(): yield from walk(v,k)
|
||||||
|
elif isinstance(o,list):
|
||||||
|
for v in o: yield from walk(v,k)
|
||||||
|
try: d=json.load(sys.stdin)
|
||||||
|
except Exception: sys.exit(1)
|
||||||
|
for hit in walk(d,sys.argv[1]):
|
||||||
|
print(hit if not isinstance(hit,(dict,list)) else json.dumps(hit)); break' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── one signed API call ───────────────────────────────────────────────
|
||||||
|
# bunq stopped REQUIRING body signatures in 2019, but signing costs nothing
|
||||||
|
# and a signed request is valid whether or not the server checks.
|
||||||
|
|
||||||
|
api() { # api <METHOD> <path> <body|""> <auth-token|"">
|
||||||
|
_m="$1"; _p="$2"; _b="$3"; _t="${4:-}"
|
||||||
|
set -- -sS -X "$_m" \
|
||||||
|
-H "user-agent: $UA" -H 'cache-control: no-cache' \
|
||||||
|
-H "x-bunq-client-request-id: $(openssl rand -hex 8)" \
|
||||||
|
-H 'x-bunq-geolocation: 0 0 0 0 000' \
|
||||||
|
-H 'x-bunq-language: en_US' -H 'x-bunq-region: nl_NL'
|
||||||
|
if [ -n "$_b" ]; then
|
||||||
|
_sig=$(printf '%s' "$_b" | openssl dgst -sha256 -sign "$KEYFILE" | openssl base64 -A)
|
||||||
|
set -- "$@" -H 'content-type: application/json' \
|
||||||
|
-H "x-bunq-client-signature: $_sig" --data-binary "$_b"
|
||||||
|
fi
|
||||||
|
[ -n "$_t" ] && set -- "$@" -H "x-bunq-client-authentication: $_t"
|
||||||
|
curl "$@" "https://$API_HOST$_p"
|
||||||
|
}
|
||||||
|
|
||||||
|
die_on_error() { # reads a response, prints it and exits if it carries an Error
|
||||||
|
_r="$1"
|
||||||
|
if printf '%s' "$_r" | grep -q '"Error"'; then
|
||||||
|
echo "bunq refused the call:" >&2
|
||||||
|
printf '%s\n' "$_r" | python3 -m json.tool >&2 2>/dev/null || printf '%s\n' "$_r" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── handshake: installation -> device-server -> session ───────────────
|
||||||
|
|
||||||
|
KEYFILE="${BUNQ_KEYFILE:-bunq-client-key.pem}"
|
||||||
|
if [ ! -f "$KEYFILE" ]; then
|
||||||
|
echo "bunq: generating a client keypair -> $KEYFILE"
|
||||||
|
openssl genrsa -out "$KEYFILE" 2048 2>/dev/null
|
||||||
|
chmod 600 "$KEYFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
INSTALL_TOKEN=$(state_get installation_token)
|
||||||
|
if [ -z "$INSTALL_TOKEN" ]; then
|
||||||
|
echo "bunq: registering the installation (once, ever)"
|
||||||
|
_pub=$(openssl rsa -in "$KEYFILE" -pubout 2>/dev/null | json_str)
|
||||||
|
_resp=$(api POST /v1/installation "{\"client_public_key\":$_pub}" "")
|
||||||
|
die_on_error "$_resp"
|
||||||
|
INSTALL_TOKEN=$(printf '%s' "$_resp" | json_get token)
|
||||||
|
[ -n "$INSTALL_TOKEN" ] || { echo "bunq: no installation token in the response" >&2; exit 1; }
|
||||||
|
state_set installation_token "$INSTALL_TOKEN"
|
||||||
|
# bunq's own public key arrives here. Keep it: it is what
|
||||||
|
# BUNQ_CALLBACK_PUBKEY on the server verifies callback signatures against.
|
||||||
|
printf '%s' "$_resp" | json_get server_public_key > bunq-server-public-key.pem || true
|
||||||
|
[ -s bunq-server-public-key.pem ] \
|
||||||
|
&& echo "bunq: saved bunq-server-public-key.pem (for BUNQ_CALLBACK_PUBKEY)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(state_get device_registered)" != "yes" ]; then
|
||||||
|
PERMITTED_IP="${BUNQ_PERMITTED_IP:-$(curl -sS --max-time 10 https://ifconfig.me)}"
|
||||||
|
[ -n "$PERMITTED_IP" ] || { echo "bunq: could not determine this machine's IP" >&2; exit 1; }
|
||||||
|
echo "bunq: binding the key to $PERMITTED_IP (this machine only — NOT the server)"
|
||||||
|
printf 'bunq: this is permanent for this key. Continue? [y/N] '
|
||||||
|
read -r _yn; [ "$_yn" = y ] || [ "$_yn" = Y ] || { echo "aborted"; exit 1; }
|
||||||
|
_resp=$(api POST /v1/device-server \
|
||||||
|
"$(python3 -c 'import json,sys
|
||||||
|
print(json.dumps({"description":"catcrafts.net financials callback registrar",
|
||||||
|
"secret":sys.argv[1],"permitted_ips":[sys.argv[2]]}))' \
|
||||||
|
"$BUNQ_KEY" "$PERMITTED_IP")" "$INSTALL_TOKEN")
|
||||||
|
die_on_error "$_resp"
|
||||||
|
state_set device_registered yes
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sessions expire, so this one is not cached.
|
||||||
|
_resp=$(api POST /v1/session-server \
|
||||||
|
"$(python3 -c 'import json,sys; print(json.dumps({"secret":sys.argv[1]}))' "$BUNQ_KEY")" \
|
||||||
|
"$INSTALL_TOKEN")
|
||||||
|
die_on_error "$_resp"
|
||||||
|
SESSION=$(printf '%s' "$_resp" | json_get token)
|
||||||
|
# NOT a naive search for "id": the session response opens with an Id object of
|
||||||
|
# its own, and taking that one silently addresses every later call to the
|
||||||
|
# wrong user. The user is whichever of these three the account type produces.
|
||||||
|
USER_ID=$(printf '%s' "$_resp" | python3 -c 'import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
for item in d.get("Response",[]):
|
||||||
|
for k in ("UserPerson","UserCompany","UserApiKey"):
|
||||||
|
u=item.get(k)
|
||||||
|
if isinstance(u,dict) and "id" in u:
|
||||||
|
print(u["id"]); sys.exit(0)')
|
||||||
|
[ -n "$SESSION" ] && [ -n "$USER_ID" ] \
|
||||||
|
|| { echo "bunq: could not open a session" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── commands ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
case "${1:-list}" in
|
||||||
|
list)
|
||||||
|
echo "bunq: user $USER_ID"
|
||||||
|
echo
|
||||||
|
echo "accounts (the donations one goes in donation_accounts in the rules file):"
|
||||||
|
api GET "/v1/user/$USER_ID/monetary-account?count=50" "" "$SESSION" | python3 -c 'import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
for item in d.get("Response",[]):
|
||||||
|
for kind,acc in item.items():
|
||||||
|
if not isinstance(acc,dict) or "id" not in acc: continue
|
||||||
|
iban=next((a.get("value") for a in acc.get("alias",[]) if a.get("type")=="IBAN"),"")
|
||||||
|
bal=(acc.get("balance") or {}).get("value","?")
|
||||||
|
aid=acc.get("id"); st=acc.get("status",""); desc=acc.get("description","")
|
||||||
|
print(f" id={aid:<10} {st:<8} {kind:<22} {desc} {iban} balance {bal}")'
|
||||||
|
echo
|
||||||
|
echo "current MUTATION filters:"
|
||||||
|
api GET "/v1/user/$USER_ID/notification-filter-url" "" "$SESSION" | python3 -m json.tool
|
||||||
|
;;
|
||||||
|
set)
|
||||||
|
ACCOUNT="${2:?usage: tools/bunq-callback.sh set <account-id> <callback-url>}"
|
||||||
|
URL="${3:?usage: tools/bunq-callback.sh set <account-id> <callback-url>}"
|
||||||
|
case "$URL" in
|
||||||
|
https://*) ;;
|
||||||
|
*) echo "bunq: the callback URL must be https" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
# Refuse to point bunq at an endpoint that is not answering yet: a filter
|
||||||
|
# whose target keeps failing is a filter bunq may disable, and a mutation
|
||||||
|
# delivered into a 404 is simply lost until the weekly reconciliation.
|
||||||
|
echo "bunq: checking the endpoint is live before registering it"
|
||||||
|
_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST \
|
||||||
|
-H 'content-type: application/json' -d '{}' "$URL" || echo 000)
|
||||||
|
if [ "$_code" != 200 ]; then
|
||||||
|
echo "bunq: $URL answered $_code, not 200." >&2
|
||||||
|
echo " Deploy the financials build and set BUNQ_CALLBACK_SECRET first." >&2
|
||||||
|
echo " (A wrong secret answers 404 by design — check the secret too.)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "bunq: installing the MUTATION filter on account $ACCOUNT"
|
||||||
|
# POST REPLACES the whole filter set for this account, so this is also how
|
||||||
|
# you change or clear one.
|
||||||
|
_resp=$(api POST "/v1/user/$USER_ID/monetary-account/$ACCOUNT/notification-filter-url" \
|
||||||
|
"$(python3 -c 'import json,sys
|
||||||
|
print(json.dumps({"notification_filters":[
|
||||||
|
{"category":"MUTATION","notification_target":sys.argv[1]}]}))' "$URL")" "$SESSION")
|
||||||
|
die_on_error "$_resp"
|
||||||
|
printf '%s\n' "$_resp" | python3 -m json.tool
|
||||||
|
echo
|
||||||
|
echo "bunq: done. Send yourself €0.01 and watch:"
|
||||||
|
echo " ssh hetzner journalctl -u catcrafts-server -f"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: tools/bunq-callback.sh [list | set <account-id> <callback-url>]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -69,7 +69,7 @@ onedir() {
|
||||||
|
|
||||||
if [ "$BUILD" = 1 ]; then
|
if [ "$BUILD" = 1 ]; then
|
||||||
echo "dev: building the server product..."
|
echo "dev: building the server product..."
|
||||||
crafter-build --local -- --product=server >"$WORK/build-server.log" 2>&1 \
|
crafter-build --product=server >"$WORK/build-server.log" 2>&1 \
|
||||||
|| { echo "dev: server build failed:" >&2; tail -20 "$WORK/build-server.log" >&2; exit 1; }
|
|| { echo "dev: server build failed:" >&2; tail -20 "$WORK/build-server.log" >&2; exit 1; }
|
||||||
SRV=$(onedir 'Catcrafts.Server-*')
|
SRV=$(onedir 'Catcrafts.Server-*')
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ if [ "$BUILD" = 1 ]; then
|
||||||
"$SRV/catcrafts-server" --feed > feed.xml
|
"$SRV/catcrafts-server" --feed > feed.xml
|
||||||
|
|
||||||
echo "dev: building the wasm bundle..."
|
echo "dev: building the wasm bundle..."
|
||||||
crafter-build --local >"$WORK/build-web.log" 2>&1 \
|
crafter-build >"$WORK/build-web.log" 2>&1 \
|
||||||
|| { echo "dev: wasm build failed:" >&2; tail -20 "$WORK/build-web.log" >&2; exit 1; }
|
|| { echo "dev: wasm build failed:" >&2; tail -20 "$WORK/build-web.log" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
157
tools/e2e.sh
157
tools/e2e.sh
|
|
@ -86,6 +86,12 @@ chmod +x "$WORK/sendmail"
|
||||||
export MAIL_COMMAND="$WORK/sendmail"
|
export MAIL_COMMAND="$WORK/sendmail"
|
||||||
export MAIL_FROM='Catcrafts <info@catcrafts.net>'
|
export MAIL_FROM='Catcrafts <info@catcrafts.net>'
|
||||||
|
|
||||||
|
# The bunq mutation callback. 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.
|
||||||
|
export BUNQ_CALLBACK_SECRET='e2e-callback-secret-not-a-real-one'
|
||||||
|
|
||||||
# The shipping rate table. Shipping has no compiled-in fallback any more — the
|
# The shipping rate table. Shipping has no compiled-in fallback any more — the
|
||||||
# carrier table is the only source of prices — so without this file every
|
# carrier table is the only source of prices — so without this file every
|
||||||
# checkout correctly refuses and the whole order suite would be testing the
|
# checkout correctly refuses and the whole order suite would be testing the
|
||||||
|
|
@ -170,6 +176,7 @@ header_has() {
|
||||||
|
|
||||||
echo "== status codes =="
|
echo "== status codes =="
|
||||||
for p in / /about /shop /shop/fp6-pmos /projects /posts /demos /demos/raytracer \
|
for p in / /about /shop /shop/fp6-pmos /projects /posts /demos /demos/raytracer \
|
||||||
|
/financials \
|
||||||
/legal/privacy /legal/terms /legal/imprint /feed.xml /sitemap.xml /api/healthz; do
|
/legal/privacy /legal/terms /legal/imprint /feed.xml /sitemap.xml /api/healthz; do
|
||||||
status "$p" 200
|
status "$p" 200
|
||||||
done
|
done
|
||||||
|
|
@ -219,10 +226,12 @@ body_has /projects "imsd" "/projects has content in the
|
||||||
body_has /projects "<title>Projects" "/projects has a real title"
|
body_has /projects "<title>Projects" "/projects has a real title"
|
||||||
body_lacks /projects "<script" "/projects ships no script at all"
|
body_lacks /projects "<script" "/projects ships no script at all"
|
||||||
body_lacks /legal/privacy "<script" "/legal/privacy ships no script"
|
body_lacks /legal/privacy "<script" "/legal/privacy ships no script"
|
||||||
|
body_has /financials "<title>Financials" "/financials has a real title"
|
||||||
|
body_lacks /financials "<script" "/financials ships no script"
|
||||||
|
|
||||||
# Placeholders are dev-only markers; one reaching production is a content bug
|
# Placeholders are dev-only markers; one reaching production is a content bug
|
||||||
# (an imprint that says PLACEHOLDER once shipped exactly that way).
|
# (an imprint that says PLACEHOLDER once shipped exactly that way).
|
||||||
for pg in /legal/privacy /legal/terms /legal/imprint /shop/fp6-pmos; do
|
for pg in /legal/privacy /legal/terms /legal/imprint /shop/fp6-pmos /financials; do
|
||||||
body_lacks "$pg" 'PLACEHOLDER' "$pg ships no placeholder markers"
|
body_lacks "$pg" 'PLACEHOLDER' "$pg ships no placeholder markers"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -627,6 +636,120 @@ body_has /sitemap.xml "/shop/fp6-pmos" "sitemap lists the product"
|
||||||
body_has /sitemap.xml "/about" "sitemap lists the about page"
|
body_has /sitemap.xml "/about" "sitemap lists the about page"
|
||||||
body_has /sitemap.xml "/legal/privacy" "sitemap lists the privacy page"
|
body_has /sitemap.xml "/legal/privacy" "sitemap lists the privacy page"
|
||||||
body_has /sitemap.xml "/demos" "sitemap lists the demos page"
|
body_has /sitemap.xml "/demos" "sitemap lists the demos page"
|
||||||
|
body_has /sitemap.xml "/financials" "sitemap lists the financials page"
|
||||||
|
|
||||||
|
echo "== the financials page =="
|
||||||
|
# Aggregate-only by construction: totals and counts, machine-readable for
|
||||||
|
# this suite via the data-fin-* attributes. Live is the page's promise, so
|
||||||
|
# it must never sit in a shared cache.
|
||||||
|
header_has /financials 'cache-control: *no-store' "financials are never cached"
|
||||||
|
body_has /financials 'data-fin-sales-count="0"' "financials start at zero sales"
|
||||||
|
# Before the bank-aggregates file exists the page says so, and publishes no
|
||||||
|
# donation figures at all — an unknowable €0 would be a lie.
|
||||||
|
body_has /financials 'not been published yet' "unpublished bank figures say so"
|
||||||
|
body_lacks /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: no restart, no cache, no delay —
|
||||||
|
# this is the liveness the donation counter depends on.
|
||||||
|
cat >"$ORDERS.financials.json" <<'JSON'
|
||||||
|
{"as_of":"2026-08-14",
|
||||||
|
"donations":{"count":3,"total_minor":4500},
|
||||||
|
"recurring":[{"label":"Hosting","total_minor":1200}],
|
||||||
|
"single":[{"label":"Inventory","total_minor":230000}]}
|
||||||
|
JSON
|
||||||
|
body_has /financials 'data-fin-donations-count="3"' "donation count picked up live"
|
||||||
|
body_has /financials 'data-fin-expenses-minor="231200"' "expense total picked up live"
|
||||||
|
body_has /financials 'Hosting' "recurring category renders"
|
||||||
|
body_has /financials 'Inventory' "one-off category renders"
|
||||||
|
body_has /financials '2026-08-14' "bank figures carry their as-of date"
|
||||||
|
|
||||||
|
echo "== the bunq mutation callback =="
|
||||||
|
# The live path: bunq PUSHES a mutation, the server classifies it against the
|
||||||
|
# rules file and folds it into the totals. This is what makes a donation tick
|
||||||
|
# the public counter while the donor is still looking at the page.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
cat >"$ORDERS.financial-rules.json" <<'JSON'
|
||||||
|
{"donation_accounts":[9911],
|
||||||
|
"rules":[{"description_contains":"hetzner","group":"recurring","label":"Hosting"},
|
||||||
|
{"iban":"NL01OWNSELF0000000","group":"ignore"}]}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
CB="/api/bunq/$BUNQ_CALLBACK_SECRET"
|
||||||
|
# bunq_post <id> <account> <value> <iban> <description> -> HTTP status
|
||||||
|
bunq_post() {
|
||||||
|
curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
--data-binary "$(printf '{"NotificationUrl":{"category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{"id":%s,"created":"2026-08-15 09:31:02.000000","monetary_account_id":%s,"amount":{"currency":"EUR","value":"%s"},"description":"%s","counterparty_alias":{"iban":"%s","display_name":"Someone"}}}}}' \
|
||||||
|
"$1" "$2" "$3" "$5" "$4")" \
|
||||||
|
"$BASE$CB"
|
||||||
|
}
|
||||||
|
# fin_attr <attribute> -> its value on the live page
|
||||||
|
fin_attr() { curl -s "$BASE/financials" | grep -o "$1=\"[0-9]*\"" | cut -d'"' -f2; }
|
||||||
|
|
||||||
|
# An endpoint guarded by a secret must not confirm its own existence: every
|
||||||
|
# unauthorised shape is the same 404 an unknown order token gets.
|
||||||
|
status "/api/bunq/wrong-secret" 404 POST '{}'
|
||||||
|
status "$CB" 404 # GET on the right URL is still not a callback
|
||||||
|
status "$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.
|
||||||
|
if [ "$(bunq_post 4823 9911 25.00 NL55BUNQ2025123456 'Thanks for imsd')" = 200 ]; then
|
||||||
|
ok "the callback accepts a mutation"
|
||||||
|
else
|
||||||
|
bad "bunq callback" "a valid notification was not accepted"
|
||||||
|
fi
|
||||||
|
if [ "$(fin_attr data-fin-donations-count)" = 4 ] \
|
||||||
|
&& [ "$(fin_attr data-fin-donations-minor)" = 7000 ]; then
|
||||||
|
ok "a donation ticks the public counter immediately"
|
||||||
|
else
|
||||||
|
bad "donation ingest" "counter did not move to 4 / 7000 cents"
|
||||||
|
fi
|
||||||
|
# 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.
|
||||||
|
bunq_post 4823 9911 25.00 NL55BUNQ2025123456 'Thanks for imsd' >/dev/null
|
||||||
|
if [ "$(fin_attr data-fin-donations-count)" = 4 ] \
|
||||||
|
&& [ "$(fin_attr data-fin-donations-minor)" = 7000 ]; then
|
||||||
|
ok "a redelivered mutation is not counted twice"
|
||||||
|
else
|
||||||
|
bad "callback idempotency" "a duplicate mutation moved the totals"
|
||||||
|
fi
|
||||||
|
# Default-deny: money no rule claims is WITHHELD from the page. It is logged
|
||||||
|
# for classification, never published as a guess.
|
||||||
|
if [ "$(bunq_post 4824 1234 90.00 NL99UNKNOWN00000000 'unlabelled transfer')" = 200 ]; then
|
||||||
|
ok "an unclassifiable mutation is still accepted (no redelivery loop)"
|
||||||
|
else
|
||||||
|
bad "unclassified mutation" "the callback answered non-2xx and will be retried forever"
|
||||||
|
fi
|
||||||
|
if [ "$(fin_attr data-fin-donations-count)" = 4 ] \
|
||||||
|
&& [ "$(fin_attr data-fin-expenses-minor)" = 231200 ]; then
|
||||||
|
ok "an unclassified mutation is withheld from every total"
|
||||||
|
else
|
||||||
|
bad "default-deny" "an unmatched mutation reached the public figures"
|
||||||
|
fi
|
||||||
|
# An outgoing bill matched by description becomes a positive expense.
|
||||||
|
bunq_post 4825 9911 -12.00 DE00HETZNER00000000 'HETZNER ONLINE GMBH' >/dev/null
|
||||||
|
if [ "$(fin_attr data-fin-expenses-minor)" = 232400 ]; then
|
||||||
|
ok "an outgoing bill lands in its expense category"
|
||||||
|
else
|
||||||
|
bad "expense ingest" "expenses did not move to 232400 cents"
|
||||||
|
fi
|
||||||
|
body_has /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 leak in 'NL55BUNQ2025123456' 'Someone' 'Thanks for imsd' '4823' '09:31'; do
|
||||||
|
body_lacks /financials "$leak" "financials leak no transaction detail ($leak)"
|
||||||
|
done
|
||||||
|
# And nothing identifying was written to disk either — the ingest ledger holds
|
||||||
|
# opaque ids and counters, and no other file learned the donor exists.
|
||||||
|
if grep -rlF 'NL55BUNQ2025123456' "$WORK" >/dev/null 2>&1; then
|
||||||
|
bad "callback storage" "a counterparty IBAN was persisted somewhere under $WORK"
|
||||||
|
else
|
||||||
|
ok "no counterparty IBAN is persisted anywhere"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "== instance-agnostic copy =="
|
echo "== instance-agnostic copy =="
|
||||||
# The account lives on one instance but posts go into communities on others, so
|
# The account lives on one instance but posts go into communities on others, so
|
||||||
|
|
@ -1108,6 +1231,38 @@ else
|
||||||
bad "notified event" "no notified event in $ORDERS"
|
bad "notified event" "no notified event in $ORDERS"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "== financials reflect the ledger =="
|
||||||
|
# Lifetime sales on /financials must equal the ledger: sum of total_minor over
|
||||||
|
# orders that have a paid status event. Derived from the ledger rather than
|
||||||
|
# written as a literal — same rule as the email count above, and for the same
|
||||||
|
# reason: "the page equals the ledger" is the actual property.
|
||||||
|
want_minor=0; want_count=0
|
||||||
|
for pid in $(grep '"type":"status"' "$ORDERS" | grep '"status":"paid"' \
|
||||||
|
| grep -o '"id":"[0-9a-f]\{32\}"' | grep -o '[0-9a-f]\{32\}' | sort -u); do
|
||||||
|
t=$(grep '"type":"order"' "$ORDERS" | grep -F "\"id\":\"$pid\"" \
|
||||||
|
| grep -o '"total_minor":[0-9]*' | head -n1 | cut -d: -f2)
|
||||||
|
want_minor=$((want_minor + t)); want_count=$((want_count + 1))
|
||||||
|
done
|
||||||
|
fin_page=$(curl -s "$BASE/financials")
|
||||||
|
got_minor=$(printf '%s' "$fin_page" | grep -o 'data-fin-sales-minor="[0-9]*"' | cut -d'"' -f2)
|
||||||
|
got_count=$(printf '%s' "$fin_page" | grep -o 'data-fin-sales-count="[0-9]*"' | cut -d'"' -f2)
|
||||||
|
if [ "$want_count" -gt 0 ] && [ "$got_minor" = "$want_minor" ] && [ "$got_count" = "$want_count" ]; then
|
||||||
|
ok "sales totals equal the ledger ($want_count orders, $want_minor cents)"
|
||||||
|
else
|
||||||
|
bad "financials sales" "ledger says $want_count/$want_minor, page says $got_count/$got_minor"
|
||||||
|
fi
|
||||||
|
# And the formatted euro figure for that total appears on the page.
|
||||||
|
if [ $((want_minor % 100)) -eq 0 ]; then
|
||||||
|
eur=$(printf '€%d' $((want_minor / 100)))
|
||||||
|
else
|
||||||
|
eur=$(printf '€%d.%02d' $((want_minor / 100)) $((want_minor % 100)))
|
||||||
|
fi
|
||||||
|
if printf '%s' "$fin_page" | grep -qF -- "$eur"; then
|
||||||
|
ok "sales total renders as $eur"
|
||||||
|
else
|
||||||
|
bad "financials formatting" "page lacks $eur"
|
||||||
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
echo "== checkout (coming soon) =="
|
echo "== checkout (coming soon) =="
|
||||||
# A perfectly valid order must be refused while the shop is closed: after
|
# A perfectly valid order must be refused while the shop is closed: after
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue