retire the bunq integration
All checks were successful
Deploy / build-deploy (push) Successful in 3m32s
All checks were successful
Deploy / build-deploy (push) Successful in 3m32s
The webhook is deregistered at bunq and the callback endpoint, its parser, default-deny classifier, dedup ledger and signature check are removed; the code is in git history if a bank feed ever comes back. /financials keeps reading the hand-maintained aggregates file, and sales + shop donations stay live from the order ledger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4c49000d46
commit
c31797bd9a
13 changed files with 70 additions and 1694 deletions
|
|
@ -1,442 +0,0 @@
|
|||
#!/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
|
||||
# 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>}"
|
||||
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
|
||||
# "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.
|
||||
_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"
|
||||
;;
|
||||
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 " payments <account-id> [count] | bunqme <account-id> [count]]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Loading…
Reference in a new issue