arcodange-bank-reco: known-patterns catalog + annotated bank-only buckets #7
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user