From 75af558b49edb88225d7bd9a974866b53f53784e Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 30 Jun 2026 14:08:35 +0200 Subject: [PATCH] Sync Indy integre dans l'interface web + anti-doublon --- compta/indy.py | 118 ++++++++++++++++++ ..._indy_id_entreprise_indy_token_and_more.py | 33 +++++ compta/models.py | 7 +- compta/templates/compta/import_csv.html | 49 +++++--- compta/views_import.py | 117 ++++++++++++----- docs/integration-indy-mcp.md | 82 ++++++++++++ fastmcp-creds | 1 + indy-mcp/.env.example | 16 +++ indy-mcp/README.md | 75 +++++++++++ indy-mcp/indy_mapping.py | 104 +++++++++++++++ indy-mcp/requirements.txt | 2 + indy-mcp/server.py | 108 ++++++++++++++++ requirements.txt | 1 + 13 files changed, 665 insertions(+), 48 deletions(-) create mode 100644 compta/indy.py create mode 100644 compta/migrations/0009_depense_indy_id_entreprise_indy_token_and_more.py create mode 100644 docs/integration-indy-mcp.md create mode 160000 fastmcp-creds create mode 100644 indy-mcp/.env.example create mode 100644 indy-mcp/README.md create mode 100644 indy-mcp/indy_mapping.py create mode 100644 indy-mcp/requirements.txt create mode 100644 indy-mcp/server.py diff --git a/compta/indy.py b/compta/indy.py new file mode 100644 index 0000000..8f7da5c --- /dev/null +++ b/compta/indy.py @@ -0,0 +1,118 @@ +""" +Synchronisation Indy : récupère les transactions via l'API privée d'Indy et les +normalise au format attendu par l'import (réutilisé par la page /import/). + +La « catégorie » Indy est un numéro de compte du plan comptable (PCG) ; on le +mappe vers nos catégories. Montants en centimes, signés. `tva_intracom` => UE. + +⚠️ API non officielle d'Indy : pour les données de l'utilisateur. Le token JWT +expire (~1 h) et doit être renouvelé. +""" +from decimal import Decimal + +import httpx + +BASE_URL = "https://app.indy.fr" +TRANSACTIONS_PATH = "/api/transactions/transactions-list" + +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"), +] + +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 normalisée, 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 + 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, taux = "intracom", Decimal(intra.get("rate", 0)) / 100 + else: + regime, taux = "nationale", Decimal(main.get("tva_rate", 0)) / 100 + return { + "indy_id": tx.get("_id", ""), + "date": tx.get("date", ""), + "libelle": tx.get("description") or tx.get("raw_description") or "(sans libellé)", + "categorie_nom": _cat(num), + "activite": _act(num), + "taux": taux, + "regime": regime, + "montant": montant, + "compte": num, + } + + +def fetch_transactions(token, date_debut=None, date_fin=None, search="", page=1, + base_url=BASE_URL): + """Appelle l'API Indy. Retourne (status, liste_transactions_brutes, erreur).""" + headers = { + "Accept": "application/json, text/plain, */*", + "Authorization": f"Bearer {token}", + "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", + } + params = {"search": search or "", "page": page} + if date_debut: + params["dateFrom"] = date_debut + if date_fin: + params["dateTo"] = date_fin + try: + with httpx.Client(base_url=base_url.rstrip("/"), headers=headers, + timeout=30, follow_redirects=True) as c: + r = c.get(TRANSACTIONS_PATH, params=params) + if r.status_code != 200: + return r.status_code, [], f"HTTP {r.status_code} (token expiré ?)" + data = r.json() + return 200, data.get("transactions", []), None + except Exception as ex: # noqa: BLE001 + return None, [], f"{type(ex).__name__}: {ex}" + + +def lignes_normalisees(token, date_debut=None, date_fin=None, search="", page=1, + base_url=BASE_URL): + """Retourne (lignes_normalisées, erreur).""" + status, txs, err = fetch_transactions(token, date_debut, date_fin, search, page, base_url) + if err: + return [], err + lignes = [n for n in (normaliser(t) for t in txs) if n] + return lignes, None diff --git a/compta/migrations/0009_depense_indy_id_entreprise_indy_token_and_more.py b/compta/migrations/0009_depense_indy_id_entreprise_indy_token_and_more.py new file mode 100644 index 0000000..f9be8ac --- /dev/null +++ b/compta/migrations/0009_depense_indy_id_entreprise_indy_token_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.15 on 2026-06-30 12:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('compta', '0008_alter_depense_montant_tva_alter_depense_taux_tva_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='depense', + name='indy_id', + field=models.CharField(blank=True, db_index=True, max_length=40), + ), + migrations.AddField( + model_name='entreprise', + name='indy_token', + field=models.TextField(blank=True, help_text='Token JWT capté dans le navigateur, pour la synchronisation. Expire ~1 h.', verbose_name='Token Indy (API)'), + ), + migrations.AddField( + model_name='facture', + name='indy_id', + field=models.CharField(blank=True, db_index=True, max_length=40), + ), + migrations.AddField( + model_name='paiement', + name='indy_id', + field=models.CharField(blank=True, db_index=True, max_length=40), + ), + ] diff --git a/compta/models.py b/compta/models.py index 4d3bbe6..f93bae0 100644 --- a/compta/models.py +++ b/compta/models.py @@ -88,6 +88,9 @@ class Entreprise(models.Model): num_tva_intracom = models.CharField("N° TVA intracommunautaire", max_length=20, blank=True) vue_tva = models.CharField( "Mode d'affichage de la déclaration TVA", max_length=10, choices=VUE_TVA, default="simplifie") + indy_token = models.TextField( + "Token Indy (API)", blank=True, + help_text="Token JWT capté dans le navigateur, pour la synchronisation. Expire ~1 h.") utilisateurs = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, related_name="entreprises", verbose_name="Utilisateurs autorisés", @@ -151,6 +154,7 @@ class Facture(models.Model): montant_tva = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0")) montant_ttc = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0")) notes = models.TextField(blank=True) + indy_id = models.CharField(max_length=40, blank=True, db_index=True) cree_le = models.DateTimeField(auto_now_add=True) class Meta: @@ -207,6 +211,7 @@ class Paiement(models.Model): categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True, related_name="paiements", help_text="Surtout pour les mouvements neutres.") + indy_id = models.CharField(max_length=40, blank=True, db_index=True) cree_le = models.DateTimeField(auto_now_add=True) class Meta: @@ -255,6 +260,7 @@ class Depense(models.Model): "Taux de TVA (%)", max_digits=4, decimal_places=2, default=Decimal("20"), choices=TAUX_TVA_CHOICES, help_text="Achat national : la TVA déductible est calculée. UE/import : taux auto-liquidé.") + indy_id = models.CharField(max_length=40, blank=True, db_index=True) cree_le = models.DateTimeField(auto_now_add=True) class Meta: @@ -266,7 +272,6 @@ class Depense(models.Model): return f"{self.date} — {self.libelle}" def save(self, *args, **kwargs): - # TVA déductible calculée : incluse dans le TTC (national), nulle en autoliq. t = self.taux_tva or Decimal("0") if self.autoliquidee: self.montant_tva = Decimal("0.00") diff --git a/compta/templates/compta/import_csv.html b/compta/templates/compta/import_csv.html index cc505fa..33b7ea3 100644 --- a/compta/templates/compta/import_csv.html +++ b/compta/templates/compta/import_csv.html @@ -10,16 +10,18 @@ * { box-sizing:border-box; } body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--txt); padding:24px; } - h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);} + h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);margin:0 0 8px;} a { color:var(--accent); } .sub { color:var(--muted); margin-bottom:14px; } .card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; margin-bottom:16px; } + .card.indy { border-color:#0e7490; } label { display:block; font-size:12px; color:var(--muted); margin:8px 0 4px; } input, select, textarea { background:#0f172a; color:var(--txt); border:1px solid #334155; border-radius:8px; padding:8px 10px; font-size:14px; width:100%; } - textarea { min-height:110px; font-family:ui-monospace,monospace; } + textarea { min-height:90px; font-family:ui-monospace,monospace; } .grid3 { display:grid; grid-template-columns:2fr 1fr 1fr; gap:12px; } - @media(max-width:700px){ .grid3{grid-template-columns:1fr;} } + .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; } + @media(max-width:700px){ .grid3,.grid2{grid-template-columns:1fr;} } button { cursor:pointer; border-radius:8px; border:1px solid #334155; background:var(--card); color:var(--txt); padding:9px 16px; font-size:14px; } button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; } @@ -53,8 +55,11 @@ {% endif %} {% if not lignes %} -
+ + {% csrf_token %} +

