# Rapport v1.5.0 — Sécurité et robustesse

Neuf points, aucune fonction nouvelle. Ce lot répare ce qui exposait ou ce qui
mentait.

**Recette** : 630 tests, 3 058 assertions, tout au vert (551 avant le lot).
`./vendor/bin/pint` passé. `npm run build` relancé — aucune vue modifiée, sortie
identique. Cinq migrations. La double authentification n'a pas été touchée :
ses trois colonnes restent inutilisées en base, comme demandé.

---

## 1. Les pièces d'identité ne s'énumèrent plus

**Fait.** `ClientDocumentPolicy` vérifie le périmètre de l'utilisateur sur
chaque pièce, et non plus un droit global. Le contrôleur (`download`, `inline`,
`autreVersion`), l'archive documentaire et l'export du relevé d'un domicilié
passent tous par elle. Le cloisonnement du commercial, qui existait en trois
copies et manquait partout ailleurs, est écrit une fois dans `App\Support\Perimetre`.

Chaque pièce reçoit une clé publique aléatoire (ULID) ; les routes la portent à
la place de l'identifiant. Les 404 anciens numéros ne redirigent pas : ils ne
répondent plus.

*Tests* : un commercial ne télécharge pas la pièce d'un client qui n'est pas le
sien ; une adresse bâtie sur l'identifiant numérique répond 404 ; l'archive ne
montre que son périmètre ; le relevé de compte est refusé hors périmètre.

**À vérifier après déploiement** : connectez-vous avec un compte commercial et
tentez la fiche, une pièce et le relevé d'un client qui ne lui est pas assigné —
les trois doivent être refusés. **Tout lien vers une pièce collé dans un e-mail
avant la mise à jour cesse de fonctionner** : il faut le reprendre depuis
l'application.

## 2. Le journal comptable peut être remis dans sa corbeille

**Fait.** `Transaction` et `ClientDocument` passent en suppression réversible.
Supprimer met à la corbeille ; l'effacement définitif exige
`suppressions.definitives`, refusée par défaut à l'administrateur. Le filtre
« Corbeille » et l'action « Restaurer » sont posés sur le journal et sur
l'archive.

Tous les soldes, agrégats et rapports passent par Eloquent : une ligne à la
corbeille cesse aussitôt de compter. Aucune requête brute ne touche ces deux
tables — vérifié.

*Tests* : la mise à la corbeille est réversible ; le solde du compte, le résultat
de l'exercice et la TVA collectée reviennent exactement à leur valeur d'avant, et
recomptent après restauration ; l'administrateur peut supprimer mais pas effacer.

**À vérifier après déploiement** : mettez une écriture à la corbeille, contrôlez
que le solde du compte bouge du montant exact, restaurez-la, contrôlez qu'il
revient.

## 3. Deux policies pour trente-cinq modèles → trente-six policies

**Fait.** Le mode strict de Filament est actif (`->strictAuthorization()`) : il
lève une erreur dès qu'un modèle n'a pas de policy, plutôt que d'autoriser en
silence. Une `BasePolicy` porte la règle commune — deux permissions, et
`suppressions.definitives` pour l'effacement définitif —, et chaque modèle a la
sienne, calée sur les permissions déjà définies dans `Roles`.

Les surcharges `canAccess()`/`canEdit()`/`canDelete()` qui répétaient mot pour
mot la policy ont été retirées des ressources : la règle n'est plus écrite à deux
endroits.

Deux écrans deviennent plus stricts, conformément à ce qu'annonçait déjà leur
porte d'entrée : les charges récurrentes exigent `tresorerie.gerer`, les
résiliations `contrats.gerer`.

*Tests* : trente-six cas rôle × écran ; un consultant de la trésorerie ne peut ni
créer, ni modifier, ni supprimer une écriture ni un compte ; le journal d'audit
n'est réécrivable par personne, pas même le super-administrateur ; un test échoue
si un modèle nouveau arrive sans policy.

**À vérifier après déploiement** : parcourez les écrans principaux avec chacun
des quatre rôles. Un refus doit être visible, jamais une page blanche.

## 4. La sauvegarde prouve quelque chose

**Fait.** Deux manques traités.

