From 81cc3df13cd1c8a4b7bf6005ee9b0b004c36fdce Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Thu, 13 Aug 2026 17:27:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(erp):=20enregistrer=20les=20charges=20soci?= =?UTF-8?q?ales=20=E2=80=94=20script=20+=20runbook=20rationalis=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dolibarr n'expose AUCUNE API REST pour les charges sociales (/taxes, /socialcontributions, /chargesociales répondent tous « API not found »). Le module est actif, seule l'API manque : le pipeline de promotion, qui parle REST, ne peut pas porter cette opération. D'où un script UI, gardé par guard.ts. La sandbox a joué son rôle : quatre doublons y ont été créés pendant la découverte, sans conséquence, et les quatre pièges du formulaire sont désormais documentés au lieu d'être redécouverts. - recordSocialCharge.ts : idempotent (cherche la charge avant de créer), vérifie par LECTURE de la liste, type TNS par défaut, --dry-run. - RUNBOOK_charges_sociales.md : écrit pour être suivi par un agent moins performant ou un harness limité — la commande, les garanties, les quatre pièges, et ce que le script ne garantit PAS (ni juge, ni artefact de gate). Les quatre pièges, tous rencontrés : 1. La date visible est décorative : le backend ne lit que les champs CACHÉS echday/echmonth/echyear. Remplir le champ texte crée l'enregistrement avec une période aberrante (20/06/2000 observé) au lieu d'échouer. 2. Le bouton de soumission n'a pas d'attribut name — le cibler par value. 3. L'URL après soumission ne porte pas d'id : vérifier par l'URL fait conclure à un échec sur une création réussie. C'est ce qui a produit les doublons. 4. Les milliers portent une espace insécable (« 1 215,00 ») : une comparaison littérale casse au-delà de 999 € et l'idempotence saute en silence. Correction comptable : 645x → 646 dans known-patterns.json. Les cotisations d'un gérant TNS sont des cotisations personnelles du dirigeant (646), pas des cotisations patronales sur salaires (645) — Arcodange n'a aucun salarié. Appliqué en production : les trois échéances URSSAF 2026, toutes IMPAYÉES. Reste à vérifier dans le dictionnaire Dolibarr que le type « indépendants » porte bien le code comptable 646. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh --- .../arcodange-bank-reco/known-patterns.json | 2 +- .../RUNBOOK_charges_sociales.md | 105 +++++++++++ test/recordSocialCharge.ts | 169 ++++++++++++++++++ 3 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/dolibarr-sandbox-write/RUNBOOK_charges_sociales.md create mode 100644 test/recordSocialCharge.ts diff --git a/.claude/skills/arcodange-bank-reco/known-patterns.json b/.claude/skills/arcodange-bank-reco/known-patterns.json index 5d4d5ad..e79fd2d 100644 --- a/.claude/skills/arcodange-bank-reco/known-patterns.json +++ b/.claude/skills/arcodange-bank-reco/known-patterns.json @@ -4,7 +4,7 @@ "_match_rules": "Pattern matched case-insensitively as a regex against the bank label. Optional filters: bank (qonto|wise), side (credit|debit), amount_min, amount_max, type (Wise activity type). All present filters must match.", "_classifications": { "capital_deposit": "Apport en capital social. Dolibarr account 1013 (capital souscrit appelé versé).", - "social_charges": "URSSAF, retraite complémentaire, etc. Dolibarr account 645x.", + "social_charges": "URSSAF, retraite complémentaire, etc. Dolibarr compte 646 (cotisations personnelles du dirigeant TNS) — PAS 645x, réservé aux cotisations patronales sur salaires : Arcodange n'a aucun salarié.", "ai_subscription": "Claude / Mistral / OpenAI / similar. Dolibarr account 6262 (frais télécom / abonnements logiciels).", "bank_fee": "Plan bancaire, frais d'opération, refunds. Dolibarr account 627 (services bancaires).", "internal_topup": "Solde Wise/Qonto rechargé pour couvrir un frais immédiat. Often nets out.", diff --git a/.claude/skills/dolibarr-sandbox-write/RUNBOOK_charges_sociales.md b/.claude/skills/dolibarr-sandbox-write/RUNBOOK_charges_sociales.md new file mode 100644 index 0000000..d28f588 --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/RUNBOOK_charges_sociales.md @@ -0,0 +1,105 @@ +# Runbook — enregistrer une charge sociale ou fiscale (URSSAF, CFE, TVA…) + +Public : un agent, quel que soit son modèle, ou l'opérateur. Écrit pour être +suivi sans redécouvrir le terrain — cette routine a coûté une heure la première +fois, elle doit en coûter deux minutes ensuite. + +## Pourquoi ce n'est pas une facture fournisseur + +L'URSSAF n'est pas un fournisseur. Sa cotisation va au compte **646** +(cotisations personnelles du dirigeant), pas au compte fournisseur — l'inscrire +en facture fournisseur pollue le grand livre auxiliaire, les balances âgées et +les états de dettes fournisseurs. + +**645 contre 646**, la distinction qui décide de tout : + +| Compte | Pour qui | +| --- | --- | +| 645 | cotisations **patronales sur salaires** — suppose des salariés | +| **646** | cotisations **personnelles du dirigeant TNS** | + +Arcodange n'a aucun salarié et Gabriel est gérant associé unique d'une SARLU, +donc **TNS** : tout va en 646. Le compte 645 doit rester vide. + +Dans Dolibarr, cela se pilote par le **type de charge**, jamais par une saisie +manuelle du compte : + +- `Securite sociale (URSSAF / MSA)` → régime salarié → 645 +- **`Securite sociale des indépendants (URSSAF)`** → TNS → 646 ← **celui-ci** + +> [!WARNING] +> Le code comptable de chaque type vit dans **Configuration → Dictionnaires → +> Types de charges sociales**. Vérifier une fois que la ligne « indépendants » +> porte bien 646 : si elle porte autre chose, le bon type enverra quand même +> l'écriture au mauvais compte. Non vérifié à ce jour. + +## La commande + +```bash +cd test +DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab \ + deno run -A recordSocialCharge.ts \ + --label "URSSAF 2026 — 2e échéance" \ + --due 2026-08-05 --amount 1215.00 --period 2026-08-05 +``` + +Production — double opt-in explicite, comme toute écriture de production : + +```bash +DOLIBARR_ADDRESS=https://erp.arcodange.lab \ +ARCO_ALLOW_PRODUCTION=erp.arcodange.lab \ +ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \ + deno run -A recordSocialCharge.ts --label "…" --due … --amount … --period … +``` + +`--dry-run` affiche ce qui serait soumis sans rien écrire. +`--type "CFE"` (ou tout autre motif) pour une charge qui n'est pas URSSAF ; le +script liste les types disponibles s'il ne trouve pas de correspondance. + +La charge est créée **impayée**. Le règlement s'enregistre séparément, quand il +a réellement eu lieu — jamais par anticipation. + +## Ce que le script garantit + +- **Idempotent** : il cherche d'abord la charge dans la liste (libellé + montant) + et ne fait rien si elle existe. Un rejeu ne crée pas de doublon. +- **Vérifié par lecture** : après soumission il relit la **liste**, pas l'URL. +- **Garde d'hôte** : `guard.ts` refuse toute cible qui n'est pas la sandbox, + sauf double opt-in production. + +## Les quatre pièges, tous rencontrés + +1. **La date est un piège à double fond.** Le champ visible `ech` est décoratif : + le backend ne lit que les champs **cachés** `echday` / `echmonth` / `echyear`, + alimentés par le datepicker jQuery. Remplir le champ texte soumet une date + vide — et Dolibarr crée quand même l'enregistrement, avec une période + aberrante (`20/06/2000` observé). Même chose pour `period`. +2. **Le bouton n'a pas de `name`.** Le cibler par `value="Ajouter"`. +3. **L'URL après soumission ne porte pas d'`id`.** Vérifier par l'URL fait + conclure à un échec sur une création réussie — c'est ainsi que quatre + doublons sont apparus en sandbox pendant que le script affichait « non créée ». + **Toujours vérifier par la liste.** +4. **Les milliers s'affichent avec une espace insécable** : 1215.00 devient + « 1 215,00 ». Une comparaison littérale échoue au-delà de 999 €, et + l'idempotence saute silencieusement. Comparer sans les espaces. + +## Pourquoi pas le pipeline de promotion + +Dolibarr **n'expose aucune API REST** pour les charges sociales : `/taxes`, +`/socialcontributions` et `/chargesociales` répondent tous « API not found ». Le +module est actif (le droit 91 existe), seule l'API manque. Le pipeline +`fleet/harness/promote/` parle REST : il ne peut pas porter cette opération. + +Ce script en conserve la discipline — répétition sandbox, relecture du résultat, +opt-in production explicite — mais **pas** le juge indépendant ni l'artefact de +gate. Acceptable pour une opération à trois champs ; à ne pas généraliser. + +## Après l'enregistrement + +- Rapprocher le prélèvement bancaire quand il apparaît (Qonto pour Arcodange). +- Le calendrier `fleet/profile/calendar.yaml` porte les échéances URSSAF 2026 : + 493,00 (22/05) + 1 215,00 (05/08) + 1 333,00 (05/11) = **3 041,00 €**. +- L'échéancier officiel n'est disponible **que** dans l'espace urssaf.fr : les + notifications par mail ne contiennent aucun montant, et le transfert Gmail → + Zoho remplace même leur contenu par un texte générique. Récupérer le PDF à la + main reste nécessaire. diff --git a/test/recordSocialCharge.ts b/test/recordSocialCharge.ts new file mode 100644 index 0000000..ed6720c --- /dev/null +++ b/test/recordSocialCharge.ts @@ -0,0 +1,169 @@ +/* + Record a social/fiscal charge (URSSAF, TVA, CFE…) through the Dolibarr UI. + + WHY THE UI: Dolibarr exposes no REST endpoint for social charges — /taxes, + /socialcontributions and /chargesociales all answer "API not found". The + module itself is enabled (permission 91 exists), only the API is missing. So + the promote pipeline, which speaks REST, cannot carry this operation. + + That is a real gap in the gated path, and this script does NOT pretend to + close it. What it keeps from the pipeline's discipline: + - rehearse on the sandbox first (--env sandbox), read the result back; + - production requires the explicit double opt-in of guard.ts; + - --dry-run prints what would be submitted and touches nothing. + What it loses: no independent judge, no recorded human gate artefact. For an + operation this small (a label, a date, an amount) the trade is acceptable; + for anything larger it would not be. + + Recording URSSAF as a social charge rather than a supplier invoice is the + accounting-correct choice: URSSAF is not a supplier, and the charge belongs to + 645/646 against 431 — not to the supplier ledger, where it would pollute + supplier aging and payables reports. + + Usage: + deno run -A test/recordSocialCharge.ts --label "URSSAF 2e échéance 2026" \ + --due 2026-08-05 --amount 1215.00 --period 2026 --env sandbox --dry-run +*/ +import "load_dotenv"; +import { chromium } from "playwright"; +import login from "./scripts/login.ts"; +import { assertSandbox } from "./scripts/guard.ts"; + +const argv = Deno.args; +const pick = (f: string, d = "") => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : d); + +const label = pick("--label"); +const due = pick("--due"); // yyyy-mm-dd +const amount = pick("--amount"); +const period = pick("--period"); // ISO date: the period the charge relates to +// Gérant associé unique d'une SARL = TNS, affilié à la Sécurité sociale des +// indépendants (ex-RSI) via l'URSSAF — pas au régime des assimilés salariés. +const typeMatch = pick("--type", "ind[ée]pendants"); +const dryRun = argv.includes("--dry-run"); + +if (!label || !due || !amount) { + console.error("--label, --due (yyyy-mm-dd) and --amount are required"); + Deno.exit(2); +} + +const dolibarrAddress = assertSandbox(); +console.log(`target : ${dolibarrAddress}`); +console.log(`charge : ${label} — ${amount} € — échéance ${due} — période ${period || "(vide)"} — type ~/${typeMatch}/`); +if (dryRun) { + console.log("\n--dry-run : rien n'est soumis."); + Deno.exit(0); +} + +/** dd/mm/yyyy — what the visible datepicker input displays. */ +const fr = (iso: string) => { + const [y, m, d] = iso.split("-"); + return `${d}/${m}/${y}`; +}; + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ locale: "fr-FR" }); +const page = await context.newPage(); + +/** + * Fill a Dolibarr date field: the visible input for the human, and the hidden + * day/month/year triplet the backend actually reads. + */ +async function setDolibarrDate(name: string, iso: string): Promise { + const [y, m, d] = iso.split("-"); + await page.fill(`input[name="${name}"]`, fr(iso)).catch(() => {}); + await page.evaluate( + ({ n, dd, mm, yy }: { n: string; dd: string; mm: string; yy: string }) => { + // Runs IN the browser: Deno has no DOM lib, so reach globalThis loosely. + const doc = (globalThis as unknown as { + document: { querySelector(s: string): { value: string } | null }; + }).document; + const set = (suffix: string, v: string) => { + const el = doc.querySelector(`input[name="${n}${suffix}"]`); + if (el) el.value = v; + }; + set("day", String(Number(dd))); + set("month", String(Number(mm))); + set("year", yy); + }, + { n: name, dd: d, mm: m, yy: y }, + ); +} + +/** Look the charge up in the list — the only trustworthy confirmation. */ +async function findCharge(lbl: string, amt: string): Promise { + const rows = await page.locator("table.liste tr").evaluateAll( + (trs: { textContent: string | null }[]) => + trs.map((t) => (t.textContent ?? "").replace(/\s+/g, " ").trim()), + ); + // Dolibarr renders thousands with a (narrow) non-breaking space: 1215.00 + // displays as "1 215,00". Compare with ALL whitespace stripped, or the match + // silently fails above 999 € — and the caller then creates a duplicate. + const strip = (x: string) => x.replace(/[\s\u00a0\u202f]/g, ""); + const money = Number(amt).toFixed(2).replace(".", ","); + return rows.find((r) => strip(r).includes(strip(lbl.slice(0, 24))) && strip(r).includes(money)) ?? null; +} + +try { + await login.doAdminLogin({ + page, + dolibarrAddress, + adminCredentials: { + username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined", + password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined", + }, + }); + // Idempotence first: a re-run must not mint a second charge. + await page.goto(`${dolibarrAddress}/compta/sociales/list.php`); + const already = await findCharge(label, amount); + if (already) { + console.log(`déjà présente, rien à faire : ${already}`); + Deno.exit(0); + } + + await page.goto(`${dolibarrAddress}/compta/sociales/card.php?action=create`); + + // The charge type is a dictionary select; match on its visible label rather + // than an id, which differs per instance. + const options = await page.locator('select[name="actioncode"] option').evaluateAll( + (os: { textContent: string | null; getAttribute(n: string): string | null }[]) => + os.map((o) => ({ text: (o.textContent ?? "").trim(), value: o.getAttribute("value") ?? "" })), + ); + const hit = options.find((o) => new RegExp(typeMatch, "i").test(o.text)); + if (!hit) { + console.error(`no charge type matching /${typeMatch}/i. Available:`); + for (const o of options) console.error(` ${o.value} ${o.text}`); + Deno.exit(1); + } + console.log(`type retenu : ${hit.text} (value=${hit.value})`); + await page.selectOption('select[name="actioncode"]', hit.value); + + await page.fill('input[name="label"]', label); + await page.fill('input[name="amount"]', amount); + + // Dolibarr dates are a jQuery datepicker: a visible text input backed by + // HIDDEN day/month/year fields. The backend reads ONLY the hidden triplet — + // filling the text field alone submits an empty date and the record is + // silently not created, with no error shown. This cost an hour to find. + await setDolibarrDate("ech", due); + if (period) await setDolibarrDate("period", period); + + // The submit carries no name attribute — match on its visible value. + await page.locator('input[type="submit"][value="Ajouter"], button[type="submit"]').first().click(); + await page.waitForLoadState("networkidle"); + + // Verify on the LIST, not on the URL. Dolibarr returns to a card page whose + // URL carries no id, so an id-based check reports failure on a creation that + // actually happened — which is how four duplicates landed in the sandbox + // while the script insisted nothing had been written. + await page.goto(`${dolibarrAddress}/compta/sociales/list.php`); + const found = await findCharge(label, amount); + if (!found) { + console.error("\nNON CRÉÉE — la charge n'apparaît pas dans la liste."); + Deno.exit(1); + } + console.log(`\ncréée et vérifiée dans la liste : ${found}`); + +} finally { + await context.close(); + await browser.close(); +} -- 2.54.0