This commit is contained in:
parent
70668af8f5
commit
e68d2c245c
17 changed files with 1801 additions and 15 deletions
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
|
||||
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; }
|
||||
SRV=$(onedir 'Catcrafts.Server-*')
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ if [ "$BUILD" = 1 ]; then
|
|||
"$SRV/catcrafts-server" --feed > feed.xml
|
||||
|
||||
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; }
|
||||
fi
|
||||
|
||||
|
|
|
|||
157
tools/e2e.sh
157
tools/e2e.sh
|
|
@ -86,6 +86,12 @@ chmod +x "$WORK/sendmail"
|
|||
export MAIL_COMMAND="$WORK/sendmail"
|
||||
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
|
||||
# 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
|
||||
|
|
@ -170,6 +176,7 @@ header_has() {
|
|||
|
||||
echo "== status codes =="
|
||||
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
|
||||
status "$p" 200
|
||||
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_lacks /projects "<script" "/projects ships no script at all"
|
||||
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
|
||||
# (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"
|
||||
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 "/legal/privacy" "sitemap lists the privacy 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 =="
|
||||
# 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"
|
||||
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
|
||||
echo "== checkout (coming soon) =="
|
||||
# A perfectly valid order must be refused while the shop is closed: after
|
||||
|
|
|
|||
Loading…
Reference in a new issue