185 lines
7.7 KiB
TypeScript
185 lines
7.7 KiB
TypeScript
/*
|
||
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();
|
||
}
|