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