① Synchroniser depuis Indy (recommandé)

+
Récupère tes transactions directement depuis l'API Indy. Anti-doublon automatique.
@@ -62,12 +67,30 @@ {% for e in entreprises %}{% endfor %}
+
+
+
+ + +
+ +
+
+ + +
+ {% csrf_token %} +

② Ou : CSV / capture d'écran (OCR)

+
- - + +
+
- +
@@ -79,7 +102,6 @@
-
L'OCR pré-remplit les lignes ; tu corriges la catégorie et le taux dans l'aperçu avant d'enregistrer.
{% endif %} @@ -87,7 +109,7 @@
{% csrf_token %} -

Aperçu éditable — {{ lignes|length }} ligne(s) {% if source == 'ocr' %}(OCR){% endif %}

+

Aperçu éditable — {{ lignes|length }} ligne(s){% if source == 'indy' %} (Indy){% elif source == 'ocr' %} (OCR){% endif %}

@@ -109,6 +131,8 @@ + + {% endfor %} @@ -127,13 +151,10 @@ for(var i=0; i intracom. -- DÉTAIL : la fiche d'une opération (Libellé / Date / Montant / Ventilation). - Extraction au mieux (les petits libellés gris sont mal lus). -Dans tous les cas : aperçu ÉDITABLE + panneau de debug OCR. +Import d'opérations : CSV, OCR (capture), ou SYNC INDY (API). +Dans tous les cas : aperçu ÉDITABLE + anti-doublon (indy_id) + panneau debug OCR. """ import csv import io @@ -21,6 +14,7 @@ from django.shortcuts import render from .models import (Entreprise, Client, Facture, LigneFacture, Paiement, Depense, Categorie, Activite, RegimeTva) +from . import indy CENT = Decimal("0.01") MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, @@ -50,6 +44,12 @@ def _date(s): return None +def _activite(s): + s = (s or "").strip().lower() + return {"vente": "vente", "service_bic": "service_bic", "service_bnc": "service_bnc", + "bic": "service_bic", "bnc": "service_bnc"}.get(s, "") + + def _regime(s): s = (s or "").strip().lower() if s in ("intracom", "intracommunautaire", "ue"): @@ -78,7 +78,7 @@ def _match_cat(text, cats): def _row(dt, libelle, montant, taux=Decimal("0"), regime=RegimeTva.NATIONALE, - categorie=None, n=0): + categorie=None, activite="", indy_id="", n=0): neutre = bool(categorie and categorie.exclure_calculs) if montant > 0: typ = "Mouvement neutre" if neutre else "Vente" @@ -86,7 +86,8 @@ def _row(dt, libelle, montant, taux=Decimal("0"), regime=RegimeTva.NATIONALE, typ = "Dépense neutre" if neutre else "Dépense" return {"n": n, "date": dt.isoformat() if dt else "", "libelle": libelle, "categorie_id": categorie.pk if categorie else "", - "taux": taux, "regime": regime, "montant": montant, "type": typ} + "taux": taux, "regime": regime, "montant": montant, "type": typ, + "activite": activite, "indy_id": indy_id} # ---- CSV ---------------------------------------------------------------- @@ -109,7 +110,7 @@ def _parse_csv(text, cats): erreurs.append(f"Ligne {n} : montant nul."); continue cat = cats.get((c[2] or "").strip().lower()) lignes.append(_row(dt, (c[1] or "").strip() or "(sans libellé)", montant, - _dec(c[4]), _regime(c[5]), cat, n)) + _dec(c[4]), _regime(c[5]), cat, _activite(c[3]), "", n)) return lignes, erreurs, [] @@ -198,7 +199,7 @@ def _parse_liste(words, annee, cats): if cat: libelle = re.sub(re.escape(cat.nom), "", texte, flags=re.I).strip(" -·,") lignes.append(_row(dt, libelle or texte or "(sans libellé)", _dec(a["t"]), - taux, regime, cat, n)) + taux, regime, cat, "", "", n)) return lignes @@ -208,18 +209,17 @@ def _parse_detail(words, cats): amounts = [w for w in words if "%" not in w["t"] and AMOUNT_RE.match(w["t"])] montant = _dec(amounts[0]["t"]) if amounts else Decimal("0") y_ref = dtok["yc"] if dtok else 99999 - haut_gauche = [w for w in words if w["x"] < 450 and w["yc"] < y_ref - and w["t"].lower() not in ("informations", "date", "libellé", "libelle")] - libelle = " ".join(w["t"] for w in sorted(haut_gauche, key=lambda w: (w["yc"], w["x"]))) + haut = [w for w in words if w["x"] < 450 and w["yc"] < y_ref + and w["t"].lower() not in ("informations", "date", "libellé", "libelle")] + libelle = " ".join(w["t"] for w in sorted(haut, key=lambda w: (w["yc"], w["x"]))) libelle = libelle.strip(" ‘'\"") or "(à compléter)" droite = [w for w in words if w["x"] > 480 and not AMOUNT_RE.match(w["t"]) and "%" not in w["t"] and w["t"].lower() not in ("ventilation(s)", "ventilation", "catégorie", "categorie", "montant", "ttc", "tva")] - texte_cat = " ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"]))) - cat = _match_cat(texte_cat, cats) + cat = _match_cat(" ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"]))), cats) if montant == 0: return [] - return [_row(dt, libelle, montant, Decimal("0"), RegimeTva.NATIONALE, cat, 1)] + return [_row(dt, libelle, montant, Decimal("0"), RegimeTva.NATIONALE, cat, "", "", 1)] def _parse_ocr(image_file, annee, cats): @@ -229,25 +229,49 @@ def _parse_ocr(image_file, annee, cats): debug = _debug_lines(words) allt = " ".join(w["t"] for w in words).lower() is_detail = ("ventilation" in allt) or ("informations" in allt and "montant" in allt) - if is_detail: - lignes = _parse_detail(words, cats); fmt = "détail" - else: - lignes = _parse_liste(words, annee, cats); fmt = "liste" + lignes = _parse_detail(words, cats) if is_detail else _parse_liste(words, annee, cats) if not lignes: - erreurs.append(f"Format « {fmt} » détecté mais aucune ligne exploitable. " - f"Regarde le texte OCR ci-dessous et complète à la main, " - f"ou utilise le CSV.") + erreurs.append("Aucune ligne exploitable détectée. Regarde le texte OCR ci-dessous " + "et complète à la main, ou utilise le CSV.") return lignes, erreurs, debug +# ---- INDY --------------------------------------------------------------- + +def _parse_indy(entreprise, token, date_debut, date_fin, cats_par_nom): + lignes_norm, err = indy.lignes_normalisees(token, date_debut or None, date_fin or None) + if err: + return [], [f"Indy : {err}"] + lignes = [] + for n, ln in enumerate(lignes_norm, start=1): + dt = _date(ln["date"]) + cat = cats_par_nom.get((ln["categorie_nom"] or "").strip().lower()) + lignes.append(_row(dt, ln["libelle"], ln["montant"], ln["taux"], + _regime(ln["regime"]), cat, ln["activite"] or "", + ln["indy_id"], n)) + return lignes, [] + + +# ---- Anti-doublon ------------------------------------------------------- + +def _deja_importe(entreprise, indy_id): + if not indy_id: + return False + return (Facture.objects.filter(entreprise=entreprise, indy_id=indy_id).exists() + or Depense.objects.filter(entreprise=entreprise, indy_id=indy_id).exists() + or Paiement.objects.filter(entreprise=entreprise, indy_id=indy_id).exists()) + + # ---- Création ----------------------------------------------------------- -def _creer(entreprise, dt, libelle, montant, taux, regime, categorie): +def _creer(entreprise, dt, libelle, montant, taux, regime, categorie, + activite="", indy_id=""): neutre = bool(categorie and categorie.exclure_calculs) if montant > 0: if neutre: Paiement.objects.create(entreprise=entreprise, date=dt, libelle=libelle, - montant_total=montant, sens="credit", categorie=categorie) + montant_total=montant, sens="credit", + categorie=categorie, indy_id=indy_id) return "neutre" client, _ = Client.objects.get_or_create(entreprise=entreprise, nom=libelle) ht = (montant / (Decimal("1") + taux / Decimal("100"))).quantize(CENT) @@ -258,15 +282,16 @@ def _creer(entreprise, dt, libelle, montant, taux, regime, categorie): while Facture.objects.filter(entreprise=entreprise, numero=numero).exists(): base += 1; numero = f"{annee}-{base:03d}" f = Facture.objects.create(entreprise=entreprise, client=client, numero=numero, - activite=entreprise.activite, date_emission=dt, - date_encaissement=dt, statut="encaissee") + activite=activite or entreprise.activite, + date_emission=dt, date_encaissement=dt, + statut="encaissee", indy_id=indy_id) LigneFacture.objects.create(facture=f, designation=libelle, quantite=Decimal("1"), prix_unitaire_ht=ht, taux_tva=taux) f.recompute_totaux() return "vente" Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle, - categorie=categorie, regime_tva=regime, - taux_tva=taux, montant_ttc=-montant) + categorie=categorie, activite=activite, regime_tva=regime, + taux_tva=taux, montant_ttc=-montant, indy_id=indy_id) return "depense" @@ -296,21 +321,47 @@ def import_csv(request): taux = request.POST.getlist("l_taux") regimes = request.POST.getlist("l_regime") montants = request.POST.getlist("l_montant") + activites = request.POST.getlist("l_activite") + indy_ids = request.POST.getlist("l_indy_id") cats_by_id = {str(c.pk): c for c in Categorie.objects.all()} compteur = {} + ignores = 0 for i in range(len(libelles)): dt = _date(dates[i]) if i < len(dates) else None montant = _dec(montants[i] if i < len(montants) else "0") + iid = indy_ids[i] if i < len(indy_ids) else "" if dt is None or montant == 0: continue + if iid and _deja_importe(entreprise, iid): + ignores += 1 + continue k = _creer(entreprise, dt, (libelles[i] or "").strip() or "(sans libellé)", montant, _dec(taux[i] if i < len(taux) else "0"), _regime(regimes[i] if i < len(regimes) else ""), - cats_by_id.get(cat_ids[i] if i < len(cat_ids) else "")) + cats_by_id.get(cat_ids[i] if i < len(cat_ids) else ""), + _activite(activites[i] if i < len(activites) else ""), iid) compteur[k] = compteur.get(k, 0) + 1 + if ignores: + compteur["déjà importées (ignorées)"] = ignores ctx["resultat"] = compteur return render(request, "compta/import_csv.html", ctx) + if action == "indy": + token = (request.POST.get("indy_token") or "").strip() + if entreprise and token: + entreprise.indy_token = token + entreprise.save(update_fields=["indy_token"]) + elif entreprise and not token: + token = entreprise.indy_token + if not entreprise or not token: + ctx["erreurs"] = ["Choisis une entreprise et renseigne le token Indy."] + return render(request, "compta/import_csv.html", ctx) + lignes, erreurs = _parse_indy(entreprise, token, request.POST.get("date_debut"), + request.POST.get("date_fin"), cats) + ctx.update({"lignes": lignes, "erreurs": erreurs, "source": "indy"}) + return render(request, "compta/import_csv.html", ctx) + + # Aperçu CSV ou OCR image = request.FILES.get("image") if image: annee = int(request.POST.get("annee") or date_cls.today().year) diff --git a/docs/integration-indy-mcp.md b/docs/integration-indy-mcp.md new file mode 100644 index 0000000..3d45ce4 --- /dev/null +++ b/docs/integration-indy-mcp.md @@ -0,0 +1,82 @@ +# Intégration Indy via MCP (Claude Desktop) + +Le serveur MCP [mcp-server-indy-compta](https://github.com/stephaneberle9/mcp-server-indy-compta) +expose tes données Indy (transactions, clients, factures) en **données structurées**. +C'est bien plus fiable que l'OCR. Deux usages : **analyse en langage naturel** et +**pont vers l'import** de JB Compta. + +> ⚠️ API non officielle d'Indy : ça peut casser si Indy change son interface, et c'est +> une zone grise vis-à-vis des CGU. L'authentification se fait par login navigateur +> (Chrome) ou un token JWT capturé qui expire et doit être renouvelé. + +## 1. Installer et brancher le MCP sur Claude Desktop + +Prérequis : Python 3.12+, [uv](https://docs.astral.sh/uv/), un compte Indy. + +```bash +git clone https://github.com/stephaneberle9/fastmcp-creds.git +git clone https://github.com/stephaneberle9/python-indy-compta.git +git clone https://github.com/stephaneberle9/mcp-server-indy-compta.git +cd mcp-server-indy-compta && uv sync +``` + +Dans Claude Desktop → `Settings… > Developer > Edit Config`, ajoute : + +```jsonc +{ + "mcpServers": { + "indy-compta": { + "command": "uv", + "args": ["run", "--with-editable", "/chemin/vers/mcp-server-indy-compta", + "mcp-server-indy-compta"] + } + } +} +``` + +Redémarre Claude Desktop. Au premier appel d'outil, un navigateur s'ouvre pour le login Indy. + +## 2. Analyse en langage naturel + +Exemples de questions à poser à Claude (avec le MCP connecté) : + +- « Montre mes dépenses de juin 2026 groupées par catégorie. » +- « Quel est mon cash-flow du 1er trimestre ? » +- « Quelles transactions de dépense n'ont pas de justificatif attaché ? » +- « Liste mes factures en retard. » + +## 3. Pont MCP → import JB Compta (le plus utile) + +Demande à Claude de **convertir tes transactions Indy au format CSV de l'app**, puis +colle le résultat dans la page **Importer** (`/import/`). + +Prompt type à utiliser dans Claude Desktop : + +> En utilisant le MCP indy-compta, récupère toutes mes transactions de **juin 2026**. +> Donne-moi le résultat en **CSV** avec exactement ces colonnes, séparées par `;` : +> `date;libelle;categorie;activite;taux_tva;regime;montant` +> Règles : +> - `date` au format `AAAA-MM-JJ` ; +> - `montant` signé (négatif = dépense, positif = encaissement) ; +> - `taux_tva` = le taux affiché (0, 5.5, 10 ou 20) ; +> - `regime` = `intracom` si la TVA est marquée « UE », sinon vide ; +> - `categorie` = le nom exact de la catégorie Indy ; +> - une seule ligne par transaction, pas de texte autour du CSV. + +Tu colles ce CSV dans `/import/` → **Analyser** → tu vérifies l'aperçu → **Importer**. + +Avantage : Claude + le MCP font le **fetch fiable et le mapping**, l'app fait la +**création** (déjà testée). Aucun token Indy à héberger sur ton serveur. + +## 4. Plus tard : sync automatique côté serveur (optionnel) + +Pour une automatisation totale (sans Claude Desktop), on pourrait intégrer la +librairie `python-indy-compta` directement dans l'app (tâche planifiée qui tire les +transactions et crée les opérations). À évaluer car cela demande : + +- un **token JWT statique** sur le serveur (le login navigateur est impossible en headless), + à renouveler à son expiration ; +- de mapper les objets de la librairie vers nos modèles (à valider avec ton compte) ; +- d'accepter la dépendance à une API non officielle. + +Le pont CSV (section 3) couvre déjà 90 % du besoin sans cette complexité. diff --git a/fastmcp-creds b/fastmcp-creds new file mode 160000 index 0000000..7701dec --- /dev/null +++ b/fastmcp-creds @@ -0,0 +1 @@ +Subproject commit 7701decfdbd2602ef1411e29c6846d50ea7f37af diff --git a/indy-mcp/.env.example b/indy-mcp/.env.example new file mode 100644 index 0000000..16df7b2 --- /dev/null +++ b/indy-mcp/.env.example @@ -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= diff --git a/indy-mcp/README.md b/indy-mcp/README.md new file mode 100644 index 0000000..fa3d981 --- /dev/null +++ b/indy-mcp/README.md @@ -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. diff --git a/indy-mcp/indy_mapping.py b/indy-mcp/indy_mapping.py new file mode 100644 index 0000000..f841d3f --- /dev/null +++ b/indy-mcp/indy_mapping.py @@ -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) diff --git a/indy-mcp/requirements.txt b/indy-mcp/requirements.txt new file mode 100644 index 0000000..f910407 --- /dev/null +++ b/indy-mcp/requirements.txt @@ -0,0 +1,2 @@ +fastmcp>=2.0 +httpx>=0.27 diff --git a/indy-mcp/server.py b/indy-mcp/server.py new file mode 100644 index 0000000..0842dd0 --- /dev/null +++ b/indy-mcp/server.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 006dbc5..d00be0f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ python-dotenv>=1.0 whitenoise>=6.6 Pillow>=10.0 pytesseract>=0.3.10 +httpx>=0.27
DateLibelléCatégorieRégime TVATaux %Montant €Type
{{ l.type }}