Merge pull request 'arcodange-bank-reco V7: avoir netting + fk_account context + wire-ref matching' (#8) from claude/arcodange-bank-reco-v7 into main
This commit was merged in pull request #8.
This commit is contained in:
@@ -169,6 +169,45 @@ Without a catalog, both look identical in the BANK-ONLY bucket — noise drowns
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## V7 bucket structure
|
||||||
|
|
||||||
|
V7 adds three improvements that reshape the output buckets:
|
||||||
|
|
||||||
|
| Bucket | Meaning | Counts toward exit-1? |
|
||||||
|
|---|---|---|
|
||||||
|
| **MATCHED** | Bank ↔ Dolibarr paired. Annotated with match kind: `[wire-ref]` (strong, via `--enrich`) or `[amt+date]` (loose). | No |
|
||||||
|
| **INTERNAL** | Wise↔Qonto consolidations (5000€ moved between Arcodange's own accounts). | No |
|
||||||
|
| **AVOIR-NETTED** | Dolibarr AVC + FAC cancellation cycles paired and excluded (the bank only saw the net). | No |
|
||||||
|
| **BANK-ONLY — known patterns** | Bank movement with a `known-patterns.json` annotation. Intentional gap. | No |
|
||||||
|
| **BANK-ONLY — unknown** | Bank movement with no Dolibarr counterpart AND no catalog pattern. **Real action item**. | Yes |
|
||||||
|
| **DOLIBARR-ONLY — on API-tracked accounts** (QON*/WIS*) | Dolibarr payment that the bank should have shown. **Real gap**. | Yes |
|
||||||
|
| **DOLIBARR-ONLY — not in API scope** (CCA1 perso etc.) | Expected gap — we have no API on those accounts. | No |
|
||||||
|
|
||||||
|
Exit code 0 iff the two "real gap" buckets are empty.
|
||||||
|
|
||||||
|
### `--enrich` — wire-reference strong matching
|
||||||
|
|
||||||
|
`bank-match.sh --enrich` fetches `/v1/transfers/{id}` for each Wise TRANSFER and reads the `reference` field (the wire memo from the sender, e.g. `FROM KISSMETRICS HOLDINGS INC FOR INVOICE FAC002CL0001002/ VENDOR:DEV`). When the reference contains a `FAC\d+(CL\d+)?` pattern matching a Dolibarr customer invoice, that pairing takes precedence over the loose date+amount match. Only the strong-matched ones get `[wire-ref]`; the rest fall through to `[amt+date]`. Cost: 1 extra HTTP call per Wise transfer.
|
||||||
|
|
||||||
|
### Avoir cycle netting
|
||||||
|
|
||||||
|
When Arcodange cancels and reissues an invoice (FAC001 → AVC001 + FAC001-NEW), the bank sees one net credit but Dolibarr stores 3 payment entries. V7 pairs AVC entries of -X with FAC entries of +X for the same socid within ±5d, surfaces them in **AVOIR-NETTED**, and excludes them from `dolibarr-only`. Removes the V6.1 noise where AVC001 + FAC001-CL00001 appeared as fake gaps.
|
||||||
|
|
||||||
|
### fk_account context
|
||||||
|
|
||||||
|
`bank-match.sh` now fetches `/bankaccounts` and tags `dolibarr-only` entries with their account ref + label. Splits into API-tracked (QON*/WIS* — real gaps) vs not-in-scope (everything else — expected). The 7 CCA1 personal-account entries that used to look like failures are now correctly classified as expected gaps.
|
||||||
|
|
||||||
|
### Effect on the baseline
|
||||||
|
|
||||||
|
| | V6 | V6.1 | V7 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MATCHED | 6 (all amt+date) | 6 | 6 (1 wire-ref strong + 5 amt+date when --enrich) |
|
||||||
|
| BANK-ONLY total | 8 mixed | 7 known + 1 UNKNOWN | 7 known + 1 UNKNOWN |
|
||||||
|
| AVOIR-NETTED | — | — | 2 (silently absorbed) |
|
||||||
|
| DOL-only TRUE GAP | 9 (noisy) | 9 (noisy) | **0** |
|
||||||
|
| DOL-only EXPECTED | — | — | 7 (CCA1 personal) |
|
||||||
|
| Exit-1 signal count | 17 (noise) | 10 (less noise) | **1** (just the +2147€ KM) |
|
||||||
|
|
||||||
## 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:
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
# Bank reconciliation: 2026-01-01 → 2026-05-31 (window ±7d, fees: off)
|
# Bank reconciliation: 2026-01-01 → 2026-05-31 (window ±7d, fees: off, enrich: on)
|
||||||
|
|
||||||
=== MATCHED (6 bank ↔ Dolibarr) ===
|
=== MATCHED (6 bank ↔ Dolibarr) ===
|
||||||
Qonto 2026-01-27 - 50.00 card Wise *Plan ↔ supplier FAF2026001 (2026-01-26, Δ-1d)
|
Qonto 2026-01-27 - 50.00 card Wise *Plan ↔[amt+date] supplier FAF2026001 (2026-01-26, Δ-1d)
|
||||||
Wise 2026-02-05 + 510.00 TRANSFER Kissmetrics Holdings Inc ↔ customer FAC001-CL0001001 (2026-02-05, Δ+0d)
|
Wise 2026-02-05 + 510.00 TRANSFER Kissmetrics Holdings Inc ↔[amt+date] customer FAC001-CL0001001 (2026-02-05, Δ+0d)
|
||||||
Wise 2026-03-06 + 5100.00 TRANSFER Kissmetrics Holdings Inc ↔ customer FAC002-CL0001002 (2026-03-12, Δ+6d)
|
Wise 2026-03-06 + 5100.00 TRANSFER Kissmetrics Holdings Inc ↔[wire-ref] customer FAC002-CL0001002 (2026-03-12, Δ+6d)
|
||||||
Qonto 2026-03-13 - 612.00 transfer DARNIS OPERATIONS ↔ supplier FAF2026008 (2026-03-13, Δ+0d)
|
Qonto 2026-03-13 - 612.00 transfer DARNIS OPERATIONS ↔[amt+date] supplier FAF2026008 (2026-03-13, Δ+0d)
|
||||||
Wise 2026-04-20 + 2550.00 TRANSFER Kissmetrics Holdings Inc ↔ customer FAC003-CL0001003 (2026-04-20, Δ+0d)
|
Wise 2026-04-20 + 2550.00 TRANSFER Kissmetrics Holdings Inc ↔[amt+date] customer FAC003-CL0001003 (2026-04-20, Δ+0d)
|
||||||
Qonto 2026-05-10 - 306.00 transfer DARNIS OPERATIONS ↔ supplier FAF2026009 (2026-05-10, Δ+0d)
|
Qonto 2026-05-10 - 306.00 transfer DARNIS OPERATIONS ↔[amt+date] supplier FAF2026009 (2026-05-10, Δ+0d)
|
||||||
|
|
||||||
=== 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
|
||||||
|
|
||||||
|
=== AVOIR-NETTED (2 Dolibarr entries pairing AVC↔FAC cancellation cycles) ===
|
||||||
|
customer 2026-02-05 -510.00 AVC001-CL0001001 ↔ netted against FAC001-CL00001
|
||||||
|
customer 2026-02-05 510.00 FAC001-CL00001 ↔ netted against AVC001-CL0001001
|
||||||
|
|
||||||
=== BANK-ONLY — known patterns (7, intentional gaps documented in known-patterns.json) ===
|
=== BANK-ONLY — known patterns (7, intentional gaps documented in known-patterns.json) ===
|
||||||
Qonto 2026-01-16 + 5.22 qonto_fee Qonto [bank_fee]
|
Qonto 2026-01-16 + 5.22 qonto_fee Qonto [bank_fee]
|
||||||
└─ Qonto fees ou refunds. Petites valeurs. Dolibarr: account 627.
|
└─ Qonto fees ou refunds. Petites valeurs. Dolibarr: account 627.
|
||||||
@@ -30,17 +34,17 @@
|
|||||||
=== BANK-ONLY — unknown (1, NEEDS attention: missing supplier invoice / unrecorded payment / new pattern) ===
|
=== 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 — on API-tracked accounts (0, REAL GAP: bank should have shown this) ===
|
||||||
supplier 2026-01-04 1.99 FAF2026003 (fk_account=3)
|
|
||||||
supplier 2026-01-06 202.80 FAF2026005 (fk_account=3)
|
|
||||||
supplier 2026-01-09 55.93 FAF2026002 (fk_account=3)
|
|
||||||
supplier 2026-01-09 148.80 FAF2026004 (fk_account=3)
|
|
||||||
supplier 2026-01-12 8.43 FAF2026006 (fk_account=3)
|
|
||||||
supplier 2026-01-15 1.30 FAF2026002 (fk_account=3)
|
|
||||||
supplier 2026-01-17 3.20 FAF2026007 (fk_account=3)
|
|
||||||
customer 2026-02-05 -510.00 AVC001-CL0001001 (fk_account=2)
|
|
||||||
customer 2026-02-05 510.00 FAC001-CL00001 (fk_account=2)
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
=== DOLIBARR-ONLY — on accounts NOT in API scope (7, expected gap: CCA1 perso etc.) ===
|
||||||
# 6 matched, 1 internal, 7 bank-known, 1 bank-UNKNOWN, 9 dolibarr-only
|
supplier 2026-01-04 1.99 FAF2026003 (CCA1 (G.RADUREAU Compte Courant Asso))
|
||||||
|
supplier 2026-01-06 202.80 FAF2026005 (CCA1 (G.RADUREAU Compte Courant Asso))
|
||||||
|
supplier 2026-01-09 55.93 FAF2026002 (CCA1 (G.RADUREAU Compte Courant Asso))
|
||||||
|
supplier 2026-01-09 148.80 FAF2026004 (CCA1 (G.RADUREAU Compte Courant Asso))
|
||||||
|
supplier 2026-01-12 8.43 FAF2026006 (CCA1 (G.RADUREAU Compte Courant Asso))
|
||||||
|
supplier 2026-01-15 1.30 FAF2026002 (CCA1 (G.RADUREAU Compte Courant Asso))
|
||||||
|
supplier 2026-01-17 3.20 FAF2026007 (CCA1 (G.RADUREAU Compte Courant Asso))
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------------------------------
|
||||||
|
# 6 matched, 1 internal, 2 avoir-netted, 7 bank-known, 1 bank-UNKNOWN, 0 dol-only-API, 7 dol-only-personal
|
||||||
# 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)
|
# 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)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
BANK_CURL="${SCRIPT_DIR}/bank-curl.sh"
|
BANK_CURL="${SCRIPT_DIR}/bank-curl.sh"
|
||||||
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
|
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
|
||||||
|
|
||||||
SINCE=""; UNTIL=""; MONTH=""; WINDOW=7; INCLUDE_FEES=0
|
SINCE=""; UNTIL=""; MONTH=""; WINDOW=7; INCLUDE_FEES=0; ENRICH=0
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--since) SINCE="$2"; shift 2 ;;
|
--since) SINCE="$2"; shift 2 ;;
|
||||||
@@ -32,6 +32,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--month) MONTH="$2"; shift 2 ;;
|
--month) MONTH="$2"; shift 2 ;;
|
||||||
--window-days) WINDOW="$2"; shift 2 ;;
|
--window-days) WINDOW="$2"; shift 2 ;;
|
||||||
--include-fees) INCLUDE_FEES=1; shift ;;
|
--include-fees) INCLUDE_FEES=1; shift ;;
|
||||||
|
--enrich) ENRICH=1; shift ;;
|
||||||
-h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
-h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "bank-match.sh: unknown arg: $1" >&2; exit 2 ;;
|
*) echo "bank-match.sh: unknown arg: $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
@@ -66,9 +67,10 @@ QURL="/v2/transactions?bank_account_id=${QONTO_ACCT}&settled_at_from=${SINCE}T00
|
|||||||
# --- 2. Pull Wise activities ---
|
# --- 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"
|
"${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 ---
|
# --- 3. Pull Dolibarr customer + supplier invoices, payments, and bank accounts ---
|
||||||
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${WORK}/dol_inv.json"
|
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${WORK}/dol_inv.json"
|
||||||
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/dol_sup.json"
|
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/dol_sup.json"
|
||||||
|
"${DOL_CURL}" '/bankaccounts' > "${WORK}/dol_acct.json"
|
||||||
|
|
||||||
mkdir -p "${WORK}/dol_pay" "${WORK}/dol_supay"
|
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
|
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
|
||||||
@@ -78,12 +80,26 @@ for id in $(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in js
|
|||||||
"${DOL_CURL}" "/supplierinvoices/${id}/payments" > "${WORK}/dol_supay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_supay/${id}.json"
|
"${DOL_CURL}" "/supplierinvoices/${id}/payments" > "${WORK}/dol_supay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_supay/${id}.json"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# --- 3b. Optional: enrich Wise TRANSFER activities with wire references ---
|
||||||
|
if [[ "${ENRICH}" == "1" ]]; then
|
||||||
|
mkdir -p "${WORK}/wise_refs"
|
||||||
|
for tid in $(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
acts = json.load(open(sys.argv[1])).get('activities') or []
|
||||||
|
for a in acts:
|
||||||
|
r = a.get('resource') or {}
|
||||||
|
if r.get('type')=='TRANSFER' and r.get('id'): print(r['id'])
|
||||||
|
" "${WORK}/wise.json"); do
|
||||||
|
"${BANK_CURL}" wise "/v1/transfers/${tid}" > "${WORK}/wise_refs/${tid}.json" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# --- 4. Match in python ---
|
# --- 4. Match in python ---
|
||||||
PATTERNS_FILE="${SCRIPT_DIR}/../known-patterns.json"
|
PATTERNS_FILE="${SCRIPT_DIR}/../known-patterns.json"
|
||||||
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${WINDOW}" "${INCLUDE_FEES}" "${PATTERNS_FILE}" <<'PY'
|
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${WINDOW}" "${INCLUDE_FEES}" "${PATTERNS_FILE}" "${ENRICH}" <<'PY'
|
||||||
import json, sys, os, re, datetime, collections
|
import json, sys, os, re, datetime, collections
|
||||||
work, since, until, window_days, include_fees, patterns_file = sys.argv[1:7]
|
work, since, until, window_days, include_fees, patterns_file, enrich = sys.argv[1:8]
|
||||||
window = int(window_days); include_fees = include_fees == "1"
|
window = int(window_days); include_fees = include_fees == "1"; enrich = enrich == "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)
|
||||||
|
|
||||||
def strip(s): return re.sub(r'<[^>]+>', '', s or '').strip()
|
def strip(s): return re.sub(r'<[^>]+>', '', s or '').strip()
|
||||||
@@ -111,7 +127,22 @@ for a in (json.load(open(os.path.join(work,"wise.json"))).get("activities") or [
|
|||||||
m = re.search(r'([\d,.]+)\s*([A-Z]{3})', pa)
|
m = re.search(r'([\d,.]+)\s*([A-Z]{3})', pa)
|
||||||
amt = float(m.group(1).replace(",", "")) if m else 0.0
|
amt = float(m.group(1).replace(",", "")) if m else 0.0
|
||||||
title = strip(a.get("title") or "")[:40]
|
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})
|
res = a.get("resource") or {}
|
||||||
|
resource_id = str(res.get("id")) if res.get("type") == "TRANSFER" else None
|
||||||
|
wise_movs.append({"bank":"Wise", "date":dt, "sign":sign, "amount":amt, "label":title, "op":typ, "matched_dol":None, "matched_internal":False, "wise_resource_id":resource_id, "wire_ref":""})
|
||||||
|
|
||||||
|
# 4b'. If --enrich, load per-transfer wire references and attach to Wise movs
|
||||||
|
if enrich:
|
||||||
|
ref_dir = os.path.join(work, "wise_refs")
|
||||||
|
if os.path.isdir(ref_dir):
|
||||||
|
for m in wise_movs:
|
||||||
|
if not m["wise_resource_id"]: continue
|
||||||
|
p = os.path.join(ref_dir, f"{m['wise_resource_id']}.json")
|
||||||
|
if not os.path.isfile(p): continue
|
||||||
|
try:
|
||||||
|
t = json.load(open(p))
|
||||||
|
m["wire_ref"] = (t.get("reference") or "")
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
bank_movs = qonto_movs + wise_movs
|
bank_movs = qonto_movs + wise_movs
|
||||||
|
|
||||||
@@ -122,7 +153,7 @@ for w in [m for m in bank_movs if m["bank"]=="Wise" and m["sign"]=="-"]:
|
|||||||
w["matched_internal"] = q; q["matched_internal"] = w
|
w["matched_internal"] = q; q["matched_internal"] = w
|
||||||
break
|
break
|
||||||
|
|
||||||
# 4d. Normalize Dolibarr payments
|
# 4d. Normalize Dolibarr payments — carry socid too for avoir netting
|
||||||
dol_pays = []
|
dol_pays = []
|
||||||
inv_by_id = {str(r["id"]): r for r in json.load(open(os.path.join(work,"dol_inv.json")))}
|
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")):
|
for fn in os.listdir(os.path.join(work,"dol_pay")):
|
||||||
@@ -132,7 +163,9 @@ for fn in os.listdir(os.path.join(work,"dol_pay")):
|
|||||||
d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date()
|
d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date()
|
||||||
if d < since_d or d > until_d: continue
|
if d < since_d or d > until_d: continue
|
||||||
amt = float(p.get("amount") or 0)
|
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})
|
dol_pays.append({"side":"customer", "ref":inv["ref"], "date":d, "amount":amt,
|
||||||
|
"fk_account":inv.get("fk_account"), "socid":inv.get("socid"),
|
||||||
|
"matched_bank":None, "netted_against":None})
|
||||||
|
|
||||||
sup_by_id = {str(r["id"]): r for r in json.load(open(os.path.join(work,"dol_sup.json")))}
|
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")):
|
for fn in os.listdir(os.path.join(work,"dol_supay")):
|
||||||
@@ -142,20 +175,71 @@ for fn in os.listdir(os.path.join(work,"dol_supay")):
|
|||||||
d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date()
|
d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date()
|
||||||
if d < since_d or d > until_d: continue
|
if d < since_d or d > until_d: continue
|
||||||
amt = float(p.get("amount") or 0)
|
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})
|
dol_pays.append({"side":"supplier", "ref":sup["ref"], "date":d, "amount":amt,
|
||||||
|
"fk_account":sup.get("fk_account"), "socid":sup.get("socid"),
|
||||||
|
"matched_bank":None, "netted_against":None})
|
||||||
|
|
||||||
# 4e. Match: each bank movement (non-internal) tries to find a Dolibarr counterpart
|
# 4d.1. AVOIR cycle netting: an AVC (credit note) for -X on socid S cancels out
|
||||||
for m in [x for x in bank_movs if not x["matched_internal"]]:
|
# a FAC for +X on the same socid, within a small date window. Bank sees the NET
|
||||||
bank_signed = m["amount"] if m["sign"]=="+" else -m["amount"]
|
# of the cycle (typically +X for the reissued FAC with the new ref scheme).
|
||||||
# For customer payments (Dol records them as positive amounts): +bank credit matches +dol customer payment
|
# Pair an AVC with a FAC of opposite sign + equal abs(amount) + same socid +
|
||||||
# For supplier payments: -bank debit matches +dol supplier payment (positive in Dol since it's the amount paid out)
|
# within ±5d. Mark both as "netted" so they're excluded from matching and
|
||||||
# Heuristic: match abs(amount) within 0.01 and date within window.
|
# excluded from the dolibarr-only failure count.
|
||||||
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]
|
avcs = [p for p in dol_pays if p["side"]=="customer" and p["ref"].startswith("AVC") and p["amount"] < 0]
|
||||||
|
for avc in avcs:
|
||||||
|
candidates = [p for p in dol_pays
|
||||||
|
if p is not avc
|
||||||
|
and p["side"]=="customer"
|
||||||
|
and p["socid"] == avc["socid"]
|
||||||
|
and abs(p["amount"] + avc["amount"]) < 0.01 # opposite signs equal magnitude
|
||||||
|
and abs((p["date"] - avc["date"]).days) <= 5
|
||||||
|
and p["netted_against"] is None
|
||||||
|
and p["matched_bank"] is None]
|
||||||
|
if candidates:
|
||||||
|
# Prefer the OLDEST (the original cancelled FAC), not the reissue.
|
||||||
|
# Heuristic: refs with shorter / older numbering scheme. If multiple,
|
||||||
|
# pick smallest date delta.
|
||||||
|
candidates.sort(key=lambda p: (abs((p["date"] - avc["date"]).days), p["ref"]))
|
||||||
|
partner = candidates[0]
|
||||||
|
avc["netted_against"] = partner["ref"]
|
||||||
|
partner["netted_against"] = avc["ref"]
|
||||||
|
|
||||||
|
# 4e. Match — two-pass:
|
||||||
|
# PASS 1 (strong) : Wise transfers with an --enrich'd wire reference containing
|
||||||
|
# a "FAC***" pattern try to match the Dolibarr invoice with
|
||||||
|
# that exact ref. This is the highest-confidence match.
|
||||||
|
# PASS 2 (loose) : remaining bank movements use the date+amount heuristic.
|
||||||
|
# Netted Dolibarr entries (avoir cycle) are excluded from both passes.
|
||||||
|
|
||||||
|
# Build customer ref -> dol payment index (only un-netted, un-matched entries)
|
||||||
|
ref_index = collections.defaultdict(list)
|
||||||
|
for p in dol_pays:
|
||||||
|
if p["matched_bank"] is None and p["netted_against"] is None:
|
||||||
|
# Strip trailing dash/suffix variants — FAC002CL0001002 vs FAC002-CL0001002 are equivalent
|
||||||
|
normalized = re.sub(r'[^A-Z0-9]', '', p["ref"].upper())
|
||||||
|
ref_index[normalized].append(p)
|
||||||
|
|
||||||
|
# Pass 1: strong match on wire references
|
||||||
|
for m in [x for x in bank_movs if not x["matched_internal"] and x.get("wire_ref")]:
|
||||||
|
refs_in_wire = re.findall(r'FAC\d+(?:CL\d+)?', (m["wire_ref"] or "").upper().replace("-",""))
|
||||||
|
for r in refs_in_wire:
|
||||||
|
if r in ref_index and ref_index[r]:
|
||||||
|
p = ref_index[r].pop(0)
|
||||||
|
m["matched_dol"] = p; m["match_kind"] = "wire-ref"
|
||||||
|
p["matched_bank"] = m
|
||||||
|
break
|
||||||
|
|
||||||
|
# Pass 2: loose date+amount match for remaining bank movements
|
||||||
|
for m in [x for x in bank_movs if not x["matched_internal"] and not x["matched_dol"]]:
|
||||||
|
candidates = [p for p in dol_pays
|
||||||
|
if p["matched_bank"] is None and p["netted_against"] is None
|
||||||
|
and abs(p["amount"] - m["amount"]) < 0.01
|
||||||
|
and abs((p["date"] - m["date"]).days) <= window]
|
||||||
if candidates:
|
if candidates:
|
||||||
# Pick smallest date delta
|
|
||||||
candidates.sort(key=lambda p: abs((p["date"] - m["date"]).days))
|
candidates.sort(key=lambda p: abs((p["date"] - m["date"]).days))
|
||||||
p = candidates[0]
|
p = candidates[0]
|
||||||
m["matched_dol"] = p; p["matched_bank"] = m
|
m["matched_dol"] = p; m["match_kind"] = "amt+date"
|
||||||
|
p["matched_bank"] = m
|
||||||
|
|
||||||
# 4f. Annotate non-matched movements with known-patterns catalog
|
# 4f. Annotate non-matched movements with known-patterns catalog
|
||||||
patterns = []
|
patterns = []
|
||||||
@@ -184,21 +268,43 @@ for m in bank_movs:
|
|||||||
m["known"] = match_pattern(m)
|
m["known"] = match_pattern(m)
|
||||||
|
|
||||||
# --- 5. Render ---
|
# --- 5. Render ---
|
||||||
|
|
||||||
|
# Load Dolibarr bank accounts (for fk_account context on dolibarr-only)
|
||||||
|
dol_accts = {}
|
||||||
|
try:
|
||||||
|
for a in json.load(open(os.path.join(work, "dol_acct.json"))):
|
||||||
|
dol_accts[str(a["id"])] = {"ref": a.get("ref","-"), "label": a.get("label","-"), "country": a.get("country_code","")}
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
# Heuristic: which Dolibarr accounts are NOT covered by Qonto/Wise API today?
|
||||||
|
# Convention: CCA = Compte Courant d'Associé (personal). Anything not QON*/WIS*
|
||||||
|
# is treated as "API-invisible" and tagged as such.
|
||||||
|
def account_kind(fk_account):
|
||||||
|
if not fk_account: return ("unknown", "fk_account=None")
|
||||||
|
a = dol_accts.get(str(fk_account))
|
||||||
|
if not a: return ("unknown", f"fk_account={fk_account} (not in /bankaccounts)")
|
||||||
|
ref = (a["ref"] or "").upper()
|
||||||
|
if ref.startswith(("QON", "WIS")):
|
||||||
|
return ("api_tracked", f"{a['ref']} ({a['label']})")
|
||||||
|
return ("personal_or_other", f"{a['ref']} ({a['label']})")
|
||||||
|
|
||||||
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']}"
|
||||||
|
|
||||||
print(f"# Bank reconciliation: {since} → {until} (window ±{window}d, fees: {'on' if include_fees else 'off'})")
|
print(f"# Bank reconciliation: {since} → {until} (window ±{window}d, fees: {'on' if include_fees else 'off'}, enrich: {'on' if enrich else 'off'})")
|
||||||
print()
|
print()
|
||||||
matched = [m for m in bank_movs if m["matched_dol"]]
|
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"]=="-"]
|
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"]]
|
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]
|
netted_dol_pairs = [p for p in dol_pays if p["netted_against"]]
|
||||||
|
dol_only = [p for p in dol_pays if p["matched_bank"] is None and p["netted_against"] is None]
|
||||||
|
|
||||||
print(f"=== MATCHED ({len(matched)} bank ↔ Dolibarr) ===")
|
print(f"=== MATCHED ({len(matched)} bank ↔ Dolibarr) ===")
|
||||||
for m in sorted(matched, key=lambda m: m["date"]):
|
for m in sorted(matched, key=lambda m: m["date"]):
|
||||||
p = m["matched_dol"]
|
p = m["matched_dol"]
|
||||||
delta = (p["date"] - m["date"]).days
|
delta = (p["date"] - m["date"]).days
|
||||||
print(fmt_bank(m) + f" ↔ {p['side']:<8} {p['ref']:<24} ({p['date']}, Δ{delta:+d}d)")
|
kind = m.get("match_kind", "?")
|
||||||
|
print(fmt_bank(m) + f" ↔[{kind}] {p['side']:<8} {p['ref']:<24} ({p['date']}, Δ{delta:+d}d)")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
print(f"=== INTERNAL (Wise↔Qonto consolidations, {len(internal)}) ===")
|
print(f"=== INTERNAL (Wise↔Qonto consolidations, {len(internal)}) ===")
|
||||||
@@ -207,6 +313,13 @@ 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()
|
||||||
|
|
||||||
|
# Avoir cycles netted out (informational; bank correctly sees only the net)
|
||||||
|
if netted_dol_pairs:
|
||||||
|
print(f"=== AVOIR-NETTED ({len(netted_dol_pairs)} Dolibarr entries pairing AVC↔FAC cancellation cycles) ===")
|
||||||
|
for p in sorted(netted_dol_pairs, key=lambda p: (p["date"], p["ref"])):
|
||||||
|
print(f" {p['side']:<8} {p['date']} {p['amount']:>9.2f} {p['ref']:<24} ↔ netted against {p['netted_against']}")
|
||||||
|
print()
|
||||||
|
|
||||||
bank_known = [m for m in bank_only if m.get("known")]
|
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")]
|
bank_unknown = [m for m in bank_only if not m.get("known")]
|
||||||
|
|
||||||
@@ -223,15 +336,28 @@ for m in sorted(bank_unknown, key=lambda m: m["date"]):
|
|||||||
print(fmt_bank(m))
|
print(fmt_bank(m))
|
||||||
print()
|
print()
|
||||||
|
|
||||||
print(f"=== DOLIBARR-ONLY ({len(dol_only)} Dolibarr payments without bank movement) ===")
|
# Split dolibarr-only by whether the fk_account is API-tracked (real gap)
|
||||||
for p in sorted(dol_only, key=lambda p: p["date"]):
|
# or personal_or_other (expected gap — we have no API on those accounts)
|
||||||
print(f" {p['side']:<8} {p['date']} {p['amount']:>9.2f} {p['ref']} (fk_account={p['fk_account']})")
|
dol_only_api = [p for p in dol_only if account_kind(p["fk_account"])[0] == "api_tracked"]
|
||||||
|
dol_only_personal = [p for p in dol_only if account_kind(p["fk_account"])[0] != "api_tracked"]
|
||||||
|
|
||||||
|
print(f"=== DOLIBARR-ONLY — on API-tracked accounts ({len(dol_only_api)}, REAL GAP: bank should have shown this) ===")
|
||||||
|
for p in sorted(dol_only_api, key=lambda p: p["date"]):
|
||||||
|
_, ctx = account_kind(p["fk_account"])
|
||||||
|
print(f" {p['side']:<8} {p['date']} {p['amount']:>9.2f} {p['ref']:<24} ({ctx})")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Verdict: only UNKNOWN bank-only and dolibarr-only count as "needs attention"
|
print(f"=== DOLIBARR-ONLY — on accounts NOT in API scope ({len(dol_only_personal)}, expected gap: CCA1 perso etc.) ===")
|
||||||
fails = len(bank_unknown) + len(dol_only)
|
for p in sorted(dol_only_personal, key=lambda p: p["date"]):
|
||||||
print("-" * 80)
|
_, ctx = account_kind(p["fk_account"])
|
||||||
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" {p['side']:<8} {p['date']} {p['amount']:>9.2f} {p['ref']:<24} ({ctx})")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Verdict: only UNKNOWN bank-only AND dol-only-on-API-tracked count as failures.
|
||||||
|
# Avoir-netted pairs and personal-account dolibarr entries are intentional/expected.
|
||||||
|
fails = len(bank_unknown) + len(dol_only_api)
|
||||||
|
print("-" * 100)
|
||||||
|
print(f"# {len(matched)} matched, {len(internal)} internal, {len(netted_dol_pairs)} avoir-netted, {len(bank_known)} bank-known, {len(bank_unknown)} bank-UNKNOWN, {len(dol_only_api)} dol-only-API, {len(dol_only_personal)} dol-only-personal")
|
||||||
print(f"# patterns loaded from {patterns_file}: {len(patterns)} pattern(s)")
|
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
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ COMMANDS
|
|||||||
probe Auth + discovery (org slug, profile id, balance ids)
|
probe Auth + discovery (org slug, profile id, balance ids)
|
||||||
qonto-transactions [--month|--since|--until] Qonto transactions table (incoming + outgoing)
|
qonto-transactions [--month|--since|--until] Qonto transactions table (incoming + outgoing)
|
||||||
wise-transactions [--month|--since|--until|--type|--enrich] Wise activities (incoming + outgoing)
|
wise-transactions [--month|--since|--until|--type|--enrich] Wise activities (incoming + outgoing)
|
||||||
match [--month|--since|--until|--window-days N] Match bank ↔ Dolibarr (3 buckets)
|
match [--month|--since|--until|--window-days N|--enrich] Match bank ↔ Dolibarr (split buckets)
|
||||||
balance Live balances + Dolibarr cross-check per fk_account
|
balance Live balances + Dolibarr cross-check per fk_account
|
||||||
curl <qonto|wise> <path> Raw read-only curl through bank-curl.sh
|
curl <qonto|wise> <path> Raw read-only curl through bank-curl.sh
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user