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
+118
View File
@@ -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
@@ -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),
),
]
+6 -1
View File
@@ -88,6 +88,9 @@ class Entreprise(models.Model):
num_tva_intracom = models.CharField("N° TVA intracommunautaire", max_length=20, blank=True) num_tva_intracom = models.CharField("N° TVA intracommunautaire", max_length=20, blank=True)
vue_tva = models.CharField( vue_tva = models.CharField(
"Mode d'affichage de la déclaration TVA", max_length=10, choices=VUE_TVA, default="simplifie") "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( utilisateurs = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="entreprises", settings.AUTH_USER_MODEL, blank=True, related_name="entreprises",
verbose_name="Utilisateurs autorisés", 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_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")) montant_ttc = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
notes = models.TextField(blank=True) 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) cree_le = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@@ -207,6 +211,7 @@ class Paiement(models.Model):
categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True, categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True,
related_name="paiements", related_name="paiements",
help_text="Surtout pour les mouvements neutres.") 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) cree_le = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@@ -255,6 +260,7 @@ class Depense(models.Model):
"Taux de TVA (%)", max_digits=4, decimal_places=2, default=Decimal("20"), "Taux de TVA (%)", max_digits=4, decimal_places=2, default=Decimal("20"),
choices=TAUX_TVA_CHOICES, choices=TAUX_TVA_CHOICES,
help_text="Achat national : la TVA déductible est calculée. UE/import : taux auto-liquidé.") 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) cree_le = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@@ -266,7 +272,6 @@ class Depense(models.Model):
return f"{self.date}{self.libelle}" return f"{self.date}{self.libelle}"
def save(self, *args, **kwargs): 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") t = self.taux_tva or Decimal("0")
if self.autoliquidee: if self.autoliquidee:
self.montant_tva = Decimal("0.00") self.montant_tva = Decimal("0.00")
+35 -14
View File
@@ -10,16 +10,18 @@
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif; body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif;
background:var(--bg); color:var(--txt); padding:24px; } 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); } a { color:var(--accent); }
.sub { color:var(--muted); margin-bottom:14px; } .sub { color:var(--muted); margin-bottom:14px; }
.card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; margin-bottom:16px; } .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; } 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; input, select, textarea { background:#0f172a; color:var(--txt); border:1px solid #334155;
border-radius:8px; padding:8px 10px; font-size:14px; width:100%; } 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; } .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); button { cursor:pointer; border-radius:8px; border:1px solid #334155; background:var(--card);
color:var(--txt); padding:9px 16px; font-size:14px; } color:var(--txt); padding:9px 16px; font-size:14px; }
button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; } button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; }
@@ -53,8 +55,11 @@
{% endif %} {% endif %}
{% if not lignes %} {% if not lignes %}
<form method="post" enctype="multipart/form-data" class="card" id="srcForm"> <!-- ===== Synchroniser depuis Indy ===== -->
<form method="post" class="card indy">
{% csrf_token %} {% csrf_token %}
<h2>① Synchroniser depuis Indy (recommandé)</h2>
<div class="sub">Récupère tes transactions directement depuis l'API Indy. Anti-doublon automatique.</div>
<div class="grid3"> <div class="grid3">
<div> <div>
<label>Entreprise</label> <label>Entreprise</label>
@@ -62,12 +67,30 @@
{% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise_id %}selected{% endif %}>{{ e.nom }}</option>{% endfor %} {% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise_id %}selected{% endif %}>{{ e.nom }}</option>{% endfor %}
</select> </select>
</div> </div>
<div><label>Du</label><input type="date" name="date_debut"></div>
<div><label>Au</label><input type="date" name="date_fin"></div>
</div>
<label>Token Indy (Bearer, sans « Bearer ») — mémorisé sur l'entreprise, expire ~1 h</label>
<textarea name="indy_token" placeholder="eyJ..."></textarea>
<div class="actions">
<button type="submit" name="action" value="indy" class="primary">Récupérer depuis Indy</button>
</div>
</form>
<!-- ===== CSV / capture ===== -->
<form method="post" enctype="multipart/form-data" class="card" id="srcForm">
{% csrf_token %}
<h2>② Ou : CSV / capture d'écran (OCR)</h2>
<div class="grid3">
<div> <div>
<label>Année (pour l'OCR)</label> <label>Entreprise</label>
<input type="number" name="annee" value="{{ annee_defaut }}"> <select name="entreprise" required>
{% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise_id %}selected{% endif %}>{{ e.nom }}</option>{% endfor %}
</select>
</div> </div>
<div><label>Année (OCR)</label><input type="number" name="annee" value="{{ annee_defaut }}"></div>
<div> <div>
<label>Photo / capture (OCR) — fichier, appareil photo, ou <b>coller</b> (Ctrl/Cmd+V)</label> <label>Photo / capture — fichier, photo, ou <b>coller</b> (Ctrl/Cmd+V)</label>
<input type="file" name="image" id="imgInput" accept="image/*" capture="environment"> <input type="file" name="image" id="imgInput" accept="image/*" capture="environment">
<div id="pasteInfo" class="sub" style="margin-top:4px"></div> <div id="pasteInfo" class="sub" style="margin-top:4px"></div>
</div> </div>
@@ -79,7 +102,6 @@
<div class="actions"> <div class="actions">
<button type="submit" name="action" value="preview" class="primary">Analyser / Aperçu</button> <button type="submit" name="action" value="preview" class="primary">Analyser / Aperçu</button>
</div> </div>
<div class="sub" style="margin-top:8px">L'OCR pré-remplit les lignes ; tu corriges la catégorie et le taux dans l'aperçu avant d'enregistrer.</div>
</form> </form>
{% endif %} {% endif %}
@@ -87,7 +109,7 @@
<form method="post" class="card"> <form method="post" class="card">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="entreprise" value="{{ entreprise_id }}"> <input type="hidden" name="entreprise" value="{{ entreprise_id }}">
<h2>Aperçu éditable — {{ lignes|length }} ligne(s) {% if source == 'ocr' %}(OCR){% endif %}</h2> <h2>Aperçu éditable — {{ lignes|length }} ligne(s){% if source == 'indy' %} (Indy){% elif source == 'ocr' %} (OCR){% endif %}</h2>
<table> <table>
<thead><tr><th>Date</th><th>Libellé</th><th>Catégorie</th><th>Régime TVA</th><th>Taux %</th><th>Montant €</th><th>Type</th></tr></thead> <thead><tr><th>Date</th><th>Libellé</th><th>Catégorie</th><th>Régime TVA</th><th>Taux %</th><th>Montant €</th><th>Type</th></tr></thead>
<tbody> <tbody>
@@ -109,6 +131,8 @@
<td><input type="number" step="0.01" name="l_taux" value="{{ l.taux }}" class="num" style="width:70px"></td> <td><input type="number" step="0.01" name="l_taux" value="{{ l.taux }}" class="num" style="width:70px"></td>
<td><input type="number" step="0.01" name="l_montant" value="{{ l.montant }}" class="num" style="width:100px"></td> <td><input type="number" step="0.01" name="l_montant" value="{{ l.montant }}" class="num" style="width:100px"></td>
<td>{{ l.type }}</td> <td>{{ l.type }}</td>
<input type="hidden" name="l_activite" value="{{ l.activite }}">
<input type="hidden" name="l_indy_id" value="{{ l.indy_id }}">
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -127,13 +151,10 @@
for(var i=0; i<e.clipboardData.items.length; i++){ for(var i=0; i<e.clipboardData.items.length; i++){
var it = e.clipboardData.items[i]; var it = e.clipboardData.items[i];
if(it.type && it.type.indexOf('image') === 0){ if(it.type && it.type.indexOf('image') === 0){
var dt = new DataTransfer(); var dt = new DataTransfer(); dt.items.add(it.getAsFile()); input.files = dt.files;
dt.items.add(it.getAsFile());
input.files = dt.files;
var info = document.getElementById('pasteInfo'); var info = document.getElementById('pasteInfo');
if(info){ info.textContent = 'Image collée — clique « Analyser ».'; info.style.color = '#34d399'; } if(info){ info.textContent = 'Image collée — clique « Analyser ».'; info.style.color = '#34d399'; }
e.preventDefault(); e.preventDefault(); break;
break;
} }
} }
}); });
+84 -33
View File
@@ -1,13 +1,6 @@
""" """
Import d'opérations : CSV (collé/uploadé) ou OCR d'une capture d'écran. 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.
OCR multi-format :
- LISTE : le tableau d'opérations (date empilée à gauche, lignes). Découpage
ancré sur le montant ; la date est reportée d'une ligne à l'autre
(Indy ne l'affiche qu'une fois par jour). « UE » => 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 csv import csv
import io import io
@@ -21,6 +14,7 @@ from django.shortcuts import render
from .models import (Entreprise, Client, Facture, LigneFacture, Paiement, from .models import (Entreprise, Client, Facture, LigneFacture, Paiement,
Depense, Categorie, Activite, RegimeTva) Depense, Categorie, Activite, RegimeTva)
from . import indy
CENT = Decimal("0.01") CENT = Decimal("0.01")
MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5,
@@ -50,6 +44,12 @@ def _date(s):
return None 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): def _regime(s):
s = (s or "").strip().lower() s = (s or "").strip().lower()
if s in ("intracom", "intracommunautaire", "ue"): 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, 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) neutre = bool(categorie and categorie.exclure_calculs)
if montant > 0: if montant > 0:
typ = "Mouvement neutre" if neutre else "Vente" 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" typ = "Dépense neutre" if neutre else "Dépense"
return {"n": n, "date": dt.isoformat() if dt else "", "libelle": libelle, return {"n": n, "date": dt.isoformat() if dt else "", "libelle": libelle,
"categorie_id": categorie.pk if categorie else "", "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 ---------------------------------------------------------------- # ---- CSV ----------------------------------------------------------------
@@ -109,7 +110,7 @@ def _parse_csv(text, cats):
erreurs.append(f"Ligne {n} : montant nul."); continue erreurs.append(f"Ligne {n} : montant nul."); continue
cat = cats.get((c[2] or "").strip().lower()) cat = cats.get((c[2] or "").strip().lower())
lignes.append(_row(dt, (c[1] or "").strip() or "(sans libellé)", montant, 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, [] return lignes, erreurs, []
@@ -198,7 +199,7 @@ def _parse_liste(words, annee, cats):
if cat: if cat:
libelle = re.sub(re.escape(cat.nom), "", texte, flags=re.I).strip(" -·,") 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"]), lignes.append(_row(dt, libelle or texte or "(sans libellé)", _dec(a["t"]),
taux, regime, cat, n)) taux, regime, cat, "", "", n))
return lignes 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"])] 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") montant = _dec(amounts[0]["t"]) if amounts else Decimal("0")
y_ref = dtok["yc"] if dtok else 99999 y_ref = dtok["yc"] if dtok else 99999
haut_gauche = [w for w in words if w["x"] < 450 and w["yc"] < y_ref 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")] 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"]))) libelle = " ".join(w["t"] for w in sorted(haut, key=lambda w: (w["yc"], w["x"])))
libelle = libelle.strip(" '\"") or "(à compléter)" 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"] 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", and w["t"].lower() not in ("ventilation(s)", "ventilation", "catégorie",
"categorie", "montant", "ttc", "tva")] "categorie", "montant", "ttc", "tva")]
texte_cat = " ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"]))) cat = _match_cat(" ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"]))), cats)
cat = _match_cat(texte_cat, cats)
if montant == 0: if montant == 0:
return [] 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): def _parse_ocr(image_file, annee, cats):
@@ -229,25 +229,49 @@ def _parse_ocr(image_file, annee, cats):
debug = _debug_lines(words) debug = _debug_lines(words)
allt = " ".join(w["t"] for w in words).lower() allt = " ".join(w["t"] for w in words).lower()
is_detail = ("ventilation" in allt) or ("informations" in allt and "montant" in allt) is_detail = ("ventilation" in allt) or ("informations" in allt and "montant" in allt)
if is_detail: lignes = _parse_detail(words, cats) if is_detail else _parse_liste(words, annee, cats)
lignes = _parse_detail(words, cats); fmt = "détail"
else:
lignes = _parse_liste(words, annee, cats); fmt = "liste"
if not lignes: if not lignes:
erreurs.append(f"Format « {fmt} » détecté mais aucune ligne exploitable. " erreurs.append("Aucune ligne exploitable détectée. Regarde le texte OCR ci-dessous "
f"Regarde le texte OCR ci-dessous et complète à la main, " "et complète à la main, ou utilise le CSV.")
f"ou utilise le CSV.")
return lignes, erreurs, debug 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 ----------------------------------------------------------- # ---- 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) neutre = bool(categorie and categorie.exclure_calculs)
if montant > 0: if montant > 0:
if neutre: if neutre:
Paiement.objects.create(entreprise=entreprise, date=dt, libelle=libelle, 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" return "neutre"
client, _ = Client.objects.get_or_create(entreprise=entreprise, nom=libelle) client, _ = Client.objects.get_or_create(entreprise=entreprise, nom=libelle)
ht = (montant / (Decimal("1") + taux / Decimal("100"))).quantize(CENT) 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(): while Facture.objects.filter(entreprise=entreprise, numero=numero).exists():
base += 1; numero = f"{annee}-{base:03d}" base += 1; numero = f"{annee}-{base:03d}"
f = Facture.objects.create(entreprise=entreprise, client=client, numero=numero, f = Facture.objects.create(entreprise=entreprise, client=client, numero=numero,
activite=entreprise.activite, date_emission=dt, activite=activite or entreprise.activite,
date_encaissement=dt, statut="encaissee") date_emission=dt, date_encaissement=dt,
statut="encaissee", indy_id=indy_id)
LigneFacture.objects.create(facture=f, designation=libelle, quantite=Decimal("1"), LigneFacture.objects.create(facture=f, designation=libelle, quantite=Decimal("1"),
prix_unitaire_ht=ht, taux_tva=taux) prix_unitaire_ht=ht, taux_tva=taux)
f.recompute_totaux() f.recompute_totaux()
return "vente" return "vente"
Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle, Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle,
categorie=categorie, regime_tva=regime, categorie=categorie, activite=activite, regime_tva=regime,
taux_tva=taux, montant_ttc=-montant) taux_tva=taux, montant_ttc=-montant, indy_id=indy_id)
return "depense" return "depense"
@@ -296,21 +321,47 @@ def import_csv(request):
taux = request.POST.getlist("l_taux") taux = request.POST.getlist("l_taux")
regimes = request.POST.getlist("l_regime") regimes = request.POST.getlist("l_regime")
montants = request.POST.getlist("l_montant") 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()} cats_by_id = {str(c.pk): c for c in Categorie.objects.all()}
compteur = {} compteur = {}
ignores = 0
for i in range(len(libelles)): for i in range(len(libelles)):
dt = _date(dates[i]) if i < len(dates) else None dt = _date(dates[i]) if i < len(dates) else None
montant = _dec(montants[i] if i < len(montants) else "0") 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: if dt is None or montant == 0:
continue continue
if iid and _deja_importe(entreprise, iid):
ignores += 1
continue
k = _creer(entreprise, dt, (libelles[i] or "").strip() or "(sans libellé)", k = _creer(entreprise, dt, (libelles[i] or "").strip() or "(sans libellé)",
montant, _dec(taux[i] if i < len(taux) else "0"), montant, _dec(taux[i] if i < len(taux) else "0"),
_regime(regimes[i] if i < len(regimes) else ""), _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 compteur[k] = compteur.get(k, 0) + 1
if ignores:
compteur["déjà importées (ignorées)"] = ignores
ctx["resultat"] = compteur ctx["resultat"] = compteur
return render(request, "compta/import_csv.html", ctx) 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") image = request.FILES.get("image")
if image: if image:
annee = int(request.POST.get("annee") or date_cls.today().year) annee = int(request.POST.get("annee") or date_cls.today().year)
+82
View File
@@ -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é.
Submodule
+1
Submodule fastmcp-creds added at 7701decfdb
+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()
+1
View File
@@ -5,3 +5,4 @@ python-dotenv>=1.0
whitenoise>=6.6 whitenoise>=6.6
Pillow>=10.0 Pillow>=10.0
pytesseract>=0.3.10 pytesseract>=0.3.10
httpx>=0.27