financial page with bank data
All checks were successful
Deploy / build-deploy (push) Successful in 1m48s

This commit is contained in:
Jorijn van der Graaf 2026-08-14 04:14:13 +02:00
commit 33c68c2f44
11 changed files with 394 additions and 87 deletions

View file

@ -185,8 +185,51 @@ for item in d.get("Response",[]):
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
# Filters registered PER ACCOUNT do not appear in the user-level list, so
# showing only that one reads as "nothing is registered" when in fact
# everything is. Both are printed, per account, or this command lies.
echo "MUTATION filters, per account:"
api GET "/v1/user/$USER_ID/monetary-account?count=50" "" "$SESSION" | python3 -c 'import json,sys
d=json.load(sys.stdin)
ids=[]
for item in d.get("Response",[]):
for acc in item.values():
if isinstance(acc,dict) and "id" in acc: ids.append(str(acc["id"]))
print(" ".join(ids))' > /tmp/.cc-accts.$$
for _a in $(cat /tmp/.cc-accts.$$); do
_f=$(api GET "/v1/user/$USER_ID/monetary-account/$_a/notification-filter-url" "" "$SESSION" \
| python3 -c 'import json,sys
d=json.load(sys.stdin)
out=[]
for item in d.get("Response",[]):
f=item.get("NotificationFilterUrl") or {}
if not f: continue
t=f.get("notification_target") or ""
# never print the callback secret: the last path segment is masked
parts=t.rsplit("/",1)
masked=parts[0]+"/"+(parts[1][:4]+"…" if len(parts)>1 and parts[1] else "")
out.append(f.get("category","?")+" -> "+masked)
print("; ".join(out) if out else "(none)")')
printf ' account %-10s %s\n' "$_a" "$_f"
done
rm -f /tmp/.cc-accts.$$
echo
echo "user-level filters (apply to ALL accounts):"
api GET "/v1/user/$USER_ID/notification-filter-url" "" "$SESSION" | python3 -c 'import json,sys
d=json.load(sys.stdin)
r=d.get("Response") or []
if not r:
print(" (none)"); raise SystemExit
for item in r:
f=item.get("NotificationFilterUrl") or {}
t=f.get("notification_target") or ""
# The callback URL ENDS IN A SHARED SECRET. Never print it whole: this
# output gets pasted into issues and terminals that keep scrollback.
parts=t.rsplit("/",1)
masked=parts[0]+"/"+(parts[1][:4]+"…" if len(parts)>1 and parts[1] else "")
accts=f.get("all_monetary_account_id") or []
cat=f.get("category","?")
print(f" {cat} -> {masked} accounts={sorted(set(accts))}")'
;;
set)
ACCOUNT="${2:?usage: tools/bunq-callback.sh set <account-id> <callback-url>}"
@ -207,6 +250,21 @@ set)
echo " (A wrong secret answers 404 by design — check the secret too.)" >&2
exit 1
fi
# "all" registers at USER level, covering every account including ones
# created later. Prefer it: bunq treats even per-account registrations as
# user-scoped entries anyway, and repeated per-account POSTs accumulate
# duplicate account ids rather than replacing cleanly. A user-level POST
# replaces the ENTIRE set, which is also how you clear stale URLs.
if [ "$ACCOUNT" = all ]; then
echo "bunq: installing the MUTATION filter for ALL accounts"
_resp=$(api POST "/v1/user/$USER_ID/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"
echo " done (previous filters replaced)"
exit 0
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.
@ -220,8 +278,165 @@ print(json.dumps({"notification_filters":[
echo "bunq: done. Send yourself €0.01 and watch:"
echo " ssh hetzner journalctl -u catcrafts-server -f"
;;
payments)
# What a real mutation actually looks like, WITHOUT printing what a real
# mutation actually says. This exists to answer "which field tells me a
# payment came from bunq.me?" empirically rather than from memory — so it
# prints the SHAPE (key names, and the values of fields that classify
# rather than identify) and masks everything that names a human.
ACCOUNT="${2:?usage: tools/bunq-callback.sh payments <account-id> [count]}"
api GET "/v1/user/$USER_ID/monetary-account/$ACCOUNT/payment?count=${3:-25}" \
"" "$SESSION" | python3 -c 'import json,sys
SAFE={"id","created","type","sub_type","amount","payment_auto_allocate_instance",
"bunqme_fundraiser_result","request_reference_split_the_bill",
"payment_arrival_expected","merchant_reference","batch_id","scheduled_id"}
d=json.load(sys.stdin)
rows=[item["Payment"] for item in d.get("Response",[]) if "Payment" in item]
if not rows:
print(" (no payments on this account)"); raise SystemExit
allkeys=set()
for p in rows: allkeys.update(p.keys())
print(f" {len(rows)} payment(s). Union of keys present:")
for k in sorted(allkeys):
mark=" <- SAFE to classify on" if k in SAFE else ""
print(f" {k}{mark}")
print()
print(" per payment (identifying fields masked):")
for p in rows:
amt=(p.get("amount") or {})
val=amt.get("value","?"); cur=amt.get("currency","?")
bm=p.get("bunqme_fundraiser_result")
bmk="yes" if bm else "no"
extra=""
if isinstance(bm,dict):
extra=" bunqme_keys=" + ",".join(sorted(bm.keys()))
pid=p.get("id"); ptype=p.get("type"); psub=p.get("sub_type")
print(f" id={pid} type={ptype} sub_type={psub} "
f"amount={val} {cur} bunqme_fundraiser_result={bmk}{extra}")'
;;
backfill)
# The categorisation worklist for a date window. READ ONLY — it publishes
# nothing and writes nothing to the server. Incoming and outgoing are
# separated because they ask different questions ("is this a donation?" vs
# "which expense category?"), and outgoing is grouped by counterparty
# because that is the unit a rule matches on.
SINCE="${2:?usage: tools/bunq-callback.sh backfill <YYYY-MM-DD> [YYYY-MM-DD]}"
UNTIL="${3:-9999-12-31}"
_tmp=$(mktemp -d)
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 acc in item.values():
if isinstance(acc,dict) and "id" in acc:
print(acc["id"], (acc.get("description") or "?").replace(" ","_"))' > "$_tmp/accts"
while read -r _id _nm; do
api GET "/v1/user/$USER_ID/monetary-account/$_id/payment?count=200" "" "$SESSION" \
> "$_tmp/pay-$_id-$_nm.json"
done < "$_tmp/accts"
python3 - "$SINCE" "$UNTIL" "$_tmp" <<'PY'
import glob, json, os, sys
since, until, tmp = sys.argv[1], sys.argv[2], sys.argv[3]
rows=[]
for path in sorted(glob.glob(os.path.join(tmp,"pay-*.json"))):
label=os.path.basename(path)[4:-5]
try: d=json.load(open(path))
except Exception: continue
for item in d.get("Response",[]):
p=item.get("Payment")
if not isinstance(p,dict): continue
created=(p.get("created") or "")[:10]
if not (since <= created <= until): continue
amt=(p.get("amount") or {})
if amt.get("currency")!="EUR": continue
try: cents=int(round(float(amt.get("value","0"))*100))
except Exception: continue
cp=(p.get("counterparty_alias") or {})
rows.append({"acct":label,"date":created,"cents":cents,
"type":p.get("type"),"sub":p.get("sub_type"),
"name":cp.get("display_name") or "?",
"iban":cp.get("iban") or "",
"desc":(p.get("description") or "").strip()[:44]})
if not rows:
print(" no EUR payments in", since, "..", until); raise SystemExit
print(f" window {since} .. {until} {len(rows)} payment(s)\n")
ins=[r for r in rows if r["cents"]>0]
outs=[r for r in rows if r["cents"]<0]
print(f" INCOMING ({len(ins)}) — decide donation / sale payout / other:")
for r in sorted(ins,key=lambda r:r["date"]):
eur=r["cents"]/100
print(f" {r['date']} {eur:>9.2f} {r['type']:<11}{r['sub']:<9} {r['name'][:22]:<22} {r['desc']}")
print(f" ---- incoming total: {sum(r['cents'] for r in ins)/100:.2f}\n")
print(f" OUTGOING ({len(outs)}) — grouped by counterparty; each group is one rule:")
groups={}
for r in outs:
key=(r["name"],r["iban"])
g=groups.setdefault(key,{"cents":0,"n":0,"descs":set()})
g["cents"]+=r["cents"]; g["n"]+=1
if r["desc"]: g["descs"].add(r["desc"])
for (name,iban),g in sorted(groups.items(),key=lambda kv:kv[1]["cents"]):
eur=-g["cents"]/100
sample=sorted(g["descs"])[0] if g["descs"] else ""
print(f" {eur:>10.2f} x{g['n']:<3} {name[:26]:<26} {iban:<20} {sample[:30]}")
print(f" ---- outgoing total: {-sum(r['cents'] for r in outs)/100:.2f}")
PY
rm -rf "$_tmp"
;;
payment)
# One payment by id, classification fields only. Used to answer "what does
# a payment that AROSE FROM a bunq.me tab actually look like on the wire?"
ACCOUNT="${2:?usage: tools/bunq-callback.sh payment <account-id> <payment-id>...}"
shift 2
for pid in "$@"; do
api GET "/v1/user/$USER_ID/monetary-account/$ACCOUNT/payment/$pid" "" "$SESSION" \
| python3 -c 'import json,sys
d=json.load(sys.stdin)
rows=[i["Payment"] for i in d.get("Response",[]) if "Payment" in i]
for p in rows:
amt=(p.get("amount") or {})
val=amt.get("value","?")
pid=p.get("id"); ptype=p.get("type"); psub=p.get("sub_type")
has_bm="bunqme_fundraiser_result" in p
print(f" id={pid} type={ptype} sub_type={psub} amount={val} "
f"has_bunqme_field={has_bm}")'
done
;;
bunqme)
# The bunq.me side. A tab records which payments fulfilled it, so this is
# the only authoritative "did this money come from a bunq.me link?" join —
# and note it takes an API CALL WITH THE KEY, which is exactly what the
# server does not have. Prints payment ids so they can be matched against
# `payments` output; no payer names or links are shown in full.
ACCOUNT="${2:?usage: tools/bunq-callback.sh bunqme <account-id> [count]}"
api GET "/v1/user/$USER_ID/monetary-account/$ACCOUNT/bunqme-tab?count=${3:-25}" \
"" "$SESSION" | python3 -c 'import json,sys
d=json.load(sys.stdin)
tabs=[i["BunqMeTab"] for i in d.get("Response",[]) if "BunqMeTab" in i]
if not tabs:
print(" (no bunq.me tabs on this account)"); raise SystemExit
for t in tabs:
url=t.get("bunqme_tab_share_url") or ""
slug=url.rsplit("/",1)[-1]
masked=slug[:3]+"…" if slug else "(none)"
entry=t.get("bunqme_tab_entry") or {}
inq=t.get("result_inquiries") or []
pids=[]
for r in inq:
node=r.get("BunqMeTabResultInquiry") or r
pay=(node.get("payment") or {})
pay=pay.get("Payment") or pay
if isinstance(pay,dict) and pay.get("id") is not None:
pids.append(str(pay["id"]))
tid=t.get("id"); st=t.get("status")
amt=(entry.get("amount_inquired") or {}).get("value","open")
joined=",".join(pids)
print(f" tab={tid} status={st} link=bunq.me/{masked} asked={amt} "
f"fulfilled_by_payment_ids=[{joined}]")'
;;
*)
echo "usage: tools/bunq-callback.sh [list | set <account-id> <callback-url>]" >&2
echo "usage: tools/bunq-callback.sh [list | set <account-id> <callback-url> |" >&2
echo " payments <account-id> [count] | bunqme <account-id> [count]]" >&2
exit 2
;;
esac

