feat(erp): indemnité d'occupation janv→juil 2026 (1 483,23 €) — paiements divers, sans tiers (#89)

Co-authored-by: Gabriel Radureau <[email protected]>
This commit was merged in pull request #89.
This commit is contained in:
2026-08-13 21:09:10 +02:00
committed by arcodange
parent 216dc0213b
commit 480888937f
13 changed files with 20267 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
/*
Enregistre un PAIEMENT DIVERS Dolibarr — un mouvement sur un compte bancaire
avec son code comptable, SANS tiers.
POURQUOI PAS UNE FACTURE FOURNISSEUR. Une dette envers l'associé n'est pas une
dette fournisseur. Lui créer une fiche fournisseur le ferait apparaître au
grand livre auxiliaire, dans les balances âgées et les états de dettes
fournisseurs — exactement l'objection déjà opposée à l'URSSAF dans
RUNBOOK_charges_sociales.md. Le gérant n'est pas un fournisseur de sa société.
Le modèle correct est direct : le compte bancaire `CCA1` (id 3) porte le numéro
comptable 45511 et son propre journal. Un paiement divers en sens DÉBIT sur ce
compte, code comptable 613, produit
débit 613xxx Locations (la charge)
crédit 45511 G. RADUREAU, compte courant (la dette envers l'associé)
Aucun tiers, aucune facture, aucune pollution du grand livre auxiliaire.
PAS D'API REST : /variouspayments répond « API not found ». Comme pour les
charges sociales, le pipeline `fleet/harness/promote/` — qui parle REST — ne
peut pas porter cette opération. Ce script en garde la discipline (répétition
sandbox, relecture, opt-in production explicite) mais pas le juge indépendant
ni l'artefact de gate.
Usage :
deno run -A test/recordVariousPayment.ts \
--label "Indemnité d'occupation — janvier 2026" \
--amount 163.23 --date 2026-01-31 --account 3 --code 613 [--dry-run]
*/
import "load_dotenv";
import { chromium, type Page } 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 amount = pick("--amount");
const date = pick("--date"); // yyyy-mm-dd
const account = pick("--account", "3"); // compte bancaire Dolibarr
const code = pick("--code", "613"); // code comptable de la CHARGE
const sens = pick("--sens", "0"); // 0 = Débit (sortie), 1 = Crédit
const paymentType = pick("--payment-type", "VIR");
const dryRun = argv.includes("--dry-run");
if (!label || !amount || !date) {
console.error("--label, --amount et --date (yyyy-mm-dd) sont requis");
Deno.exit(2);
}
const dolibarrAddress = assertSandbox();
const fr = (iso: string) => {
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
};
console.log(`cible : ${dolibarrAddress}`);
console.log(`écriture: ${label}${amount} € — ${fr(date)} — compte ${account} — code ${code} — sens ${sens === "0" ? "débit" : "crédit"}`);
if (dryRun) {
console.log("\n--dry-run : rien n'est soumis.");
Deno.exit(0);
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ locale: "fr-FR" });
const page = await context.newPage();
/**
* Remplit une date Dolibarr : le champ visible pour l'humain, et le triplet
* day/month/year CACHÉ que le backend est seul à lire. Remplir le seul champ
* texte soumet une date vide — piège déjà payé sur les charges sociales.
*/
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 }) => {
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 },
);
}
/** Cherche l'écriture dans la LISTE — seule confirmation digne de foi. */
async function find(p: Page, lbl: string, amt: string): Promise<string | null> {
await p.goto(`${dolibarrAddress}/compta/bank/various_payment/list.php?limit=200`);
const rows = await p.locator("table.liste tr").evaluateAll(
(trs: { textContent: string | null }[]) =>
trs.map((t) => (t.textContent ?? "").replace(/\s+/g, " ").trim()),
);
// Les milliers s'affichent avec une espace insécable : comparer sans espaces,
// sinon l'idempotence saute silencieusement au-delà de 999 €.
const strip = (x: string) => x.replace(/[\s ]/g, "");
const money = Number(amt).toFixed(2).replace(".", ",");
// COMPARER LE LIBELLÉ ENTIER, jamais un préfixe. Une première version tronquait
// à 24 caractères ; or « Indemnité d'occupation — » en fait exactement 24, si
// bien que mars reconnaissait février et se déclarait déjà enregistré. Six mois
// ont été silencieusement sautés. Un préfixe ne distingue que ce qui diffère
// avant lui — hypothèse qu'aucun jeu de libellés ne garantit.
const want = strip(lbl);
return rows.find((r) => {
const row = strip(r);
if (!row.includes(money)) return false;
if (row.includes(want)) return true;
// Dolibarr abrège les libellés longs avec « … » : on accepte alors la
// portion réellement affichée, et seulement elle.
const cut = row.match(/(.{12,}?)…/);
return !!cut && want.includes(cut[1].slice(-40));
}) ?? null;
}
try {
await login.doAdminLogin({
page,
dolibarrAddress,
adminCredentials: {
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
},
});
const already = await find(page, label, amount);
if (already) {
console.log(`déjà présente, rien à faire : ${already.slice(0, 110)}`);
Deno.exit(0);
}
await page.goto(`${dolibarrAddress}/compta/bank/various_payment/card.php?action=create`);
await page.fill('input[name="label"]', label);
await page.fill('input[name="amount"]', amount);
await setDolibarrDate("datep", date);
await setDolibarrDate("datev", date);
await page.selectOption('select[name="accountid"]', account);
await page.selectOption('select[name="sens"]', sens);
await page.selectOption('select[name="paymenttype"]', paymentType).catch(() => {});
// Le sélecteur de compte comptable est appairé sur le NUMÉRO affiché
// (« 613000 - Locations »), jamais sur l'id interne, qui diffère d'une
// instance à l'autre.
const opts = await page.locator('select[name="accountancy_code"] option').evaluateAll(
(os: { textContent: string | null; getAttribute(n: string): string | null }[]) =>
os.map((o) => ({ text: (o.textContent ?? "").trim(), value: o.getAttribute("value") ?? "" })),
);
const hit = opts.find((o) => new RegExp(`^${code}0*\\s*-`).test(o.text)) ??
opts.find((o) => o.text.startsWith(code));
if (!hit) {
console.error(`aucun compte comptable ne commence par « ${code} ». Candidats 61x :`);
for (const o of opts.filter((x) => /^6[12]/.test(x.text))) console.error(` ${o.text}`);
Deno.exit(1);
}
console.log(`compte retenu : ${hit.text}`);
await page.selectOption('select[name="accountancy_code"]', hit.value);
await page.locator('input[name="save"], input[type="submit"]').first().click();
await page.waitForLoadState("networkidle");
const body = (await page.locator("body").innerText()).replace(/\s+/g, " ");
const err = body.match(/Erreur[^.]{0,180}/i);
if (err) {
console.error(`\nÉCHEC — ${err[0]}`);
Deno.exit(1);
}
const found = await find(page, label, amount);
if (!found) {
console.error("\nNON CRÉÉE — l'écriture n'apparaît pas dans la liste.");
Deno.exit(1);
}
console.log(`\ncréée et vérifiée : ${found.slice(0, 110)}`);
} finally {
await context.close();
await browser.close();
}