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
170 lines
7.2 KiB
TypeScript
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();
|
|
}
|