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>
This commit is contained in:
2026-05-31 13:57:21 +02:00
parent 4252a88681
commit bd90266372
15 changed files with 1153 additions and 1 deletions

View File

@@ -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

View File

@@ -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 <path> # e.g. bank-curl.sh qonto /v2/organization
# bank-curl.sh wise <path> # e.g. bank-curl.sh wise /v2/profiles
# bank-curl.sh -i <bank> <path> # 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 <code>" 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] <qonto|wise> <api-path>" >&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: <token>
# 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

View File

@@ -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

View File

@@ -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,})', '<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>"

View File

@@ -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 <id>] # 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&current_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

View File

@@ -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):
# "<positive>+ 5,100.00 EUR</positive>" -> (+, 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