V6.1 follow-up to the bank-reco V6 ship. Splits the BANK-ONLY bucket into "known patterns" (intentional gaps, documented and classified) vs "unknown" (real action items). What the catalog covers today: - FOUREZ Quentin → capital_deposit (apport en capital 1000 € initial, notaire FOUREZ centralisateur du dépôt). Maps to Dolibarr account 1013. - URSSAF → social_charges (account 645100) - MISTRAL.AI, CLAUDE.AI → ai_subscription (account 6262) - Wise *Plan, qonto_fee → bank_fee (account 627) - BALANCE_DEPOSIT / FEATURE_CHARGE on Wise → internal_topup (self-funding pair, often nets to zero) Effect on the V6 baseline (Jan-May 2026): - Before catalog: 8 BANK-ONLY mixed entries (noise + signal) - After catalog: 7 known + 1 UNKNOWN (just the +2147 € KM Wise payment 2026-05-29 that genuinely needs a Dolibarr entry) The catalog is JSON (not YAML — stdlib only, no dependency). Schema documented in SKILL.md. Pattern matches case-insensitive regex against both bank label AND operation type. Optional filters: bank, side, amount_min, amount_max. Exit code now reflects only the UNKNOWN bank-only and dolibarr-only counts — the verdict is no longer noisy because of intentional gaps. Edit known-patterns.json as new recurring patterns emerge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
12 KiB
Bash
Executable File
238 lines
12 KiB
Bash
Executable File
#!/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 ---
|
|
PATTERNS_FILE="${SCRIPT_DIR}/../known-patterns.json"
|
|
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${WINDOW}" "${INCLUDE_FEES}" "${PATTERNS_FILE}" <<'PY'
|
|
import json, sys, os, re, datetime, collections
|
|
work, since, until, window_days, include_fees, patterns_file = sys.argv[1:7]
|
|
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
|
|
|
|
# 4f. Annotate non-matched movements with known-patterns catalog
|
|
patterns = []
|
|
if os.path.isfile(patterns_file):
|
|
try: patterns = json.load(open(patterns_file)).get("patterns", [])
|
|
except Exception as e: print(f" /!\\ failed to load {patterns_file}: {e}", file=sys.stderr)
|
|
|
|
def match_pattern(mov):
|
|
# Match against both the bank label AND the operation type — different
|
|
# banks surface useful info in different fields (Qonto puts the operation
|
|
# type in `op`, e.g. "qonto_fee"; Wise puts the activity type in `op`,
|
|
# e.g. "BALANCE_DEPOSIT", and the human title in `label`).
|
|
haystack = (mov.get("label") or "") + " | " + (mov.get("op") or "")
|
|
for pat in patterns:
|
|
if pat.get("bank") and pat["bank"] != mov["bank"].lower(): continue
|
|
if pat.get("side") and pat["side"] != ("credit" if mov["sign"]=="+" else "debit"): continue
|
|
amin = pat.get("amount_min"); amax = pat.get("amount_max")
|
|
if amin is not None and mov["amount"] < amin: continue
|
|
if amax is not None and mov["amount"] > amax: continue
|
|
if re.search(pat["pattern"], haystack, re.IGNORECASE):
|
|
return pat
|
|
return None
|
|
|
|
for m in bank_movs:
|
|
if m["matched_dol"] or m["matched_internal"]: continue
|
|
m["known"] = match_pattern(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()
|
|
|
|
bank_known = [m for m in bank_only if m.get("known")]
|
|
bank_unknown = [m for m in bank_only if not m.get("known")]
|
|
|
|
print(f"=== BANK-ONLY — known patterns ({len(bank_known)}, intentional gaps documented in known-patterns.json) ===")
|
|
for m in sorted(bank_known, key=lambda m: m["date"]):
|
|
k = m["known"]
|
|
cls = k.get("classification","?")
|
|
print(fmt_bank(m) + f" [{cls}]")
|
|
print(f" └─ {k.get('note','')}")
|
|
print()
|
|
|
|
print(f"=== BANK-ONLY — unknown ({len(bank_unknown)}, NEEDS attention: missing supplier invoice / unrecorded payment / new pattern) ===")
|
|
for m in sorted(bank_unknown, 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: only UNKNOWN bank-only and dolibarr-only count as "needs attention"
|
|
fails = len(bank_unknown) + len(dol_only)
|
|
print("-" * 80)
|
|
print(f"# {len(matched)} matched, {len(internal)} internal, {len(bank_known)} bank-known, {len(bank_unknown)} bank-UNKNOWN, {len(dol_only)} dolibarr-only")
|
|
print(f"# patterns loaded from {patterns_file}: {len(patterns)} pattern(s)")
|
|
sys.exit(0 if fails == 0 else 1)
|
|
PY
|