/* Supprime le RÈGLEMENT d'une facture client, pour pouvoir le ressaisir juste. POURQUOI CE CHEMIN EXISTE. Quand une facture a été encaissée pour un montant différent de celui qu'on lui a donné — le cas classique ici : un prix contractuel en USD, converti en euros au mauvais taux — il ne suffit pas de corriger la facture. Le règlement, lui, porte l'ancien montant, et la ligne bancaire qu'il a engendrée aussi. Tant qu'on ne le refait pas, le solde de l'ERP diverge de celui de la banque. L'API REST ne sait pas supprimer un règlement : `PUT /invoices/{id}/payments` ne met à jour que son NUMÉRO. Il faut donc l'interface. Et deux verrous s'y opposent, dans cet ordre : 1. tant que la facture est marquée PAYÉE, la fiche du règlement n'affiche AUCUN lien de suppression — pas de message, pas d'erreur, le lien est simplement absent. Il faut d'abord « Rouvrir » la facture ; 2. la confirmation n'est PAS un formulaire mais une boîte MODALE jQuery UI, dont les boutons « Oui » / « Non » n'ont ni `name` ni `value` — un sélecteur sur `input[type=submit]` ne trouve rien et la suppression n'a pas lieu, silencieusement. On clique sur le TEXTE du bouton. Le script vérifie en RELISANT la fiche du règlement : supprimée, elle ne répond plus. On ne croit pas la page de retour. Usage : deno run -A test/deleteInvoicePayment.ts --invoice 14 [--payment 7] [--dry-run] Sans --payment, le règlement est retrouvé sur la fiche de la facture. C'est préférable : les identifiants de règlement diffèrent d'une instance à l'autre, et en coder un en dur pour la production après l'avoir relevé en bac à sable est une façon commode de supprimer le mauvais. */ 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 invoice = pick("--invoice"); const payment = pick("--payment"); const dryRun = argv.includes("--dry-run"); if (!invoice) { console.error("--invoice est requis (identifiant Dolibarr de la facture)"); Deno.exit(2); } const dolibarrAddress = assertSandbox(); console.log(`cible : ${dolibarrAddress}`); console.log(`facture : id=${invoice} règlement : ${payment || "(à retrouver sur la fiche)"}`); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ locale: "fr-FR" }); const page = await context.newPage(); let pid = payment; /** Le règlement existe-t-il encore ? Sa fiche est la seule source de vérité. */ async function reglementExiste(): Promise { const res = await page.goto(`${dolibarrAddress}/compta/paiement/card.php?id=${pid}`); if (res && res.status() >= 400) return false; const txt = (await page.locator("body").innerText()).replace(/\s+/g, " "); return /Montant/.test(txt) && !/n'existe pas|does not exist|Erreur/i.test(txt); } try { await login.doAdminLogin({ page, dolibarrAddress, adminCredentials: { username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined", password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined", }, }); if (!pid) { await page.goto(`${dolibarrAddress}/compta/facture/card.php?facid=${invoice}`); const hrefs = await page.locator('a[href*="/compta/paiement/card.php?id="]') .evaluateAll((as: { getAttribute(n: string): string | null }[]) => as.map((a) => a.getAttribute("href") ?? "")); const ids = [...new Set(hrefs.map((h) => (h.match(/id=(\d+)/) || [])[1]).filter(Boolean))]; if (ids.length === 0) { console.log("aucun règlement sur cette facture, rien à faire."); Deno.exit(0); } if (ids.length > 1) { console.error(`${ids.length} règlements sur cette facture (${ids.join(", ")}) — préciser --payment.`); Deno.exit(1); } pid = ids[0]; console.log(`règlement : id=${pid} (retrouvé sur la fiche)`); } if (!await reglementExiste()) { console.log("règlement déjà absent, rien à faire."); Deno.exit(0); } const montant = ((await page.locator("body").innerText()).match(/Montant\s+([\d\s  ]+,\d{2})/) || [])[1]; console.log(`montant : ${montant ?? "?"} €`); if (dryRun) { console.log("\n--dry-run : rien n'est supprimé."); Deno.exit(0); } // Verrou 1 — rouvrir la facture, sinon le lien de suppression n'existe pas. await page.goto(`${dolibarrAddress}/compta/facture/card.php?facid=${invoice}`); const reopen = await page.locator('a[href*="action=reopen"]').first() .getAttribute("href").catch(() => null); if (reopen) { await page.goto(new URL(reopen, dolibarrAddress).toString()); console.log("facture rouverte (elle était marquée payée)."); } await page.goto(`${dolibarrAddress}/compta/paiement/card.php?id=${pid}`); const del = await page.locator('a[href*="action=delete"]').first() .getAttribute("href").catch(() => null); if (!del) { console.error( "aucun lien de suppression sur la fiche du règlement.\n" + " La facture est-elle encore marquée payée ? Le lien n'apparaît qu'une fois rouverte.", ); Deno.exit(1); } await page.goto(new URL(del, dolibarrAddress).toString()); // Verrou 2 — la page de confirmation. Sans elle, rien n'est supprimé. const oui = page.getByRole("button", { name: /^(Oui|Yes)$/ }).first(); await oui.waitFor({ state: "visible", timeout: 10_000 }).catch(() => {}); if (await oui.count() === 0) { console.error("boîte de confirmation introuvable — le bouton « Oui » n'est pas rendu."); Deno.exit(1); } await oui.click(); await page.waitForLoadState("networkidle"); if (await reglementExiste()) { console.error("ÉCHEC — le règlement répond toujours."); Deno.exit(1); } console.log("ok — règlement supprimé, vérifié par relecture de sa fiche."); } finally { await context.close(); await browser.close(); }