Files
erp/test/setInvoiceCurrency.ts
T
arcodangeandClaude Opus 5 01441df5ea feat(erp): le retainer KissMetrics se facture en dollars — adc-006 tranchée
L'opérateur a demandé « on ne peut pas emettre de facture en dollars ? ». Si, et
c'est même la bonne réponse ici — au point que la question avait déjà son créneau
au registre : adc-006 était Proposed depuis juillet, sa première question ouverte
étant mot pour mot « USD multicurrency invoices vs EUR-at-settlement ? ».

LE DÉFAUT. Le contrat fixe un prix EN DOLLARS — 2 500 USD en part fixe, 3 000 en
part différée — mais les factures étaient libellées en euros, contre-valeur figée
au taux du jour d'émission. Leur propre note l'avouait : « le montant
effectivement dû en euros sera arrêté au taux du jour du règlement ». Une facture
dont le montant affiché n'est pas le montant dû.

Le coût était réel et mesuré : FAC004, FAC006 et FAC009 ont dû être reprises le
même jour parce que leur contre-valeur ne correspondait pas aux virements reçus.
51,13 EUR d'encaissement manquaient aux livres.

Le client paie d'ailleurs en dollars : les euros reçus varient de 2 147,00 à
2 195,97 pour une prestation à prix fixe, signature d'un virement en devise
converti à l'arrivée par Wise, qui ne détient qu'un solde en euros.

LE DROIT. Une facture peut être libellée dans toute monnaie (CGI art. 289, II,
transposant la directive 2006/112 art. 230) ; seule la TVA à payer doit être
déterminée en euros, et il n'y en a pas — autoliquidation par un preneur hors UE.
Les LIVRES, eux, restent tenus en euros (C. com. art. L.123-22) : la
contre-valeur inscrite n'a pas bougé d'un centime.

PÉRIMÈTRE. Les quatre factures NON RÉGLÉES, aucune n'ayant été transmise :
FAC005, FAC007, FAC010, FAC011, toutes portées à 3 000,00 USD. Les quatre réglées
restent en euros — leur montant est exactement ce qui a été encaissé et rapproché
avec la banque. Chaque facture dit ainsi sa propre vérité.

CE QUE adc-006 DOIT CONSIGNER, ET CONSIGNE. adc-002 pose qu'« une facture validée
n'est jamais ajustée pour raison de change ». Trois l'ont pourtant été le 24/08.
L'opérateur l'a arbitré au motif qu'aucune n'avait été transmise au client, et
adc-006 supprime la situation qui rendait l'arbitrage nécessaire : une facture en
dollars n'a aucune raison d'être ajustée, son montant ne dépendant d'aucun taux.
adc-002 s'en trouve restreinte, pas abrogée — les écarts de change continuent
d'aller en 766 et 666, et deviennent le cas normal au lieu de l'exception.

test/setInvoiceCurrency.ts. Deux pièges consignés. `PUT /invoices/{id}` ACCEPTE
`multicurrency_code`, répond 200, et n'applique rien : seule l'interface change
la devise. Et ce changement ABÎME la ligne — Dolibarr recalcule le montant en
devise depuis les euros et le taux du dictionnaire, puis le changement de taux
fige la devise et recalcule les euros, si bien que la contre-valeur dérive. Le
script réunit donc les deux opérations, réécrit la ligne ENTIÈRE (le PUT n'est
pas un PATCH) et refuse si la contre-valeur euro a bougé d'un centime.

Le juge a relevé, à raison, que le titre du change-set débordait de son contenu :
le passage en devise est fait par le script, en amont. Accepté et consigné au
gate ; le manifeste n'a pas été retouché pour ne pas rompre l'empreinte sur
laquelle le gate est scellé. Son residual risk — vérifier la multidevise en
production — est honoré. Juge post-gate PASS sans dérive.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-24 15:14:24 +02:00

188 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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<Record<string, unknown>> {
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<void>): Promise<void> {
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<string, string>[])[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<string, string>[])[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();
}