#!/usr/bin/env bash # Live balances per bank account, plus a Dolibarr cross-check by fk_account. # # Usage: # bank-balance.sh # # Outputs each bank account (Qonto + Wise) with: # - current live balance (from each bank's API) # - sum of all Dolibarr payments touching it (per fk_account) # # The sum-of-payments isn't the bank balance — it's just the cumulative # operations Dolibarr has recorded on that account. Useful as a sanity check # (e.g. "did I record everything?"). The bank-side current balance is # authoritative. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BANK_CURL="${SCRIPT_DIR}/bank-curl.sh" DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh" set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a : "${WISE_PROFILE_ID:?bank-balance.sh: WISE_PROFILE_ID not set}" WORK="$(mktemp -d -t bankbal.XXXXXX)" trap 'rm -rf "${WORK}"' EXIT # Bank side "${BANK_CURL}" qonto /v2/organization > "${WORK}/qonto.json" "${BANK_CURL}" wise "/v4/profiles/${WISE_PROFILE_ID}/balances?types=STANDARD" > "${WORK}/wise.json" # Dolibarr side: bank accounts + payments per invoice "${DOL_CURL}" /bankaccounts > "${WORK}/dol_acct.json" "${DOL_CURL}" '/invoices?limit=500' > "${WORK}/dol_inv.json" "${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/dol_sup.json" mkdir -p "${WORK}/dol_pay" "${WORK}/dol_supay" for id in $(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/dol_inv.json"); do "${DOL_CURL}" "/invoices/${id}/payments" > "${WORK}/dol_pay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_pay/${id}.json" done for id in $(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/dol_sup.json"); do "${DOL_CURL}" "/supplierinvoices/${id}/payments" > "${WORK}/dol_supay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_supay/${id}.json" done python3 - "${WORK}" <<'PY' import json, os, sys, collections work = sys.argv[1] # Bank-side balances q = json.load(open(os.path.join(work, "qonto.json"))) qonto_accs = (q.get("organization") or {}).get("bank_accounts") or [] wise_bals = json.load(open(os.path.join(work, "wise.json"))) print("=" * 90) print(" BANK-SIDE balances (live)") print("=" * 90) print(f" {'bank':<8} {'ref':<20} {'currency':<3} {'balance':>12} details") print(" " + "-" * 86) qonto_total = 0.0 for a in qonto_accs: bal = float(a.get("balance") or 0); qonto_total += bal iban = (a.get("iban") or "")[-8:] print(f" {'Qonto':<8} {a.get('name','-')[:20]:<20} {a.get('balance_cents_currency','EUR'):<3} {bal:>12.2f} iban=...{iban} id={a.get('id','-')[:14]}") for b in wise_bals: amt = (b.get("amount") or {}) print(f" {'Wise':<8} {b.get('type','-'):<20} {amt.get('currency','-'):<3} {float(amt.get('value') or 0):>12.2f} id={b.get('id','-')}") print() print(f" Qonto total: {qonto_total:.2f} EUR") print(f" Wise total : {sum(float((b.get('amount') or {}).get('value') or 0) for b in wise_bals):.2f} EUR") print() # Dolibarr-side: sum of all payments per fk_account dol_accts = {str(a["id"]): a for a in json.load(open(os.path.join(work, "dol_acct.json")))} sums = collections.defaultdict(lambda: {"customer": 0.0, "supplier": 0.0}) inv = {str(r["id"]): r for r in json.load(open(os.path.join(work, "dol_inv.json")))} for fn in os.listdir(os.path.join(work, "dol_pay")): iid = fn[:-5]; i = inv.get(iid) if not i: continue fka = str(i.get("fk_account") or "?") for p in json.load(open(os.path.join(work, "dol_pay", fn))): sums[fka]["customer"] += float(p.get("amount") or 0) sup = {str(r["id"]): r for r in json.load(open(os.path.join(work, "dol_sup.json")))} for fn in os.listdir(os.path.join(work, "dol_supay")): iid = fn[:-5]; s = sup.get(iid) if not s: continue fka = str(s.get("fk_account") or "?") for p in json.load(open(os.path.join(work, "dol_supay", fn))): sums[fka]["supplier"] += float(p.get("amount") or 0) print("=" * 90) print(" DOLIBARR-SIDE cumulative payments per fk_account") print("=" * 90) print(f" {'fk_account':<10} {'ref':<10} {'label':<30} {'cust paid in':>12} {'sup paid out':>12} {'net':>10}") print(" " + "-" * 86) for fka in sorted(sums, key=lambda k: int(k) if k.isdigit() else 99): s = sums[fka] net = s["customer"] - s["supplier"] a = dol_accts.get(fka, {}) ref = a.get("ref","-")[:10]; label = (a.get("label") or "-")[:30] print(f" {fka:<10} {ref:<10} {label:<30} {s['customer']:>12.2f} {s['supplier']:>12.2f} {net:>10.2f}") print() print("# Note: Dolibarr-side numbers are CUMULATIVE since the account started in Dolibarr,") print("# not the current bank balance. Mismatch with the bank-side is expected when") print("# the account predates Dolibarr or has movements not recorded in Dolibarr") print("# (e.g. URSSAF, AI subscriptions — see bank-match.sh BANK-ONLY bucket).") PY