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:
@@ -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