feat(erp): enregistrer les charges sociales — script + runbook rationalisés

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
This commit is contained in:
2026-08-13 17:27:17 +02:00
co-authored by Claude Opus 5
parent 4d1e3ecb23
commit 81cc3df13c
3 changed files with 275 additions and 1 deletions
@@ -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.", "_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": { "_classifications": {
"capital_deposit": "Apport en capital social. Dolibarr account 1013 (capital souscrit appelé versé).", "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).", "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).", "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.", "internal_topup": "Solde Wise/Qonto rechargé pour couvrir un frais immédiat. Often nets out.",
@@ -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.
+169
View File
@@ -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<void> {
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<string | null> {
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();
}