/* Passe une facture client dans une autre devise, sans toucher à sa contre-valeur en euros. POURQUOI CE CHEMIN EXISTE. `PUT /invoices/{id}` ACCEPTE les champs `multicurrency_code` et `multicurrency_tx` et répond 200 — mais ne les applique pas : `Facture::update()` ne les traite pas. La facture reste en EUR et rien ne le signale. Seule l'interface expose les actions `editmulticurrencycode` et `editmulticurrencyrate`. POURQUOI LE SCRIPT REMET AUSSI LA LIGNE. Changer la devise ABÎME la ligne : Dolibarr recalcule `montant_devise = euros × taux_du_dictionnaire`, puis le changement de taux fige la devise et recalcule les euros. La contre-valeur euro se met donc à dériver. Le seul moyen de la ramener est de réécrire la ligne en donnant les DEUX prix — `subprice` en euros, `multicurrency_subprice` en devise. Les deux opérations sont inséparables ; les séparer laisserait la facture dans un état faux entre les deux. Et `PUT /invoices/{id}/lines/{lid}` n'est pas un PATCH : les champs absents du corps sont remis à zéro — `desc` effacé, `product_type` ramené de service à produit. Le script relit donc la ligne et la renvoie ENTIÈRE. CE QUI NE DOIT PAS BOUGER : la contre-valeur en euros. Les livres sont tenus en euros (C. com. art. L.123-22) ; libeller la facture en devise ne doit pas y déplacer un centime. Le script le vérifie et refuse si l'euro a dérivé. RAPPEL DE DROIT. Une facture peut être libellée dans toute monnaie (CGI art. 289, II — directive 2006/112, art. 230) ; seule la TVA à payer doit être déterminée en euros. Sans TVA française — autoliquidation par un preneur hors UE, CGI art. 259-1° et 283-2 — la contrainte ne s'applique pas. Usage : deno run -A test/setInvoiceCurrency.ts --id 15 --code USD \ --rate 1.164992 --mc-amount 3000.00 [--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 id = pick("--id"); const code = pick("--code", "USD"); const rate = pick("--rate"); const mcAmount = pick("--mc-amount"); // prix unitaire dans la devise const dryRun = argv.includes("--dry-run"); if (!id || !rate || !mcAmount) { console.error("--id, --rate et --mc-amount sont requis"); Deno.exit(2); } const dolibarrAddress = assertSandbox(); const apiUrl = Deno.env.get("DOLIBARR_URL") || dolibarrAddress; const apiKey = Deno.env.get("DOLIBARR_API_KEY") || ""; console.log(`cible : ${dolibarrAddress}`); console.log(`facture : id=${id} → ${mcAmount} ${code} au taux ${rate}`); /** Relit la facture par l'API : la page de retour ne prouve rien. */ async function etat(): Promise> { const r = await fetch(`${apiUrl}/api/index.php/invoices/${id}`, { headers: { DOLAPIKEY: apiKey, Accept: "application/json" }, }); return await r.json(); } const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ locale: "fr-FR" }); const page = await context.newPage(); try { const avant = await etat(); console.log(`avant : ${avant.multicurrency_code} taux ${avant.multicurrency_tx} — ` + `HT ${avant.total_ht} EUR, ${avant.multicurrency_total_ht} en devise`); if (avant.multicurrency_code === code && Number(avant.multicurrency_tx) === Number(rate)) { console.log("déjà dans cette devise à ce taux, rien à faire."); Deno.exit(0); } // La devise ne se change que sur un brouillon. On y passe nous-même plutôt // que d'exiger de l'appelant une étape séparée : la remise en brouillon fait // partie de l'opération, pas de sa préparation. if (String(avant.status) !== "0") { if (String(avant.paye) === "1") { console.error("facture RÉGLÉE — refus. Changer la devise d'une facture encaissée " + "déplacerait un montant déjà rapproché de la banque."); Deno.exit(1); } console.log(`statut ${avant.status} → remise en brouillon`); if (!dryRun) { const r = await fetch(`${apiUrl}/api/index.php/invoices/${id}/settodraft`, { method: "POST", headers: { DOLAPIKEY: apiKey, "Content-Type": "application/json" }, body: JSON.stringify({ idwarehouse: 0 }), }); if (!r.ok) { console.error(`settodraft a échoué : HTTP ${r.status}`); Deno.exit(1); } } } if (dryRun) { console.log("\n--dry-run : rien n'est soumis."); Deno.exit(0); } await login.doAdminLogin({ page, dolibarrAddress, adminCredentials: { username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined", password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined", }, }); /** Ouvre une action d'édition en ligne et soumet le formulaire qui la porte. */ async function editer(action: string, remplir: () => Promise): Promise { await page.goto(`${dolibarrAddress}/compta/facture/card.php?facid=${id}`); const href = await page.locator(`a[href*="${action}"]`).first().getAttribute("href"); if (!href) throw new Error(`action ${action} absente de la fiche — droits, ou module inactif`); await page.goto(new URL(href, dolibarrAddress).toString()); await remplir(); await page.waitForLoadState("networkidle"); } await editer("editmulticurrencycode", async () => { // Le select n'a pas de nom stable d'une version à l'autre : on le trouve // par l'option qu'il contient. const sel = page.locator(`select:has(option[value="${code}"])`).first(); await sel.selectOption(code); await sel.locator("xpath=ancestor::form").locator('input[type="submit"]').first().click(); }); await editer("editmulticurrencyrate", async () => { const inp = page.locator('input[name="multicurrency_tx"], input[name="rate"]').first(); await inp.fill(rate); await inp.locator("xpath=ancestor::form").locator('input[type="submit"]').first().click(); }); // Réparation de la ligne, indissociable de ce qui précède. On relit d'abord : // le PUT n'est pas un PATCH, tout champ omis serait remis à zéro. const ligne = ((await etat()).lines as Record[])[0]; const eurAvant = String(avant.total_ht); const puEur = (Number(eurAvant) / Number(ligne.qty || 1)).toFixed(2); const rep = await fetch(`${apiUrl}/api/index.php/invoices/${id}/lines/${ligne.id}`, { method: "PUT", headers: { DOLAPIKEY: apiKey, "Content-Type": "application/json" }, body: JSON.stringify({ desc: ligne.desc, description: ligne.desc, subprice: puEur, pu_ht: puEur, multicurrency_subprice: mcAmount, qty: ligne.qty, product_type: "1", tva_tx: ligne.tva_tx, remise_percent: ligne.remise_percent, localtax1_tx: ligne.localtax1_tx, localtax2_tx: ligne.localtax2_tx, info_bits: ligne.info_bits, special_code: ligne.special_code, rang: ligne.rang, situation_percent: ligne.situation_percent ?? "100", fk_warehouse: ligne.fk_warehouse ?? "0", pa_ht: ligne.pa_ht ?? "0", }), }); if (!rep.ok) { console.error(`réécriture de la ligne : HTTP ${rep.status}`); Deno.exit(1); } const apres = await etat(); console.log(`après : ${apres.multicurrency_code} taux ${apres.multicurrency_tx} — ` + `HT ${apres.total_ht} EUR, ${apres.multicurrency_total_ht} en devise`); if (apres.multicurrency_code !== code) { console.error("ÉCHEC — la devise relue n'est pas celle demandée."); Deno.exit(1); } if (Number(apres.total_ht) !== Number(avant.total_ht)) { console.error(`ÉCHEC — la contre-valeur euro a bougé : ${avant.total_ht} -> ${apres.total_ht}. ` + "Le passage en devise ne doit RIEN déplacer dans les livres."); Deno.exit(1); } if (Math.abs(Number(apres.multicurrency_total_ht) - Number(mcAmount)) > 0.01) { console.error(`ÉCHEC — montant en devise ${apres.multicurrency_total_ht}, attendu ${mcAmount}.`); Deno.exit(1); } const lf = (apres.lines as Record[])[0]; if (!(lf.desc || "").trim() || lf.product_type !== "1") { console.error(`ÉCHEC — la ligne a été abîmée : desc=${lf.desc ? "ok" : "VIDE"}, ` + `product_type=${lf.product_type} (attendu 1).`); Deno.exit(1); } console.log("ok — devise posée, contre-valeur euro inchangée, ligne intacte, relu par l'API."); } finally { await context.close(); await browser.close(); }