Merge pull request 'arcodange-bank-reco: known-patterns catalog + annotated bank-only buckets' (#7) from claude/arcodange-bank-reco-patterns into main

This commit was merged in pull request #7.
This commit is contained in:
2026-05-31 14:07:39 +02:00
4 changed files with 170 additions and 16 deletions

View File

@@ -124,6 +124,51 @@ Current state (V1 baseline):
- Wise **STANDARD EUR** : 5 308,25 € live - Wise **STANDARD EUR** : 5 308,25 € live
- **Total bank-side** : 9 499,79 € - **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 ## Matching heuristic — what's in v1 and what's V7
Today's match logic: Today's match logic:

View File

@@ -11,14 +11,23 @@
=== INTERNAL (Wise↔Qonto consolidations, 1) === === INTERNAL (Wise↔Qonto consolidations, 1) ===
Wise 2026-03-13 - 5000.00 TRANSFER ARCODANGE ↔ Qonto 2026-03-13 +5000.00 Wise 2026-03-13 - 5000.00 TRANSFER ARCODANGE ↔ Qonto 2026-03-13 +5000.00
=== BANK-ONLY (8 bank movements without Dolibarr counterpart) === === BANK-ONLY — known patterns (7, intentional gaps documented in known-patterns.json) ===
Qonto 2026-01-16 + 5.22 qonto_fee Qonto Qonto 2026-01-16 + 5.22 qonto_fee Qonto [bank_fee]
Qonto 2026-01-21 + 1000.00 income FOUREZ Quentin └─ Qonto fees ou refunds. Petites valeurs. Dolibarr: account 627.
Wise 2026-01-26 - 50.00 FEATURE_CHARGE For your account plan Qonto 2026-01-21 + 1000.00 income FOUREZ Quentin [capital_deposit]
Wise 2026-01-26 + 50.00 BALANCE_DEPOSIT To EUR └─ Apport en capital social initial 1000 €. Maître FOUREZ Quentin, notaire centralisateur du dépôt. Date typique : 2026-01-21. Dolibarr: account 1013.
Qonto 2026-04-03 - 172.68 card MISTRAL.AI Wise 2026-01-26 - 50.00 FEATURE_CHARGE For your account plan [internal_topup]
Qonto 2026-04-13 - 180.00 card CLAUDE.AI SUBSCRIPTION └─ Solde Wise rechargé pour couvrir un frais immédiat (souvent net zéro avec le FEATURE_CHARGE du même jour).
Qonto 2026-05-22 - 493.00 direct_debit URSSAF D ILE DE FRANCE 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 Wise 2026-05-29 + 2147.00 TRANSFER Kissmetrics Holdings Inc
=== DOLIBARR-ONLY (9 Dolibarr payments without bank movement) === === DOLIBARR-ONLY (9 Dolibarr payments without bank movement) ===
@@ -33,4 +42,5 @@
customer 2026-02-05 510.00 FAC001-CL00001 (fk_account=2) 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)

View File

@@ -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)."
}
]
}

View File

@@ -79,9 +79,10 @@ for id in $(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in js
done done
# --- 4. Match in python --- # --- 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 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" window = int(window_days); include_fees = include_fees == "1"
since_d = datetime.date.fromisoformat(since); until_d = datetime.date.fromisoformat(until) 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] p = candidates[0]
m["matched_dol"] = p; p["matched_bank"] = m 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 --- # --- 5. Render ---
def fmt_bank(m): def fmt_bank(m):
return f" {m['bank']:<5} {m['date']} {m['sign']:<2}{m['amount']:>9.2f} {m['op'][:18]:<18} {m['label']}" 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(fmt_bank(m) + f" ↔ {other['bank']} {other['date']} {other['sign']}{other['amount']:.2f}")
print() print()
print(f"=== BANK-ONLY ({len(bank_only)} bank movements without Dolibarr counterpart) ===") bank_known = [m for m in bank_only if m.get("known")]
for m in sorted(bank_only, key=lambda m: m["date"]): 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(fmt_bank(m))
print() 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(f" {p['side']:<8} {p['date']} {p['amount']:>9.2f} {p['ref']} (fk_account={p['fk_account']})")
print() print()
# Verdict # Verdict: only UNKNOWN bank-only and dolibarr-only count as "needs attention"
fails = len(bank_only) + len(dol_only) fails = len(bank_unknown) + len(dol_only)
print("-" * 80) 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) sys.exit(0 if fails == 0 else 1)
PY PY