Files
erp/test/recordSocialCharge.ts
arcodangeandClaude Opus 5 81cc3df13c 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
2026-08-13 17:27:17 +02:00

170 lines
7.2 KiB
TypeScript

/*
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();
}