feat(erp): encaissement KissMetrics du 17/08, deux échéances URSSAF, règlement des charges sociales

L'opérateur a demandé si le versement de la semaine passée avait été pris en
compte. Il ne l'était pas — et la réconciliation menée en réponse a fait sortir
trois autres trous.

L'ENCAISSEMENT. KissMetrics a viré 2 164,75 EUR le 17/08 (Wise, réf.
VENDOR:DEV), soit 2 500,00 USD au taux du jour : la part fixe du cycle M4. Le
mouvement n'était pas enregistré, si bien que FAC009 a été émise le matin même
au taux du 24/08 (2 140,45 EUR) alors que le contrat arrête le montant dû « au
taux du jour du règlement ». La facture était fausse de 24,30 EUR, et déjà
payée.

Le précédent commandait la méthode : FAC008 avait été libellée 2 185,00 EUR,
exactement la somme reçue le 20/07, même schéma de règlement anticipé. Sur
arbitrage de l'opérateur, FAC009 est repassée en brouillon, portée à
2 164,75 EUR, revalidée sous le même numéro et la même date, puis adossée au
virement. Sa note reprend la rédaction de FAC008.

Le juge a bloqué en relevant que `last_main_doc` était présent — donc, selon
lui, facture émise. Il confond PDF PRODUIT et facture TRANSMISE : le PDF avait
été produit deux heures plus tôt par l'agent lui-même pour assembler le dossier,
et le message d'envoi est un brouillon jamais envoyé. Mais il a raison sur la
conséquence, et c'est le seul de ses findings de la journée qui apporte quelque
chose : le PDF sur disque devenait faux. Il a été régénéré aussitôt. Son second
finding — `emetteur` null — tombe : le champ est null sur les huit lignes
d'encaissement Wise antérieures ; le renseigner sur la seule ligne d'août
romprait la permanence des méthodes au lieu de la servir.

LES DEUX ÉCHÉANCES URSSAF. 493,00 EUR prélevés le 22/05 et 1 215,00 EUR le
17/08 n'étaient enregistrés ni l'un ni l'autre. Créés comme charges sociales —
pas comme factures fournisseur : l'URSSAF n'est pas un fournisseur — puis
réglés depuis Qonto.

test/paySocialCharge.ts comble un trou du chemin gated : recordSocialCharge.ts
crée la charge et la laisse impayée, et rien n'enregistrait le règlement. Tant
qu'il manque, le mouvement bancaire reste orphelin et le solde de l'ERP diverge
de celui de la banque — c'est ce qui laissait ces deux échéances invisibles.
Dolibarr n'expose aucune route REST pour les charges sociales, donc le pipeline
ne peut pas porter l'opération ; le script garde ce qu'il peut de sa discipline.

Trois pièges consignés dans le fichier, dont un qui a coûté une fausse alerte :
NE PAS chercher « payée » dans la page — « ImPAYÉE » contient « payée ». Une
première version déclarait ÉCHEC sur un règlement qui venait d'aboutir, ce qui
pousse à rejouer, donc à créer un doublon. On lit le montant restant dû.

LE RUNBOOK disait 646. C'est faux depuis adc-009 : dans une SARL à l'IS, ce que
la société verse à l'URSSAF pour son gérant majoritaire est une charge de
personnel, donc 641. Corrigé, avec la mention explicite que toute version
portant 646 est périmée. Ajouté aussi ce que la fiche de charge confirme à
l'écran — « Code comptable: Inconnu » : le type de charge ne pilote PAS le
compte sur ce déploiement, contrairement à ce que le runbook laissait croire.

Enfin l'exemple `--period 2026` du runbook était invalide : le script découpe la
valeur comme une date ISO et produisait `undefined/undefined/2026`, ce qui fait
échouer la création sans message.

Reste au tableau, documenté et non traité ici : six cashbacks Wise (11,13 EUR),
un remboursement Qonto (5,22 EUR), l'abonnement Anthropic du 19/08 (90,00 EUR,
reçu pas encore arrivé), et deux encaissements client sous-enregistrés — FAC004
et FAC006, 51,13 EUR reçus de plus que ce que l'ERP porte.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-08-24 13:12:34 +02:00
co-authored by Claude Opus 5
parent f2b89d391a
commit fcddcae47b
10 changed files with 9360 additions and 20 deletions
+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();
}