Sync Indy integre dans l'interface web + anti-doublon

This commit is contained in:
2026-06-30 14:08:35 +02:00
parent 458f80f201
commit 75af558b49
13 changed files with 665 additions and 48 deletions
+16
View File
@@ -0,0 +1,16 @@
# Copie en .env et renseigne tes valeurs (voir README pour la capture du token).
INDY_BASE_URL=https://app.indy.fr
# Token JWT capté dans l'onglet Réseau (en-tête Authorization). NE PAS COMMITTER.
INDY_TOKEN=
# Si Indy utilise un en-tête / préfixe différent de "Authorization: Bearer ..."
# INDY_AUTH_HEADER=Authorization
# INDY_AUTH_PREFIX=Bearer
# Si l'auth passe par un cookie plutôt qu'un token (copie l'en-tête Cookie complet) :
# INDY_COOKIE=
# Endpoint des transactions, repéré dans DevTools (ex. /api/v1/transactions) :
INDY_TRANSACTIONS_PATH=
+75
View File
@@ -0,0 +1,75 @@
# MCP Indy custom
Petit serveur MCP qui appelle l'**API privée d'Indy** avec ton token, pour
récupérer tes transactions (et plus) sans OCR. Tu le branches sur Claude Desktop,
ou tu t'en sers comme base pour le sync de JB Compta.
> ⚠️ API non officielle, pour **tes propres données**. Zone grise vis-à-vis des CGU
> d'Indy, peut casser si Indy change son interface, et le token JWT expire.
## Étape 1 — Capturer l'API d'Indy (le cœur du sujet)
On a besoin de voir la vraie requête que fait le site quand il charge tes transactions.
1. Ouvre **https://app.indy.fr** dans Chrome et connecte-toi.
2. Appuie sur **F12** → onglet **Network** (Réseau). Coche **Fetch/XHR**.
3. Va sur ta page **Transactions** (ou recharge-la). Des requêtes apparaissent.
4. Repère celle qui **renvoie tes transactions en JSON** (clique dessus, onglet
*Response/Aperçu* : tu dois voir tes opérations). Note :
- l'**URL** complète (onglet *Headers**Request URL*) ;
- la **méthode** (GET/POST) ;
- les **paramètres** éventuels (dates, page) ;
- l'en-tête **Authorization** (commence souvent par `Bearer eyJ...`) — c'est ton token ;
ou, si pas d'Authorization, l'en-tête **Cookie**.
5. **Copie-moi** : l'URL, la méthode, les paramètres, et un **extrait du JSON de réponse**
(2-3 transactions suffisent). **Masque la valeur du token** (garde juste « Bearer … »).
Avec ça, je complète `lister_transactions` (et j'ajoute clients, factures…) avec les
bons endpoints et le bon mapping.
## Étape 2 — Configurer et lancer
```bash
cd indy-mcp
python -m venv .venv && source .venv/bin/activate # ou ton uv
pip install -r requirements.txt
cp .env.example .env # puis renseigne INDY_TOKEN (et INDY_TRANSACTIONS_PATH)
python server.py # démarre le MCP (stdio)
```
## Étape 3 — Brancher sur Claude Desktop
`Settings… > Developer > Edit Config` :
```jsonc
{
"mcpServers": {
"indy-custom": {
"command": "python",
"args": ["/chemin/vers/indy-mcp/server.py"],
"env": { "INDY_TOKEN": "eyJ...ton-token...", "INDY_BASE_URL": "https://app.indy.fr" }
}
}
}
```
Redémarre Claude Desktop.
## Outils fournis
| Outil | Rôle |
|-------|------|
| `etat` | Vérifie la config (token présent, URL). |
| `appel_api` | **Explorateur** : appelle n'importe quel endpoint. Sert à découvrir/valider une URL captée avant de la figer. |
| `lister_transactions` | Liste les transactions (une fois `INDY_TRANSACTIONS_PATH` renseigné). |
**Workflow de découverte** : une fois connecté, demande à Claude
« appelle `appel_api` avec path=`/l-url-que-tu-as-captée` » → si tu vois ton JSON,
l'endpoint est bon, on le fige dans `lister_transactions`.
## Récupérer le token rapidement
Dans DevTools → onglet **Application** (ou **Storage**) → *Local Storage* /
*Cookies* de `app.indy.fr` : le token JWT (`eyJ...`) y est souvent stocké. Sinon,
copie l'en-tête `Authorization` d'une requête dans l'onglet **Network**.
Le token **expire** : s'il ne marche plus, recapture-le.
+104
View File
@@ -0,0 +1,104 @@
"""
Mapping des transactions Indy -> lignes d'import JB Compta.
Indy ne renvoie pas un nom de catégorie mais un **numéro de compte du plan
comptable (PCG)** dans `subdivisions[].accounting_account.number`. On mappe ce
numéro vers nos catégories. Bien plus fiable que du texte.
Sortie : un dict par transaction au format attendu par l'import JB Compta :
{date, libelle, categorie, activite, taux, regime, montant}
"""
from decimal import Decimal
# Préfixe de compte PCG -> nom de catégorie JB Compta (le préfixe le plus long gagne).
COMPTE_CATEGORIE = [
("701", "Vente de produits finis"),
("707", "Vente de produits finis"),
("706", "Prestation de services"),
("605", "Matériel et outillage"),
("606", "Matériel et outillage"),
("607", "Matériel et outillage"),
("6256", "Frais de repas hors domicile"),
("6253", "Frais de repas hors domicile"),
("626", "Télécom, fournitures, documentation"),
("6181", "Télécom, fournitures, documentation"),
("6183", "Télécom, fournitures, documentation"),
("6136", "Abonnement logiciel"),
("6226", "Abonnement logiciel"),
("646", "Cotisation sociale Urssaf"),
("44551", "TVA payée"),
("44558", "TVA payée"),
("44583", "Remboursement de TVA"),
("108", "Prélèvement personnel"),
# 471 (compte d'attente) et 6252 (déplacements) -> non mappés (à catégoriser)
]
# Compte de vente -> activité (pour le multi-activité)
COMPTE_ACTIVITE = [
("701", "vente"), ("707", "vente"),
("706", "service_bic"),
]
def _cat(num):
num = str(num or "")
best = None
for pref, cat in COMPTE_CATEGORIE:
if num.startswith(pref) and (best is None or len(pref) > len(best[0])):
best = (pref, cat)
return best[1] if best else None
def _act(num):
num = str(num or "")
for pref, act in COMPTE_ACTIVITE:
if num.startswith(pref):
return act
return ""
def normaliser(tx):
"""Transaction Indy -> ligne d'import (ou None si inexploitable)."""
try:
montant = (Decimal(tx["totalAmountInCents"]) / 100).quantize(Decimal("0.01"))
except Exception:
return None
subs = [s for s in tx.get("subdivisions", [])
if not str((s.get("accounting_account") or {}).get("number", "")).startswith("512")]
if not subs:
return None
# Subdivision principale = plus gros montant absolu (hors compte bancaire 512).
main = max(subs, key=lambda s: abs(s.get("amount_in_cents", 0)))
num = str((main.get("accounting_account") or {}).get("number", ""))
intra = main.get("tva_intracom")
if intra and intra.get("type") == "eu":
regime = "intracom"
taux = (Decimal(intra.get("rate", 0)) / 100)
else:
regime = "nationale"
taux = (Decimal(main.get("tva_rate", 0)) / 100)
return {
"date": tx.get("date", ""),
"libelle": tx.get("description") or tx.get("raw_description") or "(sans libellé)",
"categorie": _cat(num),
"activite": _act(num),
"taux": taux,
"regime": regime,
"montant": montant,
"compte": num,
}
def vers_csv(transactions):
"""Liste de transactions Indy -> CSV au format import JB Compta."""
lignes = ["date;libelle;categorie;activite;taux_tva;regime;montant"]
for tx in transactions:
r = normaliser(tx)
if not r:
continue
regime = "" if r["regime"] == "nationale" else r["regime"]
taux = f"{r['taux']:.2f}".rstrip("0").rstrip(".")
lignes.append(";".join([
r["date"], r["libelle"], r["categorie"] or "", r["activite"],
taux, regime, f"{r['montant']:.2f}"]))
return "\n".join(lignes)
+2
View File
@@ -0,0 +1,2 @@
fastmcp>=2.0
httpx>=0.27
+108
View File
@@ -0,0 +1,108 @@
"""
MCP Indy custom — serveur FastMCP qui appelle l'API privee d'Indy avec ton token,
recupere tes transactions et les convertit au format d'import de JB Compta.
API non officielle d'Indy : pour TES donnees uniquement. Peut casser si Indy
change son interface ; le token JWT expire et doit etre renouvele.
"""
import os
import httpx
from fastmcp import FastMCP
import indy_mapping
BASE_URL = os.getenv("INDY_BASE_URL", "https://app.indy.fr").rstrip("/")
TOKEN = os.getenv("INDY_TOKEN", "")
AUTH_HEADER = os.getenv("INDY_AUTH_HEADER", "Authorization")
AUTH_PREFIX = os.getenv("INDY_AUTH_PREFIX", "Bearer ")
COOKIE = os.getenv("INDY_COOKIE", "")
TRANSACTIONS_PATH = os.getenv("INDY_TRANSACTIONS_PATH",
"/api/transactions/transactions-list")
mcp = FastMCP("indy-compta-custom")
def _client():
headers = {
"Accept": "application/json, text/plain, */*",
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0 Safari/537.36"),
"x-client-app-type": "web",
}
if TOKEN:
headers[AUTH_HEADER] = f"{AUTH_PREFIX}{TOKEN}"
if COOKIE:
headers["Cookie"] = COOKIE
return httpx.Client(base_url=BASE_URL, headers=headers, timeout=30,
follow_redirects=True)
def _appel_api(path, method="GET", params=None, json_body=None):
if not TOKEN and not COOKIE:
return {"erreur": "INDY_TOKEN (ou INDY_COOKIE) non configure dans .env"}
try:
with _client() as c:
r = c.request(method.upper(), path, params=params, json=json_body)
ct = r.headers.get("content-type", "")
body = r.json() if "json" in ct else r.text[:3000]
return {"status": r.status_code, "url": str(r.request.url), "data": body}
except Exception as ex: # noqa: BLE001
return {"erreur": f"{type(ex).__name__}: {ex}"}
def _fetch_transactions(search="", date_debut=None, date_fin=None, page=1):
params = {"search": search or "", "page": page}
if date_debut:
params["dateFrom"] = date_debut
if date_fin:
params["dateTo"] = date_fin
res = _appel_api(TRANSACTIONS_PATH, "GET", params=params)
if "erreur" in res:
return res, []
data = res.get("data") or {}
txs = data.get("transactions", []) if isinstance(data, dict) else []
return res, txs
@mcp.tool
def etat() -> dict:
"""Verifie la configuration : URL de base, presence du token, endpoint."""
return {"base_url": BASE_URL, "token_present": bool(TOKEN),
"auth_header": AUTH_HEADER, "transactions_path": TRANSACTIONS_PATH}
@mcp.tool
def appel_api(path: str, method: str = "GET", params: dict | None = None,
json_body: dict | None = None) -> dict:
"""Explorateur generique : appelle n'importe quel endpoint de l'API Indy."""
return _appel_api(path, method, params, json_body)
@mcp.tool
def lister_transactions(search: str = "", date_debut: str | None = None,
date_fin: str | None = None, page: int = 1) -> dict:
"""Liste brute des transactions Indy (filtres : search, dates AAAA-MM-JJ, page)."""
res, _ = _fetch_transactions(search, date_debut, date_fin, page)
return res
@mcp.tool
def transactions_pour_import(date_debut: str | None = None, date_fin: str | None = None,
search: str = "", page: int = 1) -> dict:
"""Recupere les transactions Indy au format CSV de JB Compta (a coller dans /import/).
Colonnes : date;libelle;categorie;activite;taux_tva;regime;montant
"""
res, txs = _fetch_transactions(search, date_debut, date_fin, page)
if "erreur" in res:
return res
data = res.get("data") or {}
return {
"nb_transactions_page": len(txs),
"nb_total": data.get("nbTransactions") if isinstance(data, dict) else None,
"csv": indy_mapping.vers_csv(txs),
}
if __name__ == "__main__":
mcp.run()