---
name: arcodange-bank-reco
description: Bank-side reconciliation for Arcodange — cross-check Dolibarr customer + supplier payments against the actual movements on Qonto (FR business account, full API access) and Wise (BUSINESS EUR balance, activity-list API). Five workflows — (1) probe / discover auth + IDs; (2) list Qonto transactions for a period; (3) list Wise activities including incoming KissMetrics payments (via /v1/profiles/{pid}/activities — bypasses the EU statement endpoint restriction); (4) match bank movements against Dolibarr payments in three buckets (matched, bank-only, dolibarr-only) with auto-detection of Wise↔Qonto internal consolidations; (5) live balances per account with Dolibarr cross-check per fk_account. Surfaces concrete findings — incoming KM payments not yet entered in Dolibarr, expenses on the bank without supplier invoices recorded, date drift between bank settlement and Dolibarr saisie, and personal-account fk_account=3 movements that are invisible via API. Use when the user asks "réconcilier la banque", "qu'est-ce que la banque a vu que Dolibarr n'a pas", "match bank vs ERP", "audit comptable Arcodange", "did KM actually pay X", "cohort review bank evidence". Depends on `dolibarr` for the ERP side. SKIP for write operations (this is read-only; entries go through Dolibarr UI), for non-Arcodange bank accounts, and for Wise BUSINESS balance-statement endpoints (not available to EU personal tokens — we use the activity list instead).
requires:
  bins: ["curl", "jq", "python3", "openssl"]
  auth: true
---

# arcodange-bank-reco — close the loop between Dolibarr and the bank

The V1-V5 skills tell you what Dolibarr *thinks* happened. This one tells you what the **bank actually saw**, and matches the two sides. The three buckets it produces (matched / bank-only / dolibarr-only) are the foundation for any clean accounting audit and any cohort-review evidence pack.

Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.

**CLI shortcuts:** `bin/arcodange bank probe | qonto-transactions | wise-transactions | match | balance | curl`

## Wise SCA & the EU restriction — the path we settled on

Wise has TWO ways to list movements:

1. **`/v1/profiles/{pid}/balance-statements/{balanceId}/statement.json`** — the obvious "statement" endpoint. **Returns 403 for EU personal tokens** (FR included). Wise's own docs say it: "Funding transfers and retrieving balance statements via API are not supported except for accounts based in the US, Canada, Australia, New Zealand, Singapore, and Malaysia." Even with full SCA setup (RSA keypair + uploaded public key), the BUSINESS profile statements stay 403. Don't go down that rabbit hole — we did, and Wise just keeps responding `x-2fa-approval-result: REJECTED` regardless.

2. **`/v1/profiles/{pid}/activities`** — the activity list. **Works for EU personal tokens with no SCA.** Returns incoming + outgoing in a unified HTML-tagged feed. This is what the skill uses. The cost is that amounts come back as `"<positive>+ 5,100.00 EUR</positive>"` strings instead of structured numerics, so we parse HTML out — easy.

We tried the SCA path first, didn't work, then found this. Documented so the next operator doesn't repeat the dance.

## Qonto — no surprises

Qonto's API works as documented:
- `Authorization: <login>:<secret_key>` header.
- `/v2/organization` lists bank accounts + balances.
- `/v2/transactions?bank_account_id=<id>&settled_at_from=<iso>&settled_at_to=<iso>` lists transactions with pagination via `current_page`.

Wise↔Qonto integration in the Qonto UI does NOT expose Wise data via Qonto's API. We confirmed: no `/v2/external_accounts`, no `/v2/aggregated_accounts` endpoint surfaces Wise transactions. The two banks remain separately queried, then merged in the matching layer.

## Prerequisites

1. Base skill set up ([dolibarr/README.md](../dolibarr/README.md)).
2. `.env` extended with:
   ```
   QONTO_LOGIN=arcodange-XXXXX
   QONTO_SECRET_KEY=<secret>
   QONTO_ORG_SLUG=arcodange-XXXXX
   WISE_API_TOKEN=<token>
   WISE_PROFILE_ID=<numeric id of the BUSINESS profile>
   ```
3. `chmod 600 ~/.config/arcodange-erp/.env`; propagate to the two in-repo hard copies.

