feat(erp): les deux comptes bancaires tombent au centime — reprise FAC004/FAC006, réparation FAC009, cashbacks

Suite de la réconciliation ouverte par la question de l'opérateur. Après ce
passage, l'ERP et la banque disent le même chiffre sur les deux comptes :
Qonto 2 013,30 EUR et Wise 11 863,85 EUR, écart 0,00.

CE QUI A DÉBLOQUÉ LE RESTE. L'opérateur a établi que KissMetrics n'a JAMAIS reçu
la moindre facture — le client payait le forfait de 2 500 USD sans pièce. Rien
ne s'opposait donc à corriger des factures qu'on croyait entre ses mains.

FAC004 et FAC006 portaient toutes deux 2 145,92 EUR, le même montant repris tel
quel d'un cycle à l'autre, alors que la banque a reçu 2 147,00 EUR le 29/05 et
2 195,97 EUR le 25/06 : 2 500 USD à chaque fois, au taux du jour du paiement.
51,13 EUR d'encaissement manquaient aux livres. Leurs notes qualifiaient déjà
l'écart d'« écart de change » — défendable tant qu'on ne pouvait pas toucher aux
factures ; plus vrai maintenant qu'on le peut. Une facture, un règlement, une
ligne bancaire, le même chiffre.

RÉPARATION DE FAC009. `PUT /invoices/{id}/lines/{lid}` n'est PAS un PATCH : tout
champ absent du corps est remis à zéro. La correction appliquée ce matin ne
passait que subprice, pu_ht, qty et desc — `product_type` est donc retombé de 1
(service) à 0 (produit), faisant de FAC009 la seule ligne du registre typée
« produit » dans une société qui ne vend que des prestations et facture hors UE
sous l'art. 259-1° du CGI. Rétabli.

C'est le juge pré-gate qui l'a vu, sur la répétition de FAC004 et FAC006 — où le
même appel avait en plus effacé `desc` ENTIÈREMENT. Premier finding de la
journée qui apporte quelque chose de réel, et il aurait envoyé au client deux
factures sans désignation. Les charges de ligne sont désormais complètes.

test/deleteInvoicePayment.ts. Corriger une facture encaissée suppose de refaire
son règlement, et l'API REST ne sait pas le supprimer : `PUT
/invoices/{id}/payments` ne met à jour que son NUMÉRO. Deux verrous dans
l'interface, consignés dans le fichier : tant que la facture est marquée payée
le lien de suppression est simplement ABSENT — pas d'erreur, rien — il faut
d'abord la rouvrir ; et la confirmation est une boîte MODALE jQuery dont les
boutons n'ont ni name ni value, si bien qu'un sélecteur sur input[type=submit]
ne trouve rien et que la suppression n'a pas lieu, silencieusement. Sans
--payment, le script retrouve le règlement sur la fiche : coder en dur un
identifiant relevé en bac à sable est une façon commode de supprimer le mauvais.

ABONNEMENT ANTHROPIC D'AOÛT. 90,00 EUR du 19/08, reçu 2330-6710-2536 arrivé dans
books@ le 24/08. Second abonnement à usage interne, distinct du budget IA client.

CASHBACKS WISE — compte 768. Wise parle de « remise », mais le montant suit le
SOLDE et non les frais : 0,19 EUR sur ~510 EUR en mars, 4,97 EUR sur ~9 700 EUR
en août, soit ~0,5-0,6 % l'an, stable. C'est la rémunération d'un solde, donc un
produit financier. Le seul frais Wise de l'exercice est le forfait de 50 EUR du
26/01, sans rapport. Ni 763, qui vise les revenus de créances commerciales, ni
764, réservé aux valeurs mobilières de placement : 768.

RISTOURNE QONTO — compte 627 au crédit. L'opération est typée `qonto_fee` par la
banque elle-même, côté crédit : une remise sur un service reçu vient en
diminution de la charge, elle ne crée pas un produit.

PIÈGE CATALOGUÉ. bank-match.sh continuera d'afficher ces sept écritures en
BANK-ONLY : il ne rapproche que les RÈGLEMENTS de factures et ignore les
paiements divers. Les voir listées ne veut donc PAS dire qu'elles manquent —
c'est écrit dans known-patterns.json, sans quoi le prochain agent les
ressaisirait.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-08-24 13:12:49 +02:00
co-authored by Claude Opus 5
parent fcddcae47b
commit 62fdd2325b
9 changed files with 19989 additions and 7 deletions
+147
View File
@@ -0,0 +1,147 @@
/*
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<boolean> {
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();
}