bank tranfer fix
All checks were successful
Deploy / build-deploy (push) Successful in 3m11s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 23:33:50 +02:00
commit aaa7a8ce99
10 changed files with 543 additions and 8 deletions

94
tools/pull-and-ship-credits.sh Executable file
View file

@ -0,0 +1,94 @@
#!/bin/sh
# Read the bank from a machine bunq's API key actually permits, and hand the
# result to the server that cannot read it itself.
#
# WHY THIS EXISTS, so nobody "simplifies" it back:
#
# The bunq API key is IP-restricted at the KEY level, and bunq enforces that on
# EVERY call — not only at device registration. Registering the server's IP in
# the device's permitted_ips does NOT help; that was tried on 2026-08-20 and
# every read from the server still came back "Incorrect API key or IP address".
# So the only two options are to widen the key's allowlist in the bunq app, or
# to call from an address it already permits. This script is the second.
#
# It also happens to be the shape the project's key policy wants: a bunq key
# can INITIATE PAYMENTS (bunq has no read-only scope), so the public web host
# should never hold one. Here it never does — the server only ever reads a file
# of incoming credits.
#
# tools/pull-and-ship-credits.sh
#
# Environment (BUNQ_KEY is read from the repo-root .env, which is gitignored):
# CREDITS_HOST ssh destination (default: hetzner)
# CREDITS_REMOTE the file the rail reads (default: the production path)
# CREDITS_LOCAL local accumulating copy (default: ~/.cache/catcrafts)
#
# BUNQ_PERMITTED_IPS is read from .env if set. Leaving it unset registers the
# device with "*", which sounds worse than it is HERE: bunq already enforces the
# key's own IP allowlist on every call, so "*" on the device is overridden by
# the stricter thing. Pinning it as well is defence in depth, at the price of
# breaking on a DHCP change — and a residential address does change. Note the
# registration happens ONCE, so switching later means deleting the context file
# and spending two more of the day's setup calls.
set -eu
cd "$(dirname "$0")/.."
HOST="${CREDITS_HOST:-hetzner}"
REMOTE="${CREDITS_REMOTE:-/var/lib/catcrafts/orders.jsonl.transfer-credits.jsonl}"
LOCAL_DIR="${CREDITS_LOCAL:-$HOME/.cache/catcrafts}"
LOCAL="$LOCAL_DIR/orders.jsonl"
[ -f .env ] || { echo "$0: no .env — the bunq key lives there" >&2; exit 1; }
# shellcheck disable=SC1091
set -a; . ./.env; set +a
# The .env still calls it BUNQ_KEY, from the previous integration. Accept both
# rather than making someone rename a working secret.
KEY="${BUNQ_API_KEY:-${BUNQ_KEY:-}}"
[ -n "$KEY" ] && [ -n "${TRANSFER_IBAN:-}" ] || {
echo "$0: need BUNQ_KEY (or BUNQ_API_KEY) and TRANSFER_IBAN in .env" >&2
exit 1
}
# Exactly one server binary, or fail loudly: a variant directory embeds a
# config hash, so two matches means picking one would be a coin flip.
matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' 2>/dev/null | sort)
count=$(printf '%s\n' "$matches" | grep -c . || true)
[ "$count" = 1 ] || { echo "$0: expected one Catcrafts.Server-* under bin/, found $count" >&2; exit 1; }
BIN="$matches/catcrafts-server"
mkdir -p "$LOCAL_DIR"
chmod 700 "$LOCAL_DIR"
# Pull. Appends only what is new, deduplicated on bunq's own payment id, so
# running this every few minutes over an overlapping window cannot double-count
# a payment into settling an order twice.
BUNQ_API_KEY="$KEY" "$BIN" --pull-credits --orders "$LOCAL" >/dev/null
CREDITS="$LOCAL.transfer-credits.jsonl"
[ -f "$CREDITS" ] || { echo "$0: nothing pulled, not shipping" >&2; exit 1; }
# This file is the business's bank statement in miniature, and the context file
# beside it holds a private key. The directory is 0700 already; narrow the files
# too rather than inheriting whatever umask happened to apply.
chmod 600 "$CREDITS" "$LOCAL.transfer-credits.jsonl.bunq-context.json" 2>/dev/null || true
# Ship INCOMING credits only. Outgoing lines are supplier payments and card
# spending: the matcher ignores negative amounts anyway, so sending them would
# put the business's outgoing payment history on a public-facing host for no
# settlement benefit at all.
TMP=$(mktemp); trap 'rm -f "$TMP"' EXIT
python3 - "$CREDITS" >"$TMP" <<'PY'
import json, sys
for line in open(sys.argv[1]):
line = line.strip()
if line and json.loads(line)["amount_minor"] > 0:
print(line)
PY
# Refuse to ship an empty file over a good one: a parse failure upstream must
# not blank the evidence the server settles from.
[ -s "$TMP" ] || { echo "$0: no incoming credits — refusing to overwrite the remote" >&2; exit 1; }
ssh "$HOST" "cat > $REMOTE && chown catcrafts:catcrafts $REMOTE && chmod 600 $REMOTE" < "$TMP"
echo "$0: shipped $(wc -l < "$TMP") incoming credit(s) to $HOST:$REMOTE"