*La restauration n'existait pas.* `php artisan mobdie:restaurer <archive>`
contrôle l'intégrité de l'archive, restaure la base instruction par instruction
— et non d'un bloc, où `PDO::exec()` n'aurait signalé aucune erreur au-delà de la
première — puis les documents, en refusant tout chemin qui remonterait
l'arborescence. En production, elle exige que l'on écrive le nom de la base.
`--verifier-seulement` contrôle sans rien écrire.

*L'échec était silencieux.* La constitution de l'archive est entourée ; une ligne
est écrite dans `backup_runs` au démarrage et close au succès comme à l'échec,
avec taille, durée et raison. Le courrier reste mais n'est plus la preuve, et son
propre échec ne fait plus échouer la sauvegarde. Au-delà de 36 heures sans
sauvegarde réussie — ou sans aucune sauvegarde, le cas le plus grave —, un
bandeau « Arrière-plan » paraît sur le tableau de bord.

*Tests* : sauvegarde → destruction → restauration → comparaison ; une archive
absente ou corrompue est refusée ; un dossier en lecture seule produit une ligne
d'échec avec sa raison ; le retard de 36 heures est reconnu.

**À vérifier après déploiement** : le lendemain, `backup_runs` doit porter une
ligne à succès. Puis, une fois sur un serveur de recette, relire une archive avec
`--verifier-seulement`. **Une sauvegarde qu'on n'a jamais relue n'est pas une
sauvegarde** : à refaire chaque trimestre.

## 5. Réessais sur l'IA, et plafond de dépense

**Fait.** L'API distingue désormais l'erreur transitoire (429, 5xx dont le 529
« surchargé », délai, réseau coupé) de l'erreur définitive (401, 404, 400, 413).
Les deux travaux passent à trois essais, avec attente de 30 s puis 120 s, sur les
seules transitoires ; une clé invalide échoue tout de suite. Le worker passe de
`--tries=1` à `--tries=3`.

`failed()` marque l'analyse en échec avec la raison — un travail tué par
dépassement de délai laissait l'analyse figée « en cours » indéfiniment.
`mobdie:balayer-analyses`, à l'heure, solde celles qui traînent malgré tout. Un
indicateur montre les travaux échoués, que rien ne surveillait.

Les deux travaux sont rejouables : ils purgent les extractions du tour précédent
avant d'écrire les leurs.

Un **plafond mensuel de dépense** est appliqué, livré à 500 DH, réglable, `0`
pour ne pas plafonner ; le quota de jetons, qui n'était qu'affiché, l'est aussi.
L'appel est refusé avec un message qui dit quoi faire — et il n'est pas facturé,
le contrôle ayant lieu avant l'appel HTTP.

*Tests* : le classement des codes d'erreur ; 429 → transitoire, 401 → définitive ;
trois essais et l'attente croissante ; `failed()` solde l'analyse ; le balayage
distingue une analyse figée d'une analyse fraîche ; le tour précédent est purgé ;
plafond et quota refusent sans qu'aucune requête ne parte ; `0` ne plafonne rien.

**À vérifier après déploiement** : Réglages → IA, ajuster le plafond au budget
réel. C'est le seul réglage de ce lot qui demande une décision. Surveiller aussi
`failed_jobs`, qui se remplit désormais (`php artisan queue:retry all`).

## 6. Un e-mail qui ne part pas ne perd plus de prospect

**Fait.** Le formulaire public enregistre le prospect puis tente le courrier :
son échec est journalisé, la demande aboutit. Le visiteur ne reçoit plus une
erreur 500 sur une demande pourtant enregistrée — ce qui le faisait recommencer,
et créait un doublon. Même traitement pour les alertes de conformité (le
planificateur quotidien ne meurt plus sur un SMTP muet) et pour le compte rendu
de sauvegarde.

L'envoi groupé des liens de coordonnées passe en file d'attente : sur 102 clients
la passerelle coupait vers la soixantième seconde. Un envoi qui échoue n'arrête
pas les suivants, et le compte rendu — nombre parti, clients sans adresse,
échecs nommés — arrive en notification.

`config/mail.php` reçoit un délai maximal (`MAIL_TIMEOUT`, 30 s) : il valait
`null`, et un SMTP qui pend bloquait jusqu'à la limite de PHP.

*Tests* : le prospect est en base malgré une messagerie en panne ; les alertes de
conformité aussi ; le travail groupé ignore les clients sans adresse sans les
compter en échec ; un envoi qui lève n'empêche pas les deux suivants de partir,
et le client fautif est nommé au compte rendu ; le délai SMTP est un entier
positif.

