---
suivi: 1672
date: 2026-09-23
sujet: Réparation FAC-2026-935 et verrou paiement annulé
chantier: factures / paiement / sage
type: fix
statut: poussé
hash: 14dec62f
fichiers:
  - app/Support/EtatPaiementSage.php
  - app/Services/PaiementLignesPayablesService.php
  - app/Services/IntacctPaiementStatusService.php
  - app/Services/PaiementAppymtExecutionService.php
  - app/Services/IntacctService.php
  - app/Services/FactureRetablissementPayable935Service.php
  - app/Console/Commands/RetablirFacturePayable935Command.php
  - tests/Unit/IntacctPaiementStatutMappingTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - docs/suivi/SUIVI_1672_reparation_facture_935_et_verrou_paiement_annule.md
---

# SUIVI #1672 — FAC-2026-935 et paiements Sage annulés

Reprise du diagnostic #1671, non refait. Facture FAC-2026-935 (`factures.id` 956,
dépôt 1203, INFRASUD, 1 000 € HT / 1 200 € TTC, bill 35029). `paiements_intacct`
id 892 porte APPYMT 367073, `intacct_appymt_state = V` (voided),
`intacct_apbill_state = Posted`. Robin : le virement n'est jamais parti à la
banque. Cas isolé mesuré en production le 23/09/2026 (une seule facture
`statut = en_paiement` ET `etat_paiement_sage = paye`).

Aucune migration. Aucun appel Sage en écriture. La ligne 892 n'est pas
supprimée. L'outil de forçage, la synchro Sage (anti-régression, exclusion
du bouton), le module foncier et `AppShell.vue` ne sont pas touchés.

## PROMPT ENVOYÉ

Partie A : `paidBillIdsLookup` ignore les APPYMT annulés, liste unique des
états « annulé », ne pas supprimer la trace 892, signaler sans corriger les
autres compteurs (bilan, liste, exports, KPI).

Partie B : commande artisan dédiée, dry-run par défaut, `--apply` +
confirmation pour écrire seulement `factures.statut` → `comptabilisee`,
`etat_paiement_sage` → NULL, `intacct_when_paid` → NULL,
`intacct_appymt_when_created` → NULL. Refus si l'identité ou l'état diffère.
Trace `facture_historique` via le mécanisme existant.

Partie C : la relecture Sage à l'ouverture de fiche (TTL 20 min) peut-elle
repasser la facture en payée ? Si oui, poser la garde.

## BASE GIT ET FICHES LUES

`origin/main` au départ : `2b1f6c60` (#1671). #1667 et #1670 déjà poussés
avant ce lot (`153f76a0` / `3364f3b6` pour #1670). `hectareenvol.sql` reste
non suivi.

Fiches : #1671 (diagnostic, repris tel quel), #1571 (liste APBILL Reversed /
Reversal / Extourné / Extourne — `V` écarté volontairement ; anti-régression
« déjà payé » laissée), #1616 (libellés sonde Voided / Void, fichier ACH
APADVANCE — ce lot est un APPYMT). Aucune fiche #1621.

## PARTIE A — VERROU

### Avant (`PaiementLignesPayablesService`, diagnostic #1671, lignes 447-460)

Tout `intacct_bill_id` présent dans `paiements_intacct` entrait dans le
lookup, sans regarder l'état. `collectLineStubs` (alors vers 547, maintenant
561, 540, 617, 651, 702) retire la ligne si `isset($paidLookup[$billId])`.

### Après (`paidBillIdsLookup`, lignes 448-475)

Un bill reste dans le lookup s'il a au moins une ligne dont l'état est NULL,
vide, ou hors de `EtatPaiementSage::ETATS_APPYMT_ANNULES`. Une ligne `V`
seule ne bloque plus. Une trace locale pas encore relue (état vide) bloque
toujours.

### Liste unique

