add arcodange-bank-reco — Qonto + Wise reconciliation against Dolibarr
V6 — the first cross-system skill (under arcodange-* not dolibarr-*).
Closes the loop between what Dolibarr says (ERP-internal) and what the
bank actually saw.
What ships:
- arcodange-bank-reco/scripts/bank-curl.sh unified read-only wrapper for Qonto + Wise
- arcodange-bank-reco/scripts/bank-probe.sh auth + discovery (org slug, profile id, balances)
- arcodange-bank-reco/scripts/qonto-transactions Qonto txn lister with pagination + filters
- arcodange-bank-reco/scripts/wise-transactions Wise activity lister with --enrich for wire refs
- arcodange-bank-reco/scripts/bank-match.sh 3-bucket reconciliation (matched/bank-only/dol-only)
with internal Wise↔Qonto consolidation detection
- arcodange-bank-reco/scripts/bank-balance.sh live balances + Dolibarr cumulative-by-fk_account
The headline bank-curl.sh is SCA-aware (Wise RSA dance) even though we
don't end up using it: the EU statement endpoint is region-blocked
("Funding transfers and retrieving balance statements via API are not
supported except for accounts based in the US, Canada, Australia, New
Zealand, Singapore, and Malaysia" per Wise docs). The wrapper supports
SCA so when/if Wise opens it, we're ready.
The pivot that unblocked Wise incoming: /v1/profiles/{pid}/activities
(documented at https://docs.wise.com/api-reference/activity/activitylist.md)
returns ALL movements in a unified HTML-tagged feed, no SCA required.
Parsing strips the HTML and recovers structured amount/sign/currency.
CLI integration:
- bin/arcodange bank {probe,qonto-transactions,wise-transactions,match,balance,curl}
- dolibarr/SKILL.md catalogue + Pointers updated
- dolibarr/README.md env schema extended with QONTO_*, WISE_*
Live baseline findings to raise with the cohort review (captured in
examples/bank-match-2026-01-to-05.txt):
- Wise 2026-05-29 +2147 EUR Kissmetrics NOT YET in Dolibarr
- Qonto bank-only: MISTRAL.AI 172.68, CLAUDE.AI 180, URSSAF 493, FOUREZ +1000
- 6 movements matched cleanly across Jan-May 2026
- Wise→Qonto 5000 EUR consolidation on 2026-03-13 auto-detected as internal
- Live balance: Qonto 4191.54 + Wise 5308.25 = 9499.79 EUR
V7 candidates noted in SKILL.md out-of-scope: reference-based matching
via the Wise --enrich wire refs (FOR INVOICE FAC***), multi-row Dolibarr
sub-payment aggregation, smarter avoir cycle handling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
.claude/skills/arcodange-bank-reco/scripts/bank-balance.sh
Executable file
108
.claude/skills/arcodange-bank-reco/scripts/bank-balance.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user