Sync Indy integre dans l'interface web + anti-doublon
This commit is contained in:
@@ -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=
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
fastmcp>=2.0
|
||||
httpx>=0.27
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user