To generate the tokens:
- **Qonto**: https://app.qonto.com/ → Settings → Integrations → API → Generate a new key. Copy login + secret (shown once).
- **Wise**: https://wise.com/your-account/integrations-and-tools/api-tokens → Add new token → name it `arcodange-bank-reco-readonly` → scope read-only.

The `WISE_SCA_KEY_PATH` variable exists in the `.env` schema for completeness but is **NOT REQUIRED** today — the activity-list endpoint we use doesn't need SCA. Keep the keypair generated under `~/.config/arcodange-erp/wise-sca-*.pem` so we can revisit if Wise ever opens the statement endpoint to EU tokens.

## Workflows

### 1. Probe / discovery

```bash
bin/arcodange bank probe
```

Confirms auth on both banks and prints discovered IDs (Qonto org slug, Wise profile id, balance ids). Run once after any token rotation.

### 2. Qonto transactions

```bash
bin/arcodange bank qonto-transactions                    # last 90 days
bin/arcodange bank qonto-transactions --month 2026-03
bin/arcodange bank qonto-transactions --since 2026-01-01 --until 2026-05-31
bin/arcodange bank qonto-transactions --side credit       # filter incoming only
```

Captured at [examples/qonto-transactions.txt](examples/qonto-transactions.txt). The full Jan-May 2026 view shows 9 transactions netting to +4191.54 € — exactly the current Qonto balance.

### 3. Wise activities

```bash
bin/arcodange bank wise-transactions                                       # last 365 days
bin/arcodange bank wise-transactions --month 2026-03
bin/arcodange bank wise-transactions --type TRANSFER                       # filter
bin/arcodange bank wise-transactions --since 2026-01-01 --enrich           # add wire references
```

Captured at [examples/wise-transactions.txt](examples/wise-transactions.txt). With `--enrich`, each TRANSFER is annotated with its wire reference (e.g. `FROM KISSMETRICS HOLDINGS INC FOR INVOICE FAC002CL0001002/ VENDOR:DEV`), which makes manual cross-checking trivial.

Net = +5308.25 € over the whole period, matches the live balance.

### 4. Bank ↔ Dolibarr match (the headline)

```bash
bin/arcodange bank match --month 2026-03                  # one month
bin/arcodange bank match --since 2026-01-01 --until 2026-05-31
bin/arcodange bank match --month 2026-03 --window-days 14 # looser date tolerance
bin/arcodange bank match --include-fees                   # include cashback / charges in matching
```

Exit 0 if every bank movement and every Dolibarr payment in the window pair up cleanly; exit 1 otherwise (with the unmatched entries surfaced).

Captured at [examples/bank-match-2026-01-to-05.txt](examples/bank-match-2026-01-to-05.txt) — the V1 baseline.

**Output buckets:**
- `MATCHED` — bank ↔ Dolibarr, with the date delta (`Δ+6d`) so you can see drift between bank settlement and Dolibarr saisie.
- `INTERNAL` — Wise↔Qonto consolidations auto-detected by equal-amount opposite-sign on close dates. Excluded from matching against Dolibarr (they're transfers between Arcodange's own accounts, not external operations).
- `BANK-ONLY` — bank movements with no Dolibarr counterpart. Each one is either (a) a missing supplier invoice or unrecorded incoming payment, or (b) a Wise platform fee / cashback that doesn't translate to a Dolibarr entry.
- `DOLIBARR-ONLY` — Dolibarr payments without a bank movement. Usually fk_account=3 (the CCA1 personal account, not API-visible) or the cancel-and-reissue avoir cycle (bank sees the net, Dolibarr sees the three-way breakdown).

**Known findings from the V1 baseline** (to raise during cohort review):
- **Wise 2026-05-29 +2147 € from Kissmetrics NOT in Dolibarr** — M4 invoice probably emitted but the payment hasn't been entered. Action: enter the payment in Dolibarr.
- **+1000 € FOUREZ Quentin on Qonto 2026-01-21** — unknown income, ask Gabriel.
- **MISTRAL.AI -172.68 €, CLAUDE.AI -180 €, URSSAF -493 € on Qonto** — bank-only expenses, missing supplier invoices.
- **fk_account=3 supplier payments** (~430 € cumul) — paid from G.RADUREAU CCA personal account, not visible via Qonto/Wise APIs. This is normal; documented gap.

