@@ -0,0 +1,184 @@
/ *
Enregistre un PAIEMENT DIVERS Dolibarr — un mouvement sur un compte bancaire
avec son code comptable , SANS tiers .
POURQUOI PAS UNE FACTURE FOURNISSEUR . Une dette envers l 'associé n' est pas une
dette fournisseur . Lui créer une fiche fournisseur le ferait apparaître au
grand livre auxiliaire , dans les balances â gées et les é tats de dettes
fournisseurs — exactement l 'objection déjà opposée à l' URSSAF dans
RUNBOOK_charges_sociales . md . Le gérant n ' est pas un fournisseur de sa société .
Le modèle correct est direct : le compte bancaire ` CCA1 ` ( id 3 ) porte le numéro
comptable 45511 et son propre journal . Un paiement divers en sens DÉBIT sur ce
compte , code comptable 613 , produit
débit 613 xxx Locations ( la charge )
crédit 45511 G . RADUREAU , compte courant ( la dette envers l ' associé )
Aucun tiers , aucune facture , aucune pollution du grand livre auxiliaire .
PAS D ' API REST : / v a r i o u s p a y m e n t s r é p o n d « A P I n o t f o u n d » . C o m m e p o u r l e s
charges sociales , le pipeline ` fleet/harness/promote/ ` — qui parle REST — ne
peut pas porter cette opération . Ce script en garde la discipline ( répétition
sandbox , relecture , opt - in production explicite ) mais pas le juge indépendant
ni l ' artefact de gate .
Usage :
deno run - A test / recordVariousPayment . ts \
-- label "Indemnité d'occupation — janvier 2026" \
-- amount 163.23 -- date 2026 - 01 - 31 -- account 3 -- code 613 [ -- dry - run ]
* /
import "load_dotenv" ;
import { chromium , type Page } 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 label = pick ( "--label" ) ;
const amount = pick ( "--amount" ) ;
const date = pick ( "--date" ) ; // yyyy-mm-dd
const account = pick ( "--account" , "3" ) ; // compte bancaire Dolibarr
const code = pick ( "--code" , "613" ) ; // code comptable de la CHARGE
const sens = pick ( "--sens" , "0" ) ; // 0 = Débit (sortie), 1 = Crédit
const paymentType = pick ( "--payment-type" , "VIR" ) ;
const dryRun = argv . includes ( "--dry-run" ) ;
if ( ! label || ! amount || ! date ) {
console . error ( "--label, --amount et --date (yyyy-mm-dd) sont requis" ) ;
Deno . exit ( 2 ) ;
}
const dolibarrAddress = assertSandbox ( ) ;
const fr = ( iso : string ) = > {
const [ y , m , d ] = iso . split ( "-" ) ;
return ` ${ d } / ${ m } / ${ y } ` ;
} ;
console . log ( ` cible : ${ dolibarrAddress } ` ) ;
console . log ( ` écriture: ${ label } — ${ amount } € — ${ fr ( date ) } — compte ${ account } — code ${ code } — sens ${ sens === "0" ? "débit" : "crédit" } ` ) ;
if ( dryRun ) {
console . log ( "\n--dry-run : rien n'est soumis." ) ;
Deno . exit ( 0 ) ;
}
const browser = await chromium . launch ( { headless : true } ) ;
const context = await browser . newContext ( { locale : "fr-FR" } ) ;
const page = await context . newPage ( ) ;
/ * *
* Remplit une date Dolibarr : le champ visible pour l ' humain , et le triplet
* day / month / year CACHÉ que le backend est seul à lire . Remplir le seul champ
* texte soumet une date vide — piège déjà payé sur les charges sociales .
* /
async function setDolibarrDate ( name : string , iso : string ) : Promise < void > {
const [ y , m , d ] = iso . split ( "-" ) ;
await page . fill ( ` input[name=" ${ name } "] ` , fr ( iso ) ) . catch ( ( ) = > { } ) ;
await page . evaluate (
( { n , dd , mm , yy } : { n : string ; dd : string ; mm : string ; yy : string } ) = > {
const doc = ( globalThis as unknown as {
document : { querySelector ( s : string ) : { value : string } | null } ;
} ) . document ;
const set = ( suffix : string , v : string ) = > {
const el = doc . querySelector ( ` input[name=" ${ n } ${ suffix } "] ` ) ;
if ( el ) el . value = v ;
} ;
set ( "day" , String ( Number ( dd ) ) ) ;
set ( "month" , String ( Number ( mm ) ) ) ;
set ( "year" , yy ) ;
} ,
{ n : name , dd : d , mm : m , yy : y } ,
) ;
}
/** Cherche l'écriture dans la LISTE — seule confirmation digne de foi. */
async function find ( p : Page , lbl : string , amt : string ) : Promise < string | null > {
await p . goto ( ` ${ dolibarrAddress } /compta/bank/various_payment/list.php?limit=200 ` ) ;
const rows = await p . locator ( "table.liste tr" ) . evaluateAll (
( trs : { textContent : string | null } [ ] ) = >
trs . map ( ( t ) = > ( t . textContent ? ? "" ) . replace ( /\s+/g , " " ) . trim ( ) ) ,
) ;
// Les milliers s'affichent avec une espace insécable : comparer sans espaces,
// sinon l'idempotence saute silencieusement au-delà de 999 €.
const strip = ( x : string ) = > x . replace ( /[\s ]/g , "" ) ;
const money = Number ( amt ) . toFixed ( 2 ) . replace ( "." , "," ) ;
// COMPARER LE LIBELLÉ ENTIER, jamais un préfixe. Une première version tronquait
// à 24 caractères ; or « Indemnité d'occupation — » en fait exactement 24, si
// bien que mars reconnaissait février et se déclarait déjà enregistré. Six mois
// ont été silencieusement sautés. Un préfixe ne distingue que ce qui diffère
// avant lui — hypothèse qu'aucun jeu de libellés ne garantit.
const want = strip ( lbl ) ;
return rows . find ( ( r ) = > {
const row = strip ( r ) ;
if ( ! row . includes ( money ) ) return false ;
if ( row . includes ( want ) ) return true ;
// Dolibarr abrège les libellés longs avec « … » : on accepte alors la
// portion réellement affichée, et seulement elle.
const cut = row . match ( /(.{12,}?)…/ ) ;
return ! ! cut && want . includes ( cut [ 1 ] . slice ( - 40 ) ) ;
} ) ? ? null ;
}
try {
await login . doAdminLogin ( {
page ,
dolibarrAddress ,
adminCredentials : {
username : Deno.env.get ( "DOLI_ADMIN_LOGIN" ) || "undefined" ,
password : Deno.env.get ( "DOLI_ADMIN_PASSWORD" ) || "undefined" ,
} ,
} ) ;
const already = await find ( page , label , amount ) ;
if ( already ) {
console . log ( ` déjà présente, rien à faire : ${ already . slice ( 0 , 110 ) } ` ) ;
Deno . exit ( 0 ) ;
}
await page . goto ( ` ${ dolibarrAddress } /compta/bank/various_payment/card.php?action=create ` ) ;
await page . fill ( 'input[name="label"]' , label ) ;
await page . fill ( 'input[name="amount"]' , amount ) ;
await setDolibarrDate ( "datep" , date ) ;
await setDolibarrDate ( "datev" , date ) ;
await page . selectOption ( 'select[name="accountid"]' , account ) ;
await page . selectOption ( 'select[name="sens"]' , sens ) ;
await page . selectOption ( 'select[name="paymenttype"]' , paymentType ) . catch ( ( ) = > { } ) ;
// Le sélecteur de compte comptable est appairé sur le NUMÉRO affiché
// (« 613000 - Locations »), jamais sur l'id interne, qui diffère d'une
// instance à l'autre.
const opts = await page . locator ( 'select[name="accountancy_code"] option' ) . evaluateAll (
( os : { textContent : string | null ; getAttribute ( n : string ) : string | null } [ ] ) = >
os . map ( ( o ) = > ( { text : ( o . textContent ? ? "" ) . trim ( ) , value : o.getAttribute ( "value" ) ? ? "" } ) ) ,
) ;
const hit = opts . find ( ( o ) = > new RegExp ( ` ^ ${ code } 0* \\ s*- ` ) . test ( o . text ) ) ? ?
opts . find ( ( o ) = > o . text . startsWith ( code ) ) ;
if ( ! hit ) {
console . error ( ` aucun compte comptable ne commence par « ${ code } ». Candidats 61x : ` ) ;
for ( const o of opts . filter ( ( x ) = > /^6[12]/ . test ( x . text ) ) ) console . error ( ` ${ o . text } ` ) ;
Deno . exit ( 1 ) ;
}
console . log ( ` compte retenu : ${ hit . text } ` ) ;
await page . selectOption ( 'select[name="accountancy_code"]' , hit . value ) ;
await page . locator ( 'input[name="save"], input[type="submit"]' ) . first ( ) . click ( ) ;
await page . waitForLoadState ( "networkidle" ) ;
const body = ( await page . locator ( "body" ) . innerText ( ) ) . replace ( /\s+/g , " " ) ;
const err = body . match ( /Erreur[^.]{0,180}/i ) ;
if ( err ) {
console . error ( ` \ nÉCHEC — ${ err [ 0 ] } ` ) ;
Deno . exit ( 1 ) ;
}
const found = await find ( page , label , amount ) ;
if ( ! found ) {
console . error ( "\nNON CRÉÉE — l'écriture n'apparaît pas dans la liste." ) ;
Deno . exit ( 1 ) ;
}
console . log ( ` \ ncréée et vérifiée : ${ found . slice ( 0 , 110 ) } ` ) ;
} finally {
await context . close ( ) ;
await browser . close ( ) ;
}