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:
198
.claude/skills/arcodange-bank-reco/scripts/bank-match.sh
Executable file
198
.claude/skills/arcodange-bank-reco/scripts/bank-match.sh
Executable file
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
# Match bank movements (Qonto + Wise) against Dolibarr payments.
|
||||
#
|
||||
# Usage:
|
||||
# bank-match.sh [--month YYYY-MM | --since YYYY-MM-DD --until YYYY-MM-DD]
|
||||
# [--window-days N] # date tolerance, default 7
|
||||
# [--include-fees] # include Wise cashback / charges (default off)
|
||||
#
|
||||
# Output: three buckets
|
||||
# - MATCHED bank movement ↔ Dolibarr payment
|
||||
# - BANK-ONLY bank movement with no Dolibarr counterpart (potential
|
||||
# missing supplier invoice or unrecorded incoming payment)
|
||||
# - DOLIBARR-ONLY Dolibarr payment with no bank movement (timing or error)
|
||||
#
|
||||
# Internal Wise↔Qonto consolidations (e.g. 5000 € moved Wise→Qonto same day)
|
||||
# are auto-detected and excluded from matching against Dolibarr.
|
||||
#
|
||||
# Exit 0 if everything in the window matches cleanly, 1 if there's any bank-only
|
||||
# or dolibarr-only entry.
|
||||
|
||||
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"
|
||||
|
||||
SINCE=""; UNTIL=""; MONTH=""; WINDOW=7; INCLUDE_FEES=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--since) SINCE="$2"; shift 2 ;;
|
||||
--until) UNTIL="$2"; shift 2 ;;
|
||||
--month) MONTH="$2"; shift 2 ;;
|
||||
--window-days) WINDOW="$2"; shift 2 ;;
|
||||
--include-fees) INCLUDE_FEES=1; shift ;;
|
||||
-h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "bank-match.sh: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "${MONTH}" ]]; then
|
||||
SINCE="${MONTH}-01"
|
||||
UNTIL="$(python3 -c "import calendar; y,m=map(int,'${MONTH}'.split('-')); print(f'{y:04d}-{m:02d}-{calendar.monthrange(y,m)[1]:02d}')")"
|
||||
fi
|
||||
[[ -z "${SINCE}" ]] && SINCE="$(python3 -c "import datetime; print((datetime.date.today()-datetime.timedelta(days=365)).strftime('%Y-%m-%d'))")"
|
||||
[[ -z "${UNTIL}" ]] && UNTIL="$(python3 -c "import datetime; print(datetime.date.today().strftime('%Y-%m-%d'))")"
|
||||
|
||||
set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a
|
||||
: "${WISE_PROFILE_ID:?bank-match.sh: WISE_PROFILE_ID not set}"
|
||||
|
||||
WORK="$(mktemp -d -t bankmatch.XXXXXX)"
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
# --- 1. Pull Qonto transactions ---
|
||||
TMP_ORG=$(mktemp -t qontoorg.XXXXXX.json)
|
||||
"${BANK_CURL}" qonto /v2/organization > "${TMP_ORG}"
|
||||
QONTO_ACCT=$(python3 -c "
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
accs = (d.get('organization') or {}).get('bank_accounts') or []
|
||||
print([a for a in accs if a.get('status')=='active'][0]['id'])" "${TMP_ORG}")
|
||||
rm -f "${TMP_ORG}"
|
||||
|
||||
QURL="/v2/transactions?bank_account_id=${QONTO_ACCT}&settled_at_from=${SINCE}T00:00:00Z&settled_at_to=${UNTIL}T23:59:59Z&per_page=100"
|
||||
"${BANK_CURL}" qonto "${QURL}" > "${WORK}/qonto.json"
|
||||
|
||||
# --- 2. Pull Wise activities ---
|
||||
"${BANK_CURL}" wise "/v1/profiles/${WISE_PROFILE_ID}/activities?size=100&since=${SINCE}T00:00:00.000Z&until=${UNTIL}T23:59:59.999Z" > "${WORK}/wise.json"
|
||||
|
||||
# --- 3. Pull Dolibarr customer + supplier invoices and their payments ---
|
||||
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${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
|
||||
|
||||
# --- 4. Match in python ---
|
||||
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${WINDOW}" "${INCLUDE_FEES}" <<'PY'
|
||||
import json, sys, os, re, datetime, collections
|
||||
work, since, until, window_days, include_fees = sys.argv[1:6]
|
||||
window = int(window_days); include_fees = include_fees == "1"
|
||||
since_d = datetime.date.fromisoformat(since); until_d = datetime.date.fromisoformat(until)
|
||||
|
||||
def strip(s): return re.sub(r'<[^>]+>', '', s or '').strip()
|
||||
|
||||
# 4a. Normalize Qonto
|
||||
qonto_movs = []
|
||||
for t in (json.load(open(os.path.join(work,"qonto.json"))).get("transactions") or []):
|
||||
dt = datetime.date.fromisoformat((t.get("settled_at") or "")[:10])
|
||||
if dt < since_d or dt > until_d: continue
|
||||
amt = float(t.get("amount") or 0)
|
||||
sign = "+" if t.get("side") == "credit" else "-"
|
||||
label = t.get("label") or t.get("operation_type") or "-"
|
||||
qonto_movs.append({"bank":"Qonto", "date":dt, "sign":sign, "amount":amt, "label":label[:40], "op":t.get("operation_type",""), "matched_dol":None, "matched_internal":False})
|
||||
|
||||
# 4b. Normalize Wise
|
||||
wise_movs = []
|
||||
for a in (json.load(open(os.path.join(work,"wise.json"))).get("activities") or []):
|
||||
dt = datetime.date.fromisoformat((a.get("createdOn") or "")[:10])
|
||||
if dt < since_d or dt > until_d: continue
|
||||
typ = a.get("type","-")
|
||||
if not include_fees and typ in ("BALANCE_CASHBACK", "BALANCE_INTEREST"):
|
||||
continue
|
||||
pa = strip(a.get("primaryAmount") or "")
|
||||
sign = "+" if pa.startswith("+") else "-"
|
||||
m = re.search(r'([\d,.]+)\s*([A-Z]{3})', pa)
|
||||
amt = float(m.group(1).replace(",", "")) if m else 0.0
|
||||
title = strip(a.get("title") or "")[:40]
|
||||
wise_movs.append({"bank":"Wise", "date":dt, "sign":sign, "amount":amt, "label":title, "op":typ, "matched_dol":None, "matched_internal":False})
|
||||
|
||||
bank_movs = qonto_movs + wise_movs
|
||||
|
||||
# 4c. Detect internal Wise<->Qonto consolidations: same date, equal amount, opposite signs, one Wise + one Qonto
|
||||
for w in [m for m in bank_movs if m["bank"]=="Wise" and m["sign"]=="-"]:
|
||||
for q in [m for m in bank_movs if m["bank"]=="Qonto" and m["sign"]=="+" and not m["matched_internal"]]:
|
||||
if abs(w["amount"] - q["amount"]) < 0.01 and abs((w["date"] - q["date"]).days) <= 3:
|
||||
w["matched_internal"] = q; q["matched_internal"] = w
|
||||
break
|
||||
|
||||
# 4d. Normalize Dolibarr payments
|
||||
dol_pays = []
|
||||
inv_by_id = {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]; inv = inv_by_id.get(iid)
|
||||
if not inv: continue
|
||||
for p in json.load(open(os.path.join(work,"dol_pay",fn))):
|
||||
d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date()
|
||||
if d < since_d or d > until_d: continue
|
||||
amt = float(p.get("amount") or 0)
|
||||
dol_pays.append({"side":"customer", "ref":inv["ref"], "date":d, "amount":amt, "fk_account":inv.get("fk_account"), "matched_bank":None})
|
||||
|
||||
sup_by_id = {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]; sup = sup_by_id.get(iid)
|
||||
if not sup: continue
|
||||
for p in json.load(open(os.path.join(work,"dol_supay",fn))):
|
||||
d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date()
|
||||
if d < since_d or d > until_d: continue
|
||||
amt = float(p.get("amount") or 0)
|
||||
dol_pays.append({"side":"supplier", "ref":sup["ref"], "date":d, "amount":amt, "fk_account":sup.get("fk_account"), "matched_bank":None})
|
||||
|
||||
# 4e. Match: each bank movement (non-internal) tries to find a Dolibarr counterpart
|
||||
for m in [x for x in bank_movs if not x["matched_internal"]]:
|
||||
bank_signed = m["amount"] if m["sign"]=="+" else -m["amount"]
|
||||
# For customer payments (Dol records them as positive amounts): +bank credit matches +dol customer payment
|
||||
# For supplier payments: -bank debit matches +dol supplier payment (positive in Dol since it's the amount paid out)
|
||||
# Heuristic: match abs(amount) within 0.01 and date within window.
|
||||
candidates = [p for p in dol_pays if p["matched_bank"] is None and abs(p["amount"] - m["amount"]) < 0.01 and abs((p["date"] - m["date"]).days) <= window]
|
||||
if candidates:
|
||||
# Pick smallest date delta
|
||||
candidates.sort(key=lambda p: abs((p["date"] - m["date"]).days))
|
||||
p = candidates[0]
|
||||
m["matched_dol"] = p; p["matched_bank"] = m
|
||||
|
||||
# --- 5. Render ---
|
||||
def fmt_bank(m):
|
||||
return f" {m['bank']:<5} {m['date']} {m['sign']:<2}{m['amount']:>9.2f} {m['op'][:18]:<18} {m['label']}"
|
||||
|
||||
print(f"# Bank reconciliation: {since} → {until} (window ±{window}d, fees: {'on' if include_fees else 'off'})")
|
||||
print()
|
||||
matched = [m for m in bank_movs if m["matched_dol"]]
|
||||
internal = [m for m in bank_movs if m["matched_internal"] and m["sign"]=="-"]
|
||||
bank_only = [m for m in bank_movs if not m["matched_dol"] and not m["matched_internal"]]
|
||||
dol_only = [p for p in dol_pays if p["matched_bank"] is None]
|
||||
|
||||
print(f"=== MATCHED ({len(matched)} bank ↔ Dolibarr) ===")
|
||||
for m in sorted(matched, key=lambda m: m["date"]):
|
||||
p = m["matched_dol"]
|
||||
delta = (p["date"] - m["date"]).days
|
||||
print(fmt_bank(m) + f" ↔ {p['side']:<8} {p['ref']:<24} ({p['date']}, Δ{delta:+d}d)")
|
||||
print()
|
||||
|
||||
print(f"=== INTERNAL (Wise↔Qonto consolidations, {len(internal)}) ===")
|
||||
for m in sorted(internal, key=lambda m: m["date"]):
|
||||
other = m["matched_internal"]
|
||||
print(fmt_bank(m) + f" ↔ {other['bank']} {other['date']} {other['sign']}{other['amount']:.2f}")
|
||||
print()
|
||||
|
||||
print(f"=== BANK-ONLY ({len(bank_only)} bank movements without Dolibarr counterpart) ===")
|
||||
for m in sorted(bank_only, key=lambda m: m["date"]):
|
||||
print(fmt_bank(m))
|
||||
print()
|
||||
|
||||
print(f"=== DOLIBARR-ONLY ({len(dol_only)} Dolibarr payments without bank movement) ===")
|
||||
for p in sorted(dol_only, key=lambda p: p["date"]):
|
||||
print(f" {p['side']:<8} {p['date']} {p['amount']:>9.2f} {p['ref']} (fk_account={p['fk_account']})")
|
||||
print()
|
||||
|
||||
# Verdict
|
||||
fails = len(bank_only) + len(dol_only)
|
||||
print("-" * 80)
|
||||
print(f"# {len(matched)} matched, {len(internal)} internal, {len(bank_only)} bank-only, {len(dol_only)} dolibarr-only")
|
||||
sys.exit(0 if fails == 0 else 1)
|
||||
PY
|
||||
Reference in New Issue
Block a user