arcodange-bank-reco: add known-patterns.json catalog + bank-match annotation
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>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
60
.claude/skills/arcodange-bank-reco/known-patterns.json
Normal file
60
.claude/skills/arcodange-bank-reco/known-patterns.json
Normal 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)."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user