From bd90266372a7f0e5b92a5db55b4c8ef4ecd88c93 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 31 May 2026 13:57:21 +0200 Subject: [PATCH] =?UTF-8?q?add=20arcodange-bank-reco=20=E2=80=94=20Qonto?= =?UTF-8?q?=20+=20Wise=20reconciliation=20against=20Dolibarr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V6 — the first cross-system skill (under arcodange-* not dolibarr-*). Closes the loop between what Dolibarr says (ERP-internal) and what the bank actually saw. What ships: - arcodange-bank-reco/scripts/bank-curl.sh unified read-only wrapper for Qonto + Wise - arcodange-bank-reco/scripts/bank-probe.sh auth + discovery (org slug, profile id, balances) - arcodange-bank-reco/scripts/qonto-transactions Qonto txn lister with pagination + filters - arcodange-bank-reco/scripts/wise-transactions Wise activity lister with --enrich for wire refs - arcodange-bank-reco/scripts/bank-match.sh 3-bucket reconciliation (matched/bank-only/dol-only) with internal Wise↔Qonto consolidation detection - arcodange-bank-reco/scripts/bank-balance.sh live balances + Dolibarr cumulative-by-fk_account The headline bank-curl.sh is SCA-aware (Wise RSA dance) even though we don't end up using it: the EU statement endpoint is region-blocked ("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" per Wise docs). The wrapper supports SCA so when/if Wise opens it, we're ready. The pivot that unblocked Wise incoming: /v1/profiles/{pid}/activities (documented at https://docs.wise.com/api-reference/activity/activitylist.md) returns ALL movements in a unified HTML-tagged feed, no SCA required. Parsing strips the HTML and recovers structured amount/sign/currency. CLI integration: - bin/arcodange bank {probe,qonto-transactions,wise-transactions,match,balance,curl} - dolibarr/SKILL.md catalogue + Pointers updated - dolibarr/README.md env schema extended with QONTO_*, WISE_* Live baseline findings to raise with the cohort review (captured in examples/bank-match-2026-01-to-05.txt): - Wise 2026-05-29 +2147 EUR Kissmetrics NOT YET in Dolibarr - Qonto bank-only: MISTRAL.AI 172.68, CLAUDE.AI 180, URSSAF 493, FOUREZ +1000 - 6 movements matched cleanly across Jan-May 2026 - Wise→Qonto 5000 EUR consolidation on 2026-03-13 auto-detected as internal - Live balance: Qonto 4191.54 + Wise 5308.25 = 9499.79 EUR V7 candidates noted in SKILL.md out-of-scope: reference-based matching via the Wise --enrich wire refs (FOR INVOICE FAC***), multi-row Dolibarr sub-payment aggregation, smarter avoir cycle handling. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/arcodange-bank-reco/SKILL.md | 150 +++++++++++++ .../examples/bank-balance.txt | 24 +++ .../examples/bank-match-2026-01-to-05.txt | 36 ++++ .../examples/bank-probe.txt | 37 ++++ .../examples/qonto-transactions.txt | 15 ++ .../examples/wise-transactions.txt | 16 ++ .../scripts/bank-balance.sh | 108 ++++++++++ .../arcodange-bank-reco/scripts/bank-curl.sh | 107 ++++++++++ .../arcodange-bank-reco/scripts/bank-match.sh | 198 ++++++++++++++++++ .../arcodange-bank-reco/scripts/bank-probe.sh | 140 +++++++++++++ .../scripts/qonto-transactions.sh | 116 ++++++++++ .../scripts/wise-transactions.sh | 154 ++++++++++++++ .claude/skills/dolibarr/README.md | 9 + .claude/skills/dolibarr/SKILL.md | 3 +- bin/arcodange | 41 ++++ 15 files changed, 1153 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/arcodange-bank-reco/SKILL.md create mode 100644 .claude/skills/arcodange-bank-reco/examples/bank-balance.txt create mode 100644 .claude/skills/arcodange-bank-reco/examples/bank-match-2026-01-to-05.txt create mode 100644 .claude/skills/arcodange-bank-reco/examples/bank-probe.txt create mode 100644 .claude/skills/arcodange-bank-reco/examples/qonto-transactions.txt create mode 100644 .claude/skills/arcodange-bank-reco/examples/wise-transactions.txt create mode 100755 .claude/skills/arcodange-bank-reco/scripts/bank-balance.sh create mode 100755 .claude/skills/arcodange-bank-reco/scripts/bank-curl.sh create mode 100755 .claude/skills/arcodange-bank-reco/scripts/bank-match.sh create mode 100755 .claude/skills/arcodange-bank-reco/scripts/bank-probe.sh create mode 100755 .claude/skills/arcodange-bank-reco/scripts/qonto-transactions.sh create mode 100755 .claude/skills/arcodange-bank-reco/scripts/wise-transactions.sh diff --git a/.claude/skills/arcodange-bank-reco/SKILL.md b/.claude/skills/arcodange-bank-reco/SKILL.md new file mode 100644 index 0000000..e1642ab --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/SKILL.md @@ -0,0 +1,150 @@ +--- +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 `"+ 5,100.00 EUR"` 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: :` header. +- `/v2/organization` lists bank accounts + balances. +- `/v2/transactions?bank_account_id=&settled_at_from=&settled_at_to=` 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= + QONTO_ORG_SLUG=arcodange-XXXXX + WISE_API_TOKEN= + WISE_PROFILE_ID= + ``` +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 € + +## 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. diff --git a/.claude/skills/arcodange-bank-reco/examples/bank-balance.txt b/.claude/skills/arcodange-bank-reco/examples/bank-balance.txt new file mode 100644 index 0000000..ef5ac6f --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/examples/bank-balance.txt @@ -0,0 +1,24 @@ +========================================================================================== + BANK-SIDE balances (live) +========================================================================================== + bank ref currency balance details + -------------------------------------------------------------------------------------- + Qonto Compte principal EUR 4191.54 iban=...25639215 id=019b93fe-3cb8- + Wise STANDARD EUR 5308.25 id=147831931 + + Qonto total: 4191.54 EUR + Wise total : 5308.25 EUR + +========================================================================================== + DOLIBARR-SIDE cumulative payments per fk_account +========================================================================================== + fk_account ref label cust paid in sup paid out net + -------------------------------------------------------------------------------------- + 1 QON1 QONTO 0.00 968.00 -968.00 + 2 WIS2 WISE EURO 8160.00 0.00 8160.00 + 3 CCA1 G.RADUREAU Compte Courant Asso 0.00 429.75 -429.75 + +# Note: Dolibarr-side numbers are CUMULATIVE since the account started in Dolibarr, +# not the current bank balance. Mismatch with the bank-side is expected when +# the account predates Dolibarr or has movements not recorded in Dolibarr +# (e.g. URSSAF, AI subscriptions — see bank-match.sh BANK-ONLY bucket). diff --git a/.claude/skills/arcodange-bank-reco/examples/bank-match-2026-01-to-05.txt b/.claude/skills/arcodange-bank-reco/examples/bank-match-2026-01-to-05.txt new file mode 100644 index 0000000..4a6bb7f --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/examples/bank-match-2026-01-to-05.txt @@ -0,0 +1,36 @@ +# Bank reconciliation: 2026-01-01 → 2026-05-31 (window ±7d, fees: off) + +=== MATCHED (6 bank ↔ Dolibarr) === + Qonto 2026-01-27 - 50.00 card Wise *Plan ↔ 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-03-06 + 5100.00 TRANSFER Kissmetrics Holdings Inc ↔ customer FAC002-CL0001002 (2026-03-12, Δ+6d) + Qonto 2026-03-13 - 612.00 transfer DARNIS OPERATIONS ↔ supplier FAF2026008 (2026-03-13, Δ+0d) + Wise 2026-04-20 + 2550.00 TRANSFER Kissmetrics Holdings Inc ↔ customer FAC003-CL0001003 (2026-04-20, Δ+0d) + Qonto 2026-05-10 - 306.00 transfer DARNIS OPERATIONS ↔ supplier FAF2026009 (2026-05-10, Δ+0d) + +=== 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 + Wise 2026-05-29 + 2147.00 TRANSFER Kissmetrics Holdings Inc + +=== DOLIBARR-ONLY (9 Dolibarr payments without bank movement) === + 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) + +-------------------------------------------------------------------------------- +# 6 matched, 1 internal, 8 bank-only, 9 dolibarr-only diff --git a/.claude/skills/arcodange-bank-reco/examples/bank-probe.txt b/.claude/skills/arcodange-bank-reco/examples/bank-probe.txt new file mode 100644 index 0000000..85b71f8 --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/examples/bank-probe.txt @@ -0,0 +1,37 @@ +================================================================================ + bank-probe — auth + discovery +================================================================================ + +--- QONTO --- + base : https://thirdparty.qonto.com + auth shape : Authorization: : + env vars : QONTO_LOGIN= QONTO_SECRET_KEY= QONTO_ORG_SLUG=arcodange-1246 + + [OK] auth succeeded + slug : arcodange-1246 + legal_name : ARCODANGE + legal_country : FR + 1 bank account(s): + id=019b93fe-3cb8-7e28-9404-ee637f7aff27 name=Compte principal ...iban=25639215 status=active balance=4191.54 + +--- WISE --- + base : https://api.wise.com + auth shape : Authorization: Bearer + env vars : WISE_API_TOKEN= WISE_PROFILE_ID=82958299 + + [OK] auth succeeded + 2 profile(s): + id=82958299 type=BUSINESS name=ARCODANGE ← .env WISE_PROFILE_ID + id=82958415 type=PERSONAL name=Gabriel Jonathan Marc Radureau + +--- WISE balances (for the BUSINESS profile) --- + 1 balance(s): + id=147831931 type=STANDARD currency=EUR balance=5308.25 EUR name=None + +--- summary --- + .env should ultimately contain: + QONTO_LOGIN= + QONTO_SECRET_KEY= + QONTO_ORG_SLUG=arcodange-1246 + WISE_API_TOKEN= + WISE_PROFILE_ID= diff --git a/.claude/skills/arcodange-bank-reco/examples/qonto-transactions.txt b/.claude/skills/arcodange-bank-reco/examples/qonto-transactions.txt new file mode 100644 index 0000000..1a4836c --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/examples/qonto-transactions.txt @@ -0,0 +1,15 @@ +# Qonto transactions on account 019b93fe... (2026-01-01 → 2026-05-31) + +date side amount cur op label +---------------------------------------------------------------------------------------------------- +2026-01-16 credit 5.22 EUR qonto_fee Qonto +2026-01-21 credit 1000.00 EUR income FOUREZ Quentin +2026-01-27 debit 50.00 EUR card Wise *Plan +2026-03-13 credit 5000.00 EUR income ARCODANGE +2026-03-13 debit 612.00 EUR transfer DARNIS OPERATIONS +2026-04-03 debit 172.68 EUR card MISTRAL.AI +2026-04-13 debit 180.00 EUR card CLAUDE.AI SUBSCRIPTION +2026-05-10 debit 306.00 EUR transfer DARNIS OPERATIONS +2026-05-22 debit 493.00 EUR direct_debit URSSAF D ILE DE FRANCE +---------------------------------------------------------------------------------------------------- +# 9 txn(s) — credit total: 6005.22, debit total: 1813.68, net: +4191.54 diff --git a/.claude/skills/arcodange-bank-reco/examples/wise-transactions.txt b/.claude/skills/arcodange-bank-reco/examples/wise-transactions.txt new file mode 100644 index 0000000..c5e6ee4 --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/examples/wise-transactions.txt @@ -0,0 +1,16 @@ +# Wise activities for profile (window 2026-01-01 → 2026-05-31) + +date type status sign amount cur title +------------------------------------------------------------------------------------------- +2026-01-26 BALANCE_DEPOSIT COMPLETED + 50.00 EUR To EUR +2026-01-26 FEATURE_CHARGE COMPLETED - 50.00 EUR For your account plan +2026-02-05 TRANSFER COMPLETED + 510.00 EUR Kissmetrics Holdings Inc +2026-03-05 BALANCE_CASHBACK COMPLETED + 0.19 EUR Cashback +2026-03-06 TRANSFER COMPLETED + 5100.00 EUR Kissmetrics Holdings Inc +2026-03-13 TRANSFER COMPLETED - 5000.00 EUR ARCODANGE +2026-04-06 BALANCE_CASHBACK COMPLETED + 0.35 EUR Cashback +2026-04-20 TRANSFER COMPLETED + 2550.00 EUR Kissmetrics Holdings Inc +2026-05-07 BALANCE_CASHBACK COMPLETED + 0.71 EUR Cashback +2026-05-29 TRANSFER COMPLETED + 2147.00 EUR Kissmetrics Holdings Inc +------------------------------------------------------------------------------------------- +# 10 activity(ies) — credit: +10358.25, debit: -5050.00, net: +5308.25 diff --git a/.claude/skills/arcodange-bank-reco/scripts/bank-balance.sh b/.claude/skills/arcodange-bank-reco/scripts/bank-balance.sh new file mode 100755 index 0000000..6a36ad2 --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/scripts/bank-balance.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Live balances per bank account, plus a Dolibarr cross-check by fk_account. +# +# Usage: +# bank-balance.sh +# +# Outputs each bank account (Qonto + Wise) with: +# - current live balance (from each bank's API) +# - sum of all Dolibarr payments touching it (per fk_account) +# +# The sum-of-payments isn't the bank balance — it's just the cumulative +# operations Dolibarr has recorded on that account. Useful as a sanity check +# (e.g. "did I record everything?"). The bank-side current balance is +# authoritative. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BANK_CURL="${SCRIPT_DIR}/bank-curl.sh" +DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh" + +set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a +: "${WISE_PROFILE_ID:?bank-balance.sh: WISE_PROFILE_ID not set}" + +WORK="$(mktemp -d -t bankbal.XXXXXX)" +trap 'rm -rf "${WORK}"' EXIT + +# Bank side +"${BANK_CURL}" qonto /v2/organization > "${WORK}/qonto.json" +"${BANK_CURL}" wise "/v4/profiles/${WISE_PROFILE_ID}/balances?types=STANDARD" > "${WORK}/wise.json" + +# Dolibarr side: bank accounts + payments per invoice +"${DOL_CURL}" /bankaccounts > "${WORK}/dol_acct.json" +"${DOL_CURL}" '/invoices?limit=500' > "${WORK}/dol_inv.json" +"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/dol_sup.json" + +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 + "${DOL_CURL}" "/invoices/${id}/payments" > "${WORK}/dol_pay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_pay/${id}.json" +done +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_sup.json"); do + "${DOL_CURL}" "/supplierinvoices/${id}/payments" > "${WORK}/dol_supay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_supay/${id}.json" +done + +python3 - "${WORK}" <<'PY' +import json, os, sys, collections +work = sys.argv[1] + +# Bank-side balances +q = json.load(open(os.path.join(work, "qonto.json"))) +qonto_accs = (q.get("organization") or {}).get("bank_accounts") or [] +wise_bals = json.load(open(os.path.join(work, "wise.json"))) + +print("=" * 90) +print(" BANK-SIDE balances (live)") +print("=" * 90) +print(f" {'bank':<8} {'ref':<20} {'currency':<3} {'balance':>12} details") +print(" " + "-" * 86) +qonto_total = 0.0 +for a in qonto_accs: + bal = float(a.get("balance") or 0); qonto_total += bal + iban = (a.get("iban") or "")[-8:] + print(f" {'Qonto':<8} {a.get('name','-')[:20]:<20} {a.get('balance_cents_currency','EUR'):<3} {bal:>12.2f} iban=...{iban} id={a.get('id','-')[:14]}") +for b in wise_bals: + amt = (b.get("amount") or {}) + print(f" {'Wise':<8} {b.get('type','-'):<20} {amt.get('currency','-'):<3} {float(amt.get('value') or 0):>12.2f} id={b.get('id','-')}") +print() +print(f" Qonto total: {qonto_total:.2f} EUR") +print(f" Wise total : {sum(float((b.get('amount') or {}).get('value') or 0) for b in wise_bals):.2f} EUR") +print() + +# Dolibarr-side: sum of all payments per fk_account +dol_accts = {str(a["id"]): a for a in json.load(open(os.path.join(work, "dol_acct.json")))} +sums = collections.defaultdict(lambda: {"customer": 0.0, "supplier": 0.0}) + +inv = {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")): + iid = fn[:-5]; i = inv.get(iid) + if not i: continue + fka = str(i.get("fk_account") or "?") + for p in json.load(open(os.path.join(work, "dol_pay", fn))): + sums[fka]["customer"] += float(p.get("amount") or 0) + +sup = {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")): + iid = fn[:-5]; s = sup.get(iid) + if not s: continue + fka = str(s.get("fk_account") or "?") + for p in json.load(open(os.path.join(work, "dol_supay", fn))): + sums[fka]["supplier"] += float(p.get("amount") or 0) + +print("=" * 90) +print(" DOLIBARR-SIDE cumulative payments per fk_account") +print("=" * 90) +print(f" {'fk_account':<10} {'ref':<10} {'label':<30} {'cust paid in':>12} {'sup paid out':>12} {'net':>10}") +print(" " + "-" * 86) +for fka in sorted(sums, key=lambda k: int(k) if k.isdigit() else 99): + s = sums[fka] + net = s["customer"] - s["supplier"] + a = dol_accts.get(fka, {}) + ref = a.get("ref","-")[:10]; label = (a.get("label") or "-")[:30] + print(f" {fka:<10} {ref:<10} {label:<30} {s['customer']:>12.2f} {s['supplier']:>12.2f} {net:>10.2f}") +print() +print("# Note: Dolibarr-side numbers are CUMULATIVE since the account started in Dolibarr,") +print("# not the current bank balance. Mismatch with the bank-side is expected when") +print("# the account predates Dolibarr or has movements not recorded in Dolibarr") +print("# (e.g. URSSAF, AI subscriptions — see bank-match.sh BANK-ONLY bucket).") +PY diff --git a/.claude/skills/arcodange-bank-reco/scripts/bank-curl.sh b/.claude/skills/arcodange-bank-reco/scripts/bank-curl.sh new file mode 100755 index 0000000..21a56b3 --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/scripts/bank-curl.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Read-only curl wrapper for the two banks Arcodange uses: Qonto + Wise. +# +# Usage: +# bank-curl.sh qonto # e.g. bank-curl.sh qonto /v2/organization +# bank-curl.sh wise # e.g. bank-curl.sh wise /v2/profiles +# bank-curl.sh -i # include curl's -i (response headers) +# +# Reads credentials from ../../dolibarr/.env (the shared canonical file +# used by every dolibarr-* and arcodange-* skill). Required vars: +# QONTO_LOGIN, QONTO_SECRET_KEY (for qonto) +# WISE_API_TOKEN (for wise) +# +# Exits non-zero on HTTP >=400 and writes the body to stdout + a short +# "bank-curl.sh: HTTP " message to stderr — same shape as dol-curl.sh. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/../../dolibarr/.env" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "bank-curl.sh: missing ${ENV_FILE}" >&2 + echo " See dolibarr/README.md for the .env schema; arcodange-bank-reco extends it" >&2 + echo " with QONTO_LOGIN, QONTO_SECRET_KEY, WISE_API_TOKEN, WISE_PROFILE_ID." >&2 + exit 2 +fi +set -a; source "${ENV_FILE}"; set +a + +PASSTHRU=() +while [[ $# -gt 2 ]]; do + PASSTHRU+=("$1"); shift +done + +if [[ $# -lt 2 ]]; then + echo "bank-curl.sh: usage: bank-curl.sh [curl-opts] " >&2 + exit 2 +fi +BANK="$1"; API_PATH="$2" + +case "${BANK}" in + qonto) + : "${QONTO_LOGIN:?bank-curl.sh: QONTO_LOGIN not set in .env}" + : "${QONTO_SECRET_KEY:?bank-curl.sh: QONTO_SECRET_KEY not set in .env}" + BASE="https://thirdparty.qonto.com" + AUTH_HEADER="Authorization: ${QONTO_LOGIN}:${QONTO_SECRET_KEY}" + ;; + wise) + : "${WISE_API_TOKEN:?bank-curl.sh: WISE_API_TOKEN not set in .env}" + BASE="https://api.wise.com" + AUTH_HEADER="Authorization: Bearer ${WISE_API_TOKEN}" + ;; + *) + echo "bank-curl.sh: unknown bank '${BANK}' (use qonto or wise)" >&2 + exit 2 + ;; +esac + +BODY_FILE="$(mktemp -t bankcurl.XXXXXX)" +HEADERS_FILE="$(mktemp -t bankcurlhdr.XXXXXX)" +trap 'rm -f "${BODY_FILE}" "${HEADERS_FILE}"' EXIT + +do_call() { + local extra_header_1="${1:-}" extra_header_2="${2:-}" + local extra_args=() + [[ -n "${extra_header_1}" ]] && extra_args+=("-H" "${extra_header_1}") + [[ -n "${extra_header_2}" ]] && extra_args+=("-H" "${extra_header_2}") + curl -sS \ + -H "${AUTH_HEADER}" \ + -H "Accept: application/json" \ + --max-time 30 \ + -o "${BODY_FILE}" \ + -D "${HEADERS_FILE}" \ + -w "%{http_code}" \ + ${PASSTHRU[@]+"${PASSTHRU[@]}"} \ + ${extra_args[@]+"${extra_args[@]}"} \ + "${BASE}${API_PATH}" +} + +HTTP_CODE=$(do_call) + +# Wise SCA flow: a 403 with x-2fa-approval-result: REJECTED + x-2fa-approval: +# header means the endpoint is sensitive and the call must be signed with our +# registered RSA private key. We sign the one-time token and retry. +if [[ "${BANK}" == "wise" && "${HTTP_CODE}" == "403" ]]; then + ONE_TIME=$(awk 'tolower($1) == "x-2fa-approval:" { gsub(/[\r\n]/, "", $2); print $2; exit }' "${HEADERS_FILE}") + if [[ -n "${ONE_TIME}" ]]; then + KEY_PATH="${WISE_SCA_KEY_PATH:-}" + # Expand ~ if used + KEY_PATH="${KEY_PATH/#\~/$HOME}" + if [[ -z "${KEY_PATH}" || ! -f "${KEY_PATH}" ]]; then + echo "bank-curl.sh: Wise endpoint requires SCA but WISE_SCA_KEY_PATH is missing." >&2 + echo " Generate a keypair, upload the public key to Wise, set WISE_SCA_KEY_PATH in .env." >&2 + echo " See arcodange-bank-reco/SKILL.md for the setup steps." >&2 + cat "${BODY_FILE}" + exit 1 + fi + SIGNATURE=$(printf '%s' "${ONE_TIME}" | openssl dgst -sha256 -sign "${KEY_PATH}" | base64 | tr -d '\n') + HTTP_CODE=$(do_call "x-2fa-approval: ${ONE_TIME}" "X-Signature: ${SIGNATURE}") + fi +fi + +cat "${BODY_FILE}" +if [[ "${HTTP_CODE}" -ge 400 ]]; then + echo "bank-curl.sh: HTTP ${HTTP_CODE} on ${BANK}${API_PATH}" >&2 + exit 1 +fi diff --git a/.claude/skills/arcodange-bank-reco/scripts/bank-match.sh b/.claude/skills/arcodange-bank-reco/scripts/bank-match.sh new file mode 100755 index 0000000..e43d521 --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/scripts/bank-match.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Match bank movements (Qonto + Wise) against Dolibarr payments. +# +# Usage: +# bank-match.sh [--month YYYY-MM | --since YYYY-MM-DD --until YYYY-MM-DD] +# [--window-days N] # date tolerance, default 7 +# [--include-fees] # include Wise cashback / charges (default off) +# +# Output: three buckets +# - MATCHED bank movement ↔ Dolibarr payment +# - BANK-ONLY bank movement with no Dolibarr counterpart (potential +# missing supplier invoice or unrecorded incoming payment) +# - DOLIBARR-ONLY Dolibarr payment with no bank movement (timing or error) +# +# Internal Wise↔Qonto consolidations (e.g. 5000 € moved Wise→Qonto same day) +# are auto-detected and excluded from matching against Dolibarr. +# +# Exit 0 if everything in the window matches cleanly, 1 if there's any bank-only +# or dolibarr-only entry. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BANK_CURL="${SCRIPT_DIR}/bank-curl.sh" +DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh" + +SINCE=""; UNTIL=""; MONTH=""; WINDOW=7; INCLUDE_FEES=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --since) SINCE="$2"; shift 2 ;; + --until) UNTIL="$2"; shift 2 ;; + --month) MONTH="$2"; shift 2 ;; + --window-days) WINDOW="$2"; shift 2 ;; + --include-fees) INCLUDE_FEES=1; shift ;; + -h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "bank-match.sh: unknown arg: $1" >&2; exit 2 ;; + esac +done + +if [[ -n "${MONTH}" ]]; then + SINCE="${MONTH}-01" + UNTIL="$(python3 -c "import calendar; y,m=map(int,'${MONTH}'.split('-')); print(f'{y:04d}-{m:02d}-{calendar.monthrange(y,m)[1]:02d}')")" +fi +[[ -z "${SINCE}" ]] && SINCE="$(python3 -c "import datetime; print((datetime.date.today()-datetime.timedelta(days=365)).strftime('%Y-%m-%d'))")" +[[ -z "${UNTIL}" ]] && UNTIL="$(python3 -c "import datetime; print(datetime.date.today().strftime('%Y-%m-%d'))")" + +set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a +: "${WISE_PROFILE_ID:?bank-match.sh: WISE_PROFILE_ID not set}" + +WORK="$(mktemp -d -t bankmatch.XXXXXX)" +trap 'rm -rf "${WORK}"' EXIT + +# --- 1. Pull Qonto transactions --- +TMP_ORG=$(mktemp -t qontoorg.XXXXXX.json) +"${BANK_CURL}" qonto /v2/organization > "${TMP_ORG}" +QONTO_ACCT=$(python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +accs = (d.get('organization') or {}).get('bank_accounts') or [] +print([a for a in accs if a.get('status')=='active'][0]['id'])" "${TMP_ORG}") +rm -f "${TMP_ORG}" + +QURL="/v2/transactions?bank_account_id=${QONTO_ACCT}&settled_at_from=${SINCE}T00:00:00Z&settled_at_to=${UNTIL}T23:59:59Z&per_page=100" +"${BANK_CURL}" qonto "${QURL}" > "${WORK}/qonto.json" + +# --- 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" + +# --- 3. Pull Dolibarr customer + supplier invoices and their payments --- +"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${WORK}/dol_inv.json" +"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/dol_sup.json" + +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 + "${DOL_CURL}" "/invoices/${id}/payments" > "${WORK}/dol_pay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_pay/${id}.json" +done +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_sup.json"); do + "${DOL_CURL}" "/supplierinvoices/${id}/payments" > "${WORK}/dol_supay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_supay/${id}.json" +done + +# --- 4. Match in python --- +python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${WINDOW}" "${INCLUDE_FEES}" <<'PY' +import json, sys, os, re, datetime, collections +work, since, until, window_days, include_fees = sys.argv[1:6] +window = int(window_days); include_fees = include_fees == "1" +since_d = datetime.date.fromisoformat(since); until_d = datetime.date.fromisoformat(until) + +def strip(s): return re.sub(r'<[^>]+>', '', s or '').strip() + +# 4a. Normalize Qonto +qonto_movs = [] +for t in (json.load(open(os.path.join(work,"qonto.json"))).get("transactions") or []): + dt = datetime.date.fromisoformat((t.get("settled_at") or "")[:10]) + if dt < since_d or dt > until_d: continue + amt = float(t.get("amount") or 0) + sign = "+" if t.get("side") == "credit" else "-" + label = t.get("label") or t.get("operation_type") or "-" + qonto_movs.append({"bank":"Qonto", "date":dt, "sign":sign, "amount":amt, "label":label[:40], "op":t.get("operation_type",""), "matched_dol":None, "matched_internal":False}) + +# 4b. Normalize Wise +wise_movs = [] +for a in (json.load(open(os.path.join(work,"wise.json"))).get("activities") or []): + dt = datetime.date.fromisoformat((a.get("createdOn") or "")[:10]) + if dt < since_d or dt > until_d: continue + typ = a.get("type","-") + if not include_fees and typ in ("BALANCE_CASHBACK", "BALANCE_INTEREST"): + continue + pa = strip(a.get("primaryAmount") or "") + sign = "+" if pa.startswith("+") else "-" + m = re.search(r'([\d,.]+)\s*([A-Z]{3})', pa) + amt = float(m.group(1).replace(",", "")) if m else 0.0 + 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}) + +bank_movs = qonto_movs + wise_movs + +# 4c. Detect internal Wise<->Qonto consolidations: same date, equal amount, opposite signs, one Wise + one Qonto +for w in [m for m in bank_movs if m["bank"]=="Wise" and m["sign"]=="-"]: + for q in [m for m in bank_movs if m["bank"]=="Qonto" and m["sign"]=="+" and not m["matched_internal"]]: + if abs(w["amount"] - q["amount"]) < 0.01 and abs((w["date"] - q["date"]).days) <= 3: + w["matched_internal"] = q; q["matched_internal"] = w + break + +# 4d. Normalize Dolibarr payments +dol_pays = [] +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")): + iid = fn[:-5]; inv = inv_by_id.get(iid) + if not inv: continue + for p in json.load(open(os.path.join(work,"dol_pay",fn))): + d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date() + if d < since_d or d > until_d: continue + 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}) + +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")): + iid = fn[:-5]; sup = sup_by_id.get(iid) + if not sup: continue + for p in json.load(open(os.path.join(work,"dol_supay",fn))): + d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date() + if d < since_d or d > until_d: continue + 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}) + +# 4e. Match: each bank movement (non-internal) tries to find a Dolibarr counterpart +for m in [x for x in bank_movs if not x["matched_internal"]]: + bank_signed = m["amount"] if m["sign"]=="+" else -m["amount"] + # For customer payments (Dol records them as positive amounts): +bank credit matches +dol customer payment + # For supplier payments: -bank debit matches +dol supplier payment (positive in Dol since it's the amount paid out) + # Heuristic: match abs(amount) within 0.01 and date within window. + 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] + if candidates: + # Pick smallest date delta + candidates.sort(key=lambda p: abs((p["date"] - m["date"]).days)) + p = candidates[0] + m["matched_dol"] = p; p["matched_bank"] = 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']}" + +print(f"# Bank reconciliation: {since} → {until} (window ±{window}d, fees: {'on' if include_fees else 'off'})") +print() +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"]=="-"] +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] + +print(f"=== MATCHED ({len(matched)} bank ↔ Dolibarr) ===") +for m in sorted(matched, key=lambda m: m["date"]): + p = m["matched_dol"] + delta = (p["date"] - m["date"]).days + print(fmt_bank(m) + f" ↔ {p['side']:<8} {p['ref']:<24} ({p['date']}, Δ{delta:+d}d)") +print() + +print(f"=== INTERNAL (Wise↔Qonto consolidations, {len(internal)}) ===") +for m in sorted(internal, key=lambda m: m["date"]): + other = m["matched_internal"] + 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"]): + print(fmt_bank(m)) +print() + +print(f"=== DOLIBARR-ONLY ({len(dol_only)} Dolibarr payments without bank movement) ===") +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) +print("-" * 80) +print(f"# {len(matched)} matched, {len(internal)} internal, {len(bank_only)} bank-only, {len(dol_only)} dolibarr-only") +sys.exit(0 if fails == 0 else 1) +PY diff --git a/.claude/skills/arcodange-bank-reco/scripts/bank-probe.sh b/.claude/skills/arcodange-bank-reco/scripts/bank-probe.sh new file mode 100755 index 0000000..0ca7fe7 --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/scripts/bank-probe.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Auth + discovery probe for Qonto + Wise. Run this once after dropping +# fresh tokens into .env. It confirms auth works and prints the IDs you +# need (Qonto bank account ids, Wise business profile id + balance ids). +# +# Usage: +# bank-probe.sh +# +# Token values are NEVER printed. Only metadata + slugs / ids are. +# Output is safe to commit as an examples/ baseline. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BANK_CURL="${SCRIPT_DIR}/bank-curl.sh" + +set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a + +# A redactor that masks anything that looks like a token / secret / key +# in case the API ever echoes credentials back to us. Defense in depth. +redact() { + python3 - <<'PY' +import sys, re +s = sys.stdin.read() +# Mask long alphanumeric runs (typical tokens) and any explicit "token"/"secret"/"key" values +s = re.sub(r'([A-Fa-f0-9]{32,})', '', s) +s = re.sub(r'([A-Za-z0-9_-]{40,})', '', s) +s = re.sub(r'("(?:token|secret|key|password)"\s*:\s*")[^"]*(")', + r'\1\2', s, flags=re.IGNORECASE) +print(s, end='') +PY +} + +echo "================================================================================" +echo " bank-probe — auth + discovery" +echo "================================================================================" +echo + +# ---------------------------- Qonto ---------------------------- +echo "--- QONTO ---" +echo " base : https://thirdparty.qonto.com" +echo " auth shape : Authorization: :" +echo " env vars : QONTO_LOGIN=${QONTO_LOGIN:+} QONTO_SECRET_KEY=${QONTO_SECRET_KEY:+} QONTO_ORG_SLUG=${QONTO_ORG_SLUG:-}" +echo + +if QONTO_ORG_RAW="$("${BANK_CURL}" qonto /v2/organization 2>&1)"; then + python3 - </dev/null || true + +# Robust version: write to tmp file, parse from there (avoids quoting hell) +TMP_QONTO=$(mktemp -t qonto.XXXXXX.json) +trap 'rm -f "${TMP_QONTO}"' EXIT +if "${BANK_CURL}" qonto /v2/organization > "${TMP_QONTO}"; then + python3 - "${TMP_QONTO}" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +org = d.get("organization") or {} +print(f" [OK] auth succeeded") +print(f" slug : {org.get('slug')}") +print(f" legal_name : {org.get('legal_name')}") +print(f" legal_country : {org.get('legal_country')}") +accs = org.get("bank_accounts") or [] +print(f" {len(accs)} bank account(s):") +for a in accs: + iban = a.get("iban") or "" + iban_tail = iban[-8:] if iban else "-" + print(f" id={a.get('id')} name={a.get('name','-')} ...iban={iban_tail} status={a.get('status','-')} balance={a.get('balance','-')} {a.get('balance_cents_currency','')}") +PY +else + echo " [XX] Qonto auth FAILED — check QONTO_LOGIN / QONTO_SECRET_KEY in .env" +fi + +echo +# ---------------------------- Wise ---------------------------- +echo "--- WISE ---" +echo " base : https://api.wise.com" +echo " auth shape : Authorization: Bearer " +echo " env vars : WISE_API_TOKEN=${WISE_API_TOKEN:+} WISE_PROFILE_ID=${WISE_PROFILE_ID:-}" +echo + +TMP_WISE_PROFILES=$(mktemp -t wise.XXXXXX.json) +trap 'rm -f "${TMP_QONTO}" "${TMP_WISE_PROFILES}"' EXIT +if "${BANK_CURL}" wise /v2/profiles > "${TMP_WISE_PROFILES}"; then + python3 - "${TMP_WISE_PROFILES}" "${WISE_PROFILE_ID:-}" <<'PY' +import json, sys +profiles = json.load(open(sys.argv[1])) +env_pid = sys.argv[2] +print(f" [OK] auth succeeded") +print(f" {len(profiles)} profile(s):") +business_pid = None +for p in profiles: + name = p.get("fullName") or p.get("businessName") or (p.get("details") or {}).get("name", "-") + marker = "" + if str(p.get("id")) == env_pid: + marker = " ← .env WISE_PROFILE_ID" + if p.get("type") == "BUSINESS" and not business_pid: + business_pid = p.get("id") + print(f" id={p.get('id')} type={p.get('type','-'):<10} name={name}{marker}") +if env_pid and business_pid and str(business_pid) != env_pid: + print(f" [!!] WISE_PROFILE_ID in .env ({env_pid}) is NOT the BUSINESS profile ({business_pid}).") + print(f" Use the BUSINESS one for Arcodange.") +elif not env_pid and business_pid: + print(f" [→] Set WISE_PROFILE_ID={business_pid} in .env (BUSINESS profile).") +PY +else + echo " [XX] Wise auth FAILED — check WISE_API_TOKEN in .env" +fi + +echo +# Wise balances + balance ids (needed for /balance-statements queries) +if [[ -n "${WISE_PROFILE_ID:-}" ]]; then + echo "--- WISE balances (for the BUSINESS profile) ---" + TMP_WISE_BAL=$(mktemp -t wisebal.XXXXXX.json) + trap 'rm -f "${TMP_QONTO}" "${TMP_WISE_PROFILES}" "${TMP_WISE_BAL}"' EXIT + if "${BANK_CURL}" wise "/v4/profiles/${WISE_PROFILE_ID}/balances?types=STANDARD" > "${TMP_WISE_BAL}"; then + python3 - "${TMP_WISE_BAL}" <<'PY' +import json, sys +bal = json.load(open(sys.argv[1])) +print(f" {len(bal)} balance(s):") +for b in bal: + amt = (b.get('amount') or {}).get('value', '?') + cur = (b.get('amount') or {}).get('currency', '?') + print(f" id={b.get('id')} type={b.get('type','-'):<8} currency={cur:<3} balance={amt} {cur} name={b.get('name','-')}") +PY + else + echo " [XX] /balances fetch failed — token may not have balances scope, or profile id is wrong." + fi +fi + +echo +echo "--- summary ---" +echo " .env should ultimately contain:" +echo " QONTO_LOGIN=" +echo " QONTO_SECRET_KEY=" +echo " QONTO_ORG_SLUG=$(grep -E '^QONTO_ORG_SLUG' "${SCRIPT_DIR}/../../dolibarr/.env" | cut -d= -f2 || echo '')" +echo " WISE_API_TOKEN=" +echo " WISE_PROFILE_ID=" diff --git a/.claude/skills/arcodange-bank-reco/scripts/qonto-transactions.sh b/.claude/skills/arcodange-bank-reco/scripts/qonto-transactions.sh new file mode 100755 index 0000000..2a76c04 --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/scripts/qonto-transactions.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# List Qonto transactions for a period, on one bank account. +# +# Usage: +# qonto-transactions.sh [--since YYYY-MM-DD] [--until YYYY-MM-DD] +# [--account ] # default: first active account +# [--month YYYY-MM] +# [--side credit|debit] # filter +# [--json] # raw JSON, no table +# +# Output: a compact table — date | side | amount | currency | op type | label. +# Pagination is followed automatically. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BANK_CURL="${SCRIPT_DIR}/bank-curl.sh" + +SINCE=""; UNTIL=""; MONTH=""; ACCOUNT=""; SIDE=""; FMT="table" +while [[ $# -gt 0 ]]; do + case "$1" in + --since) SINCE="$2"; shift 2 ;; + --until) UNTIL="$2"; shift 2 ;; + --month) MONTH="$2"; shift 2 ;; + --account) ACCOUNT="$2"; shift 2 ;; + --side) SIDE="$2"; shift 2 ;; + --json) FMT="json"; shift ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "qonto-transactions.sh: unknown arg: $1" >&2; exit 2 ;; + esac +done + +# Default window: --month overrides --since/--until; otherwise last 90 days. +if [[ -n "${MONTH}" ]]; then + SINCE="${MONTH}-01" + # Last day of month — works for any month using python + UNTIL="$(python3 -c "import datetime,calendar; y,m=map(int,'${MONTH}'.split('-')); print(f'{y:04d}-{m:02d}-{calendar.monthrange(y,m)[1]:02d}')")" +fi +[[ -z "${SINCE}" ]] && SINCE="$(python3 -c "import datetime; print((datetime.date.today()-datetime.timedelta(days=90)).strftime('%Y-%m-%d'))")" +[[ -z "${UNTIL}" ]] && UNTIL="$(python3 -c "import datetime; print(datetime.date.today().strftime('%Y-%m-%d'))")" + +# Discover default account if not specified +if [[ -z "${ACCOUNT}" ]]; then + TMP_ORG=$(mktemp -t qontoorg.XXXXXX.json) + trap 'rm -f "${TMP_ORG}"' EXIT + "${BANK_CURL}" qonto /v2/organization > "${TMP_ORG}" + ACCOUNT=$(python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +accs = (d.get('organization') or {}).get('bank_accounts') or [] +active = [a for a in accs if a.get('status') == 'active'] +if not active: sys.exit('no active account') +print(active[0]['id']) +" "${TMP_ORG}") +fi + +# Paginate +TMP_ALL=$(mktemp -t qontoall.XXXXXX.json) +trap 'rm -f "${TMP_ORG:-/dev/null}" "${TMP_ALL}"' EXIT +echo '[]' > "${TMP_ALL}" + +page=1 +while :; do + TMP_PAGE=$(mktemp -t qontop.XXXXXX.json) + Q="bank_account_id=${ACCOUNT}&settled_at_from=${SINCE}T00:00:00Z&settled_at_to=${UNTIL}T23:59:59Z&per_page=100¤t_page=${page}" + "${BANK_CURL}" qonto "/v2/transactions?${Q}" > "${TMP_PAGE}" + # Merge transactions into the all-array + python3 - "${TMP_ALL}" "${TMP_PAGE}" <<'PY' +import json, sys +all_arr = json.load(open(sys.argv[1])) +page = json.load(open(sys.argv[2])) +all_arr.extend(page.get("transactions") or []) +json.dump(all_arr, open(sys.argv[1], "w")) +PY + NEXT=$(python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +m = d.get('meta') or {} +print(m.get('next_page') or '') +" "${TMP_PAGE}") + rm -f "${TMP_PAGE}" + [[ -z "${NEXT}" || "${NEXT}" == "None" ]] && break + page=$((page+1)) +done + +if [[ "${FMT}" == "json" ]]; then + cat "${TMP_ALL}" + exit 0 +fi + +python3 - "${TMP_ALL}" "${SIDE}" "${SINCE}" "${UNTIL}" "${ACCOUNT}" <<'PY' +import json, sys +txs = json.load(open(sys.argv[1])) +side_filter, since, until, account = sys.argv[2:6] +if side_filter: + txs = [t for t in txs if t.get("side") == side_filter] +txs.sort(key=lambda t: t.get("settled_at") or "") + +print(f"# Qonto transactions on account {account[:8]}... ({since} → {until})") +print() +print(f"{'date':<10} {'side':<6} {'amount':>10} {'cur':<3} {'op':<14} {'label':<40}") +print("-" * 100) +total_credit = total_debit = 0.0 +for t in txs: + dt = (t.get("settled_at") or "")[:10] + amt = float(t.get("amount") or 0) + side = t.get("side") or "-" + op = (t.get("operation_type") or "-")[:14] + label = (t.get("label") or "-")[:40] + cur = t.get("currency") or "-" + print(f"{dt:<10} {side:<6} {amt:>10.2f} {cur:<3} {op:<14} {label:<40}") + if side == "credit": total_credit += amt + if side == "debit": total_debit += amt +print("-" * 100) +print(f"# {len(txs)} txn(s) — credit total: {total_credit:.2f}, debit total: {total_debit:.2f}, net: {total_credit - total_debit:+.2f}") +PY diff --git a/.claude/skills/arcodange-bank-reco/scripts/wise-transactions.sh b/.claude/skills/arcodange-bank-reco/scripts/wise-transactions.sh new file mode 100755 index 0000000..09b0ce0 --- /dev/null +++ b/.claude/skills/arcodange-bank-reco/scripts/wise-transactions.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# List Wise activities (incoming + outgoing) for a period. +# +# Usage: +# wise-transactions.sh [--since YYYY-MM-DD] [--until YYYY-MM-DD] +# [--month YYYY-MM] +# [--type TRANSFER|CARD_ORDER|BALANCE_CASHBACK|FEATURE_CHARGE|BALANCE_DEPOSIT|...] +# [--status COMPLETED|CANCELLED|IN_PROGRESS|UPCOMING|REQUIRES_ATTENTION] +# [--enrich] # also fetch /v1/transfers/{id} for the wire reference +# [--json] # raw list, no table +# +# Backed by GET /v1/profiles/{WISE_PROFILE_ID}/activities — does NOT require +# SCA and DOES expose incoming transfers (unlike /balance-statements which is +# region-restricted for EU personal tokens, see SKILL.md). +# +# Pagination is followed automatically via the cursor. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BANK_CURL="${SCRIPT_DIR}/bank-curl.sh" + +SINCE=""; UNTIL=""; MONTH=""; TYPE=""; STATUS=""; ENRICH=0; FMT="table" +while [[ $# -gt 0 ]]; do + case "$1" in + --since) SINCE="$2"; shift 2 ;; + --until) UNTIL="$2"; shift 2 ;; + --month) MONTH="$2"; shift 2 ;; + --type) TYPE="$2"; shift 2 ;; + --status) STATUS="$2"; shift 2 ;; + --enrich) ENRICH=1; shift ;; + --json) FMT="json"; shift ;; + -h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "wise-transactions.sh: unknown arg: $1" >&2; exit 2 ;; + esac +done + +set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a +: "${WISE_PROFILE_ID:?wise-transactions.sh: WISE_PROFILE_ID not set in .env}" + +# Date handling — Wise wants full ISO 8601 with millis + Z +if [[ -n "${MONTH}" ]]; then + SINCE="${MONTH}-01" + UNTIL="$(python3 -c "import calendar; y,m=map(int,'${MONTH}'.split('-')); print(f'{y:04d}-{m:02d}-{calendar.monthrange(y,m)[1]:02d}')")" +fi +[[ -z "${SINCE}" ]] && SINCE="$(python3 -c "import datetime; print((datetime.date.today()-datetime.timedelta(days=365)).strftime('%Y-%m-%d'))")" +[[ -z "${UNTIL}" ]] && UNTIL="$(python3 -c "import datetime; print(datetime.date.today().strftime('%Y-%m-%d'))")" + +SINCE_ISO="${SINCE}T00:00:00.000Z" +UNTIL_ISO="${UNTIL}T23:59:59.999Z" + +WORK="$(mktemp -d -t wiseact.XXXXXX)" +trap 'rm -rf "${WORK}"' EXIT + +# Paginate +echo '[]' > "${WORK}/all.json" +CURSOR="" +PAGE=1 +while :; do + Q="size=100&since=${SINCE_ISO}&until=${UNTIL_ISO}" + [[ -n "${TYPE}" ]] && Q="${Q}&monetaryResourceType=${TYPE}" + [[ -n "${STATUS}" ]] && Q="${Q}&status=${STATUS}" + [[ -n "${CURSOR}" ]] && Q="${Q}&nextCursor=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "${CURSOR}")" + TMP_PAGE=$(mktemp -t wisepg.XXXXXX.json) + "${BANK_CURL}" wise "/v1/profiles/${WISE_PROFILE_ID}/activities?${Q}" > "${TMP_PAGE}" + python3 - "${WORK}/all.json" "${TMP_PAGE}" <<'PY' +import json, sys +all_arr = json.load(open(sys.argv[1])) +page = json.load(open(sys.argv[2])) +all_arr.extend(page.get("activities") or []) +json.dump(all_arr, open(sys.argv[1], "w")) +PY + CURSOR=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('cursor') or '')" "${TMP_PAGE}") + rm -f "${TMP_PAGE}" + if [[ -z "${CURSOR}" || "${CURSOR}" == "None" ]]; then + break + fi + PAGE=$((PAGE+1)) +done + +# Optional enrichment: for each TRANSFER, fetch /v1/transfers/{id} and merge `reference` +if [[ "${ENRICH}" == "1" ]]; then + python3 - "${WORK}/all.json" <<'PY' > "${WORK}/ids.txt" +import json, sys +acts = json.load(open(sys.argv[1])) +for a in acts: + r = a.get("resource") or {} + if r.get("type") == "TRANSFER" and r.get("id"): + print(r["id"]) +PY + > "${WORK}/refs.json" + echo '{}' > "${WORK}/refs.json" + while read -r tid; do + [[ -z "${tid}" ]] && continue + TMP_T=$(mktemp -t wiset.XXXXXX.json) + if "${BANK_CURL}" wise "/v1/transfers/${tid}" > "${TMP_T}" 2>/dev/null; then + python3 - "${WORK}/refs.json" "${TMP_T}" "${tid}" <<'PY' +import json, sys +refs = json.load(open(sys.argv[1])) +t = json.load(open(sys.argv[2])) +refs[sys.argv[3]] = t.get("reference") or "" +json.dump(refs, open(sys.argv[1], "w")) +PY + fi + rm -f "${TMP_T}" + done < "${WORK}/ids.txt" +fi + +if [[ "${FMT}" == "json" ]]; then + cat "${WORK}/all.json" + exit 0 +fi + +python3 - "${WORK}/all.json" "${WORK}/refs.json" "${ENRICH}" "${SINCE}" "${UNTIL}" <<'PY' +import json, sys, re, os +acts_path, refs_path, enrich, since, until = sys.argv[1:6] +acts = json.load(open(acts_path)) +refs = json.load(open(refs_path)) if os.path.exists(refs_path) else {} +def strip(s): return re.sub(r'<[^>]+>', '', s or '').strip() +def parse_amount(s): + # "+ 5,100.00 EUR" -> (+, 5100.00, EUR) + s = strip(s) + sign = "+" if s.startswith("+") else "-" + m = re.search(r'([\d,.]+)\s*([A-Z]{3})', s) + if not m: return sign, 0.0, "?" + return sign, float(m.group(1).replace(",", "")), m.group(2) + +# Sort by date (ascending) +acts.sort(key=lambda a: a.get("createdOn") or "") + +print(f"# Wise activities for profile (window {since} → {until})") +print() +header = f"{'date':<10} {'type':<18} {'status':<10} {'sign':<4} {'amount':>10} {'cur':<3} {'title':<28}" +if int(enrich): header += " reference" +print(header) +print("-" * (len(header) + 30 if int(enrich) else len(header))) +total_credit = total_debit = 0.0 +for a in acts: + dt = (a.get("createdOn") or "")[:10] + sign, amt, cur = parse_amount(a.get("primaryAmount") or "") + typ = a.get("type", "-") + st = a.get("status", "-") + title = strip(a.get("title") or "")[:28] + line = f"{dt:<10} {typ:<18} {st:<10} {sign:<4} {amt:>10.2f} {cur:<3} {title:<28}" + if int(enrich): + r = a.get("resource") or {} + ref = refs.get(str(r.get("id", "")), "") if r.get("type") == "TRANSFER" else "" + line += f" {ref}" + print(line) + if sign == "+": total_credit += amt + if sign == "-": total_debit += amt +print("-" * (len(header) + 30 if int(enrich) else len(header))) +print(f"# {len(acts)} activity(ies) — credit: +{total_credit:.2f}, debit: -{total_debit:.2f}, net: {total_credit - total_debit:+.2f}") +PY diff --git a/.claude/skills/dolibarr/README.md b/.claude/skills/dolibarr/README.md index d75fced..3c22404 100644 --- a/.claude/skills/dolibarr/README.md +++ b/.claude/skills/dolibarr/README.md @@ -10,6 +10,15 @@ DOLIBARR_URL=https://erp.arcodange.lab DOLIBARR_API_KEY= DOLIBARR_USER=ai_agent DOLIBARR_PASSWORD= + +# Required by arcodange-bank-reco only (omit if you only use dolibarr-* skills) +QONTO_LOGIN=arcodange-XXXXX +QONTO_SECRET_KEY= +QONTO_ORG_SLUG=arcodange-XXXXX # same as login in most cases +WISE_API_TOKEN= +WISE_PROFILE_ID= +# Optional: only needed if Wise ever opens the EU statement endpoint +WISE_SCA_KEY_PATH=~/.config/arcodange-erp/wise-sca-private.pem EOF chmod 600 .claude/skills/dolibarr/.env ``` diff --git a/.claude/skills/dolibarr/SKILL.md b/.claude/skills/dolibarr/SKILL.md index 08bf152..12af146 100644 --- a/.claude/skills/dolibarr/SKILL.md +++ b/.claude/skills/dolibarr/SKILL.md @@ -150,7 +150,8 @@ Not available on this account (intentionally): `/setup/modules` (admin-only), `/ - Workflow skill for thirdparty completeness audit (any client / supplier): [dolibarr-thirdparty-completeness](../dolibarr-thirdparty-completeness/SKILL.md). - Workflow skill for supplier-side TVA déductible (CA3 lignes 19 / 20 / 17+24): [dolibarr-tva-deductible](../dolibarr-tva-deductible/SKILL.md). - Workflow skill for composite CA3-ready TVA summary (collectée + déductible + net): [dolibarr-tva-summary](../dolibarr-tva-summary/SKILL.md). -- Future workflow skills follow the `dolibarr-` convention. Each one depends on this skill for connection + permissions + endpoint reference; each one keeps its triggers focused on its specific business workflow. +- **Bank-side reconciliation** (Qonto + Wise ↔ Dolibarr matching): [arcodange-bank-reco](../arcodange-bank-reco/SKILL.md). +- Future workflow skills follow the `dolibarr-` convention (ERP-internal) or `arcodange-` (cross-system, like bank reconciliation). Each one depends on this skill for connection + permissions + endpoint reference; each one keeps its triggers focused on its specific business workflow. ## Out of scope diff --git a/bin/arcodange b/bin/arcodange index 910fff4..5e6cffd 100755 --- a/bin/arcodange +++ b/bin/arcodange @@ -68,6 +68,14 @@ COMMANDS snapshot [--out FILE|--print-only] Bundle full read-only state into one JSON + bank Bank-side data (Qonto + Wise) + Dolibarr reconciliation + probe Auth + discovery (org slug, profile id, balance ids) + qonto-transactions [--month|--since|--until] Qonto transactions table (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) + balance Live balances + Dolibarr cross-check per fk_account + curl Raw read-only curl through bank-curl.sh + whoami GET /users/info — confirm auth ping GET /status — liveness + Dolibarr version curl Raw read-only curl through dol-curl.sh @@ -195,6 +203,39 @@ EOF exec "${SKILLS}/dolibarr-data-snapshot/scripts/snapshot.sh" "$@" ;; + bank) + sub="${1:-help}"; shift || true + case "${sub}" in + probe) exec "${SKILLS}/arcodange-bank-reco/scripts/bank-probe.sh" "$@" ;; + qonto-transactions) exec "${SKILLS}/arcodange-bank-reco/scripts/qonto-transactions.sh" "$@" ;; + wise-transactions) exec "${SKILLS}/arcodange-bank-reco/scripts/wise-transactions.sh" "$@" ;; + match) exec "${SKILLS}/arcodange-bank-reco/scripts/bank-match.sh" "$@" ;; + balance) exec "${SKILLS}/arcodange-bank-reco/scripts/bank-balance.sh" "$@" ;; + curl) + if [[ $# -lt 2 ]]; then + echo "arcodange bank curl: usage: bank curl " >&2; exit 2 + fi + exec "${SKILLS}/arcodange-bank-reco/scripts/bank-curl.sh" "$@" + ;; + help|-h|--help) + cat <<'EOF' +arcodange bank — bank-side data (Qonto + Wise) and Dolibarr reconciliation. + + probe Auth + discovery (org slug, profile id, balance ids) + qonto-transactions [--month|--since|--until] Qonto transactions table + wise-transactions [--month|--since|--until|--type|--enrich] Wise activities (incoming + outgoing) + match [--month|--since|--until|--window-days N] Match bank ↔ Dolibarr (3 buckets) + balance Live balances + Dolibarr cross-check per fk_account + curl Raw read-only curl through bank-curl.sh + +Requires QONTO_LOGIN, QONTO_SECRET_KEY, QONTO_ORG_SLUG, WISE_API_TOKEN, +WISE_PROFILE_ID in .env. See arcodange-bank-reco/SKILL.md for setup. +EOF + ;; + *) echo "arcodange bank: unknown subcommand '${sub}' (try 'arcodange bank help')" >&2; exit 2 ;; + esac + ;; + whoami) exec "${DOLC}" /users/info ;;