`App\Support\EtatPaiementSage::ETATS_APPYMT_ANNULES` = `V`, `VOID`, `VOIDED`.
`estAppymtAnnule()` est le seul test. Ce n'est pas
`config erp.intacct.apbill_precheck_ignore_states` (#1571) : cette liste
décrit l'APBILL, et `V` n'y a pas été ajouté. `V` est la valeur API de
production ; VOID / VOIDED sont les libellés de la sonde #1616, colonne
`varchar(10)`.

Appelants de cette liste dans ce lot :

- `PaiementLignesPayablesService::paidBillIdsLookup`
- `IntacctPaiementStatusService::mapperVersEtatErp` (garde partie C)
- `PaiementAppymtExecutionService::validerLigneMetier` (le bouton Traiter
  jetait « déjà payé » sur toute trace, y compris `V`)
- `IntacctService::billHasAppymtPayment` (lecture APPYMTDETAIL : le champ
  `STATE` est demandé ; si chaque ligne est annulée, le pré-check ne bloque
  plus). Ce n'est pas une écriture Sage.

La ligne 892 n'est ni supprimée ni modifiée.

### A.4 — vérifié, défauts hors périmètre (non corrigés)

| Endroit | Constat |
|---|---|
| `BilanFinancierService::statutsDepotPayesMarcheKpi` (1250-1255) | « Facturé payé » = dépôts `paye` et `valide` seulement. Ne lit pas `paiements_intacct`. Ce lot ne change pas `depot_factures.statut` (reste `comptabilisee`). Le total ne bouge pas par ce code. |
| `DepotReglementStatutService::paiementEngage` (232) | `exists()` sur le dépôt, sans état. Une trace `V` fait encore « paiement engagé » avant le lookup corrigé. |
| `DepotReglementStatutService::billEstDansPaiementsIntacct` (259) | Si le lookup passé est vide, repli `exists()` sans filtre d'état. |
| `FactureDecomptabilisationService` (204) | Refuse la décomptabilisation dès qu'une ligne `paiements_intacct` existe, y compris `V`. |
| `RattrapageFacturesFilePaiement` (218) | Exclut un bill dès qu'une ligne existe, y compris `V`. |
| `RapprochementTresorerieService` | Somme tous les `PaiementIntacct` du programme / mois, sans état. |
| `FactureSuppressionPolicy` (70) | Bloque la suppression dès qu'une trace existe. |
| `PaiementAppymtExecutionService::mettreAJourStatutFactureApresPaiement` (138, 176) | `exists()` sans état. Appelé après un APPYMT neuf : la nouvelle ligne suffit à conclure. Un bill qui n'aurait QUE la trace voidée serait compté payé à tort. Hors du chemin de FAC-2026-935 avant le prochain Traiter. |
| `FactureSuperAdminCorrectionService` | Avertit sur toute ligne `paiements_intacct`. Outil de forçage = lot séparé, non touché. |
| `factures:annuler-paiement` | Supprime les traces et ne vide pas `etat_paiement_sage`. Ne pas l'utiliser ici. |

Compteurs de liste et exports : aucun autre filtre « bill présent = payé »
que ceux ci-dessus n'a été trouvé sur `paiements_intacct` pour la file de
préparation. La frise lit `etat_paiement_sage`, pas la table des paiements.

### File de paiement — mesure

Base locale injoignable (`SQLSTATE[HY000] [2002]` vers `127.0.0.1:3306`).
Le nombre de factures dans l'écran n'a pas été mesuré ici.

La partie A seule ne fait pas entrer la 956 : `eligibleFacturesQuery` exige
`factures.statut = comptabilisee`, et la 956 est encore `en_paiement` tant
que la commande n'a pas écrit. Elle peut faire entrer d'autres factures déjà
`comptabilisee`, éligibles au workflow, dont toutes les lignes
`paiements_intacct` sont annulées. Requête à lancer en production **avant**
`--apply` (et juste après le déploiement, avant de considérer le lot sain) :

    SELECT f.id, f.reference, f.statut
    FROM factures f
    JOIN depot_factures d
      ON d.facture_id = f.id AND d.deleted_at IS NULL
    WHERE f.deleted_at IS NULL
      AND f.statut = 'comptabilisee'
      AND d.statut = 'comptabilisee'
      AND d.saisie_historique = 0
      AND EXISTS (
        SELECT 1 FROM workflow_validations wv
        WHERE wv.facture_id = f.id
          AND wv.niveau = 'paiement_effectif'
          AND wv.statut = 'en_attente'
      )
      AND NOT EXISTS (
        SELECT 1 FROM workflow_validations wv
        WHERE wv.facture_id = f.id
          AND wv.niveau = 'paiement_effectif'
          AND wv.statut = 'valide'
      )
      AND EXISTS (
        SELECT 1 FROM paiements_intacct p
        WHERE p.intacct_bill_id IN (d.intacct_bill_id, f.intacct_record_no, f.intacct_bill_id)
          AND UPPER(TRIM(p.intacct_appymt_state)) IN ('V', 'VOID', 'VOIDED')
      )
      AND NOT EXISTS (
        SELECT 1 FROM paiements_intacct p
        WHERE p.intacct_bill_id IN (d.intacct_bill_id, f.intacct_record_no, f.intacct_bill_id)
          AND (
            p.intacct_appymt_state IS NULL
            OR TRIM(p.intacct_appymt_state) = ''
            OR UPPER(TRIM(p.intacct_appymt_state)) NOT IN ('V', 'VOID', 'VOIDED')
          )
      );

Si ce SELECT renvoie des lignes, la partie A les fait entrer sans la
commande. S'arrêter et les examiner avant `--apply`. Le diagnostic #1671
annonçait un cas isolé sur `en_paiement` + `paye` ; ce SELECT vise un autre
ensemble (déjà `comptabilisee`, bloquées seulement par une trace annulée).

Après `--apply`, l'écran doit gagner exactement FAC-2026-935 en plus de
l'état mesuré juste avant la commande.

## PARTIE B — COMMANDE

`php artisan factures:retablir-fac-2026-935 {facture}`

Dry-run par défaut. `--apply` puis confirmation interactive. Sans TTY,
`confirm()` refuse : rien n'est écrit.

Écrit, et rien d'autre, sur la facture 956 seulement :

- `factures.statut` : `en_paiement` → `comptabilisee`
- `etat_paiement_sage` : `paye` → NULL
- `intacct_when_paid` → NULL
- `intacct_appymt_when_created` → NULL

Refus si l'id n'est pas 956, si la référence n'est pas FAC-2026-935, si les
montants ne sont pas 1000 / 1200, si le statut, l'état Sage ou les dates
(2026-09-10) diffèrent, si `virement_execute_at` ou `paiement_manuel_at`
n'est pas NULL, si les bill ids (facture, record no, dépôt) ne sont pas
35029, si le dépôt n'est pas 1203 `comptabilisee` avec `date_paiement` NULL,
si le workflow `paiement_effectif` n'est pas `en_attente` sans ligne
`valide`, ou si la ligne 892 n'est pas le bill 35029 / APPYMT 367073 /
1 200 € / état annulé.

`Facture::query()` et `DepotFacture::query()` appliquent SoftDeletes
(`deleted_at IS NULL` visible dans le SQL de l'échec de connexion locale).
`PaiementIntacct` et `WorkflowValidation` n'ont pas de soft delete.

Aucun `IntacctService` dans la commande ni dans
`FactureRetablissementPayable935Service`. Transaction locale, `lockForUpdate`
sur la facture, relecture, `assertEcritureAttendue` (rollback si un champ
« intact » a bougé).

Audit : `DepotFacture::logHistorique` (`Historisable`), action
`retablissement_payable_935`, `system: true`, commentaire qui cite l'extourne
APPYMT état V et le fait qu'aucun virement n'était parti. Écrit
`facture_historique.facture_id` + `depot_facture_id`.

Droits : commande console, hors Gate / groupes M365. `Gate::before` vrai
pour GS_ADMIN_ERP ne prouve aucun refus. Le dry-run et la confirmation sont
les seuls freins.

## PARTIE C — RELECTURE

Sans garde, la réparation ne tient pas. Après écriture, `etat_paiement_sage`
n'est plus `paye`, donc l'anti-régression de
`FacturePaiementStatutSyncService::appliquerEtat` (ne jamais reculer un
`paye`) ne protège plus. `estEligible` est vrai pour `comptabilisee` + état
NULL. L'ouverture de fiche, TTL 20 min dépassé (`intacct_paiement_synced_at`
= 2026-09-23 14:34:53), rappelle Sage.

`estApBillPaye` conclut PAYE si WHENPAID est renseigné et TOTALDUE ≈ 0, ou
si STATE = Paid, même quand l'APPYMT est voidé. DISPLAYSTATE Confirmed /
Delivered sur le header retenu conclut aussi. La synchro réécrirait alors
`statut = payee` (ou un rang inférieur, qui ressortirait la facture de la
file).

Garde retenue, dans `IntacctPaiementStatusService::mapperVersEtatErp`, après
l'extourne APBILL (#1571) et **avant** `estApBillPaye` et DISPLAYSTATE : si
l'état du header APPYMT retenu est annulé (`V` / `VOID` / `VOIDED`), le
mapper retourne `null`. Un signal `null` dans `appliquerEtat` ne met à jour
que `intacct_paiement_synced_at`. Statut `comptabilisee` et
`etat_paiement_sage` NULL restent. L'anti-régression et l'exclusion des
`paye` par le bouton de synchro ne sont pas modifiées (lot séparé).

Priorité des headers inchangée : un APPYMT vivant Confirmed ou `P` l'emporte
sur un `V` (priorité 1). Le test 8 (rouvrir après 20 min) tient par cette
garde, pas par l'anti-régression.

`intacct_apbill_state = Posted` est normal : le bill existe, seul le
paiement est annulé. Les ids Sage sont conservés pour le repaiement.

Le 23/09 à 14:34, la synchro a touché `intacct_paiement_synced_at` sans
passer le statut à `payee` : à cet instant le mapping n'était pas PAYE
(l'anti-régression n'empêche pas la réécriture du statut quand il n'est pas
déjà `payee`). La garde couvre aussi un WHENPAID résiduel lu plus tard.

## CLOSURES — captures relues

- `paidBillIdsLookup` : `function ($q) use ($annules, $placeholders)` — les
  deux variables sont utilisées dans le `NOT IN`.
- `validerLigneMetier` : `static function (PaiementIntacct $row)` — aucun
  `use`.
- `appliquer` : `function () use ($factureId)` — `$factureId` utilisé dans
  `whereKey` et `charger`.
- `lireEtat` : `static fn (WorkflowValidation $w)` — aucun `use`.
- `billHasAppymtPayment` et le mapper : pas de closure ajoutée.

## VÉRIFICATIONS LOCALES

`php -l` : « No syntax errors detected » sur chaque PHP modifié (sortie
brute dans la synthèse).

ReflectionClass + `$app->make()` : commande, `PaiementLignesPayablesService`,
`FacturePaiementStatutSyncService`, `FactureRetablissementPayable935Service`
s'instancient. `method_exists authorize` = no (commande et services, pas des
contrôleurs). Commande enregistrée : `factures:retablir-fac-2026-935`.