**À vérifier après déploiement** : ajouter `MAIL_TIMEOUT=30` au `.env` (30 s par
défaut s'il est absent). Puis un envoi groupé sur trois clients pour lire le
compte rendu en notification.

## 7. Les écrans qui calculaient deux fois

**Fait.** `ComptaReport` et `CompteCourantAssocie` sont mémorisés le temps de la
requête ; `tresorerie()` et `soldes()` ne se recalculent plus quand `dettes()` et
`detteTotale()` les redemandent. `Account::soldeCourant()` fait une requête au
lieu de deux. Les orphelins de compte courant se comptent en SQL au lieu de
charger tous les mouvements en mémoire. `RecouvrementWidget` calcule son
ancienneté moyenne en SQL. `AlertesDomiciliationWidget` passe par le service
partagé de complétude au lieu de recharger tous les clients — ce qui garantit en
prime que sa carte et l'écran « Dossiers incomplets » ne puissent pas diverger.
`LegalCompleteness` lit la relation déjà chargée au lieu d'une requête par
client. Les tables des factures et des échéances préchargent leurs relations.

Quatre index posés : `invoices.statut`, `invoices.date`,
`transactions.direction`, et la clé étrangère
`domiciliation_installments.invoice_id`, qui manquait tout à fait.

Une invalidation explicite accompagne la mémorisation : toute écriture, tout
rattachement de compte courant oublie le calcul, sans quoi un écran qui saisit
puis réaffiche montrerait le solde d'avant sa propre saisie.

*Tests* : le nombre de requêtes du tableau de bord est identique avec 6 clients
et avec 30 ; un second appel à `tresorerie()` ne coûte aucune requête ; le solde
d'un compte en coûte exactement une ; `detteTotale()` n'en coûte aucune après
`soldes()`.

**À vérifier après déploiement** : rien de visible. Le test de comptage est le
garde-fou : s'il échoue un jour, c'est qu'une requête est revenue.

> **Note sur la migration** : les `invoice_id` qui ne désignaient aucune facture
> sont mis à `NULL` avant la pose de la contrainte. Sur la base de production ces
> lignes étaient nombreuses — c'est le défaut que l'écran de l'échéancier
> contournait déjà.

## 8. Le message qui trompait

**Fait.** Il annonçait que « les envois d'e-mails ne partiront pas ». Faux :
devis, factures et relances partent en synchrone, file arrêtée ou non. Il nomme
maintenant ce qui s'arrête réellement — les analyses de pièces et les envois
groupés — et précise que les envois commerciaux ne sont pas concernés.

## 9. Douze commandes de reprise mises à part

**Fait.** Douze scripts quittent `mobdie:` pour `reprise:` et vivent dans
`app/Console/Commands/Reprise`. Un socle commun, `CommandeDeReprise`, affiche à
l'exécution à quelle reprise chacune a servi et pourquoi on ne la rejoue pas, et
refuse de s'exécuter en production sans confirmation interactive (`--force` pour
un script).

`reprise:ventiler-tva` refuse désormais de tourner sans `--annee` : sans année,
elle retraiterait tout l'historique, exercices clos compris.
`--tout-l-historique` le demande explicitement.

Les dix commandes d'exploitation gardent `mobdie:` : `sauvegarde`, `restaurer`,
`planificateur-quotidien`, `balayer-analyses`, `diagnostic`, `indexer-archive`,
`generer-commissions`, `reparer-permissions`, `charger-dump`, `generer-dump`.

*Tests* : douze commandes sous `reprise:` et aucune ancienne survivante ; toutes
dérivent du socle et acceptent `--force` ; la phrase de service est affichée ;
`ventiler-tva` refuse sans année ; la confirmation de production est refusable.

**À vérifier après déploiement** : si un cron ou un script maison appelle l'une
des douze anciennes commandes, corriger son nom — l'ancien n'existe plus. La
table de correspondance est dans `DEPLOIEMENT.md`, §v1.5.0.

---

## Ce qui n'a pas été fait, et pourquoi

La **double authentification** est laissée de côté à la demande de l'exploitant.
Les trois colonnes `two_factor_*` restent inutilisées en base : c'est assumé, et
ce n'est pas un oubli.