View file

@ -158,6 +158,51 @@ if [ "$RAIL" = mollie ]; then
esac
fi
# /financials has two inputs and a fresh dev run has neither: sales fold out of
# the order ledger, donations and expenses come from the bank-aggregates file.
# So the page honestly renders its empty state — which is worth previewing too,
# and is why this is opt-in rather than always on.
#
# DEV_FINANCIALS=1 seeds both with obviously-sample figures, so the FILLED
# layout can be designed against without waiting for real money or touching
# production. The aggregates file is re-read on every request, so you can edit
# it while the server runs and just refresh.
#
# DEV_FINANCIALS=<path> instead previews a REAL aggregates file — the one
# staged for production, say. No sample orders are seeded in that case: sales
# fold from the ledger, and inventing them would misrepresent the very figures
# you are checking. A closed shop showing €0 of sales is the truth.
if [ "${DEV_FINANCIALS:-0}" != 0 ] && [ "${DEV_FINANCIALS:-0}" != 1 ]; then
if [ ! -f "$DEV_FINANCIALS" ]; then
echo "dev: DEV_FINANCIALS='$DEV_FINANCIALS' is not a file" >&2
exit 1
fi
cp "$DEV_FINANCIALS" "$WORK/orders.jsonl.financials.json"
echo "dev: previewing REAL financials from $DEV_FINANCIALS (no sample orders seeded)"
elif [ "${DEV_FINANCIALS:-0}" = 1 ]; then
# Labels say SAMPLE and the amounts are flat round numbers on purpose:
# plausible-looking figures here were once mistaken for real bank data.
# Nothing in this block comes from anywhere — it exists to fill the layout.
cat > "$WORK/orders.jsonl.financials.json" <<'JSON'
{"as_of":"2026-01-01",
"donations":{"count":4,"total_minor":10000},
"expenses":[{"label":"SAMPLE hosting","total_minor":10000},
{"label":"SAMPLE insurance","total_minor":20000},
{"label":"SAMPLE inventory","total_minor":300000},
{"label":"SAMPLE payment fees","total_minor":5000}]}
JSON
# Two paid orders, written straight to the ledger: the product is
# coming-soon, so checkout refuses and there is no other way to make the
# sales row non-zero. Same event shapes Orders.cpp appends.
cat > "$WORK/orders.jsonl" <<'JSON'
{"type":"order","at":"2026-08-10T10:00:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","ref":"CC-111111","product":"fp6-pmos","color":"green","quantity":1,"unit_minor":56330,"email":"sample@example.org","name":"Sample Buyer","street":"1 Example St","postal":"1000AA","city":"Amsterdam","country":"NL","goods_minor":56330,"shipping_minor":713,"total_minor":57043,"vat_included":true,"status":"awaiting_payment","pay_choice":"bank","pay_url":"","pay_id":"dev-1"}
{"type":"status","at":"2026-08-10T10:04:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","status":"paid","via":"ideal"}
{"type":"order","at":"2026-08-12T14:30:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","ref":"CC-222222","product":"fp6-pmos","color":"white","quantity":1,"unit_minor":65488,"email":"other@example.org","name":"Other Buyer","street":"2 Example Rd","postal":"3000BB","city":"Rotterdam","country":"DE","goods_minor":65488,"shipping_minor":2500,"total_minor":67988,"vat_included":true,"status":"awaiting_payment","pay_choice":"crypto","pay_url":"","pay_id":"dev-2"}
{"type":"status","at":"2026-08-12T14:33:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","status":"paid","via":"bitcoin"}
JSON
echo "dev: seeded SAMPLE financials (DEV_FINANCIALS=1) — figures are invented"
fi
"$SRV/catcrafts-server" --serve "$BACKEND_PORT" \
--orders="$WORK/orders.jsonl" --rail="$RAIL" \
--redirect-base="http://localhost:$PORT" >"$WORK/server.log" 2>&1 &
@ -189,10 +234,17 @@ cat <<EOF
/posts fediverse posts
/demos demo list; /demos/raytracer loads the wasm
/legal/privacy privacy notice
/financials open financials$(case "${DEV_FINANCIALS:-0}" in
0) printf '%s' " — empty; DEV_FINANCIALS=1 seeds sample figures,
DEV_FINANCIALS=<file> previews a real one";;
1) printf '%s' " (SAMPLE data — invented figures)";;
*) printf '%s' " (real figures from $DEV_FINANCIALS)";; esac)
/feed.xml Atom feed
/media/* mirrored post media (run tools/fetch-media.sh to populate)
Orders from this session go to a temp file and are discarded on exit.
Orders from this session go to a temp file and are discarded on exit.$([ "${DEV_FINANCIALS:-0}" = 1 ] && printf '%s' "
Edit the aggregates and just refresh — they are re-read every request:
\$EDITOR $WORK/orders.jsonl.financials.json")
Payment rail: $RAIL$([ "$RAIL" = fake ] && printf '%s' " — simulate a customer paying with:
touch $WORK/orders.jsonl.fake-paid")
Ctrl-C to stop.

View file

@ -654,13 +654,18 @@ body_lacks /financials 'data-fin-donations-count' "no donation figures before th
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}]}
"expenses":[{"label":"Hosting","total_minor":1200},
{"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"
# Net = (donations 4500 + sales 0) - expenses 231200. Negative on purpose:
# a shop that has bought stock but not sold it is exactly this shape, and the
# figure has to survive going below zero.
body_has /financials 'data-fin-net-minor="-226700"' "net is published and may be negative"
body_has /financials '€-2267' "a negative net renders with its sign"
body_has /financials 'Hosting' "an expense category renders"
body_has /financials 'Inventory' "a second expense category renders"
body_has /financials '2026-08-14' "bank figures carry their as-of date"
echo "== the bunq mutation callback =="
@ -672,7 +677,7 @@ echo "== the bunq mutation callback =="
# 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"},
"rules":[{"description_contains":"hetzner","group":"expense","label":"Hosting"},
{"iban":"NL01OWNSELF0000000","group":"ignore"}]}
JSON