Files
Gabriel Radureau bd90266372 add arcodange-bank-reco — Qonto + Wise reconciliation against Dolibarr
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) <noreply@anthropic.com>
2026-05-31 13:57:21 +02:00

141 lines
5.5 KiB
Bash
Executable File

#!/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,})', '<REDACTED-HEX>', s)
s = re.sub(r'([A-Za-z0-9_-]{40,})', '<REDACTED-TOKEN>', s)
s = re.sub(r'("(?:token|secret|key|password)"\s*:\s*")[^"]*(")',
r'\1<REDACTED>\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: <login>:<secret>"
echo " env vars : QONTO_LOGIN=${QONTO_LOGIN:+<set>} QONTO_SECRET_KEY=${QONTO_SECRET_KEY:+<set>} QONTO_ORG_SLUG=${QONTO_ORG_SLUG:-<unset>}"
echo
if QONTO_ORG_RAW="$("${BANK_CURL}" qonto /v2/organization 2>&1)"; then
python3 - <<PY
import json
d = json.loads(${QONTO_ORG_RAW@Q}) if False else json.loads("""${QONTO_ORG_RAW}""")
PY
fi 2>/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 <token>"
echo " env vars : WISE_API_TOKEN=${WISE_API_TOKEN:+<set>} WISE_PROFILE_ID=${WISE_PROFILE_ID:-<unset>}"
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=<set>"
echo " QONTO_SECRET_KEY=<set>"
echo " QONTO_ORG_SLUG=$(grep -E '^QONTO_ORG_SLUG' "${SCRIPT_DIR}/../../dolibarr/.env" | cut -d= -f2 || echo '<from probe>')"
echo " WISE_API_TOKEN=<set>"
echo " WISE_PROFILE_ID=<the BUSINESS id from above>"