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:
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user