`IntacctPaiementStatutMappingTest` : 14 passed. Le cas nouveau : APBILL
Posted + WHENPAID + TOTALDUE 0 + DISPLAYSTATE Confirmed + état `V` ou
`VOIDED` → null ; le même bill avec état `C` reste PAYE. Les cas Reversed /
Confirmed+C existants passent.

Dry-run local impossible : MySQL `127.0.0.1:3306` refusé. Aucun SELECT avant
/ après n'a été exécuté ici. Aucun `--apply` lancé. Les tests 1 à 10 de la
recette se font en production, dry-run d'abord.

## DÉPLOIEMENT-TEST

Sur le serveur OVH, après push :

    git pull origin main
    php artisan optimize:clear
    php artisan view:clear
    php artisan journal:sync

Pas de `route:clear` (aucune route ajoutée). Pas de `migrate`. Jamais
`npm`, `composer`, `optimize`, `config:cache`, `route:cache`, `view:cache`.

Puis, en CLI sur le serveur (la commande n'appelle pas Sage) :

1. SELECT d'ampleur (ci-dessus). Si des lignes autres que le cas prévu :
   s'arrêter.
2. SELECT des quatre champs + dépôt + workflow + `paiements_intacct` 892.
3. `php artisan factures:retablir-fac-2026-935 956` (dry-run). Les quatre
   changements s'affichent. Refaire le SELECT : identique.
4. `php artisan factures:retablir-fac-2026-935 956 --apply` et confirmer.
5. SELECT : les quatre champs cibles, le reste inchangé, ligne 892 état V,
   une ligne `facture_historique` action `retablissement_payable_935`.
6. Écran de préparation : FAC-2026-935 présente, effectif +1 par rapport au
   comptage fait juste avant l'étape 4.
7. Frise : étapes aval plus en vert (`etat_paiement_sage` NULL).
8. Bilan du programme : inchangé (`depot.statut` toujours `comptabilisee`).
9. `php artisan factures:retablir-fac-2026-935 1` (ou toute autre id) :
   refus, aucune écriture.
10. Rouvrir la fiche plus de 20 minutes après : reste `comptabilisee`, ne
    repasse pas `payee`.

## LEÇON

Un APPYMT `V` stocké en local n'est pas un paiement. Le lookup, le bouton
Traiter et le mapper de relecture doivent partager
`EtatPaiementSage::estAppymtAnnule`. Vider `etat_paiement_sage` sans cette
garde rend la facture à nouveau éligible à la synchro, et WHENPAID résiduel
la repasse payée. La liste APBILL d'extourne (#1571) ne doit pas recevoir
`V`. Ne pas lancer `factures:annuler-paiement` sur ce cas : elle efface la
preuve d'extourne.
