From 4b6a5f7529e77953f23538644fd776e0ae67cdb6 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 31 May 2026 14:06:46 +0200 Subject: [PATCH] arcodange-bank-reco: add known-patterns.json catalog + bank-match annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/skills/arcodange-bank-reco/SKILL.md | 45 ++++++++++++++ .../examples/bank-match-2026-01-to-05.txt | 28 ++++++--- .../arcodange-bank-reco/known-patterns.json | 60 +++++++++++++++++++ .../arcodange-bank-reco/scripts/bank-match.sh | 53 +++++++++++++--- 4 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 .claude/skills/arcodange-bank-reco/known-patterns.json diff --git a/.claude/skills/arcodange-bank-reco/SKILL.md b/.claude/skills/arcodange-bank-reco/SKILL.md index e1642ab..694ff86 100644 --- a/.claude/skills/arcodange-bank-reco/SKILL.md +++ b/.claude/skills/arcodange-bank-reco/SKILL.md @@ -124,6 +124,51 @@ Current state (V1 baseline): - Wise **STANDARD EUR** : 5 308,25 € live - **Total bank-side** : 9 499,79 € +## Known-patterns catalog ([known-patterns.json](known-patterns.json)) + +Bank movements that have no Dolibarr counterpart fall into two groups: + +1. **Intentional gaps** — operational expenses or one-off events the operator knows about (URSSAF mensuel, AI subs, capital deposit, Wise plan fees). These keep recurring but their accounting treatment is well-understood. +2. **Real action items** — incoming payments not yet entered, expenses missing a supplier invoice, anomalies. + +Without a catalog, both look identical in the BANK-ONLY bucket — noise drowns the signal. The catalog is an operator-curated list of patterns; `bank-match.sh` reads it and splits BANK-ONLY into two sub-buckets: + +- **BANK-ONLY — known patterns** : annotated with `[classification]` + a one-line note (which Dolibarr account to use, etc.). Don't action; just verify. +- **BANK-ONLY — unknown** : the real signal. Each entry is either a missing supplier invoice, an unrecorded payment, or a new pattern to add to the catalog. + +**Schema** (JSON, see [known-patterns.json](known-patterns.json) for current entries): + +```json +{ + "patterns": [ + { + "pattern": "regex (case-insensitive, matched against bank label + operation type)", + "classification": "capital_deposit | social_charges | ai_subscription | bank_fee | internal_topup | personal_apport | needs_classification", + "bank": "qonto | wise (optional, default both)", + "side": "credit | debit (optional, default both)", + "amount_min": 0.0, "amount_max": 99999.0, // optional numeric bounds + "note": "human-readable context — what this is, which Dolibarr account, recurring schedule, etc." + } + ] +} +``` + +**Editing workflow:** +1. Run `bin/arcodange bank match` → look at BANK-ONLY unknown. +2. For each recurring entry that's "expected", add a pattern to `known-patterns.json`. +3. Re-run match → the entry should now appear in the known sub-bucket. +4. For one-off action items (e.g. "+2147 € KM May 29 not in Dolibarr"), don't add a pattern — enter it in Dolibarr instead. + +**V6.1 catalog** ships with these patterns for the current Arcodange baseline: +- `FOUREZ Quentin` → capital_deposit (initial 1000 € apport via notaire, 2026-01-21) +- `URSSAF` → social_charges +- `MISTRAL.AI` / `CLAUDE.AI` → ai_subscription +- `Wise *Plan` → bank_fee (Wise account plan billed via Qonto card) +- `qonto_fee` → bank_fee +- `BALANCE_DEPOSIT|For your account plan` → internal_topup (the Wise +50/-50 self-funding pair) + +After applying the catalog to the V6 baseline, the **only remaining BANK-UNKNOWN** is the **+2147 € KissMetrics payment on 2026-05-29** that hasn't been entered in Dolibarr — the actual signal. + ## Matching heuristic — what's in v1 and what's V7 Today's match logic: diff --git a/.claude/skills/arcodange-bank-reco/examples/bank-match-2026-01-to-05.txt b/.claude/skills/arcodange-bank-reco/examples/bank-match-2026-01-to-05.txt index 4a6bb7f..141f33f 100644 --- a/.claude/skills/arcodange-bank-reco/examples/bank-match-2026-01-to-05.txt +++ b/.claude/skills/arcodange-bank-reco/examples/bank-match-2026-01-to-05.txt @@ -11,14 +11,23 @@ === INTERNAL (Wise↔Qonto consolidations, 1) === Wise 2026-03-13 - 5000.00 TRANSFER ARCODANGE ↔ Qonto 2026-03-13 +5000.00 -=== BANK-ONLY (8 bank movements without Dolibarr counterpart) === - Qonto 2026-01-16 + 5.22 qonto_fee Qonto - Qonto 2026-01-21 + 1000.00 income FOUREZ Quentin - Wise 2026-01-26 - 50.00 FEATURE_CHARGE For your account plan - Wise 2026-01-26 + 50.00 BALANCE_DEPOSIT To EUR - Qonto 2026-04-03 - 172.68 card MISTRAL.AI - Qonto 2026-04-13 - 180.00 card CLAUDE.AI SUBSCRIPTION - Qonto 2026-05-22 - 493.00 direct_debit URSSAF D ILE DE FRANCE +=== BANK-ONLY — known patterns (7, intentional gaps documented in known-patterns.json) === + Qonto 2026-01-16 + 5.22 qonto_fee Qonto [bank_fee] + └─ Qonto fees ou refunds. Petites valeurs. Dolibarr: account 627. + Qonto 2026-01-21 + 1000.00 income FOUREZ Quentin [capital_deposit] + └─ Apport en capital social initial 1000 €. Maître FOUREZ Quentin, notaire centralisateur du dépôt. Date typique : 2026-01-21. Dolibarr: account 1013. + Wise 2026-01-26 - 50.00 FEATURE_CHARGE For your account plan [internal_topup] + └─ Solde Wise rechargé pour couvrir un frais immédiat (souvent net zéro avec le FEATURE_CHARGE du même jour). + Wise 2026-01-26 + 50.00 BALANCE_DEPOSIT To EUR [internal_topup] + └─ Solde Wise rechargé pour couvrir un frais immédiat (souvent net zéro avec le FEATURE_CHARGE du même jour). + Qonto 2026-04-03 - 172.68 card MISTRAL.AI [ai_subscription] + └─ Mistral AI API subscription. Récurrent mensuel. Dolibarr: account 6262 + supplier 'Mistral AI'. + Qonto 2026-04-13 - 180.00 card CLAUDE.AI SUBSCRIPTION [ai_subscription] + └─ Claude AI subscription (Anthropic). Récurrent mensuel. Dolibarr: account 6262 + supplier 'Anthropic'. + Qonto 2026-05-22 - 493.00 direct_debit URSSAF D ILE DE FRANCE [social_charges] + └─ Cotisations sociales URSSAF (régime mensuel/trimestriel). Dolibarr: account 645100 (charges de sécurité sociale). + +=== BANK-ONLY — unknown (1, NEEDS attention: missing supplier invoice / unrecorded payment / new pattern) === Wise 2026-05-29 + 2147.00 TRANSFER Kissmetrics Holdings Inc === DOLIBARR-ONLY (9 Dolibarr payments without bank movement) === @@ -33,4 +42,5 @@ customer 2026-02-05 510.00 FAC001-CL00001 (fk_account=2) -------------------------------------------------------------------------------- -# 6 matched, 1 internal, 8 bank-only, 9 dolibarr-only +# 6 matched, 1 internal, 7 bank-known, 1 bank-UNKNOWN, 9 dolibarr-only +# patterns loaded from /Users/gabrielradureau/Work/Arcodange/erp/.claude/worktrees/happy-wilson-ee5645/.claude/skills/arcodange-bank-reco/scripts/../known-patterns.json: 7 pattern(s) diff --git a/.claude/skills/arcodange-bank-reco/known-patterns.json b/.claude/skills/arcodange-bank-reco/known-patterns.json new file mode 100644 index 0000000..84b63e6 --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/known-patterns.json @@ -0,0 +1,60 @@ +{ + "_schema": "v1", + "_description": "Operator-curated catalogue of known recurring/intentional bank movements. Used by bank-match.sh to annotate the BANK-ONLY bucket so the operator can immediately tell 'needs Dolibarr entry' from 'documented intentional gap'. Edit this file as new recurring patterns emerge.", + "_match_rules": "Pattern matched case-insensitively as a regex against the bank label. Optional filters: bank (qonto|wise), side (credit|debit), amount_min, amount_max, type (Wise activity type). All present filters must match.", + "_classifications": { + "capital_deposit": "Apport en capital social. Dolibarr account 1013 (capital souscrit appelé versé).", + "social_charges": "URSSAF, retraite complémentaire, etc. Dolibarr account 645x.", + "ai_subscription": "Claude / Mistral / OpenAI / similar. Dolibarr account 6262 (frais télécom / abonnements logiciels).", + "bank_fee": "Plan bancaire, frais d'opération, refunds. Dolibarr account 627 (services bancaires).", + "internal_topup": "Solde Wise/Qonto rechargé pour couvrir un frais immédiat. Often nets out.", + "personal_apport": "Apport en compte courant d'associé (Gabriel finançant Arcodange depuis son perso). Dolibarr account 4551.", + "needs_classification": "Pattern catched but no Dolibarr account assignment defined yet; surface for review." + }, + "patterns": [ + { + "pattern": "FOUREZ.*Quentin", + "classification": "capital_deposit", + "bank": "qonto", + "side": "credit", + "note": "Apport en capital social initial 1000 €. Maître FOUREZ Quentin, notaire centralisateur du dépôt. Date typique : 2026-01-21. Dolibarr: account 1013." + }, + { + "pattern": "URSSAF", + "classification": "social_charges", + "bank": "qonto", + "side": "debit", + "note": "Cotisations sociales URSSAF (régime mensuel/trimestriel). Dolibarr: account 645100 (charges de sécurité sociale)." + }, + { + "pattern": "MISTRAL\\.AI", + "classification": "ai_subscription", + "side": "debit", + "note": "Mistral AI API subscription. Récurrent mensuel. Dolibarr: account 6262 + supplier 'Mistral AI'." + }, + { + "pattern": "CLAUDE\\.AI", + "classification": "ai_subscription", + "side": "debit", + "note": "Claude AI subscription (Anthropic). Récurrent mensuel. Dolibarr: account 6262 + supplier 'Anthropic'." + }, + { + "pattern": "Wise.*Plan", + "classification": "bank_fee", + "side": "debit", + "note": "Wise account plan billed via card. Wise's internal fee for keeping the BUSINESS profile active." + }, + { + "pattern": "qonto_fee", + "classification": "bank_fee", + "bank": "qonto", + "note": "Qonto fees ou refunds. Petites valeurs. Dolibarr: account 627." + }, + { + "pattern": "BALANCE_DEPOSIT|For your account plan", + "classification": "internal_topup", + "bank": "wise", + "note": "Solde Wise rechargé pour couvrir un frais immédiat (souvent net zéro avec le FEATURE_CHARGE du même jour)." + } + ] +} diff --git a/.claude/skills/arcodange-bank-reco/scripts/bank-match.sh b/.claude/skills/arcodange-bank-reco/scripts/bank-match.sh index e43d521..b5c2e38 100755 --- a/.claude/skills/arcodange-bank-reco/scripts/bank-match.sh +++ b/.claude/skills/arcodange-bank-reco/scripts/bank-match.sh @@ -79,9 +79,10 @@ for id in $(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in js done # --- 4. Match in python --- -python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${WINDOW}" "${INCLUDE_FEES}" <<'PY' +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 = sys.argv[1:6] +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) @@ -156,6 +157,32 @@ for m in [x for x in bank_movs if not x["matched_internal"]]: 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']}" @@ -180,8 +207,19 @@ for m in sorted(internal, key=lambda m: m["date"]): 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"]): +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() @@ -190,9 +228,10 @@ 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) +# 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_only)} bank-only, {len(dol_only)} dolibarr-only") +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