Fix montants (locale) + timeout fetch Indy + message d'erreur

This commit is contained in:
2026-06-30 14:57:36 +02:00
parent 75af558b49
commit 53fbf19a0b
8 changed files with 152 additions and 7 deletions
Binary file not shown.
Binary file not shown.
+15 -3
View File
@@ -5,7 +5,7 @@ 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 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. 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 API non officielle d'Indy : pour les données de l'utilisateur. Le token JWT
expire (~1 h) et doit être renouvelé. expire (~1 h) et doit être renouvelé.
""" """
from decimal import Decimal from decimal import Decimal
@@ -96,14 +96,26 @@ def fetch_transactions(token, date_debut=None, date_fin=None, search="", page=1,
params["dateFrom"] = date_debut params["dateFrom"] = date_debut
if date_fin: if date_fin:
params["dateTo"] = date_fin params["dateTo"] = date_fin
timeout = httpx.Timeout(12.0, connect=8.0)
try: try:
with httpx.Client(base_url=base_url.rstrip("/"), headers=headers, with httpx.Client(base_url=base_url.rstrip("/"), headers=headers,
timeout=30, follow_redirects=True) as c: timeout=timeout, follow_redirects=True) as c:
r = c.get(TRANSACTIONS_PATH, params=params) r = c.get(TRANSACTIONS_PATH, params=params)
if r.status_code != 200: if r.status_code != 200:
return r.status_code, [], f"HTTP {r.status_code} (token expiré ?)" return r.status_code, [], (
f"HTTP {r.status_code} — token expire, ou requete bloquee par "
f"Cloudflare depuis le serveur.")
if "json" not in r.headers.get("content-type", "").lower():
return r.status_code, [], (
"Reponse non-JSON (probablement Cloudflare). Le conteneur n'arrive "
"pas a joindre Indy directement. Recupere le CSV depuis ton poste "
"(MCP/CLI) et colle-le ci-dessous.")
data = r.json() data = r.json()
return 200, data.get("transactions", []), None return 200, data.get("transactions", []), None
except httpx.TimeoutException:
return None, [], ("Delai depasse en joignant Indy : le conteneur n'a peut-etre "
"pas d'acces Internet, ou Cloudflare bloque. Utilise le CSV "
"depuis ton poste.")
except Exception as ex: # noqa: BLE001 except Exception as ex: # noqa: BLE001
return None, [], f"{type(ex).__name__}: {ex}" return None, [], f"{type(ex).__name__}: {ex}"
+3 -3
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> {% load l10n %}<!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -128,8 +128,8 @@
{% for val,lab in regimes %}<option value="{{ val }}" {% if val == l.regime %}selected{% endif %}>{{ lab }}</option>{% endfor %} {% for val,lab in regimes %}<option value="{{ val }}" {% if val == l.regime %}selected{% endif %}>{{ lab }}</option>{% endfor %}
</select> </select>
</td> </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_taux" value="{{ l.taux|unlocalize }}" 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|unlocalize }}" 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_activite" value="{{ l.activite }}">
<input type="hidden" name="l_indy_id" value="{{ l.indy_id }}"> <input type="hidden" name="l_indy_id" value="{{ l.indy_id }}">
+8
View File
@@ -0,0 +1,8 @@
"""Page d'installation du favori « Indy → CSV »."""
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
@login_required
def bookmarklet(request):
return render(request, "compta/bookmarklet.html", {})
+1 -1
View File
@@ -20,4 +20,4 @@ if [ "$SEED_DEMO" = "1" ]; then
fi fi
echo "→ Démarrage de Gunicorn sur :8000" echo "→ Démarrage de Gunicorn sur :8000"
exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 60
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Indy → CSV — Favori</title>
<style>
body{font-family:system-ui,Segoe UI,Roboto,sans-serif;background:#0f172a;color:#e2e8f0;
max-width:760px;margin:0 auto;padding:28px;line-height:1.5;}
h1{font-size:22px} h2{font-size:16px;color:#94a3b8;margin-top:26px}
a.bm{display:inline-block;background:#38bdf8;color:#04263a;font-weight:700;
padding:10px 18px;border-radius:10px;text-decoration:none;font-size:16px}
ol{padding-left:20px} li{margin:6px 0}
textarea{width:100%;height:120px;background:#1e293b;color:#e2e8f0;border:1px solid #334155;
border-radius:8px;padding:10px;font-family:ui-monospace,monospace;font-size:12px}
code{background:#1e293b;padding:1px 6px;border-radius:5px}
.note{color:#94a3b8;font-size:14px}
.warn{background:#7f1d1d;padding:10px 14px;border-radius:8px;margin-top:10px}
</style>
</head>
<body>
<h1>Favori « Indy → CSV »</h1>
<p class="note">Un clic sur la page Indy → tes transactions sont copiées au format CSV,
prêtes à coller dans JB Compta. Pas de token à chercher, la 2FA est déjà passée,
et tout se fait dans ton navigateur (donc pas de souci d'accès serveur).</p>
<h2>Installation (30 s)</h2>
<ol>
<li>Affiche ta <b>barre de favoris</b> (Ctrl+Maj+B dans Chrome).</li>
<li><b>Glisse-dépose</b> ce bouton dans la barre de favoris :
&nbsp; <a class="bm" id="lnk" href="#">Indy → CSV</a></li>
<li>Va sur <code>app.indy.fr</code> (connecté), puis <b>clique le favori</b>.</li>
<li>Saisis la période → le CSV est copié → colle-le dans <code>/import/</code> de JB Compta.</li>
</ol>
<div class="warn">Si le glisser-déposer ne marche pas : crée un favori manuellement,
puis colle le code ci-dessous comme <b>URL</b> du favori.</div>
<h2>Code (installation manuelle)</h2>
<textarea id="ta" readonly onclick="this.select()"></textarea>
<h2>Notes</h2>
<ul class="note">
<li>À utiliser <b>uniquement</b> quand l'onglet actif est <code>app.indy.fr</code>.</li>
<li>Aucun secret n'est stocké : le favori lit ta session en cours.</li>
<li>Si une alerte « Erreur Indy » apparaît, reconnecte-toi à Indy et réessaie.</li>
<li>Le mapping des catégories (compte comptable → catégorie) est le même que dans l'app.</li>
</ul>
<script>
var BM = "javascript:(async()=>{const CC=[['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']];const CA=[['701','vente'],['707','vente'],['706','service_bic']];const cat=n=>{n=String(n||'');let b=null;for(const[p,c]of CC){if(n.startsWith(p)&&(!b||p.length>b[0].length))b=[p,c];}return b?b[1]:'';};const act=n=>{n=String(n||'');for(const[p,a]of CA){if(n.startsWith(p))return a;}return '';};const norm=tx=>{const c=tx.totalAmountInCents;if(c==null)return null;const mt=(c/100).toFixed(2);const ss=(tx.subdivisions||[]).filter(s=>!String((s.accounting_account||{}).number||'').startsWith('512'));if(!ss.length)return null;let m=ss[0];for(const s of ss)if(Math.abs(s.amount_in_cents||0)>Math.abs(m.amount_in_cents||0))m=s;const num=String((m.accounting_account||{}).number||'');let rg='',t2=0;if(m.tva_intracom&&m.tva_intracom.type==='eu'){rg='intracom';t2=(m.tva_intracom.rate||0)/100;}else{t2=(m.tva_rate||0)/100;}const lib=(tx.description||tx.raw_description||'').replace(/;/g,' ');return[tx.date||'',lib,cat(num),act(num),String(t2),rg,mt].join(';');};const getTok=()=>new Promise(r=>{const o=indexedDB.open('firebaseLocalStorageDb');o.onsuccess=()=>{try{const q=o.result.transaction('firebaseLocalStorage','readonly').objectStore('firebaseLocalStorage').getAll();q.onsuccess=()=>{const v=(q.result||[]).map(x=>x.value).find(v=>v&&v.stsTokenManager&&v.stsTokenManager.accessToken);r(v?v.stsTokenManager.accessToken:null);};q.onerror=()=>r(null);}catch(e){r(null);}};o.onerror=()=>r(null);});const d1=prompt('Date debut (AAAA-MM-JJ)');if(!d1)return;const d2=prompt('Date fin (AAAA-MM-JJ)');if(!d2)return;const tk=await getTok();const H={'Accept':'application/json','x-client-app-type':'web'};if(tk)H['Authorization']='Bearer '+tk;let all=[],pg=1,tot=1e9;try{while(all.length<tot){const u='/api/transactions/transactions-list?'+new URLSearchParams({search:'',dateFrom:d1,dateTo:d2,page:pg});const r=await fetch(u,{headers:H,credentials:'include'});if(!r.ok)throw new Error('HTTP '+r.status);const j=await r.json();tot=j.nbTransactions!=null?j.nbTransactions:(j.transactions||[]).length;const t=j.transactions||[];if(!t.length)break;all=all.concat(t);pg++;if(pg>60)break;}}catch(e){alert('Erreur Indy : '+e.message);return;}const rows=all.map(norm).filter(Boolean);const NL=String.fromCharCode(10);const csv='date;libelle;categorie;activite;taux_tva;regime;montant'+NL+rows.join(NL);try{await navigator.clipboard.writeText(csv);alert(rows.length+' operations -> CSV copie ! Colle-le dans JB Compta (/import/).');}catch(e){window.prompt('Copie ce CSV (Ctrl+C) :',csv);}})();";
document.getElementById('lnk').href = BM;
document.getElementById('ta').value = BM;
</script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
/*
* Bookmarklet « Indy → CSV » — version lisible (documentation).
* Le bouton prêt à installer est dans bookmarklet.html.
*
* Usage : être connecté sur app.indy.fr, cliquer le favori. Il demande une
* période, récupère les transactions (dans TON navigateur = autorisé, 2FA déjà
* passée, pas de problème Cloudflare ni d'accès serveur), construit le CSV au
* format JB Compta et le copie dans le presse-papier. Tu le colles dans /import/.
*/
(async () => {
const CC = [
["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"],
];
const CA = [["701", "vente"], ["707", "vente"], ["706", "service_bic"]];
const cat = (n) => { n = String(n || ""); let b = null; for (const [p, c] of CC) { if (n.startsWith(p) && (!b || p.length > b[0].length)) b = [p, c]; } return b ? b[1] : ""; };
const act = (n) => { n = String(n || ""); for (const [p, a] of CA) { if (n.startsWith(p)) return a; } return ""; };
const norm = (tx) => {
const c = tx.totalAmountInCents; if (c == null) return null;
const mt = (c / 100).toFixed(2);
const ss = (tx.subdivisions || []).filter(s => !String((s.accounting_account || {}).number || "").startsWith("512"));
if (!ss.length) return null;
let m = ss[0]; for (const s of ss) if (Math.abs(s.amount_in_cents || 0) > Math.abs(m.amount_in_cents || 0)) m = s;
const num = String((m.accounting_account || {}).number || "");
let rg = "", tx2 = 0;
if (m.tva_intracom && m.tva_intracom.type === "eu") { rg = "intracom"; tx2 = (m.tva_intracom.rate || 0) / 100; }
else { tx2 = (m.tva_rate || 0) / 100; }
const lib = (tx.description || tx.raw_description || "").replace(/;/g, " ");
return [tx.date || "", lib, cat(num), act(num), String(tx2), rg, mt].join(";");
};
const getTok = () => new Promise(r => {
const o = indexedDB.open("firebaseLocalStorageDb");
o.onsuccess = () => { try {
const q = o.result.transaction("firebaseLocalStorage", "readonly").objectStore("firebaseLocalStorage").getAll();
q.onsuccess = () => { const v = (q.result || []).map(x => x.value).find(v => v && v.stsTokenManager && v.stsTokenManager.accessToken); r(v ? v.stsTokenManager.accessToken : null); };
q.onerror = () => r(null);
} catch (e) { r(null); } };
o.onerror = () => r(null);
});
const d1 = prompt("Date début (AAAA-MM-JJ)"); if (!d1) return;
const d2 = prompt("Date fin (AAAA-MM-JJ)"); if (!d2) return;
const tk = await getTok();
const H = { "Accept": "application/json", "x-client-app-type": "web" };
if (tk) H["Authorization"] = "Bearer " + tk;
let all = [], pg = 1, tot = 1e9;
try {
while (all.length < tot) {
const u = "/api/transactions/transactions-list?" + new URLSearchParams({ search: "", dateFrom: d1, dateTo: d2, page: pg });
const r = await fetch(u, { headers: H, credentials: "include" });
if (!r.ok) throw new Error("HTTP " + r.status);
const j = await r.json();
tot = j.nbTransactions != null ? j.nbTransactions : (j.transactions || []).length;
const t = j.transactions || []; if (!t.length) break;
all = all.concat(t); pg++; if (pg > 60) break;
}
} catch (e) { alert("Erreur Indy : " + e.message + "\nEs-tu bien connecté sur app.indy.fr ?"); return; }
const rows = all.map(norm).filter(Boolean);
const csv = "date;libelle;categorie;activite;taux_tva;regime;montant\n" + rows.join("\n");
try { await navigator.clipboard.writeText(csv); alert(rows.length + " opérations -> CSV copié ! Colle-le dans JB Compta (/import/)."); }
catch (e) { window.prompt("Copie ce CSV (Ctrl+C) :", csv); }
})();