#!/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