feat(erp): cycle M4 KissMetrics, et les deux comptes bancaires ramenés au centime (#96)

Co-authored-by: Gabriel Radureau <[email protected]>
This commit was merged in pull request #96.
This commit is contained in:
2026-08-24 13:13:32 +02:00
committed by arcodange
parent a8b27d2996
commit 58705fda56
36 changed files with 53738 additions and 27 deletions
+106
View File
@@ -0,0 +1,106 @@
/*
Fait produire à Dolibarr le PDF d'une facture client, puis l'enregistre.
POURQUOI CE CHEMIN EXISTE. Valider une facture par l'API **ne génère aucun
PDF** : le fichier n'existe sur le disque que lorsque quelqu'un a demandé sa
production. `PUT /documents/builddoc` le ferait, mais ce droit n'est accordé à
aucun scope agent (voir test/scopes.ts) et répond 403. Le seul chemin ouvert
est donc l'interface — celui que l'opérateur emprunterait lui-même.
Deux pièges :
- le PDF n'est PAS servi par la page : on demande sa production par l'UI,
puis on le relit par l'API `/documents/download`, qui prouve du même coup
qu'il est bien déposé et lisible par un tiers ;
- `action=builddoc` exige le jeton CSRF de session. On ne le devine pas : on
le relit sur un lien de la fiche.
Usage :
deno run -A test/buildInvoicePdf.ts --ref FAC009-CL0001009 --out dossier/
*/
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 ref = pick("--ref");
const out = pick("--out", ".");
const model = pick("--model", "sponge");
const dryRun = argv.includes("--dry-run");
if (!ref) {
console.error("--ref est requis (ex. FAC009-CL0001009)");
Deno.exit(2);
}
const dolibarrAddress = assertSandbox();
console.log(`cible : ${dolibarrAddress}`);
console.log(`facture: ${ref}`);
console.log(`modèle : ${model}`);
if (dryRun) {
console.log("\n--dry-run : rien n'est produit.");
Deno.exit(0);
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ locale: "fr-FR" });
const page = await context.newPage();
try {
await login.doAdminLogin({
page,
dolibarrAddress,
adminCredentials: {
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
},
});
const card = `${dolibarrAddress}/compta/facture/card.php?ref=${encodeURIComponent(ref)}`;
await page.goto(card);
const body = await page.locator("body").innerText();
if (!body.includes(ref)) {
console.error(`facture introuvable : ${card}`);
Deno.exit(1);
}
// Identifiant réel de la facture, tel que l'URL le porte après résolution du ref.
const idMatch = (await page.locator('a[href*="facid="]').first().getAttribute("href")
.catch(() => null))?.match(/facid=(\d+)/);
const anyToken = (await page.locator('a[href*="token="]').first().getAttribute("href")
.catch(() => null))?.match(/token=([a-zA-Z0-9]+)/);
if (!anyToken) {
console.error("aucun jeton de session lisible sur la fiche — droits insuffisants ?");
Deno.exit(1);
}
const token = anyToken[1];
const id = idMatch?.[1];
const buildUrl = `${dolibarrAddress}/compta/facture/card.php` +
`?${id ? `facid=${id}` : `ref=${encodeURIComponent(ref)}`}` +
`&action=builddoc&token=${token}&model=${model}`;
await page.goto(buildUrl);
// On ne croit pas la page de retour : on relit le fichier par l'API.
const apiUrl = Deno.env.get("DOLIBARR_URL") || dolibarrAddress;
const apiKey = Deno.env.get("DOLIBARR_API_KEY") || "";
const res = await fetch(
`${apiUrl}/api/index.php/documents/download` +
`?modulepart=facture&original_file=${encodeURIComponent(`${ref}/${ref}.pdf`)}`,
{ headers: { DOLAPIKEY: apiKey, Accept: "application/json" } },
);
const payload = await res.json();
if (!res.ok || !payload.content) {
console.error(`le PDF n'est pas déposé : HTTP ${res.status} ${JSON.stringify(payload).slice(0, 200)}`);
Deno.exit(1);
}
const bytes = Uint8Array.from(atob(payload.content), (c) => c.charCodeAt(0));
const dest = out.endsWith(".pdf") ? out : `${out.replace(/\/$/, "")}/${ref}.pdf`;
await Deno.writeFile(dest, bytes);
console.log(`ok — ${dest} (${bytes.length} octets)`);
} finally {
await browser.close();
}
+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();
}
+149
View File
@@ -0,0 +1,149 @@
/*
Enregistre le RÈGLEMENT d'une charge sociale, par l'interface.
POURQUOI CE CHEMIN EXISTE. `recordSocialCharge.ts` crée la charge — et la
laisse impayée, comme le veut Dolibarr. Tant que le règlement n'est pas saisi,
le mouvement bancaire reste orphelin : `bank-match.sh` le classe BANK-ONLY et
le solde de l'ERP diverge de celui de la banque. C'est ce trou qui laissait
deux échéances URSSAF invisibles jusqu'au 24/08/2026.
Dolibarr n'expose AUCUNE route REST pour les charges sociales — /taxes,
/socialcontributions et /chargesociales répondent tous « API not found ». La
voie gated du pipeline, qui parle REST, ne peut donc pas porter l'opération.
Ce script garde ce qu'il peut de sa discipline : répétition en bac à sable,
double opt-in explicite pour la production, --dry-run, et vérification par
RELECTURE DE L'OBJET.
Trois pièges :
- la date est un datepicker jQuery : un champ visible `re` doublé d'un
triplet CACHÉ reday/remonth/reyear, et le backend ne lit QUE le triplet.
Remplir le champ visible seul soumet une date vide, sans erreur ;
- le champ du montant porte l'identifiant de la charge dans son nom —
`amount_<id>`, pas `amount` ;
- la page de retour affiche « paiement enregistré » avant même que l'objet
soit relu. On recharge la fiche de la charge et on lit son statut.
Usage :
deno run -A test/paySocialCharge.ts --id 5 --date 2026-08-17 \
--amount 1215.00 --account 1 --type 3 [--dry-run]
# --type : 3 = prélèvement, 2 = virement, 6 = carte
*/
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 date = pick("--date"); // yyyy-mm-dd
const amount = pick("--amount");
const account = pick("--account"); // id du compte bancaire
const type = pick("--type", "3"); // 3 = ordre de prélèvement
const note = pick("--note");
const dryRun = argv.includes("--dry-run");
if (!id || !date || !amount || !account) {
console.error("--id, --date, --amount et --account sont requis");
Deno.exit(2);
}
const dolibarrAddress = assertSandbox();
console.log(`cible : ${dolibarrAddress}`);
console.log(`charge : id=${id}${amount} € le ${date} sur le compte ${account} (type ${type})`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ locale: "fr-FR" });
const page = await context.newPage();
/** Renseigne le champ visible ET le triplet caché que le backend lit seul. */
async function setDate(prefix: string, iso: string): Promise<void> {
const [y, m, d] = iso.split("-");
await page.fill(`input[name="${prefix}"]`, `${d}/${m}/${y}`).catch(() => {});
await page.evaluate(
({ p, dd, mm, yy }: { p: string; dd: string; mm: string; yy: string }) => {
const doc = (globalThis as unknown as {
document: { querySelector(s: string): { value: string } | null };
}).document;
const set = (s: string, v: string) => {
const el = doc.querySelector(`input[name="${p}${s}"]`);
if (el) el.value = v;
};
set("day", String(Number(dd)));
set("month", String(Number(mm)));
set("year", yy);
},
{ p: prefix, dd: d, mm: m, yy: y },
);
}
/**
* Reste à payer, lu sur la fiche de la charge. Seule confirmation qui vaille.
*
* NE PAS chercher « payée » dans la page : « ImPAYÉE » contient « payée », et
* la fiche affiche de toute façon les deux mots. Une première version le
* faisait et déclarait ÉCHEC sur un règlement qui venait d'être enregistré —
* un faux négatif qui pousse à rejouer, donc à créer un doublon. On lit le
* MONTANT, qui ne ment pas.
*/
async function resteAPayer(): Promise<number | null> {
await page.goto(`${dolibarrAddress}/compta/sociales/card.php?id=${id}`);
const txt = (await page.locator("body").innerText()).replace(/\s+/g, " ");
const m = txt.match(/Reste à payer\s*:?\s*([\d\s\u00a0\u202f]+,\d{2})/i);
if (!m) return null;
return Number(m[1].replace(/[\s\u00a0\u202f]/g, "").replace(",", "."));
}
try {
await login.doAdminLogin({
page,
dolibarrAddress,
adminCredentials: {
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
},
});
const avant = await resteAPayer();
if (avant === null) {
console.error(`charge introuvable, ou fiche illisible : /compta/sociales/card.php?id=${id}`);
Deno.exit(1);
}
console.log(`avant : reste à payer ${avant.toFixed(2)}`);
if (avant === 0) {
console.log("déjà réglée, rien à faire.");
Deno.exit(0);
}
if (dryRun) {
console.log("\n--dry-run : rien n'est soumis.");
Deno.exit(0);
}
// Le lien porte le jeton de session ; on le lit plutôt que de le deviner.
const href = await page.locator('a[href*="paiement_charge"]').first().getAttribute("href");
if (!href) {
console.error("aucun lien « Saisir règlement » — charge déjà réglée, ou droits insuffisants.");
Deno.exit(1);
}
await page.goto(new URL(href, dolibarrAddress).toString());
await setDate("re", date);
await page.selectOption('select[name="paiementtype"]', type);
await page.selectOption('select[name="accountid"]', account);
await page.fill(`input[name="amount_${id}"]`, amount); // le nom porte l'id
if (note) await page.fill('textarea[name="note"]', note);
await page.locator('input[name="save"]').click();
await page.waitForLoadState("networkidle");
const apres = await resteAPayer();
console.log(`après : reste à payer ${apres === null ? "?" : apres.toFixed(2)}`);
if (apres !== 0) {
console.error("ÉCHEC — la charge n'est pas soldée après soumission.");
Deno.exit(1);
}
console.log("ok — règlement enregistré et relu sur la fiche.");
} finally {
await context.close();
await browser.close();
}
+94
View File
@@ -0,0 +1,94 @@
/*
Corrige l'adresse électronique portée par la fiche société.
POURQUOI CE CHEMIN EXISTE. `GET /setup/company` répond 403 à tout scope agent
(« open to admin users only »), et aucune route REST n'écrit les constantes
MAIN_INFO_SOCIETE_*. La fiche société n'est donc modifiable que par
l'interface — celle que l'opérateur emprunterait lui-même.
POURQUOI ÇA COMPTE. Cette adresse s'imprime en en-tête de CHAQUE facture. Une
divergence entre la fiche et `static/config/company.json` ne se voit nulle part
dans l'ERP : elle ne se découvre qu'en lisant un PDF déjà parti chez le client.
C'est ainsi qu'elle a été trouvée le 2026-08-24.
Un piège : la page de retour réaffiche la valeur soumise, qu'elle ait été
enregistrée ou non. On RECHARGE la page avant de vérifier — jamais la page de
retour.
Usage :
deno run -A test/setCompanyEmail.ts --email [email protected] [--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 email = pick("--email");
const dryRun = argv.includes("--dry-run");
// Le champ s'appelle simplement `mail` — PAS `MAIN_INFO_SOCIETE_MAIL`, malgré
// le nom de la constante qu'il alimente et malgré la convention suivie par
// tous ses voisins du même formulaire (ADDRESS, ZIP, TOWN…). Relevé sur la
// page le 2026-08-24 ; ne pas le « corriger » de mémoire.
const FIELD = 'input[name="mail"]';
if (!email || !email.includes("@")) {
console.error("--email est requis, et doit ressembler à une adresse");
Deno.exit(2);
}
const dolibarrAddress = assertSandbox();
console.log(`cible : ${dolibarrAddress}`);
console.log(`email : ${email}`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ locale: "fr-FR" });
const page = await context.newPage();
try {
await login.doAdminLogin({
page,
dolibarrAddress,
adminCredentials: {
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
},
});
const edit = `${dolibarrAddress}/admin/company.php?action=edit`;
await page.goto(edit);
const field = page.locator(FIELD);
if (await field.count() === 0) {
console.error("champ `mail` absent de /admin/company.php — droits insuffisants ?");
Deno.exit(1);
}
const before = await field.inputValue();
console.log(`avant : ${before || "(vide)"}`);
if (before === email) {
console.log("déjà à cette valeur, rien à faire.");
Deno.exit(0);
}
if (dryRun) {
console.log("\n--dry-run : rien n'est soumis.");
Deno.exit(0);
}
await field.fill(email);
await page.locator('input[type="submit"][name="save"], input[type="submit"][value*="Enregistrer"]')
.first().click();
await page.waitForLoadState("networkidle");
// On ne croit pas la page de retour : on recharge et on relit le champ.
await page.goto(edit, { waitUntil: "networkidle" });
const after = await page.locator(FIELD).inputValue();
console.log(`après : ${after || "(vide)"}`);
if (after !== email) {
console.error("ÉCHEC — la valeur relue ne correspond pas à celle soumise.");
Deno.exit(1);
}
console.log("ok — fiche société mise à jour et relue.");
} finally {
await browser.close();
}