### 5. Live balances

```bash
bin/arcodange bank balance
```

Prints live balances per bank + the Dolibarr-side cumulative-payments-per-fk_account for cross-reference. Captured at [examples/bank-balance.txt](examples/bank-balance.txt).

Current state (V1 baseline):
- Qonto **Compte principal** : 4 191,54 € live
- 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.

## V7 bucket structure

V7 adds three improvements that reshape the output buckets:

| Bucket | Meaning | Counts toward exit-1? |
|---|---|---|
| **MATCHED** | Bank ↔ Dolibarr paired. Match kind: `[tx-id]` (exact — the payment's `transaction_id` equals the feed tx id; date-independent), `[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.

### Matching priority — exact tx-id first

Matching runs in three passes, highest confidence first:

1. **`[tx-id]` (exact)** — a Dolibarr payment whose stored `num` (`llx_bank.num_chq`,
   set from the règlement's `transaction_id`, see `dolibarr-sandbox-write`) equals the
   feed transaction's own id. **Date-window-independent** — the id is proof, so it
   matches even when bank settlement and Dolibarr saisie are weeks apart. Record
   payments with their `transaction_id` and reconciliation becomes deterministic.
2. **`[wire-ref]` (strong)** — via `--enrich`, below.
3. **`[amt+date]` (loose)** — the fallback heuristic.

**Canonical short form — the varchar(50) constraint.** Qonto feed ids run ~67 chars
(`<org>-<n>-<n>-transaction-<uuid>`) but Dolibarr stores `num_payment` in
**varchar(50)** columns, so règlements store the **UUID suffix** — the canonical
short form `payment-record.sh` normalizes to (strip everything through
`transaction-`). PASS 0 therefore compares **both sides in raw AND short form**:
a num stored short (the varchar(50) form) or long (historical) matches either way.
Wise ids (short numerics, no `transaction-`) are untouched.

Offline proof: `tests/run-tests.sh` runs `bank-match.sh --fixtures` on
`tests/fixtures/` (no credentials, no network, nothing written) — a long Qonto
feed id matches one règlement stored short and one stored long, each ~19d outside
the ±7d window (so only PASS 0 can pair them), plus the empty-num negative case.

### `--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

Today's match logic:
- **Amount equality** within 0.01 € (absolute value).
- **Date proximity** within `--window-days` (default 7 — covers most settlement drift).
- **Direction-aware** (bank credit ↔ Dolibarr customer payment; bank debit ↔ Dolibarr supplier payment).
- **Smallest date delta wins** when multiple candidates qualify.
- **Internal consolidation detection** by equal-amount opposite-sign cross-bank within ±3d.

V7 improvements to consider:
- **Reference-based matching** using the `--enrich` wire reference: search the Wise reference text for `FAC\d+` patterns and match by ref string. Stronger than date+amount when wire ref is informative.
- **Multi-row aggregation** for Dolibarr sub-payments: if invoice FAFXXX has two sub-payments on different dates summing to one bank movement, aggregate Dolibarr-side before matching.
- **Avoir cycle handling**: the V1 AVC001/FAC001-CL00001/FAC001-CL0001001 dance produces 3 Dolibarr-only rows for 1 bank credit. A smarter matcher would net the AVOIR before matching.

These are deliberately deferred — V1's accuracy is "good enough to surface the real issues" and the simple heuristic is easy to reason about.

## Out of scope

- **Writes**: the API tokens are read-only; payment recording happens in Dolibarr UI.
- **Wise BUSINESS balance statements via API** — region-blocked for EU personal tokens (documented above).
- **Wise transactions routed through Qonto** — Qonto's API doesn't expose them; the integration is UI-only.
- **fk_account=3 (CCA1 personal account)** — not API-accessible. Movements there must be reconciled manually against personal bank statements.
- **Bank statement export to CSV** — possible V8 fallback if any of the APIs go away; out of scope today.
- **Currency conversion** — everything is EUR. Multi-currency would need adapting the amount parser.
