diff --git a/CHANGELOG.md b/CHANGELOG.md index e120cc9fb..4bd35c911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ### Fixed +- **Le PDF d'une facture changeait après son émission ([#387](https://github.com/guycorbaz/kesh/issues/387)).** Kesh le régénérait à chaque téléchargement, à partir des données du moment : modifier vos coordonnées, l'adresse du client ou la mise en page modifiait aussi les factures **déjà envoyées**, et deux téléchargements successifs ne rendaient jamais le même fichier. Impossible, dans ces conditions, de produire la copie exacte d'une pièce émise. + + **Désormais**, le PDF est **figé** au premier téléchargement ou au premier envoi par e-mail qui suit la validation. Kesh le conserve avec son empreinte et rend ensuite ce même fichier, octet pour octet — y compris pour une facture annulée par un avoir. La fiche facture affiche *Document figé le …*. La dévalidation détache le document ; la revalidation en figera un nouveau. Les factures validées avant cette version sont figées à leur prochain téléchargement, avec les données du moment. + + ⚠️ **Le répertoire des documents (`KESH_DOCUMENTS_DIR`) contient désormais des pièces à conserver, et il doit être sauvegardé et restauré avec la base** : ni le dump MariaDB ni le fichier `.keshbackup` ne les contiennent ([#503](https://github.com/guycorbaz/kesh/issues/503)). Un fichier manquant n'est jamais régénéré en silence : le téléchargement est refusé avec un message qui nomme le fichier à restaurer, et un administrateur peut, à défaut, **refiger** la facture — un nouveau document, tracé au journal d'audit avec l'ancienne et la nouvelle empreinte. Le manuel administrateur décrit la procédure. + - **Le tableau de bord disait faux ([#388](https://github.com/guycorbaz/kesh/issues/388), [#389](https://github.com/guycorbaz/kesh/issues/389)).** La tuile *Dernières écritures* n'appelait rien et affichait « Aucune écriture » sur une comptabilité pleine ; elle liste désormais les cinq dernières écritures. *Factures ouvertes* n'affichait aucun montant et, pour un rôle Consultation, toujours « Aucune facture ouverte » : elle donne maintenant le nombre de factures ouvertes et leur **reste dû**, dont la part échue, à tous les rôles. Une tuile qui ne peut pas se charger le dit. Le solde des comptes bancaires est nommé **solde comptable** — il vient du grand livre, pas de la banque —, et la tuile le confronte au **dernier relevé CAMT importé** : solde du relevé, date et **écart**, corrigé des dates de valeur des mouvements rapprochés. Un compte du grand livre partagé par plusieurs comptes bancaires n'est plus compté deux fois dans le total, et les soldes ne passent plus par des nombres flottants. - **La balance des comptes ne concordait pas avec le bilan ([#385](https://github.com/guycorbaz/kesh/issues/385)).** Elle ne montrait que les **mouvements** de la période : le compte « clients » de la balance 2026 ne valait pas celui du bilan 2026, qui reporte les soldes depuis l'origine. Deux rapports, un compte, deux nombres — c'est ce qu'un réviseur trouve en dix minutes. @@ -54,6 +60,10 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ### Changed +- **`invoices.csv` de l'export de souveraineté gagne trois colonnes** — `pdf_sha256`, `pdf_frozen_at`, `pdf_language`, l'empreinte, la date et la langue du PDF émis ([#387](https://github.com/guycorbaz/kesh/issues/387)). Elles s'insèrent **avant** `version`, `created_at` et `updated_at` : un outil qui lit ce fichier par position de colonne doit être ajusté. Le PDF lui-même n'est pas dans l'archive. L'API expose `pdfFrozenAt` et `pdfLanguage` sur la facture, et une route d'administration `POST /api/v1/invoices/{id}/pdf/refreeze` refige un document dont le fichier a disparu. + +- **Le PDF téléchargé d'une facture est dans la langue du client**, comme celui qui part par e-mail ([#387](https://github.com/guycorbaz/kesh/issues/387)). Il suivait jusqu'ici la langue de l'installation : le document téléchargé n'était donc pas forcément celui que le client avait reçu. Le premier téléchargement, quel que soit le rôle de l'utilisateur — consultation comprise —, fige le document et l'inscrit au journal d'audit. Une **clé d'API en lecture** fige aussi, à son nom — et une simple requête `HEAD` suffit. Une facture **annulée** dont le fichier manque ne peut pas être refigée : seule la restauration du fichier la répare. Restaurer une sauvegarde **antérieure** au gel fera figer un nouveau document au téléchargement suivant. Les rappels, eux, restent produits à chaque envoi ([#502](https://github.com/guycorbaz/kesh/issues/502)). + - **« Total liquidités » devient « Total (solde comptable) »** sur le tableau de bord ([#389](https://github.com/guycorbaz/kesh/issues/389)) : le chiffre est le solde du grand livre, pas l'argent en banque. *(API : `GET /api/v1/bank-accounts` ajoute `statementClosingBalance`, `statementDate` et `ledgerBalanceAtStatement` ; les montants restent des chaînes décimales.)* - **La balance des comptes change de colonnes — écran, PDF et CSV ([#385](https://github.com/guycorbaz/kesh/issues/385)).** Ouverture | Débit | Crédit | Clôture : la colonne **« Solde »**, qui était le net des mouvements de la période et se prenait pour un solde de compte, disparaît de l'écran et du PDF. ⚠️ **Dans le CSV, la colonne `Solde` est remplacée** par `SoldeOuverture` (avant `TotalDebit`) et `SoldeCloture` (en dernier) : un outil qui lit ce fichier par position doit être adapté. Le CSV porte aussi la ligne du résultat reporté. *(API : `openingBalance` et `closingBalance` par compte, `retainedEarnings` et `openingBalanced` au rapport ; `balance` garde son sens — le net des mouvements.)* diff --git a/README.md b/README.md index 13ebc1ec3..540cd13a5 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - **Comptabilité en partie double** — plan comptable suisse, écritures validées, journal d'audit consultable à l'écran - **Carnet d'adresses & catalogue produits** — contacts, conditions de paiement, TVA -- **Facturation QR Bill 2.2** — génération PDF conforme au standard suisse, **adresses structurées (type S)** conformes à l'exigence SIX en vigueur (rue / n° / NPA / localité séparés) ✓ +- **Facturation QR Bill 2.2** — génération PDF conforme au standard suisse, **adresses structurées (type S)** conformes à l'exigence SIX en vigueur (rue / n° / NPA / localité séparés) ; le PDF émis est **figé** au premier téléchargement ou envoi, dans la langue du client, et rendu ensuite à l'identique ✓ - **Ventilation du chiffre d'affaires par ligne de facture** — chaque ligne peut porter son propre **compte de produit** (honoraires, marchandises, prestations…), sélectionnable dans le formulaire ; une ligne laissée vide suit le compte par défaut de la société, affiché explicitement. L'écriture comptable ventile le crédit sur les bons comptes dès la validation, avoirs compris — compte de résultat exploitable sans reclassement manuel ✓ - **Compte de produit sur la fiche article** — chaque article du catalogue porte **son** compte de produit, choisi une fois plutôt que ressaisi à chaque facture ; la ligne montée depuis le catalogue le **recopie** automatiquement, puis reste autonome — vidée, elle suit le compte par défaut de la société ✓ - **Coordonnées de l'émetteur sur la facture** — téléphone, e-mail et site web figurent sous votre adresse sur le PDF, saisis une fois dans les réglages ; chaque champ est facultatif et n'occupe aucune place s'il est vide ✓ diff --git a/_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md b/_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md new file mode 100644 index 000000000..7f16638af --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md @@ -0,0 +1,570 @@ +# Story 25.6-b : Le PDF d'une facture est figé — une pièce émise ne change plus + +Status: done + +**Issue : [#387]**, que cette story **ferme** : la PR porte `closes #387` dans le **titre ET le corps**. Branche +`story/25-6-b-pdf-facture-archive`, partie de `main` (`d7c74f02`). Seconde moitié de la 25-6 (la 25-6-a a traité #388 et +#389). + +## Arbitrages (Guy — 1 à 6 le 2026-10-03, 7 et 8 le 2026-10-04) + +1. **Quand figer** : au **premier rendu après validation** (premier téléchargement OU premier envoi par e-mail) — pas + dans la transaction de validation. Les factures déjà validées sont figées à leur prochain rendu, avec les données du + moment (leurs PDF passés n'ont jamais été conservés). +2. **Langue** : celle du **client** (`resolve_language` : `contact.language`, sinon la langue d'instance), la règle de + l'e-mail. Le téléchargement rend désormais **le document envoyé**. +3. **Stockage** : des **colonnes nullables sur `invoices`**, pas de nouvelle table (une table neuve rendrait toutes les + sauvegardes existantes non importables, `post_restore.rs:20-36`). +4. **Dévalidation** : le PDF figé est **détaché** ; son empreinte va au journal d'audit. (Elle reste refusée pour une + facture envoyée par e-mail — inchangé.) +5. **Facture annulée par un avoir** : son PDF figé, s'il existe, reste servi. +6. **Empreinte** SHA-256 au journal d'audit au gel, **vérifiée à chaque lecture**. + +7. **(2026-10-04, validation P1)** **Refiger, geste d'administrateur** : un PDF figé dont le fichier a disparu (le cas + de toute restauration tant que [#503] n'est pas livrée) peut être **refigé** par un administrateur — nouveau document, + ancienne et nouvelle empreinte au journal d'audit. Sans ce geste, une facture envoyée dont le fichier manque serait + sans issue : la dévalidation est refusée, et rien ne refige. +8. **(2026-10-04, validation P1)** **Le gel par tout rôle** : le premier téléchargement fige, que l'acteur soit + Consultation ou une clé d'API en lecture — une pièce sortie de Kesh est figée, qui que ce soit qui l'ait fait sortir ; + l'audit nomme l'acteur. + +**Hors périmètre, en CR** : figer les PDF d'avoir et de rappel ([#502]) ; emporter les fichiers dans `.keshbackup` +([#503]). + +## Story + +En tant que **personne qui émet des factures dans Kesh**, +je veux que **le PDF d'une facture, une fois émis, ne change plus** — même si je modifie ensuite mes coordonnées, celles +du client ou la mise en page, +afin de **pouvoir produire, dans cinq ans, la copie exacte de la facture envoyée**, ce qu'un contrôle demande. + +## Les faits, vérifiés dans le code (cartographie du 2026-10-03) + +- **Régénéré à chaque demande** : `GET /api/v1/invoices/{id}/pdf` (`crates/kesh-api/src/routes/invoice_pdf.rs:23-56`) + appelle `invoice_pdf_service::render(…, state.config.locale, …)` ; `render` (`crates/kesh-api/src/routes/invoice_pdf_service.rs:177-269`) relit + **en direct** la facture, le contact (nom, adresse, `client_number`), le compte bancaire principal, la société, les + textes i18n. Il exige le statut `validated` (`:211-213`). +- **Deux langues** : le téléchargement rend dans la langue de l'**installation** ; l'envoi par e-mail + (`invoice_email.rs:732`, rendu `:785-787`, pièce jointe `:797-801`) dans celle du **client** (`resolve_language`, + `:69-71`). Aujourd'hui, le PDF téléchargé n'est donc pas forcément celui qui a été envoyé. +- **Non reproductible** : `kesh-qrbill` inscrit `now_utc()` comme date de création (`crates/kesh-qrbill/src/pdf.rs:28-35`, + `:78-83`) et printpdf 0.7 un `/ID` aléatoire. Deux rendus identiques n'ont pas le même SHA-256 : **seul un fichier figé + une fois fait preuve**. +- **Le stockage réutilisable** : `crates/kesh-api/src/document_storage.rs` — `store_document(dir, bytes, ext, + original_filename, mime_type) -> io::Result` (`:84-143`), nommage `{sha256}.{ext}`, écriture atomique + (temporaire + `sync_all` + `rename`), refus d'un contenu vide, extension contrôlée ; `read_document(dir, storage_path)` + (`:154-167`) contrôle le chemin et distingue `NotFound` / `InvalidPath` / `Io`. Répertoire : `KESH_DOCUMENTS_DIR` + (`config.rs:253-255`, défaut `/data/documents`). Patron de service HTTP : `serve_document` + (`routes/imported_supplier_invoices.rs:415-445`) — fichier absent → **410** `SourceDocumentGone` (`errors.rs:914`). +- **Cycle de vie** : validation `validate_invoice` (`kesh-db/src/repositories/invoices.rs:1836`, commit `:2205-2208`) — + dans **kesh-db**, alors que le rendu est dans **kesh-api** ; dévalidation `unvalidate` (`invoices.rs:1441`, refus si + envoyée par e-mail `:1529-1541`, `UPDATE … status='draft'` `:1576-1578`, audit `:1602`) ; l'avoir passe la facture en + `cancelled` (`credit_notes.rs:583-586`) — **son PDF n'est plus servi** (`render` refuse). +- **Frontend** : bouton PDF de la fiche facture affiché seulement si `validated` (`invoices/[id]/+page.svelte:756-767`, + `downloadPdf` `:671-675`). +- ⚠️ **La chaîne 25-4 (non mergée) touche les mêmes lignes** (validation P2, M6) : elle ajoute `rounding_amount` à + `Invoice`, à `INVOICE_COLUMNS`, à `FIND_INVOICE_SCOPED_SQL`, aux `SELECT` en dur de `invoices.rs`, à l'en-tête CSV et + à son test, et modifie l'`UPDATE` de `unvalidate`. Conflits textuels attendus au merge ; la constante unique de + colonnes (AC 1-bis) inclura `rounding_amount` si la chaîne est mergée la première, et les références de lignes + ci-dessous se décaleront. +- **Migrations** : 70 sur `main`. ⚠️ La chaîne 25-4 (non mergée) en ajoute quatre, datées `20261001000001`–`…04` : la + migration de cette story est datée **`20261003000001`** pour s'ordonner après elles ; les compteurs (`upgrade_path`, + audit d'idempotence) se recomposeront au merge. + +## Acceptance Criteria + +**AC 1 — Le schéma.** Migration `20261003000001_invoices_frozen_pdf.sql` : `ALTER TABLE invoices ADD COLUMN` +`pdf_storage_path VARCHAR(512) NULL`, `pdf_sha256 CHAR(64) NULL`, `pdf_frozen_at DATETIME(3) NULL`, +`pdf_language CHAR(2) NULL`, et une contrainte **tout-ou-rien** (`CHECK` : les quatre nuls, ou les quatre renseignés avec +`CHAR_LENGTH(pdf_sha256) = 64`). DDL pur, **non breaking** (`ADD COLUMN` nullable — P1/P3, pas de bump de +`kesh_version_min_required`). Squash de test **régénéré par `scripts/regen-test-schema.sh`**, jamais édité à la main +(`crates/kesh-db/test-schema/README.md`) — sans quoi `the_real_migrator_matches_the_migrations_directory` +(`test_schema_guard.rs:497`) rougit ; `migrations_upgrade_path.rs:100-104` (`assert_eq!(total, 70)`, fenêtre positionnelle +`total - 36`, `:38`, `:57`) relevé — **70 → 71** sur cette branche, **74 → 75** si la chaîne 25-4 est mergée d'abord ; ligne au +tableau `docs/migrations-idempotence-audit.md` et compteurs **recomptés depuis la source** (P5) ; sites positionnels +inspectés (P6, `grep -rn "migrations.len()\|apply_migrations_up_to" crates/`) ; pas d'écriture de données (P7 sans +objet). + +**AC 1-bis — L'entité et ses colonnes (validation P1, B1).** `Invoice` (`entities/invoice.rs:15`, `FromRow`) gagne les +quatre champs. Ses colonnes sont aujourd'hui **énumérées en dur** — `FIND_INVOICE_SCOPED_SQL` +(`invoices.rs:52-56`), les `SELECT` de `invoices.rs:1304-1308` et `:2511-2516`, `INVOICE_COLUMNS` +(`reconciliation.rs:49-51`) — et un oubli échoue **à l'exécution**, pas à la compilation (`invoices.rs:277`). Ces listes — +**quatre** sites : `invoices.rs:54`, `:1306`, `:2513`, `reconciliation.rs:50` (`grep -rnE "emailed_at, *emailed_to" crates`) +— sont ramenées à **une seule constante** de colonnes (DRY), et les littéraux `Invoice { … }` des tests complétés +(`invoice_email.rs:1452`, `exports/csv_tables.rs:1665`, `invoice_pdf_service.rs:633`, `grep -rn "dunning_paused_note:" +crates`). `invoice_snapshot_json` (`invoices.rs:58`) porte l'empreinte. + +**AC 2 — Figer au premier rendu.** Un service unique `kesh-api` (ex. `issued_invoice_pdf::get_or_freeze`) est le **seul** +chemin vers le PDF d'une facture, pour le téléchargement **et** pour l'e-mail : +- facture **déjà figée** → relit le fichier, **vérifie** son SHA-256 contre `pdf_sha256`, rend ces octets ; +- facture `validated` **non figée** → rend le PDF dans la **langue du client** (`resolve_language`), l'écrit par + `store_document` (`ext = "pdf"`), puis pose les quatre colonnes par une fonction `kesh-db` qui fait, **dans une seule + transaction**, l'`UPDATE … WHERE id = ? AND company_id = ? AND pdf_storage_path IS NULL AND status = 'validated' AND version = ?` + (la **version lue au rendu** — c'est `render_document`, qui charge la facture (`invoice_pdf_service.rs:198-215`), qui + la rend ; ses appelants « rappel » (`invoice_email.rs:557`, `:1239`) l'ignorent ; validation P2, M1) et + l'audit `invoice.pdf_frozen` (empreinte, langue, **acteur** — utilisateur ou clé d'API) — l'empreinte n'existe jamais + sans sa trace. ⚠️ **La garde `status = 'validated'` est indispensable** (validation P1, A1) : sans elle, une dévalidation + intercalée entre le rendu et l'`UPDATE` laisserait un PDF figé sur un brouillon, servi comme émis à la revalidation. + ⚠️ **Et la garde `version` aussi** : une séquence dévalidation → modification → revalidation tenant dans la fenêtre + passerait la seule garde de statut et figerait un PDF rendu sur les **anciennes** lignes (le numéro est conservé). + Le gel **ne touche pas `version`** (B2 : sinon une dévalidation ou un avoir lancés depuis une fiche ouverte avant le + téléchargement tomberaient en 409) ; `updated_at` bouge (`ON UPDATE`). `store_document` est synchrone et fait `fsync` : + appelé par `spawn_blocking` (B3) ; +- **si zéro ligne** : relire la facture. Colonnes renseignées (un rendu concurrent a figé) → vérifier l'empreinte et + servir **le document de l'autre**, jamais son propre rendu ; colonnes nulles et facture plus `validated` (dévalidée, ou + **annulée par un avoir** intercalé) → `InvoiceNotValidated` (ou `InvoiceCancelled` pour une annulée — un code qui dit + vrai, P2 L5), **sans nouvelle tentative** ; colonnes nulles et facture **`validated` à une autre version** (elle a + changé pendant le rendu) → **une seule** nouvelle tentative (rendu + pose), puis **409** `INVOICE_CHANGED` si la + course se répète. En pratique : une pause des rappels, un règlement ou un avoir **concurrents**, dans la seconde du + rendu, déclenchent la nouvelle tentative ; un envoi par e-mail non (`mark_emailed` ne touche pas `version`) — pas de + 409 en usage normal (P3, L2) ; +- **échec d'écriture du fichier** (`KESH_DOCUMENTS_DIR` non inscriptible) → **500**, rien n'est servi — jamais un rendu + non figé présenté comme le document (C-F6) ; +- facture `cancelled` **figée** → rend le PDF figé ; `cancelled` **non figée** ou `draft` → refus inchangé + (`InvoiceNotValidated`). + +Le rendu, la validation des préconditions (adresse, banque principale, nombre de lignes) et leurs codes d'erreur sont +**inchangés** : `render` reste la fonction de rendu, le service ne fait que l'encadrer. + +**AC 3 — L'intégrité.** Fichier figé **absent** → **410**, code dédié (`INVOICE_PDF_GONE`, sur le patron de +`SourceDocumentGone`) — jamais une régénération silencieuse, qui produirait un autre document. Son message **nomme la +cause et le remède** : le fichier `{empreinte}.pdf` manque sous `KESH_DOCUMENTS_DIR`, à restaurer depuis la sauvegarde de ce +répertoire. Empreinte **différente** → +**500** + `tracing::error!` (le fichier a été altéré) — jamais servi. + +**AC 3-bis — Refiger (arbitrage 7).** `POST /api/v1/invoices/{id}/pdf/refreeze`, **Administrateur seulement** (bloc des +routes d'administration, donc refusé aux clés d'API — `require_not_pat`), **sans corps** : +- **accepté seulement si** la facture est figée **et** que son fichier est **absent** (le cas du 410) ; fichier présent → + **409** `INVOICE_PDF_PRESENT` (on ne remplace pas un document qui existe) ; facture non figée → **409** + `INVOICE_PDF_NOT_FROZEN` (le prochain rendu la figera) ; fichier présent mais **altéré** → **409** + `INVOICE_PDF_INTEGRITY` (le 500 d'intégrité se diagnostique, il ne se recouvre pas d'un nouveau document) ; le manuel + admin dit la procédure : restaurer `{empreinte}.pdf` depuis la sauvegarde ; à défaut, **écarter** le fichier altéré + (le déplacer hors du répertoire), ce qui ramène au cas 410, puis refiger (P2, M7) ; +- rend un nouveau document par le même chemin que le gel (langue du client **actuelle**, données du moment), l'écrit, + et remplace les quatre colonnes **dans une transaction** avec l'audit `invoice.pdf_refrozen` portant l'**ancienne** et + la **nouvelle** empreinte et l'acteur ; gardes `pdf_sha256 = ` **et `status = 'validated'`** sur + l'`UPDATE` (deux refigeages concurrents : un seul réussit ; un avoir intercalé : refusé — P2 L4) ; +- une facture **`cancelled`** dont le fichier manque **ne peut pas** être refigée — `render` refuse ce statut : limite + assumée, écrite au manuel ; +- à l'écran, la fiche facture d'un administrateur affiche, quand le téléchargement répond 410, un bouton **« Refiger le + document »** avec une confirmation qui dit ce que le geste fait — un nouveau document, **pas l'original**, tracé — et + qui **conseille de restaurer d'abord** le fichier (un volume de documents mal monté après une mise à jour produirait + des 410 en série ; refiger remplacerait définitivement des originaux qui existent encore ailleurs, P2 L6) ; +- ⚠️ **gardes structurelles** que la route fait rougir (P2, M3) : le compte des constructeurs du bloc d'administration + (`lib.rs:186`, **25 → 26**) et `admin_pat_denied_e2e.rs` (`:46`, `:757`, `ADMIN_COUPLES`) ; le registre des routes + d'audit (`audit_route_registry.rs`, `LIB_ROUTES` : `("post", "…refreeze…", Traced)`) **et ses trois totaux** + (`:436-470`) — `LIB_ROUTES.len()` **109 → 110**, routes tracées **91 → 92** (message de l'assertion réécrit), + `LIB_ROUTES.len() + TEST_ENDPOINT_ROUTES.len()` **112 → 113** ; le registre des libellés + (`audit_label_registry.rs` : `invoice.pdf_frozen` et `invoice.pdf_refrozen` dans `audit_labels::ACTIONS`, libellé dans + les 4 `.ftl` ; si l'action est un paramètre d'une fonction commune, le site entre dans `SITES_INDIRECTS`). + +**AC 4 — L'e-mail joint le document figé.** `send_invoice_email` passe par le service : la pièce jointe est **octet pour +octet** celle du téléchargement. Le premier envoi d'une facture non figée la fige — **et le gel survit à un échec SMTP** +(le document a été produit ; la facture n'est simplement pas marquée envoyée, C-F3). Le corps du message reste rédigé dans +la langue **actuelle** du client ; si celle-ci a changé depuis le gel, la pièce jointe reste dans la langue **figée** +(limite assumée, A4). Un client **archivé** : l'envoi reste refusé (`ContactArchived`, inchangé) ; le téléchargement, lui, +fige avec les données du client archivé (C-F5). Les rappels sont **inchangés** +(document distinct, [#502]). + +**AC 5 — La dévalidation détache.** `unvalidate` remet les quatre colonnes à `NULL` dans sa transaction ; l'audit de +dévalidation porte l'empreinte détachée. Le fichier reste sur le disque (nommé par son empreinte, il peut être partagé ; +le supprimer n'est pas nécessaire). À la revalidation, le premier rendu fige un **nouveau** document. + +**AC 5-bis — L'export de souveraineté.** `serialize_invoices_csv` (`exports/csv_tables.rs:495-521`) exporte +`pdf_sha256`, `pdf_frozen_at` **et `pdf_language`** (un fait du document émis) ; `pdf_storage_path` est **exempté +explicitement**, dérivable de l'empreinte, et le doc-comment de la garde d'en-tête (`:1690-1691`, « exactement les +colonnes de la struct `Invoice` ») est réécrit avec cette exception (P2, M5) (la preuve d'émission appartient aux données de l'utilisateur ; précédent #262), et son +test d'en-tête (`:1696-1705`) est mis à jour. Les fichiers eux-mêmes ne sont pas dans l'export (CSV). La sauvegarde +`.keshbackup` lit les colonnes dans `INFORMATION_SCHEMA` : rien à faire, les colonnes nullables sont couvertes, et une +sauvegarde antérieure reste importable (`check_schema_compat`, colonnes nullables non requises). + +**AC 6 — L'écran.** Le DTO de la facture expose `pdfFrozenAt` (et la langue) ; la fiche facture affiche le bouton PDF +pour une facture `validated` **ou** `cancelled` **figée**, et une mention discrète « Document figé le … » quand il l'est. +Libellés dans les 4 locales. + +**AC 7 — Tests.** Chacun aurait échoué avant le patch : +- deux téléchargements successifs rendent **les mêmes octets** (aujourd'hui, non : date et `/ID`) ; +- le PDF est dans la **langue du client** (contact en allemand, installation en français) ; +- l'e-mail joint **les octets du téléchargement** (et le premier envoi fige) ; +- changer l'adresse du client **après** le gel ne change pas le PDF ; +- **l'`UPDATE` conditionnel, de façon déterministe** : colonnes pré-posées par une autre voie, puis pose → zéro ligne, + et le service relit et sert le document déjà posé ; mutation « garde `pdf_storage_path IS NULL` retirée » tuée (un + `tokio::join!` passerait aussi sans la garde, C-F7) ; +- **la garde de statut** : facture dévalidée entre le rendu et la pose (simulée en appelant la pose après la + dévalidation) → zéro ligne, rien de figé sur le brouillon ; mutation « garde `status` retirée » tuée (A1) ; +- le gel **ne change pas `version`** ; +- **la garde `version`** (P3, M2) : dévalidation → modification → revalidation, puis pose avec l'**ancienne** version → + zéro ligne, la nouvelle tentative rend et fige les **nouvelles** lignes ; une course qui se répète → 409 + `INVOICE_CHANGED` ; mutation « garde `version` retirée » tuée ; +- l'audit `invoice.pdf_frozen` nomme l'acteur, et partage la transaction de la pose ; +- un envoi e-mail en **échec SMTP** laisse la facture figée, et le téléchargement suivant rend les mêmes octets ; +- changer la **langue** du client après le gel ne change pas le PDF ; +- une facture à **10 lignes** : erreur inchangée, rien de figé ; +- `KESH_DOCUMENTS_DIR` non inscriptible → 500, colonnes nulles ; +- l'export CSV porte `pdf_sha256` et `pdf_frozen_at` ; +- fichier supprimé → 410 `INVOICE_PDF_GONE` ; fichier altéré → 500, rien servi ; +- dévalidation → colonnes nulles, audit portant l'empreinte ; revalidation → nouveau document ; +- facture annulée par un avoir **après** gel → PDF servi ; **sans** gel → refus ; +- isolation : un PDF figé d'une autre société n'est jamais servi (IDOR) ; +- la contrainte tout-ou-rien rejette un état partiel ; +- Vitest de la fiche : bouton pour `cancelled` figée, absent pour `cancelled` non figée ; mention « figé le » ; +- **refiger** : fichier supprimé → 410 → refigeage par un Admin → 200, nouveau document, audit avec les deux + empreintes ; refusé pour un Comptable (403), une clé d'API, une facture non figée (409), un fichier présent (409), un + fichier **altéré** (409 `INVOICE_PDF_INTEGRITY`), une facture `cancelled` ; deux refigeages concurrents → un seul ; +- **le gel par un rôle Consultation** : le premier téléchargement d'un Consultation fige, et l'audit le nomme ; +- E2E (`authedApiContext`, `pdfRes.body()`) : télécharger deux fois une facture rend le même fichier ; une facture + annulée par un avoir garde son PDF. Chaque test crée **sa propre** facture : les fichiers figés persistent sous + `/tmp/kesh-e2e/documents` d'une exécution à l'autre. + +**AC 8 — Le manuel et le CHANGELOG.** Sites que le patch rend faux, **nommés** (validation P1, C-F2 ; P2, H1 et M2) — +`grep -nE "génère le PDF|PDF QR Bill généré|téléchargeable depuis|PDF joint|ne laisse aucune trace" docs/manual/fr/user-manual.tex` : +- `user-manual.tex:769` (« À la validation, Kesh … génère le PDF QR Bill »), `:780` (« Validée : … PDF QR Bill + généré ») et `:846` (« À la validation, Kesh génère un PDF conforme… ») : le PDF est **figé au premier téléchargement + ou envoi** ; +- `:789`, `:797` (coordonnées de l'émetteur « apparaissent sur le PDF ») et `:815` (le numéro de client « apparaît + ensuite sur toutes les factures ») : **sur les factures non encore figées** — un PDF figé ne change plus ; +- le bouton **« Refiger le document »** et le message du 410, visibles à l'écran (côté administrateur) ; +- `:855` (« téléchargeable depuis la facture validée … pour l'archiver ») : figé, identique d'un téléchargement à + l'autre, et servi aussi pour une facture **annulée** qui l'a été ; un PDF figé avant l'avoir ne porte pas de mention + d'annulation (A8) ; +- `:879` (« Pièce jointe : la QR-facture PDF, attachée ») : c'est le PDF **figé**, octet pour octet celui du + téléchargement ; +- `:928` (« Langue de correspondance … détermine la langue de l'e-mail et du PDF joint ») : aussi du téléchargement, et + figée au premier rendu ; +- `:1136` (« un PDF téléchargé puis transmis à la main ne laisse aucune trace ») : il laisse désormais une trace (audit + `invoice.pdf_frozen`, empreinte) ; la dévalidation reste possible tant que la facture n'a pas été envoyée **par Kesh**, + et détache le document — un PDF déjà transmis par vous reste valable mais n'est plus celui que Kesh conserve (A7) ; +- `:1011` et suivantes : le **rappel**, lui, est toujours régénéré ([#502]). + +Manuel admin : `admin-manual.tex:741`, `:790` (`KESH_DOCUMENTS_HOST_DIR`), `:811` et la § 5.1.1 — `KESH_DOCUMENTS_DIR` +porte aussi les **PDF de factures émises**, pièces à conserver ; il **doit être sauvegardé à part** tant que [#503] n'est +pas livrée. ⚠️ **Le chapitre « Sauvegarde et restauration » le rend faux, et c'est le site le plus cher** (P2, H1) — un +administrateur qui suit la procédure documentée perd tous les PDF émis à la première restauration : +- `:1376-1381` (« Stratégie recommandée » : `mariadb-dump`, volume `kesh_db_data`, export ZIP) : **ajouter + `KESH_DOCUMENTS_DIR`** ; +- `:1444` (restauration) et `:1480` et suivantes (Hyper Backup) : **sauvegarder et restaurer le volume des documents avec + la base** ; +- `:1583` (le `.keshbackup` contient « toutes les données d'installation ») et le tableau `:1648-1654` (« Installation + Kesh complète ») : **sauf les fichiers** ([#503]) ; +- `:1353` (rôle Consultation, « aucune mutation ») et `:1774` (portée `read` d'une clé d'API, « GET/HEAD/OPTIONS ») : un + premier téléchargement **fige** le document et écrit au journal d'audit (arbitrage 8). + +Pour la suite : un 410 `INVOICE_PDF_GONE` se répare **de préférence** en restaurant le fichier `{empreinte}.pdf` dans ce répertoire — +c'est le seul moyen de retrouver le document **original** ; à défaut, un administrateur peut **refiger** la facture +(nouveau document, réémission tracée au journal d'audit) — sauf une facture annulée. PDF régénérés, contrôlés **aplatis**. CHANGELOG `[0.12.1]` : `Fixed` (#387), `Changed` (le +téléchargement rend la langue du client). + +## Tasks / Subtasks + +- [x] **T1 — migration** (AC 1) : SQL, squash, garde de schéma, audit d'idempotence, P6. +- [x] **T2 — le service de gel** (AC 1-bis, 2, 3) : `kesh-db` (entité `Invoice` et **constante unique** de colonnes, + pose conditionnelle + audit en une transaction, détachement), `kesh-api` (service, `spawn_blocking`) et **six variantes d'erreur neuves** (P3, M3 — aucune n'existe ; seule + `InvoiceNotValidated` existe, `errors.rs:338`), chacune avec statut HTTP, code JSON et message dans les 4 locales : + `InvoicePdfGone` (410 `INVOICE_PDF_GONE`), `InvoiceChanged` (409 `INVOICE_CHANGED`), `InvoicePdfPresent` (409 + `INVOICE_PDF_PRESENT`), `InvoicePdfNotFrozen` (409 `INVOICE_PDF_NOT_FROZEN`), `InvoicePdfIntegrity` (409 + `INVOICE_PDF_INTEGRITY`, au refigeage ; 500 à la lecture), `InvoiceCancelled` (code à fixer sur le patron + d'`InvoiceNotValidated`). +- [x] **T3 — les deux consommateurs** (AC 2, 4) : route de téléchargement, envoi par e-mail. +- [x] **T3-bis — refiger** (AC 3-bis) : route d'administration, trois codes 409, audit, bouton et confirmation à l'écran, + et les gardes structurelles (compte du bloc d'administration, `admin_pat_denied_e2e`, registres d'audit et de libellés). +- [x] **T4 — la dévalidation** (AC 5). +- [x] **T5 — l'écran** (AC 6). +- [x] **T6 — tests** (AC 7). +- [x] **T6-bis — export de souveraineté** (AC 5-bis). +- [x] **T7 — manuels, CHANGELOG** (AC 8). +- [x] **T8 — gates** : backend complet, frontend complet, **E2E complet** — le backend E2E doit avoir + `KESH_DOCUMENTS_DIR` inscriptible (`docs/testing.md:167`, `:197`) : sans lui, tout téléchargement de PDF répondra 500. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- **Ne jamais régénérer automatiquement ni silencieusement** un PDF figé introuvable : c'est fabriquer une pièce qui + n'a pas été émise. 410, et le dire. **Seul le refigeage explicite d'un administrateur**, tracé avec les deux + empreintes (arbitrage 7), produit un nouveau document. +- **Ne pas** figer dans la transaction de validation (arbitrage 1) : la validation ne doit pas dépendre du rendu. +- **Ne pas** créer de table (arbitrage 3). +- **Ne pas** toucher aux rappels ni aux avoirs ([#502]). +- **Ne pas** recopier `serve_document` : en extraire la partie commune si besoin (DRY). + +### Limites assumées + +- Une facture validée **avant** cette version est figée à son prochain rendu, avec les données du moment : son PDF + d'origine n'a jamais été conservé. +- Les fichiers figés ne sont pas dans la sauvegarde `.keshbackup` ([#503]) : après une restauration sans + `KESH_DOCUMENTS_DIR`, ils répondent 410. +- Une facture annulée **jamais rendue** n'a pas de PDF (refus inchangé). +- **Fichiers orphelins** : deux premiers rendus concurrents, ou un arrêt entre l'écriture et la pose, laissent un fichier + jamais référencé (les rendus ne sont pas reproductibles, donc nommés différemment). Inoffensif — nommé par son + empreinte, jamais servi — et non nettoyé. +- **Langue** : le corps d'un e-mail suit la langue actuelle du client, la pièce jointe la langue figée. +- Une facture **annulée** dont le fichier figé manque ne peut pas être refigée (`render` refuse ce statut) : seule la + restauration du fichier la répare. +- Le **refigeage est irréversible** : il remplace la référence au document d'origine ; l'empreinte d'origine ne reste + qu'au journal d'audit. +- **`GET /api/v1/invoices/{id}/pdf` écrit** désormais (gel, audit) — et `HEAD` aussi, axum le servant par le handler + `get`. Le registre des routes d'audit (`audit_route_registry.rs:186`) ne balaie que les méthodes d'écriture : cette + route mutante lui échappe. **Angle mort assumé**, écrit dans le registre par un commentaire nommant la route. +- `store_document` fait `sync_all` puis `rename`, sans `fsync` du répertoire parent : après un arrêt brutal, le nom peut + se perdre alors que la base a commité la pose — un 410, réparable par refigeage (P2, L7). +- Tests : les cas qui suppriment ou altèrent un fichier utilisent **leur propre** `documents_dir` (patron + `inbox_import_e2e.rs:78`), pas le répertoire partagé `/tmp/kesh-documents-test` ; la contrainte de l'AC 1 borne aussi + `pdf_language` à `FR`, `DE`, `IT`, `EN` (P2, L8). + +### Modules + +`kesh-db` (migration, entité et colonnes, pose et dévalidation), `kesh-api` (service, routes PDF et e-mail, erreur, +export CSV), `kesh-i18n`, +`frontend` (fiche facture) — **quatre modules de code**, sous le seuil (+ `docs`, `CHANGELOG`). + +### References + +- Issue [#387] ; audit du 2026-08-26 (fiscaliste § II.4) ; CR [#502], [#503]. +- Cartographie du 2026-10-03 (agent d'exploration, lecture seule) — références reprises ci-dessus. + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5 (`claude-opus-5-5`). + +### Debug Log References + +Journaux sous `target/gate-logs/` : `25-6-b-backend.log`, `25-6-b-frontend.log`, `25-6-b-e2e.log`, +`manual-25-6-b.log`. + +### Completion Notes List + +- **Service unique** `kesh-api/src/routes/issued_invoice_pdf.rs` (`get_or_freeze`, `pose`, `refreeze`) : seul chemin + vers le PDF d'une facture. L'ancienne fonction `invoice_pdf_service::render` a été **retirée** pour qu'aucun appel + ne contourne le gel ; `render_document` reste (rappels, #502) et porte désormais `invoice_version`. La nouvelle + tentative unique est extraite (`with_one_retry`) pour être testée de façon déterministe, sans course réelle. +- **`kesh-db`** : `freeze_pdf` (trois gardes + audit `invoice.pdf_frozen` dans la transaction), `refreeze_pdf` + (gardes `pdf_sha256 = old` et `status`, audit `invoice.pdf_refrozen` avec les deux empreintes), `find_by_id` + (facture sans ses lignes). `unvalidate` détache les quatre colonnes ; `invoice_snapshot_json` porte `pdfSha256`. +- **Liste unique de colonnes** (AC 1-bis) : `macro_rules! invoice_columns` + `const INVOICE_COLUMNS` dans + `repositories/invoices.rs`. Macro parce que `concat!` n'accepte que des littéraux. Les quatre sites en dur sont + remplacés. +- **Six erreurs** dans `errors.rs`, messages dans les 4 locales : `INVOICE_CANCELLED` (400), `INVOICE_PDF_GONE` + (410, nomme `{empreinte}.pdf`), `INVOICE_CHANGED`, `INVOICE_PDF_PRESENT`, `INVOICE_PDF_NOT_FROZEN`, + `INVOICE_PDF_INTEGRITY` (409). À la **lecture**, un fichier altéré répond `Internal` (500), conformément à l'AC 3. +- **Écart avec la spec, recompté depuis la source** : l'AC 3-bis annonçait le bloc d'administration à « 25 → 26 ». Il + comptait déjà **27** couples sur `main` (5 `GET` + 22 mutants) — la prose de `lib.rs` et de + `admin_pat_denied_e2e.rs` était périmée. Il passe à **28** (5 + 23) ; le test asserte `ADMIN_COUPLES.len()` et non + un littéral, il était donc vert malgré la prose. Prose et compteurs recalés. Les trois totaux du registre d'audit + sont ceux de la spec : 110 / 92 / 113. +- **Garde non anticipée par la spec** : `chaque_colonne_du_schema_est_exportee_ou_ecartee` (25-5-a) a rougi au gate + complet sur `invoices.pdf_storage_path`. La colonne est inscrite à `COLONNES_HORS_EXPORT` avec son motif. +- **Angle mort assumé** écrit dans `audit_route_registry.rs` : `GET /invoices/{id}/pdf` (et `HEAD`) écrit désormais. +- **`MockMailer`** capture les octets de la pièce jointe (`attachment_bytes`) : sans cela, « l'e-mail joint les + octets du téléchargement » n'était pas prouvable. +- **Écran** : bouton PDF extrait en snippet (facture validée, ou annulée figée), mention « Document figé le », + bouton « Refiger le document » pour un admin après un 410, avec une confirmation qui conseille de restaurer + d'abord. `sitesTotal` 1758 → 1767 (neuf sites, ventilés dans le test). +- **Tests neufs** (périmètre : `main` → ce commit) : 9 `kesh-db` (`tests/invoice_frozen_pdf.rs`), 23 `kesh-api` HTTP + et service (`tests/invoice_frozen_pdf_e2e.rs`), 3 unitaires (`with_one_retry`), 1 CSV + (`serialize_invoices_csv_porte_le_pdf_fige`), 8 Vitest (`invoice-pdf-page.test.ts`), 2 Playwright + (`invoice-frozen-pdf.spec.ts`). +- **Mutations** (chacune restaurée puis `touch`) : + - 7 sur les gardes `kesh-db`, chacune tuée par le test qui la nomme : `IS NULL`, `status` (pose), `version`, + `pdf_sha256 = old`, `status` (refigeage), détachement, empreinte au snapshot ; + - 6 sur le service : gel ignoré, langue de l'instance, empreinte non contrôlée, adoption de notre propre rendu, + refigeage d'un fichier présent, refigeage d'une annulée — toutes tuées ; + - 4 sur la fiche facture : condition de gel retirée, garde `isAdmin` retirée, tout échec pose `pdfGone`, envoi + sans confirmation — toutes tuées. + - ⚠️ « Gel ignoré » n'est tuée que par la spec de la facture annulée : sur une facture validée, la seconde pose + échoue sur `IS NULL` et le service **adopte** le document déjà figé. La conception se défend elle-même, et ce + test-là ne le distingue pas. +- **Gates**, base remise à zéro avant chacun : + - backend `scripts/test-fast.sh --ci` : **2583/2583**, 4 skipped. Un premier passage s'était arrêté sur la garde + d'export ci-dessus, corrigée avant le second ; + - frontend : `check` 0 erreur, `lint-i18n-ownership` PASS, `test:unit` **853/853**, `build` OK ; + - **E2E complet** (06:40 UTC) : 227 passés, 11 échecs, 19 skipped. Neuf sont **attendus** : KF-029 ×7, et KF-051 + ×2 (`invoices.spec.ts:415`, `:439`, avant 12:00 UTC). Les deux autres étaient **la spec neuve**, dont le montage + omettait `ensurePrimaryBankAccountViaApi` (400 `INVOICE_NOT_PDF_READY`). Corrigée, elle est **rejouée seule** : + 2/2. ⚠️ La suite complète n'a pas été relancée après cette correction, qui ne touche que le montage de la spec. +- Manuels FR régénérés (`make admin user`), 0 référence indéfinie, texte contrôlé dans les PDF aplatis. + +### File List + +- `CHANGELOG.md`, `README.md`, `docs/testing.md`, `docs/migrations-idempotence-audit.md` +- `docs/manual/fr/admin-manual.tex`, `docs/manual/fr/admin-manual.pdf`, `docs/manual/fr/user-manual.tex`, + `docs/manual/fr/user-manual.pdf` +- `crates/kesh-db/migrations/20261003000001_invoices_frozen_pdf.sql` (nouveau), `crates/kesh-db/migrations.sha384`, + `crates/kesh-db/test-schema/0001_schema_squash.sql`, `crates/kesh-db/tests/migrations_upgrade_path.rs` +- `crates/kesh-db/src/entities/invoice.rs`, `crates/kesh-db/src/repositories/invoices.rs`, + `crates/kesh-db/src/repositories/reconciliation.rs`, `crates/kesh-db/tests/invoice_frozen_pdf.rs` (nouveau) +- `crates/kesh-api/src/routes/issued_invoice_pdf.rs` (nouveau), `crates/kesh-api/src/routes/mod.rs`, + `crates/kesh-api/src/routes/invoice_pdf.rs`, `crates/kesh-api/src/routes/invoice_pdf_service.rs`, + `crates/kesh-api/src/routes/invoice_email.rs`, `crates/kesh-api/src/routes/invoices.rs`, + `crates/kesh-api/src/errors.rs`, `crates/kesh-api/src/audit_labels.rs`, `crates/kesh-api/src/lib.rs`, + `crates/kesh-api/src/mail/mod.rs`, `crates/kesh-api/src/exports/csv_tables.rs` +- `crates/kesh-api/tests/invoice_frozen_pdf_e2e.rs` (nouveau), `crates/kesh-api/tests/admin_pat_denied_e2e.rs`, + `crates/kesh-api/tests/audit_route_registry.rs` +- `crates/kesh-reconciliation/src/matching.rs` +- `crates/kesh-i18n/locales/{fr,de,it,en}-CH/messages.ftl` +- `frontend/src/lib/features/invoices/invoices.api.ts`, `frontend/src/lib/features/invoices/invoices.types.ts`, + `frontend/src/lib/shared/i18n-keys.test.ts`, `frontend/src/routes/(app)/invoices/[id]/+page.svelte`, + `frontend/src/routes/(app)/invoices/[id]/invoice-pdf-page.test.ts` (nouveau), + `frontend/src/routes/(app)/invoices/[id]/invoice-settlements-page.test.ts`, + `frontend/tests/e2e/invoice-frozen-pdf.spec.ts` (nouveau) + +## Change Log + +- **2026-10-05** — Revue de code P4 **ciblée** (Haiku, une lentille, prompt `25-6-b-review-prompt-p4.md`, sur + `78b16da6` seul) : **0 finding**. Le « 0 » de Haiku a été **vérifié par l'orchestrateur** (`grep -nF` : les trois + chemins qui servent un PDF figé — lecture directe et deux adoptions — sont sous la garde `Usage::Send` ; le `match` + du refus de refigeage donne un code juste pour chaque statut). **Boucle close en 4 passes** : P1 Sonnet ×3 3M/7L → + P2 Opus ×2 5M/8L → P3 Sonnet ×2 0 > LOW (8L) → P4 Haiku ciblée 0. Modèles en rotation, prompts versionnés p1–p4. + - **Gates complets** (base remise à zéro) : backend `scripts/test-fast.sh --ci` **2588/2588** (4 skipped) ; + frontend `check` 0 erreur, `lint-i18n-ownership` PASS, `test:unit` **862/862**, `build` OK ; **E2E complet** + (07:25 UTC) **229 passés**, 19 skipped, **9 échecs, tous attendus** : KF-029 ×7 et KF-051 ×2 + (`invoices.spec.ts:415`, `:439`, avant 12:00 UTC). La spec neuve `invoice-frozen-pdf` passe dans la suite. + - → **`done`**. +- **2026-10-05** — Revue de code P3 (Sonnet ×2 : R = la remédiation `b2eeaab9`, C = acceptation ; prompt + `25-6-b-review-prompt-p3.md`) : **0 CRITICAL/HIGH/MEDIUM, 8 LOW** — **critère d'arrêt atteint**. Trend : P1 3M/7L + → P2 5M/8L (dont 2 MED nés de P1) → P3 0 > LOW. Recomptes de la lentille C (migrations 71, bloc admin 28, registre + 110/92/113, parité des 15 clés neuves, tests de P2) **tous exacts**. + - **Appliqués** (quatre touchent la production, d'où une passe ciblée P4) : + - relecture après refigeage isolée de son `try` : un échec de lecture ne se fait plus passer pour un échec du + refigeage (clé `invoice-pdf-refreeze-reload-failed`, 4 locales ; `sitesTotal` 1765 → 1766) ; + - une facture figée entre deux tentatives est **adoptée** (`render_and_pose`), sous la garde de l'usage ; + - la garde `Usage::Send` vaut aussi à l'adoption dans `pose` (avoir intercalé après un gel concurrent) ; + - refus du refigeage : le statut est examiné avant l'empreinte — une dévalidation intercalée ne répond plus + « rien à refiger » ; + - décompte de P1 corrigé (test inbox durci, pas neuf) ; CHANGELOG : clé d'API, `HEAD`, restauration, annulée. + - **Non appliqué** : retrait des marques d'isolation de tous les messages à argument (`t_args`), y compris un + texte de droite à gauche — même choix que `contacts.rs`, marginal en contexte suisse. + - Tests : 1 service neuf (`un_envoi_n_adopte_pas_le_gel_d_une_facture_annulee_entre_temps`), 1 Vitest neuf. Deux + mutations tuées (garde d'adoption retirée, relecture remise dans le même `try`). Le réordonnancement du refus de + refigeage n'a **pas** de test : la course n'est pas reproductible sans point d'injection. +- **2026-10-05** — Revue de code P2 (Opus ×2 : R = la remédiation `8ac0a4de`, F = périmètre complet ; prompt + `25-6-b-review-prompt-p2.md`) : **0 CRITICAL/HIGH, 5 MED, 8 LOW**. **Deux des MED viennent de la remédiation P1** : + R2-2 directement, et F-M1 parce que P1 affiche désormais le message du serveur, avec ses marques invisibles. + - **MED** : + - **R2-2** : le 410 d'une facture **annulée** proposait de refiger, ce que l'écran ne permet plus. `InvoicePdfGone` + porte `refreezable` ; une clé `error-invoice-pdf-gone-cancelled` (4 locales) ne propose que la restauration ; + - **F-M1** : le message du 410 entourait l'empreinte des marques d'isolation BiDi de Fluent — copiée pour chercher + le fichier dans une sauvegarde, elle ne trouvait rien, et l'administrateur pouvait refiger un original qui + existait encore. Marques retirées dans `t_args` (tous les messages d'erreur à argument) ; + - **F-M2** : aucun test du gel par une clé d'API, que les deux manuels promettent tracé au nom de la clé. Test + HTTP ajouté (clé `read`) ; + - **F-M3** : le test de langue ne vérifiait que la colonne. Il vérifie désormais le document (« Rechnung », + « Zahlteil », pas « Section paiement ») ; + - **R2-1**, **antérieur à la story** et trouvé par propagation du symptôme de M1 : « trop de lignes » affichait + `{ $count }` brut sur les fiches facture **et** avoir. Les deux tables sont fusionnées dans + `shared/utils/pdf-error.ts` (`pdfErrorMessage`), qui reprend le message du serveur pour les codes à variable. + - **LOW** : + - **R2-4** : la garde d'envoi est aussi tenue **dans le service** (`Usage::Send`), sur la facture qu'il relit ; + - **F-L1** : la facture est relue à chaque tentative, et un rendu d'une autre version que celle dont la langue + est tirée est rejoué ; + - **F-L2** : après un refigeage, la fiche se relit (la réponse ne porte pas « Déjà réglé / Reste dû »). ⚠️ Le + même défaut existe, **antérieur**, après un envoi par e-mail (`+page.svelte`, `sendInvoiceEmail`) : non traité ; + - **F-L3** : le message d'`INVOICE_CANCELLED` ne prétend plus que l'annulation est survenue « pendant la + préparation » ; + - **R2-3** : ordre des refus de l'envoi et commentaire de test mis à jour ; + - **R2-5** : tests Vitest — `setTimeout(0)` avant une assertion d'absence, `vi.spyOn` restauré ; + - **F-L4, F-L5** (manuels) : `HEAD` fige aussi ; une facture envoyée avant la v0.12.1 puis annulée n'a pas de + PDF ; une sauvegarde restaurée antérieure au gel fera figer un nouveau document. + - **Défaut antérieur relevé, non traité** : la communication de la QR (« Facture {numéro} ») est écrite en + français quelle que soit la langue du client (`invoice_pdf_service.rs:369`). + - Tests neufs : 3 HTTP/service, 5 Vitest (`pdf-error.test.ts`), assertions durcies sur 3 tests. **Mutations tuées** + (7) : marques non retirées, `refreezable` toujours vrai, garde `Usage::Send`, clé d'API non transmise, rendu dans + la langue de l'installation, « trop de lignes » tiré du catalogue, fiche non relue après refigeage. La relecture + par tentative (F-L1) n'a **pas** de test : la course n'est pas reproductible sans point d'injection. + - Gardes i18n recalées et ventilées : `sitesTotal` 1767 → 1765, `sitesNonResolus` 34 → 33 ; entrée tolérée + `invoice-pdf-error-generic` retirée de `i18n-repli-divergent-actif` (plus divergente). + - Gate ciblé : `fmt`, `clippy` workspace, 144 tests Rust (`invoice_frozen_pdf_e2e`, `invoice_send_email_e2e`, + `inbox_import_e2e`, `invoice_pdf_e2e`, `test(error)`), Vitest `shared` + fiches 210/210, `check` 0 erreur. + Gate complet au push. +- **2026-10-05** — Revue de code P1 (Sonnet ×3, prompt `25-6-b-review-prompt-p1.md`) : **0 CRITICAL/HIGH, 3 MED, + 7 LOW**, tous retenus et corrigés. + - **MED** : + - **B1** : `send-email` envoyait une facture **annulée** figée — la seule garde de statut vivait dans le rendu, que + le service court-circuite pour servir le PDF figé. Garde rétablie dans le handler ; + - **M1** (A et C, convergents) : le toast du 410 affichait `{ $sha256 }` brut — le catalogue servi au frontend est + résolu sans arguments et l'emporte sur `err.message`. Le message du serveur est repris tel quel ; le mock + Vitest honore désormais un catalogue (il rendait toujours le repli, ce qui cachait le défaut) ; + - **M2** : la note de l'export de souveraineté (manuel utilisateur) ne disait pas que les PDF émis n'y sont pas. + - **LOW** : + - bouton « Refiger » proposé sur une facture annulée, où le geste échoue toujours : retiré ; + - `pdfGone` jamais remis à faux : un téléchargement réussi le remet ; + - repli d'erreur du refigeage qui parlait de « téléchargement » : clé `invoice-pdf-refreeze-error` (4 locales) ; + - **L3** : l'import de l'inbox supprimait, sur un champ QR trop long, un fichier archivé nommé par son contenu — + possiblement un PDF figé ou le justificatif d'une autre société. Suppression retirée ; + - facture déjà envoyée avant la v0.12.1 : le document figé n'est pas forcément celui reçu — écrit au manuel ; + - le bouton n'apparaît qu'après un téléchargement refusé — écrit au manuel ; + - CHANGELOG : trois colonnes insérées dans `invoices.csv` (lecture par position), route et champs d'API. + - Tests (périmètre `9d842984` → `8ac0a4de`) : 1 HTTP neuf (B1), 3 Vitest neufs, et 1 test inbox existant + **durci** (`import_field_too_long_returns_failed_not_500`, pas un test neuf — corrigé en P3). **Mutations tuées** : garde de B1 retirée, suppression de + l'inbox remise, message du 410 tiré du catalogue, condition `validated` du bouton retirée, `pdfGone` non remis. + - Recomptes de la lentille C (migrations 71, bloc admin 28, registre 110/92/113, `sitesTotal` 1767, parité i18n, + décomptes de tests) : **tous exacts**. + - Gate ciblé : `fmt`, `clippy` workspace, `binary(invoice_frozen_pdf_e2e | invoice_send_email_e2e | + inbox_import_e2e | invoice_pdf_e2e)` 95/95, Vitest de la fiche et `i18n-keys` 27/27. Gate complet au push. +- **2026-10-05** — Développement (dev-story) : T1 à T8. Service de gel, refigeage, détachement, export, écran, + manuels. Gates : backend 2583/2583, frontend 853/853, E2E 227 passés + 9 échecs attendus (+ la spec neuve rejouée + seule après correction de son montage, 2/2). Écart de la spec sur le bloc admin (27 → 28, et non 25 → 26), recompté + depuis la source. → `review`. +- **2026-10-05** — Validation P4 ciblée (Haiku, prompt `25-6-b-validate-prompt-p4.md`) : **0 finding**. Sa première + vérification résumait sa sortie au lieu de la citer : **reprise par l'orchestrateur** (`audit_route_registry.rs` porte + bien 109, 91 et 112) ; le « six sites » restant est celui de P1, exact. **Boucle close** : P1 4H/12M (Sonnet ×3) → P2 + 1H/7M (Opus) → P3 3M (Sonnet) → P4 0 (Haiku). +- **2026-10-05** — Validation P3 (Sonnet, prompt `25-6-b-validate-prompt-p3.md`) : **0 CRITICAL/HIGH, 3 MED, 4 LOW**, + retenus. + - **MED** : + - les trois totaux du registre des routes d'audit (109, 91, 112), que la route fera rougir (M1) ; + - les tests de la garde `version` et du refigeage d'un fichier altéré (M2) ; + - les six variantes d'erreur à créer, nommées à la T2 (M3). + - **LOW** : + - « cinq » sites et non « six » pour H1 de P2 ; + - la fenêtre de la garde `version` écrite — pas de 409 en usage normal, `mark_emailed` ne touche pas `version` ; + - `render_document` porte la version ; + - le site `:879` (pièce jointe). + - La P3 a vérifié chaque site de manuel cité et chaque garde structurelle. Trend : P1 4H/12M → P2 1H/7M → P3 3M. +- **2026-10-05** — Arbitrage de Guy (« d'accord avec tes recommandations ») : **pas de découpage**. +- **2026-10-04** — Validation P2 (Opus, prompt `25-6-b-validate-prompt-p2.md`) : **1 HIGH, 7 MED, 8 LOW**, retenus. + - **H1** : le chapitre « Sauvegarde et restauration » du manuel admin n'était pas nommé, alors que c'est lui qui fait + perdre les PDF émis à la première restauration. Cinq sites sont ajoutés à l'AC 8. + - **M1** : la garde de statut ne fermait pas la séquence dévalidation → modification → revalidation. La pose est aussi + gardée par la version lue au rendu, avec une seule nouvelle tentative, puis `INVOICE_CHANGED`. + - **M2** : six autres sites du manuel, dont « Consultation : aucune mutation » et la portée `read` des clés d'API. + - **M3** : les gardes structurelles de la nouvelle route, et le `GET` mutant hors registre (angle mort écrit). + - **M4** : Dev Notes réconciliées avec l'arbitrage 7. + - **M5** : `pdf_language` exporté, `pdf_storage_path` exempté explicitement. + - **M6** : la chaîne 25-4 touche les mêmes lignes. + - **M7** : refigeage refusé pour un fichier altéré, avec code et procédure. + - **LOW** : quatre sites et non six ; « ~15 » recompté à douze ; titre des arbitrages ; course avoir/refigeage ; code + vrai pour une facture annulée ; impasse et irréversibilité en limites ; `fsync` du répertoire ; répertoire de tests + dédié ; `pdf_language` borné. + - ⚠️ **Signal de découpage** : HIGH → HIGH (P1 → P2), formellement atteint. Le HIGH de P2 est un site de manuel non + nommé, pas un défaut de conception ; aucun défaut recyclé ; toujours quatre modules. **Arbitrage à Guy**, + recommandation : ne pas découper. +- **2026-10-04** — Validation P1 (Sonnet ×3, prompt `25-6-b-validate-prompt-p1.md`) : **4 HIGH, 12 MED**, LOW. + - **HIGH retenus** : + - **A1** : un gel pouvait atterrir sur un **brouillon**, si une dévalidation s'intercalait entre le rendu et la pose. + La garde `status = 'validated'` est ajoutée à l'`UPDATE` ; + - **B1** : les colonnes d'`Invoice`, écrites en dur à six endroits, sont ramenées à une constante unique ; + - **C-F1** : le 410 sur un fichier figé perdu était une impasse. Le geste « refiger » a été soumis à Guy et retenu + (arbitrage 7) ; + - **C-F2** : six sites du manuel sont désormais nommés. + - **MEDIUM retenus** (douze) : + - le cas « zéro ligne » défini ; + - l'audit dans la transaction de la pose, avec l'acteur ; + - le gel par Consultation, soumis à Guy et accepté (arbitrage 8) ; + - le gel ne touche pas `version` ; + - `spawn_blocking` ; + - le squash régénéré par script, et les compteurs de `migrations_upgrade_path` ; + - l'export de souveraineté ; + - l'échec SMTP après le gel, et le changement de langue ; + - le client archivé ; + - l'écriture de fichier impossible ; + - un test déterministe de l'`UPDATE` conditionnel ; + - le PDF transmis hors de Kesh, puis détaché. + - **LOW** : les références (répertoire `routes/`), l'E2E et `KESH_DOCUMENTS_DIR`, la facture à 10 lignes, les fichiers + orphelins. +- **2026-10-03** — Créée (Guy : « ok » aux six recommandations et aux deux CR, #502 et #503). + +[#387]: https://github.com/guycorbaz/kesh/issues/387 +[#502]: https://github.com/guycorbaz/kesh/issues/502 +[#503]: https://github.com/guycorbaz/kesh/issues/503 diff --git a/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p1.md b/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p1.md new file mode 100644 index 000000000..5d66d3020 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p1.md @@ -0,0 +1,61 @@ +# Prompt — revue de code P1, Story 25-6-b (le PDF d'une facture est figé) + +*Versionné le 2026-10-05. Trois lentilles (Sonnet), contexte frais chacune.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-b-pdf-facture-archive`. **Diff à revoir : `git diff main 9d842984`** +(migration `51d1696f`, service et tests `bd753fdc`, écran/export/manuels `9d842984`). Fiche : +`_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md` (arbitrages 1–8, AC 1 à 8, Dev Notes, Dev Agent +Record). Issues : `gh issue view 387`, `502`, `503`. Règles : `CLAUDE.md`. + +## Lentilles + +- **A — Blind hunter** : le diff **seul**, sans la fiche. + - `crates/kesh-api/src/routes/issued_invoice_pdf.rs` : chaque branche de `get_or_freeze`, `pose`, `refreeze`, + `with_one_retry` — erreurs propagées ou avalées, `spawn_blocking` (panique, annulation), ordre écriture du + fichier → pose → audit, ce qui est servi dans chaque cas. + - `crates/kesh-db/src/repositories/invoices.rs` : `freeze_pdf`, `refreeze_pdf` (liaisons dans l'ordre — compte-les + contre les `?`), transaction et rollback, `unvalidate` (l'`UPDATE` unique), la macro `invoice_columns!` et + `INVOICE_COLUMNS` (ordre identique aux champs de `Invoice` ? `FromRow` lit par nom ou par position ?). + - `errors.rs` : les six variantes, statuts HTTP, messages et arguments Fluent. + - Svelte 5 : `+page.svelte` de la fiche facture — snippet, `$derived`, état `pdfGone` (quand se remet-il à faux ?). +- **B — Edge-case hunter** : le diff et le code environnant. + - **Courses et gardes**, refaites à la main : deux premiers téléchargements simultanés ; un envoi e-mail pendant un + téléchargement ; une dévalidation, un avoir, un règlement, une pause des rappels entre rendu et pose ; deux + refigeages ; un refigeage pendant un avoir. Pour chacun : qu'est-ce qui est figé, servi, tracé ? + - **Tous les chemins qui rendent un PDF de facture** — pars du symptôme : + `grep -rn "render_document\|PdfDocument::Invoice\|generate_qr_bill_pdf" crates/`. Un chemin contourne-t-il le + gel (lot d'envois, aperçu, rappel, export, test endpoint) ? + - **Tous les chemins qui changent `status` ou les lignes d'une facture** : + `grep -rn "UPDATE invoices" crates/kesh-db/src` — chacun est-il compatible avec un PDF figé (détache-t-il quand + il le faut, bumpe-t-il `version` quand les données rendues changent) ? + - Les fichiers : `KESH_DOCUMENTS_DIR` relatif, lien symbolique, fichier vide, `storage_path` corrompu en base, + `HEAD` sur la route `GET`. La restauration `.keshbackup` (`post_restore.rs`, `check_schema_compat`) et l'export + CSV : une sauvegarde antérieure s'importe-t-elle ? la colonne écartée l'est-elle à bon droit ? + - Les tests : déterministes ? chaque mutation déclarée au Dev Agent Record est-elle tuée par l'assertion écrite, et + non par un effet de bord ? le E2E Playwright crée-t-il sa propre facture ? +- **C — Acceptance auditor** : chaque AC (1, 1-bis, 2, 3, 3-bis, 4, 5, 5-bis, 6, 7, 8) tenu, point par point ? + - Recompte depuis la source : bloc admin (`ADMIN_COUPLES`, `lib.rs` « exactement 28 »), registre d'audit + (110 / 92 / 113), `sitesTotal` 1767 (`frontend/src/lib/shared/i18n-keys.test.ts`), parité des clés neuves dans les + 4 `.ftl`, compteurs de `docs/migrations-idempotence-audit.md` et de `migrations_upgrade_path.rs`, décomptes de + tests du Dev Agent Record (`main` → `9d842984`). + - **Le manuel** : `docs/manual/fr/user-manual.tex` et `admin-manual.tex`, **et leurs PDF aplatis** + (`pdftotext f.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`). Chaque site nommé par l'AC 8 est-il + traité ? Le manuel promet-il quelque chose que le code ne fait pas, ou omet-il un flux que le code ouvre (un + écran, une route, une clé d'API) ? Pars du symptôme : + `grep -nE "PDF|figé|refig" docs/manual/fr/*.tex`. + - Le CHANGELOG `[0.12.1]`, le README, `docs/testing.md`. + - Les écarts déclarés au Dev Agent Record (bloc admin 27 → 28 au lieu de 25 → 26 ; garde d'export non anticipée ; + E2E complet non relancé après la correction du montage de la spec) sont-ils exacts et acceptables ? + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (commande et sortie, ou code cité relu), +scénario d'échec, correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la sortie d'un +`grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, `sqlx`, +`cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`sed -n`, `git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p2.md b/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p2.md new file mode 100644 index 000000000..d1c79c321 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p2.md @@ -0,0 +1,53 @@ +# Prompt — revue de code P2, Story 25-6-b (le PDF d'une facture est figé) + +*Versionné le 2026-10-05. Deux lentilles (Opus), contexte frais chacune. Rotation : P1 Sonnet ×3 → P2 Opus.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-b-pdf-facture-archive`. Fiche : +`_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md` — lire le **Change Log** (entrée « Revue de code +P1 ») pour savoir ce qui a été corrigé. Règles : `CLAUDE.md`. Issues : `gh issue view 387`. + +La remédiation de P1 touche trois modules (`routes/invoice_email.rs`, `inbox_import.rs`, la fiche facture Svelte) : +la passe reprend donc le périmètre complet, avec une lentille braquée sur la remédiation. + +## Lentilles + +- **R — Regression hunter** : **`git show 8ac0a4de` seul**. Chaque correctif de P1 a-t-il introduit un défaut ? + - La garde de statut ajoutée à `send_invoice_email` : l'ordre des refus a-t-il changé pour un appelant qui + l'observait (tests `invoice_send_email_e2e`, frontend `SendEmailDialog`, lot d'envois) ? un statut légitime + est-il désormais refusé ? + - Le nettoyage de l'inbox retiré : que devient un fichier orphelin ? un ré-import du même fichier ? le test ajouté + prouve-t-il ce qu'il dit (le nom qu'il écrit est-il exactement celui que l'import aurait supprimé) ? + - La fiche : la nouvelle branche du `catch`, `pdfGone` remis à faux, la condition `validated` du bouton, la clé + `invoice-pdf-refreeze-error` — le mock Vitest qui honore un catalogue prouve-t-il le défaut M1 tel qu'il se + produirait avec le vrai `i18nMsg` (`frontend/src/lib/shared/utils/i18n.svelte.ts`) ? + - **Propagation** : le symptôme M1 (message à argument Fluent tiré du catalogue côté frontend) existe-t-il + ailleurs ? Pars du symptôme : `grep -rn "{ \$" crates/kesh-i18n/locales/fr-CH/messages.ftl | grep "^.*error-"` + puis cherche si ces codes d'erreur sont résolus par `i18nMsg(clé, err.message)` dans `frontend/src`. +- **F — Full-scope adversary** : `git diff main 8ac0a4de`, avec la fiche. Ce que P1 n'a **pas** exercé, d'après ses + rapports : + - la restauration `.keshbackup` (`crates/kesh-api/src/routes/admin/` ou `backup*`, `post_restore.rs`, + `check_schema_compat`) : une sauvegarde **antérieure** à la migration s'importe-t-elle ? une sauvegarde + **postérieure** restaure-t-elle les quatre colonnes ? une facture restaurée figée sans son fichier répond-elle + bien 410 ? + - `HEAD /api/v1/invoices/{id}/pdf` : fige-t-il ? est-ce acceptable et écrit ? + - `KESH_DOCUMENTS_DIR` relatif ou lien symbolique (l'inbox canonicalise le sien — et le service de gel ?) ; + - les clés d'API : le gel par une clé `read` est-il tracé au nom de la clé (`actor_api_key_id`) ? testé ? + - les trois `.ftl` DE/IT/EN : clés, variables `{ $sha256 }` présentes partout où le FR les a ; + - les corps des tests Rust `crates/kesh-api/tests/invoice_frozen_pdf_e2e.rs` et + `crates/kesh-db/tests/invoice_frozen_pdf.rs` : un test qui passerait **à vide** (assertion vraie par + construction, montage qui ne produit pas l'état annoncé) ? + - le **manuel** (`docs/manual/fr/*.tex` **et PDF aplatis**, `pdftotext f.pdf - | tr '\n' ' ' | tr -s ' '` vers + `target/gate-logs/`) contre le code : chaque promesse est-elle tenue ? chaque flux ouvert est-il dit ? + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (commande et sortie, ou code cité relu), +scénario d'échec, correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la sortie d'un +`grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, `sqlx`, +`cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`sed -n`, `git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p3.md b/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p3.md new file mode 100644 index 000000000..c25d18b8c --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p3.md @@ -0,0 +1,56 @@ +# Prompt — revue de code P3, Story 25-6-b (le PDF d'une facture est figé) + +*Versionné le 2026-10-05. Deux lentilles (Sonnet), contexte frais chacune. Rotation : P1 Sonnet ×3 → P2 Opus ×2 → P3 +Sonnet ×2.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-b-pdf-facture-archive`. Fiche : +`_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md` — lire le **Change Log** (entrées « Revue de code +P1 » et « P2 »). Règles : `CLAUDE.md`. + +**Constat qui motive la passe** : en P2, deux des cinq MEDIUM venaient de la remédiation P1. La remédiation P2 +(`b2eeaab9`) touche encore la production en plusieurs modules : `errors.rs` (`t_args`, `InvoicePdfGone`), +`routes/issued_invoice_pdf.rs` (`Usage`, relecture par tentative), `routes/invoice_pdf.rs`, `routes/invoice_email.rs`, +`frontend/src/lib/shared/utils/pdf-error.ts` (nouveau), les fiches facture et avoir. + +## Lentilles + +- **R — Regression hunter** : **`git show b2eeaab9` seul**, puis le code qu'il touche. + - `t_args` retire désormais U+2068/U+2069 de **tous** les messages d'erreur à argument : un appelant comptait-il + sur elles (test, frontend, texte bidirectionnel) ? `grep -n "t_args(" crates/kesh-api/src/errors.rs`. + - `InvoicePdfGone { sha256, refreezable }` : chaque site de construction donne-t-il la bonne valeur ? un refus + `refreezable = true` sur une facture annulée, ou l'inverse, est-il possible ? + - `Usage::Send` : la garde est-elle avant ou après le service ? double garde cohérente avec celle du handler ? + - `render_and_pose` relit la facture et compare `rendered.invoice_version` : une boucle infinie, un `Changed` + systématique (version lue par `render_document` différente pour une raison structurelle) ? + - `pdfErrorMessage` : chaque code des anciennes tables est-il conservé (`git show b2eeaab9^:…` pour les deux + fiches) ? un message qui était traduit côté client et l'est désormais côté serveur — quelle langue ? + - La fiche relue après refigeage : un échec de la relecture laisse-t-il l'écran dans un état faux (refigeage + réussi, erreur affichée) ? + - **Propagation** : le symptôme R2-1 (variable brute d'un message tiré du catalogue) existe-t-il ailleurs que + sur le PDF ? Pars du symptôme : les clés `.ftl` à variable (`grep -n "{ \$" crates/kesh-i18n/locales/fr-CH/messages.ftl`) + croisées avec les `i18nMsg(, …)` **sans** troisième argument dans `frontend/src`. +- **C — Acceptance auditor** : l'état `b2eeaab9`, chaque AC (1 à 8) contre le code. + - Recompte depuis la source : `sitesTotal` (1765), `sitesNonResolus` (33), parité des clés neuves dans les 4 + `.ftl`, décomptes de tests des trois entrées du Change Log, compteurs des registres (bloc admin 28, routes + 110/92/113), migrations 71. + - **Le manuel** : `docs/manual/fr/user-manual.tex` et `admin-manual.tex` **et leurs PDF aplatis** + (`pdftotext f.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`). Le texte dit-il ce que fait le code — + notamment le 410 d'une facture annulée, `HEAD`, la clé d'API, la restauration, l'export ? Pars du symptôme : + `grep -nE "PDF|figé|refig|410" docs/manual/fr/*.tex`. + - Le CHANGELOG `[0.12.1]` : dit-il les changements visibles de P1 et P2 (envoi d'une facture annulée refusé, + message du 410) ? + - Les « non traités » déclarés au Change Log (communication QR en français, fiche non relue après un envoi + par e-mail, F-L1 sans test) : exacts ? acceptables ? + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (commande et sortie, ou code cité relu), +scénario d'échec, correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la sortie d'un +`grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, `sqlx`, +`cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`sed -n`, `git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p4.md b/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p4.md new file mode 100644 index 000000000..bf0e5e6c8 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-b-review-prompt-p4.md @@ -0,0 +1,40 @@ +# Prompt — revue de code P4 ciblée, Story 25-6-b + +*Versionné le 2026-10-05. **Une lentille** (Haiku), contexte frais. Passe ciblée (`CLAUDE.md` § « La passe ciblée ») : +P3 n'a rendu que des LOW ; leur remédiation, `78b16da6`, touche la production — c'est elle seule qu'on relit.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-b-pdf-facture-archive`. **Diff à revoir : `git show 78b16da6`** +(un seul commit, à lire tel quel). Contexte : l'entrée « Revue de code P3 » du Change Log de +`_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md`. + +## Lentille — Regression hunter + +Pour chacun des quatre changements de production, dis s'il introduit un défaut : + +1. `crates/kesh-api/src/routes/issued_invoice_pdf.rs`, `render_and_pose` : une facture déjà figée à la relecture est + adoptée par `adopt`. Ce chemin peut-il servir un document à un **envoi** pour une facture non validée ? boucler ? + court-circuiter une garde de `get_or_freeze` ? +2. Même fichier, `adopt` et `pose(…, usage)` : la garde `Usage::Send` est-elle tenue sur **chaque** chemin qui rend + un PDF à un envoi ? Liste ces chemins (`grep -n "Adopted\|serve_frozen" crates/kesh-api/src/routes/issued_invoice_pdf.rs`). +3. Même fichier, fin de `refreeze` : le `match` sur le statut — chaque statut possible (`draft`, `validated`, + `cancelled`, autre) donne-t-il un code juste ? lequel répond une facture **validée** dont l'empreinte a changé ? +4. `frontend/src/routes/(app)/invoices/[id]/+page.svelte`, `confirmRefreeze` : la relecture isolée — que voit + l'utilisateur si le refigeage échoue ? si la relecture échoue ? `refreezeSubmitting` retombe-t-il dans les deux cas ? + +Et : les tests ajoutés (`un_envoi_n_adopte_pas_le_gel_d_une_facture_annulee_entre_temps`, le Vitest « relecture +échoue ») prouvent-ils ce qu'ils disent ? Le CHANGELOG ajouté dit-il vrai ? + +⚠️ **Pour tout finding affirmant qu'un code est absent ou présent, cite la sortie d'un `grep -nF` sur le fichier** — +pas un numéro de ligne lu dans le diff, qui peut être décalé. + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, preuve citée, scénario d'échec, correction. +⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, `sqlx`, +`cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`sed -n`, `git log`/`show`/`diff`, `gh issue view`. diff --git a/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p1.md new file mode 100644 index 000000000..a84e73ea7 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p1.md @@ -0,0 +1,49 @@ +# Prompt — validation P1, Story 25-6-b (le PDF d'une facture est figé) + +*Versionné le 2026-10-03. Trois lentilles (Sonnet), contexte frais chacune.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-b-pdf-facture-archive`. **Fiche :** +`_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md` (tête `9048d8e8`). Issue : `gh issue view 387` ; CR +`gh issue view 502`, `503`. Règles : `CLAUDE.md` (en particulier la § *Migration breaking policy*, P1 à P8). La fiche +décrit du travail **à faire**, et ses six **arbitrages** sont rendus par le Project Lead : un finding qui les conteste +doit dire pourquoi ils seraient faux, pas seulement qu'un autre choix existe. + +## Lentilles + +- **A — Justesse et intégrité.** Le mécanisme garantit-il vraiment « un seul document par facture » ? L'ordre « écrire le + fichier, puis `UPDATE … WHERE pdf_storage_path IS NULL` » : que se passe-t-il si l'`UPDATE` échoue après l'écriture, si + deux rendus concurrents écrivent deux fichiers (noms différents, non reproductibles), si le serveur meurt entre les deux ? + Le perdant d'une course relit-il le bon document ? La vérification d'empreinte à la lecture et le 410 sont-ils + cohérents avec un fichier partagé ? La langue figée (`pdf_language`) : que devient-elle si le client change de langue + après le gel ? L'envoi par e-mail d'une facture **déjà figée** dans une autre langue que celle du client actuel ? La + dévalidation qui détache : un PDF déjà **téléchargé** (et transmis hors Kesh) puis détaché — la fiche le dit-elle ? + RBAC : qui peut déclencher le gel (un Consultation qui télécharge le premier fige le document — est-ce un problème) ? +- **B — Faisabilité dans le code.** Chaque référence `fichier:ligne` existe-t-elle et dit-elle ce que la fiche affirme + (`grep -nF`, `sed -n`) ? `document_storage::store_document` est-il appelable depuis le service (synchrone, chemins, + `DocumentMeta`) ? `invoice_pdf_service::render` exige `validated` : comment le service rend-il une facture `cancelled` + figée sans le modifier ? Le DTO de la facture (`routes/invoices.rs`) et les `SELECT` de `kesh-db` qui lisent les + colonnes de `invoices` (inventaire : `grep -rn "FROM invoices" crates/kesh-db/src`) — un `SELECT *` ou un mapping + `FromRow` casserait-il avec quatre colonnes de plus ? La sauvegarde (`crates/kesh-db/src/backup.rs`, `post_restore.rs` + `check_schema_compat`) et l'export de souveraineté (`crates/kesh-api/src/exports/global.rs`) traitent-ils les colonnes + neuves sans changement, ou faut-il les inscrire quelque part ? La migration datée `20261003000001` est-elle compatible + avec les gardes de `crates/kesh-db/tests/migrations_upgrade_path.rs` ? Le périmètre (quatre modules) est-il exact ? +- **C — Complétude et tests.** Chaque AC est-il testable, et le test prévu aurait-il échoué avant le patch ? Cas + manquants : facture validée puis contact **supprimé ou archivé** ; société qui change de langue d'instance ; envoi + e-mail qui **échoue** après le gel (le document est-il figé quand même ?) ; `KESH_DOCUMENTS_DIR` non inscriptible au + moment du gel ; facture à 0 ligne ou limites de `render`. Les E2E prévus sont-ils réalisables (comparer les octets de + deux téléchargements dans Playwright) ? Le manuel : relis `docs/manual/fr/user-manual.tex` (section des factures, PDF, + envoi par e-mail, dévalidation) et `admin-manual.tex` (`KESH_DOCUMENTS_DIR`, sauvegarde) **et les PDF aplatis** + (`pdftotext … | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) : la fiche nomme-t-elle tous les sites que le patch + rendra faux ? Pars du symptôme : `grep -rn "régénér\|PDF de la facture\|langue de l'installation\|KESH_DOCUMENTS_DIR" docs`. + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit, **preuve** (commande ET sortie, `grep -nF` pour toute +présence ou absence), correction proposée. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p2.md new file mode 100644 index 000000000..f124613b2 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p2.md @@ -0,0 +1,46 @@ +# Prompt — validation P2, Story 25-6-b (le PDF d'une facture est figé) + +*Versionné le 2026-10-04. Une passe (Opus), contexte frais, les trois lentilles. Trend : P1 4 HIGH / ~15 MED (Sonnet ×3). +La remédiation ajoute une route (refiger) et réécrit plusieurs AC : protocole complet, attention particulière au diff.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-b-pdf-facture-archive`. **Fiche :** +`_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md` (tête `5ed8d632`). Remédiation de P1 : +`git diff cd4a3275 5ed8d632`. Issue `gh issue view 387` ; CR `502`, `503`. Règles : `CLAUDE.md` (§ *Migration breaking +policy* P1 à P8 ; § *Pattern batch* sans objet). Les **huit arbitrages** sont rendus par Guy : un finding qui en conteste +un doit dire pourquoi il serait faux, pas qu'un autre choix existe. + +## Ce que tu vérifies + +1. **La remédiation de P1** : + - la garde `status = 'validated'` sur la pose : ferme-t-elle vraiment la course gel/dévalidation (lis `unvalidate`, + `crates/kesh-db/src/repositories/invoices.rs:1441-1610`, et l'avoir, `credit_notes.rs:583`) ? Et la course + gel/**avoir** — un avoir qui passe la facture en `cancelled` entre le rendu et la pose : le document n'est-il alors + jamais figé, et est-ce le bon comportement ? + - **refiger (AC 3-bis)** : la route dans le bloc d'administration (`crates/kesh-api/src/lib.rs`, marqueur + `KESH-ADMIN-ROUTES-END`, `require_admin_role`, `require_not_pat`) ; ses conditions (fichier absent / présent / + altéré / non figé / `cancelled`) sont-elles complètes et sans contradiction avec AC 2, AC 3 ? La garde + `pdf_sha256 = ` ; le registre des routes d'audit (`crates/kesh-api/tests/audit_route_registry.rs`) impose-t-il + d'y inscrire la route ? Le refigeage d'une facture dont le **client a changé de langue** ; + - l'audit dans la transaction de la pose (`audit_log::insert_in_tx`) et l'acteur « clé d'API » : l'audit sait-il + nommer une clé d'API aujourd'hui (`grep -rn "api_key_id" crates/kesh-db/src/repositories/audit_log.rs`) ? + - la constante unique de colonnes (AC 1-bis) : réalisable sans casser `INVOICE_COLUMNS` de `reconciliation.rs`, qui + préfixe peut-être par un alias de table ? + - l'export CSV (AC 5-bis) et son test d'en-tête. +2. **Cohérence interne** : la fiche dit-elle la même chose partout (arbitrages, AC, tâches, limites, *Modules*, Change + Log) ? En particulier « jamais régénérer » (Dev Notes) contre « refiger » (arbitrage 7). +3. **Le manuel** : les sites nommés à l'AC 8 existent-ils et disent-ils ce que la fiche affirme (`sed -n` sur + `docs/manual/fr/user-manual.tex` et `admin-manual.tex`) ? En manque-t-il (`grep -rn "PDF" docs/manual/fr/user-manual.tex`) ? +4. **Périmètre** : toujours quatre modules de code ? Le signal de découpage (§ *Règle de splitting préventif*) est-il + atteint par l'ajout de la route ? + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit, **preuve** (commande ET sortie, `grep -nF` pour toute +présence ou absence), correction proposée. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p3.md new file mode 100644 index 000000000..f1db8370d --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p3.md @@ -0,0 +1,40 @@ +# Prompt — validation P3, Story 25-6-b (le PDF d'une facture est figé) + +*Versionné le 2026-10-05. Une passe (Sonnet), contexte frais. Trend : P1 4H/12M (Sonnet ×3) → P2 1H/7M/8L (Opus) ; pas de +découpage (Guy). La remédiation de P2 change une règle de concurrence (garde `version`) et ajoute des sites de manuel : +fiche entière, attention particulière au diff.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-b-pdf-facture-archive`. **Fiche :** +`_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md`. Remédiation de P2 : `git diff ac125b29 HEAD`. +Règles : `CLAUDE.md`. Les huit arbitrages sont rendus par Guy. + +## Ce que tu vérifies + +1. **La garde `version` (AC 2)** : `render` peut-il exposer la version **lue** sans changer son comportement pour ses + autres appelants (`grep -rn "invoice_pdf_service::render\|render_document" crates`) ? La règle « une seule nouvelle + tentative, puis 409 `INVOICE_CHANGED` » est-elle cohérente avec les trois cas « zéro ligne » ? Le gel ne touche pas + `version` : la garde `version = ?` reste-t-elle juste si **un autre** chemin incrémente `version` sans changer le + contenu rendu (envoi par e-mail `mark_emailed`, pause des rappels, règlement — `grep -n "version = version + 1" + crates/kesh-db/src/repositories/invoices.rs`) ? Faut-il alors une nouvelle tentative fréquente, voire un 409 en usage + normal ? +2. **Les sites de manuel ajoutés** (AC 8) : chacun existe-t-il et dit-il ce que la fiche affirme ? `sed -n` sur + `docs/manual/fr/admin-manual.tex` (`:1353`, `:1376-1381`, `:1444`, `:1480`, `:1583`, `:1648-1654`, `:1774`) et + `user-manual.tex` (`:789`, `:797`, `:815`, `:846`). +3. **Les gardes structurelles** (AC 3-bis) : le compte `lib.rs:186` (25), `admin_pat_denied_e2e.rs:46`, `:757`, + `audit_route_registry.rs` (`LIB_ROUTES`, `extract_counted` `:186`), `audit_label_registry.rs` — existent-ils tels que + la fiche les décrit ? +4. **Cohérence interne** : arbitrages, AC, tâches, limites, *Modules*, Change Log — y a-t-il une phrase qui contredit une + autre depuis la remédiation (par exemple « jamais régénérer » contre refiger ; codes HTTP de la même situation écrits + deux fois différemment ; « six » contre « quatre » sites) ? `grep -nE "409|410|500" `. + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit, **preuve** (commande ET sortie, `grep -nF` pour toute +présence ou absence), correction proposée. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, +`git log`/`show`/`diff`, `gh issue view`. diff --git a/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p4.md new file mode 100644 index 000000000..3ec37ad2c --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-b-validate-prompt-p4.md @@ -0,0 +1,32 @@ +# Prompt — validation P4 ciblée, Story 25-6-b + +*Versionné le 2026-10-05. **Une lentille** (Haiku), contexte frais — passe ciblée sur la remédiation de P3. Trend : +P1 4H/12M → P2 1H/7M → P3 3M/4L.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-b-pdf-facture-archive`. **Diff à relire, un seul commit, aplati : +`git show 54337553`** (la fiche `_bmad-output/implementation-artifacts/25-6-b-pdf-facture-archive.md`). La fiche décrit du +travail **à faire** : un finding qui reproche au code de ne pas encore le porter sera rejeté. + +## Lentille — regression hunter + +1. **Les trois totaux du registre** : `sed -n 430,475p crates/kesh-api/tests/audit_route_registry.rs` — les valeurs 109, + 91, 112 y sont-elles, et la fiche les porte-t-elle à 110, 92, 113 ? +2. **Les six variantes d'erreur** : chacune est-elle employée de façon cohérente dans les AC (`grep -nE + "INVOICE_PDF_GONE|INVOICE_CHANGED|INVOICE_PDF_PRESENT|INVOICE_PDF_NOT_FROZEN|INVOICE_PDF_INTEGRITY|InvoiceCancelled" `) + — même statut HTTP partout où la même situation est décrite ? `InvoicePdfIntegrity` est dite « 409 au refigeage, 500 à + la lecture » : l'AC 3 dit-elle bien 500 pour la lecture ? +3. **`render_document` porte la version** : `sed -n 195,220p crates/kesh-api/src/routes/invoice_pdf_service.rs` — + charge-t-il bien la facture ? Ses appelants « rappel » (`grep -n "render_document" crates/kesh-api/src/routes/invoice_email.rs`) + existent-ils aux lignes citées ? +4. **Résidus** : la fiche dit-elle encore « six sites » pour H1 de P2 ? `grep -n "Six sites\|six sites" `. + +## Ce que tu rends + +Findings avec sévérité, endroit, **preuve obligatoire : la commande ET sa sortie** — un finding sans sortie sera rejeté, +un « 0 finding » sans les sorties des quatre vérifications aussi. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base (`scripts/*` dont +`scripts/prepare-release.sh`, `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, `cargo test`/`nextest`, +`npm run`, `npx`, `gh issue create`/`comment`/`edit`). Autorisés : lecture, `grep`, `sed`, `git log`/`show`/`diff`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 43d6b28ee..cee8ecebf 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -372,7 +372,7 @@ development_status: 25-5-etats-et-export: split # [#385] [#386] — la **Balance des comptes ne concorde pas avec le Bilan**, il manque la colonne « solde d'ouverture » (III.4) ; et **l'export de souverainete omet les achats, les avoirs, les projets et la piste d'audit** (V.6) — *il promet plus qu'il ne tient*. 25-6-tableau-de-bord-et-dettes: split # 2026-10-03 DECOUPEE en 25-6-a (#388 #389) et 25-6-b (#387). [#388] [#389] [#387] (#384 ramenee en 25-4-d le 2026-09-27) — la tuile « Dernieres ecritures » **n'appelle rien** et affiche toujours « Aucune ecriture » (*une tuile qui est un decor*) ; le « solde bancaire » est le solde COMPTABLE, donc faux ; imputer l'ECART d'un reglement partiel (escompte, frais bancaires, perte sur debiteur) ; et **le PDF d'une facture n'est jamais archive**, il est regenere a la volee (IV.9) — *un document reemis n'est pas le document envoye*. 25-6-a-tableau-de-bord: done # 2026-10-03 CREEE, validee (4 passes), implementee, revue close en 3 passes (4M -> 2M -> 0) (partie de main). [#388] [#389] — dernieres ecritures branchees, factures ouvertes chiffrees (tout role), solde COMPTABLE nomme + dernier releve et ecart ; closes #388 #389. - 25-6-b-pdf-facture-archive: backlog # [#387] — figer le PDF de la facture a la validation (patron des justificatifs entrants : SHA-256, ON DELETE RESTRICT). + 25-6-b-pdf-facture-archive: done # 2026-10-05 DONE : revue close en 4 passes (3M -> 5M -> 0 -> 0), gates 2588/862/E2E 229 + 9 attendus ; non poussee, PR closes #387 sur accord ; 2026-10-03 CREEE (partie de main), validee (4 passes) ; arbitrages de Guy (gel au premier rendu, langue du client, colonnes sur invoices) ; CR #502 #503. epic-16: done # 2026-08-11 (nuit) MERGÉ — dernière story 16-3b via PR #296 (squash `593bb845`). #152, #144 et #151 fermées. v0.9.0 prête à taguer. # 2026-07-26 KICKOFF (rétro Epic 14, décision D-D ; GO Guy après publication de v0.8.0). Branche `story/16-1-compte-produit-par-ligne` créée sur main `ef6cdf52`. Séquence : 16-1 (#152 socle par ligne) → 16-2 (#144 défaut catalogue) → 16-3 (#151 reliquat PDF). # ÉLU PROCHAIN EPIC — kickoff dès le merge de la PR groupée Epic 14. Reste #144 (compte produit au catalogue) + #152 (compte produit par ligne) ; #151 (récap TVA facture) déjà mergé hors-bande via PR #267 (bb3c8d64, partiel : coord. émetteur / n° client restants). Capte aussi les CR utilisateurs #265/#266/#268. # Facturation avancée (#144/#152/#151) — Epic dédié acté 2026-06-04 # 2026-07-26 ORDRE INVERSÉ au kickoff (#152 avant #144). Motif ground-truth : `invoice_lines` ne # porte AUCUNE colonne de compte ET ne référence même pas `products` (pas de `product_id` — cf. diff --git a/crates/kesh-api/src/audit_labels.rs b/crates/kesh-api/src/audit_labels.rs index f08a6cfcc..dbf9754f2 100644 --- a/crates/kesh-api/src/audit_labels.rs +++ b/crates/kesh-api/src/audit_labels.rs @@ -118,6 +118,8 @@ pub const ACTIONS: &[&str] = &[ "invoice.emailed", "invoice.paid", "invoice.partially_settled", + "invoice.pdf_frozen", + "invoice.pdf_refrozen", "invoice.reminder_cancelled", "invoice.reminder_sent", "invoice.settlement_cancelled", diff --git a/crates/kesh-api/src/errors.rs b/crates/kesh-api/src/errors.rs index 2ae3314c3..160501e61 100644 --- a/crates/kesh-api/src/errors.rs +++ b/crates/kesh-api/src/errors.rs @@ -42,10 +42,19 @@ pub(crate) fn t(key: &str, default: &str) -> String { } /// Résout un message i18n avec arguments Fluent, fallback sur `default`. +/// +/// ⚠️ Fluent entoure chaque variable interpolée de marques d'isolation BiDi +/// (U+2068 / U+2069), invisibles à l'écran mais **copiées** avec le texte. Un +/// message d'erreur se lit, et parfois se copie : celui du PDF figé manquant +/// (Story 25-6-b) nomme le fichier `{empreinte}.pdf` à chercher dans une +/// sauvegarde — copié avec ses marques, il ne trouverait rien. Elles sont donc +/// retirées (précédent : `routes/contacts.rs`, libellé des conditions). fn t_args(key: &str, default: &str, args: &FluentArgs<'_>) -> String { let guard = I18N.read().expect("I18N read lock"); match guard.as_ref() { - Some((bundle, locale)) => bundle.format(locale, key, Some(args)), + Some((bundle, locale)) => bundle + .format(locale, key, Some(args)) + .replace(['\u{2068}', '\u{2069}'], ""), None => default.to_string(), } } @@ -337,6 +346,47 @@ pub enum AppError { #[error("Facture non validée")] InvoiceNotValidated, + // --- Story 25-6-b (#387) — le PDF figé --- + /// La facture est annulée par un avoir — pendant le rendu de son PDF, ou + /// avant un refigeage : rien n'a été figé (400, sur le patron + /// d'[`Self::InvoiceNotValidated`]). + #[error("Facture annulée")] + InvoiceCancelled, + + /// Le PDF figé est référencé mais son fichier manque sous + /// `KESH_DOCUMENTS_DIR` (410). Jamais régénéré en silence : ce serait + /// fabriquer une pièce qui n'a pas été émise. Porte l'empreinte, que le + /// message nomme. + #[error("PDF figé introuvable : {sha256}.pdf")] + InvoicePdfGone { + sha256: String, + /// Le refigeage est-il possible ? Faux pour une facture annulée : le + /// message ne propose alors que la restauration (revue P2). + refreezable: bool, + }, + + /// La facture a changé deux fois de suite pendant le rendu de son PDF + /// (409) : rien n'a été figé, l'utilisateur réessaie. + #[error("Facture modifiée pendant le rendu du PDF")] + InvoiceChanged, + + /// Refigeage refusé : le fichier du PDF figé est présent (409) — on ne + /// remplace pas un document qui existe. + #[error("PDF figé présent")] + InvoicePdfPresent, + + /// Refigeage refusé : la facture n'a pas de PDF figé (409) — son + /// prochain rendu la figera. + #[error("PDF non figé")] + InvoicePdfNotFrozen, + + /// Refigeage refusé : le fichier est présent mais son empreinte ne + /// correspond plus (409). Un fichier altéré se diagnostique, il ne se + /// recouvre pas d'un nouveau document. À la **lecture**, la même + /// altération répond 500 (`Internal`), rien n'est servi. + #[error("PDF figé altéré")] + InvoicePdfIntegrity, + /// Un pré-requis applicatif manque pour générer le PDF : adresse contact, /// compte bancaire primary, IBAN invalide, etc. Le `String` contient la /// description i18n renvoyée au client. @@ -1429,6 +1479,74 @@ impl IntoResponse for AppError { AppError::InvoiceNotPdfReady(msg) => { build_response(StatusCode::BAD_REQUEST, "INVOICE_NOT_PDF_READY", &msg) } + + // Story 25-6-b (#387) — le PDF figé. + AppError::InvoiceCancelled => build_response( + StatusCode::BAD_REQUEST, + "INVOICE_CANCELLED", + &t( + "error-invoice-cancelled", + "La facture est annulée par un avoir : son PDF ne peut pas être produit, et aucun document n'a été figé.", + ), + ), + AppError::InvoicePdfGone { + sha256, + refreezable, + } => { + // Une facture annulée ne se refige pas : son message ne propose + // que la restauration (revue P2, R2-2). + let (key, fallback) = if refreezable { + ( + "error-invoice-pdf-gone", + format!( + "Le PDF émis de cette facture est introuvable : le fichier {sha256}.pdf manque dans le répertoire des documents (KESH_DOCUMENTS_DIR). Restaurez-le depuis la sauvegarde de ce répertoire ; à défaut, un administrateur peut refiger la facture." + ), + ) + } else { + ( + "error-invoice-pdf-gone-cancelled", + format!( + "Le PDF émis de cette facture annulée est introuvable : le fichier {sha256}.pdf manque dans le répertoire des documents (KESH_DOCUMENTS_DIR). Seule sa restauration depuis la sauvegarde de ce répertoire le répare." + ), + ) + }; + let mut args = FluentArgs::new(); + args.set("sha256", sha256); + let msg = t_args(key, &fallback, &args); + build_response(StatusCode::GONE, "INVOICE_PDF_GONE", &msg) + } + AppError::InvoiceChanged => build_response( + StatusCode::CONFLICT, + "INVOICE_CHANGED", + &t( + "error-invoice-changed", + "La facture a été modifiée pendant la préparation de son PDF. Réessayez.", + ), + ), + AppError::InvoicePdfPresent => build_response( + StatusCode::CONFLICT, + "INVOICE_PDF_PRESENT", + &t( + "error-invoice-pdf-present", + "Le PDF émis de cette facture est présent : il n'y a rien à refiger.", + ), + ), + AppError::InvoicePdfNotFrozen => build_response( + StatusCode::CONFLICT, + "INVOICE_PDF_NOT_FROZEN", + &t( + "error-invoice-pdf-not-frozen", + "Cette facture n'a pas encore de PDF figé : son prochain téléchargement le figera.", + ), + ), + AppError::InvoicePdfIntegrity => build_response( + StatusCode::CONFLICT, + "INVOICE_PDF_INTEGRITY", + &t( + "error-invoice-pdf-integrity", + "Le fichier du PDF émis a été altéré : son empreinte ne correspond plus. Restaurez-le depuis la sauvegarde, ou écartez-le du répertoire des documents avant de refiger.", + ), + ), AppError::InvoiceTooManyLinesForPdf(n) => { // #151 code-review : le cap n'est plus un nombre fixe (il dépend du // récap TVA et du type de document), donc message sans « max ». diff --git a/crates/kesh-api/src/exports/csv_tables.rs b/crates/kesh-api/src/exports/csv_tables.rs index 6f74932d0..f973c97c7 100644 --- a/crates/kesh-api/src/exports/csv_tables.rs +++ b/crates/kesh-api/src/exports/csv_tables.rs @@ -515,6 +515,13 @@ pub fn serialize_invoices_csv(rows: &[Invoice], writer: W) -> Result<( // #262 : suspension de relance (Story 21-5a) — idem, désormais exportée. "dunning_paused_at", "dunning_paused_note", + // Story 25-6-b (#387) : la preuve d'émission appartient aux données de + // l'utilisateur. `pdf_storage_path` n'est PAS exporté : il se dérive de + // l'empreinte (`{pdf_sha256}.pdf`) et ne désigne qu'un fichier du + // serveur — les fichiers eux-mêmes ne sont pas dans ce CSV. + "pdf_sha256", + "pdf_frozen_at", + "pdf_language", "version", "created_at", "updated_at", @@ -541,6 +548,10 @@ pub fn serialize_invoices_csv(rows: &[Invoice], writer: W) -> Result<( // Suspension de relance débiteur (Story 21-5a). fmt_opt_dt(i.dunning_paused_at), fmt_opt_str(&i.dunning_paused_note), + // PDF émis figé (Story 25-6-b). + fmt_opt_str(&i.pdf_sha256), + fmt_opt_dt(i.pdf_frozen_at), + fmt_opt_str(&i.pdf_language), i.version.to_string(), fmt_dt(i.created_at), fmt_dt(i.updated_at), @@ -1679,6 +1690,10 @@ mod tests { project_id: None, dunning_paused_at: Some(naive_dt(2026, 6, 15, 14, 0, 0)), dunning_paused_note: Some("litige; \"en cours\"".into()), // RFC 4180 escape + pdf_storage_path: Some(format!("{}.pdf", "ab".repeat(32))), + pdf_sha256: Some("ab".repeat(32)), + pdf_frozen_at: Some(naive_dt(2026, 6, 2, 9, 29, 0)), + pdf_language: Some("DE".into()), version: 3, created_at: naive_dt(2026, 6, 1, 8, 0, 0), updated_at: naive_dt(2026, 6, 15, 14, 0, 0), @@ -1687,8 +1702,10 @@ mod tests { // ----- #262 — invoices serializer : complétude des colonnes souveraineté ----- - /// Garde anti-dérive (#262) : le header DOIT lister exactement les 19 colonnes - /// de la struct `Invoice`, dans l'ordre. Tout `ADD COLUMN` répercuté dans la + /// Garde anti-dérive (#262) : le header DOIT lister les colonnes de la struct + /// `Invoice`, dans l'ordre — **toutes sauf une** : `pdf_storage_path` + /// (Story 25-6-b), exemptée parce qu'elle se dérive de `pdf_sha256` et ne + /// désigne qu'un fichier du serveur. 22 colonnes sur 23. Tout `ADD COLUMN` répercuté dans la /// struct force la mise à jour de l'export ET de ce test — symétrique de la /// discipline P5 (audit idempotence). Empêche qu'un champ (comme `emailed_*` / /// `dunning_paused_*`, jadis oubliés) manque silencieusement de l'export. @@ -1702,7 +1719,8 @@ mod tests { header, "id;company_id;contact_id;invoice_number;status;date;due_date;\ payment_terms;total_amount;journal_entry_id;paid_at;emailed_at;emailed_to;\ - project_id;dunning_paused_at;dunning_paused_note;version;created_at;updated_at" + project_id;dunning_paused_at;dunning_paused_note;pdf_sha256;pdf_frozen_at;\ + pdf_language;version;created_at;updated_at" ); } @@ -1732,6 +1750,25 @@ mod tests { ); } + /// Story 25-6-b (#387, AC 5-bis) : l'empreinte, l'instant et la langue du + /// PDF émis sont exportés ; le chemin de stockage ne l'est pas. + #[test] + fn serialize_invoices_csv_porte_le_pdf_fige() { + let mut buf = Vec::new(); + serialize_invoices_csv(&[sample_invoice()], &mut buf).expect("serialize ok"); + let text = String::from_utf8(buf).expect("utf8"); + let row = text.lines().nth(1).expect("ligne de données"); + let sha = "ab".repeat(32); + assert!( + row.contains(&format!(";{sha};2026-06-02T09:29:00Z;DE;")), + "pdf_sha256 / pdf_frozen_at / pdf_language manquants: {row}" + ); + assert!( + !row.contains(&format!("{sha}.pdf")), + "pdf_storage_path ne doit pas sortir: {row}" + ); + } + // ----- AC #30(a) — accounts serializer ----- #[test] @@ -2229,6 +2266,11 @@ mod tests { /// leur motif. Chacune est une colonne **technique** : aucune ne porte une /// information que l'export ne dirait pas déjà ailleurs. const COLONNES_HORS_EXPORT: &[(&str, &str, &str)] = &[ + ( + "invoices", + "pdf_storage_path", + "chemin du PDF figé sous `KESH_DOCUMENTS_DIR` (Story 25-6-b) : dérivé de `pdf_sha256` (exporté, `{sha256}.pdf`), il ne désigne qu'un fichier du serveur — les fichiers ne sont pas dans ce CSV", + ), ( "accounts", "singleton_role", diff --git a/crates/kesh-api/src/inbox_import.rs b/crates/kesh-api/src/inbox_import.rs index 232c13cee..f27789ea7 100644 --- a/crates/kesh-api/src/inbox_import.rs +++ b/crates/kesh-api/src/inbox_import.rs @@ -526,7 +526,6 @@ async fn process_one_file( ); } }; - let storage_path = doc.storage_path.clone(); // (8) Staging : INSERT imported_supplier_invoices (status='to_complete'). let new = NewImportedSupplierInvoice::from_scanned(company_id, &scanned, doc); @@ -556,10 +555,11 @@ async fn process_one_file( } DbError::DataLengthOrRange(_) => { // Champ QR tiers sur-long (hors SIX 2.2) → échec par-fichier - // propre (D2). Nettoyage best-effort de l'orphelin archivé - // (content-addressed → idempotent : un ré-import réécrirait - // le même chemin). - let _ = std::fs::remove_file(documents_root.join(&storage_path)); + // propre (D2). ⛔ Le fichier archivé n'est PAS supprimé : nommé + // par son contenu, il peut être partagé — justificatif d'une + // autre société, ou PDF de facture figé (Story 25-6-b) au même + // octet près. Un orphelin est inoffensif ; une suppression à + // tort ferait répondre 410 à une pièce émise. dispose_failed( path, failed_dir, diff --git a/crates/kesh-api/src/lib.rs b/crates/kesh-api/src/lib.rs index c6e3202df..808eac2b0 100644 --- a/crates/kesh-api/src/lib.rs +++ b/crates/kesh-api/src/lib.rs @@ -183,7 +183,7 @@ pub fn build_router(state: AppState, static_dir: String) -> Router { // KESH-ADMIN-ROUTES-BEGIN // ⚠️ Bloc borné — Story 22-4a (#167). Le test `admin_pat_denied_e2e` lit CE // fichier par `include_str!`, compte les constructeurs de méthode entre les - // deux marqueurs, et exige exactement 25. Toute route ajoutée ici sans son + // deux marqueurs, et exige exactement 28. Toute route ajoutée ici sans son // couple dans la liste du test fait ROUGIR le test : c'est le rappel qui // manquait, et qui a laissé 16 routes sur 19 sans garde anti-PAT. // Ne pas déplacer ni reformuler les marqueurs sans ajuster le test. @@ -310,6 +310,13 @@ pub fn build_router(state: AppState, static_dir: String) -> Router { // Elle efface `audit_log` (`kesh-seed`, `DELETE` non scopé) : c'était le // second des trois chemins par lesquels la piste de contrôle se perdait. .route("/api/v1/onboarding/reset", post(routes::onboarding::reset)) + // Story 25-6-b (#387, arbitrage 7) : REFIGER le PDF d'une facture dont + // le fichier a disparu. Admin uniquement, refusé aux clés d'API : le + // geste remplace définitivement la référence au document d'origine. + .route( + "/api/v1/invoices/{id}/pdf/refreeze", + post(routes::invoice_pdf::refreeze_invoice_pdf), + ) // ⚠️⚠️ TOUTE ROUTE S'AJOUTE AU-DESSUS DE CETTE LIGNE. ⚠️⚠️ // // `route_layer` n'enveloppe que les routes DÉJÀ enregistrées au moment diff --git a/crates/kesh-api/src/mail/mod.rs b/crates/kesh-api/src/mail/mod.rs index 5b960df8c..c322275af 100644 --- a/crates/kesh-api/src/mail/mod.rs +++ b/crates/kesh-api/src/mail/mod.rs @@ -139,6 +139,9 @@ pub struct CapturedEmail { pub attachment_filename: Option, pub attachment_content_type: Option, pub attachment_size: usize, + /// Story 25-6-b (#387) — les octets de la pièce jointe, pour prouver qu'elle + /// est celle du téléchargement (le PDF émis, figé), pas un autre rendu. + pub attachment_bytes: Vec, } #[derive(Debug, Default, Clone)] @@ -225,6 +228,10 @@ impl Mailer for MockMailer { attachment_filename: email.attachment.as_ref().map(|a| a.filename.clone()), attachment_content_type: email.attachment.as_ref().map(|a| a.content_type.clone()), attachment_size: email.attachment.as_ref().map_or(0, |a| a.bytes.len()), + attachment_bytes: email + .attachment + .as_ref() + .map_or_else(Vec::new, |a| a.bytes.clone()), }; let fail = self.fail; Box::pin(async move { diff --git a/crates/kesh-api/src/routes/invoice_email.rs b/crates/kesh-api/src/routes/invoice_email.rs index 5b5c852ce..8e115974c 100644 --- a/crates/kesh-api/src/routes/invoice_email.rs +++ b/crates/kesh-api/src/routes/invoice_email.rs @@ -7,7 +7,7 @@ //! nécessaire car les routes templates (20-1) sont Admin-only alors que //! l'expéditeur est Comptable+. //! - `POST /api/v1/invoices/{id}/send-email` — envoie la facture (PDF -//! QR-facture joint via `invoice_pdf_service::render`) au **destinataire +//! QR-facture joint via `issued_invoice_pdf::get_or_freeze`, le PDF émis) au **destinataire //! verrouillé** `contacts.email` (décision #13 epic-20 — seuls objet et //! corps sont éditables), puis marque `invoices.emailed_at`/`emailed_to` //! et audite `invoice.emailed`. Le texte envoyé est celui du body de la @@ -37,6 +37,7 @@ use crate::mail::{EmailAttachment, OutgoingEmail}; use crate::middleware::auth::CurrentUser; use crate::routes::invoice_pdf_service; use crate::routes::invoices::InvoiceResponse; +use crate::routes::issued_invoice_pdf::{self, PdfContext}; /// Réponse de `GET /api/v1/invoices/{id}/email-preview`. #[derive(Debug, Serialize)] @@ -726,9 +727,11 @@ async fn best_effort_reminder_audit( /// /// Séquence de gardes (AC #15 Story 20-3b1, dans cet ordre) : /// auth/tenant → rate-limit 429 → SMTP prêt 412 → facture scopée 404 → -/// e-mail contact 400 → contenu vide 422 → rendu PDF (erreurs héritées -/// 20-3a) → envoi SMTP (échec 500, facture NON marquée) → marquage -/// `emailed_at` + audit → 200. +/// facture validée 400 (Story 25-6-b : la garde vivait dans le rendu, que le +/// service de gel court-circuite pour une facture figée) → e-mail contact 400 +/// → contenu vide 422 → PDF émis (`issued_invoice_pdf`, `Usage::Send` ; erreurs +/// de rendu 20-3a inchangées) → envoi SMTP (échec 500, facture NON marquée, +/// mais le gel survit) → marquage `emailed_at` + audit → 200. pub async fn send_invoice_email( State(state): State, Extension(current_user): Extension, @@ -767,6 +770,13 @@ pub async fn send_invoice_email( let (invoice, lines) = invoices::find_by_id_with_lines(&state.pool, company.id, id) .await? .ok_or(AppError::Database(DbError::NotFound))?; + // ⛔ Story 25-6-b (#387, revue P1) : seule une facture VALIDÉE s'envoie. + // Cette garde vivait dans le rendu ; le service de gel sert le PDF figé + // d'une facture ANNULÉE par un avoir (pour le téléchargement), et sans elle + // une facture annulée repartait au client par l'API. + if invoice.status != "validated" { + return Err(AppError::InvoiceNotValidated); + } let contact = load_active_contact(&state.pool, invoice.contact_id, company.id).await?; // Destinataire VERROUILLÉ = contacts.email (décision #13 epic-20). @@ -778,13 +788,22 @@ pub async fn send_invoice_email( return Err(AppError::InvoiceEmailEmptyContent); } - // Rendu PDF dans la langue du contact (le corps ET la pièce jointe - // partagent la même locale — décision #11 epic-20). Erreurs héritées - // 20-3a inchangées (validated, ≤ 9 lignes, adresse, banque primary). - let language = resolve_language(&contact, &company); - let locale = kesh_i18n::Locale::from(language.as_str()); + // Story 25-6-b (#387) : la pièce jointe est le PDF ÉMIS — octet pour + // octet celui du téléchargement. Le premier envoi d'une facture non + // figée la fige, et le gel survit à un échec SMTP : le document a été + // produit, la facture n'est simplement pas marquée envoyée. Le corps + // suit la langue ACTUELLE du contact ; la pièce jointe, la langue figée. + // Erreurs de rendu héritées 20-3a inchangées. + let ctx = PdfContext { + pool: &state.pool, + i18n: &state.i18n, + documents_dir: std::path::Path::new(state.config.documents_dir.as_str()), + company: &company, + user_id: current_user.user_id, + actor_api_key_id: current_user.api_key_id, + }; let rendered = - invoice_pdf_service::render(&state.pool, &state.i18n, locale, &company, id).await?; + issued_invoice_pdf::get_or_freeze(&ctx, id, issued_invoice_pdf::Usage::Send).await?; let email = OutgoingEmail { to: to.clone(), @@ -1466,6 +1485,10 @@ mod tests { project_id: None, dunning_paused_at: None, dunning_paused_note: None, + pdf_storage_path: None, + pdf_sha256: None, + pdf_frozen_at: None, + pdf_language: None, version: 1, created_at: chrono::NaiveDateTime::default(), updated_at: chrono::NaiveDateTime::default(), diff --git a/crates/kesh-api/src/routes/invoice_pdf.rs b/crates/kesh-api/src/routes/invoice_pdf.rs index 7c2adc87f..894b70d91 100644 --- a/crates/kesh-api/src/routes/invoice_pdf.rs +++ b/crates/kesh-api/src/routes/invoice_pdf.rs @@ -2,22 +2,27 @@ //! //! Thin wrapper HTTP depuis la Story 20.3a : la génération complète //! (chargement DB, validations, mapping, PDF) vit dans -//! `invoice_pdf_service::render`. Le handler ne garde que l'authentification +//! `invoice_pdf_service::render_document`. Le handler ne garde que l'authentification //! (`get_company_for`), le log, et la construction de la `Response` -//! (`Content-Type` + `Content-Disposition`). La langue est résolue via -//! `state.config.locale` (instance-level, pattern Story 2.1) — pas de champ -//! langue sur `CurrentUser`. +//! (`Content-Type` + `Content-Disposition`). +//! +//! Story 25-6-b (#387) : le PDF servi est le document **émis** — figé au +//! premier rendu après validation, dans la langue du **client**, puis relu à +//! l'identique (`issued_invoice_pdf`). Le premier téléchargement écrit donc +//! (fichier, colonnes, audit `invoice.pdf_frozen`), quel que soit le rôle de +//! l'acteur (arbitrage 8). ⚠️ `HEAD` aussi, axum le servant par ce handler. use crate::middleware::auth::CurrentUser; -use axum::Extension; use axum::extract::{Path, State}; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; use crate::AppState; use crate::errors::AppError; use crate::helpers::get_company_for; -use crate::routes::invoice_pdf_service; +use crate::routes::invoices::InvoiceResponse; +use crate::routes::issued_invoice_pdf::{self, PdfContext}; /// `GET /api/v1/invoices/:id/pdf` — téléchargement PDF d'une facture validée. pub async fn get_invoice_pdf( @@ -33,9 +38,16 @@ pub async fn get_invoice_pdf( "PDF download requested" ); + let ctx = PdfContext { + pool: &state.pool, + i18n: &state.i18n, + documents_dir: std::path::Path::new(state.config.documents_dir.as_str()), + company: &company, + user_id: current_user.user_id, + actor_api_key_id: current_user.api_key_id, + }; let rendered = - invoice_pdf_service::render(&state.pool, &state.i18n, state.config.locale, &company, id) - .await?; + issued_invoice_pdf::get_or_freeze(&ctx, id, issued_invoice_pdf::Usage::Download).await?; // Content-Disposition : filename sanitizé (par le service). let disposition = format!( @@ -54,3 +66,29 @@ pub async fn get_invoice_pdf( ); Ok(resp) } + +/// `POST /api/v1/invoices/:id/pdf/refreeze` — **Admin** (arbitrage 7, Story +/// 25-6-b). Refige le PDF d'une facture figée dont le fichier a disparu : un +/// **nouveau** document, pas l'original, tracé au journal d'audit avec +/// l'ancienne et la nouvelle empreinte. Sans corps. Rend la facture à jour. +pub async fn refreeze_invoice_pdf( + State(state): State, + Extension(current_user): Extension, + Path(id): Path, +) -> Result, AppError> { + let company = get_company_for(¤t_user, &state.pool).await?; + let ctx = PdfContext { + pool: &state.pool, + i18n: &state.i18n, + documents_dir: std::path::Path::new(state.config.documents_dir.as_str()), + company: &company, + user_id: current_user.user_id, + actor_api_key_id: current_user.api_key_id, + }; + issued_invoice_pdf::refreeze(&ctx, id).await?; + let (invoice, lines) = + kesh_db::repositories::invoices::find_by_id_with_lines(&state.pool, company.id, id) + .await? + .ok_or(AppError::Database(kesh_db::errors::DbError::NotFound))?; + Ok(Json(InvoiceResponse::from_parts(invoice, lines))) +} diff --git a/crates/kesh-api/src/routes/invoice_pdf_service.rs b/crates/kesh-api/src/routes/invoice_pdf_service.rs index 04c75eae2..2e20b46fb 100644 --- a/crates/kesh-api/src/routes/invoice_pdf_service.rs +++ b/crates/kesh-api/src/routes/invoice_pdf_service.rs @@ -74,6 +74,10 @@ pub struct RenderedInvoicePdf { /// de son attachment). Dérivé de `invoice.invoice_number` via /// `sanitize_filename`. pub filename_base: String, + /// Story 25-6-b (#387) — la `version` de la facture **lue pour ce rendu**. + /// Le gel la pose en garde : une facture qui a changé pendant le rendu + /// n'est jamais figée sur des données périmées. + pub invoice_version: i32, } /// Le document à produire (Story 25-4-b2, #416) : la facture, ou un **rappel** @@ -161,40 +165,25 @@ pub async fn reminder_amounts( }) } -/// Génère le PDF QR-facture d'une facture **validée**, scopée à -/// `company` (anti-IDOR). +/// Génère le PDF QR-facture d'une facture **validée** — la facture ou un +/// rappel (Story 25-4-b2) —, scopée à `company` (anti-IDOR). /// /// Contrat d'autorisation : `company` DOIT provenir d'une source déjà /// autorisée pour l'utilisateur courant (typiquement `get_company_for`) — /// le service ne re-vérifie pas ce droit. Le scoping de la facture est /// garanti par `find_by_id_with_lines(pool, company.id, …)`. /// -/// Reproduit exactement la séquence historique de `get_invoice_pdf` -/// (Story 5.3) : chargement facture + lignes, validations (statut, -/// nombre de lignes, contact, compte bancaire primary), mapping -/// `kesh-qrbill`, génération. Toutes les erreurs remontent en `AppError` -/// avec les mêmes variantes que le endpoint historique. -pub async fn render( - pool: &sqlx::MySqlPool, - i18n: &kesh_i18n::I18nBundle, - locale: Locale, - company: &Company, - invoice_id: i64, -) -> Result { - render_document( - pool, - i18n, - locale, - company, - invoice_id, - PdfDocument::Invoice, - ) - .await -} - -/// [`render`], pour un document au choix : la facture, ou un rappel (Story -/// 25-4-b2). Le titre du rappel est résolu dans `locale` — celle du contact +/// Séquence : chargement facture + lignes, validations (statut, nombre de +/// lignes, contact, compte bancaire primary), mapping `kesh-qrbill`, +/// génération. Le titre du rappel est résolu dans `locale` — celle du contact /// pour un envoi —, pas dans celle de l'installation. +/// +/// ⛔ **Story 25-6-b (#387) : la facture ne se rend plus directement.** Son +/// PDF passe par `issued_invoice_pdf::get_or_freeze`, qui le fige au premier +/// rendu et le relit ensuite ; appeler ici avec [`PdfDocument::Invoice`] +/// produirait un document qui n'est pas celui émis. L'ancienne fonction +/// `render` a été retirée pour cette raison. Les rappels, eux, restent +/// régénérés (#502). pub async fn render_document( pool: &sqlx::MySqlPool, i18n: &kesh_i18n::I18nBundle, @@ -265,6 +254,7 @@ pub async fn render_document( Ok(RenderedInvoicePdf { bytes, filename_base, + invoice_version: invoice.version, }) } @@ -647,6 +637,10 @@ mod tests { project_id: None, dunning_paused_at: None, dunning_paused_note: None, + pdf_storage_path: None, + pdf_sha256: None, + pdf_frozen_at: None, + pdf_language: None, version: 1, created_at: chrono::NaiveDateTime::default(), updated_at: chrono::NaiveDateTime::default(), diff --git a/crates/kesh-api/src/routes/invoices.rs b/crates/kesh-api/src/routes/invoices.rs index c23166bb9..f98ff1d56 100644 --- a/crates/kesh-api/src/routes/invoices.rs +++ b/crates/kesh-api/src/routes/invoices.rs @@ -233,6 +233,11 @@ pub struct InvoiceResponse { /// n'était jusqu'alors signalée par aucune surface de lecture. pub dunning_paused_at: Option, pub dunning_paused_note: Option, + /// Story 25-6-b (#387, AC 6) — instant et langue du PDF figé ; `None` + /// tant que la facture n'a jamais été rendue depuis sa validation. Le + /// chemin et l'empreinte ne sortent pas : ils ne servent qu'au serveur. + pub pdf_frozen_at: Option, + pub pdf_language: Option, /// P6 (review pass 2) : `is_overdue` calculé backend (source unique de /// vérité pour « aujourd'hui » — évite la désync TZ client/serveur). /// `true` ssi `status == 'validated' && paid_at IS NULL && due_date < today_utc`. @@ -317,6 +322,8 @@ impl InvoiceResponse { project_id: invoice.project_id, dunning_paused_at: invoice.dunning_paused_at, dunning_paused_note: invoice.dunning_paused_note, + pdf_frozen_at: invoice.pdf_frozen_at, + pdf_language: invoice.pdf_language, is_overdue, amount_settled: None, amount_due: None, diff --git a/crates/kesh-api/src/routes/issued_invoice_pdf.rs b/crates/kesh-api/src/routes/issued_invoice_pdf.rs new file mode 100644 index 000000000..48d3762e3 --- /dev/null +++ b/crates/kesh-api/src/routes/issued_invoice_pdf.rs @@ -0,0 +1,512 @@ +//! Story 25-6-b (#387) — le PDF **émis** d'une facture : figé au premier rendu +//! après validation, puis relu à l'identique. +//! +//! Ce module est le **seul** chemin vers le PDF d'une facture, pour le +//! téléchargement comme pour l'envoi par e-mail. Il encadre +//! [`invoice_pdf_service::render_document`], qui reste la fonction de rendu +//! (préconditions et codes d'erreur inchangés) : +//! +//! - facture **figée** → relit le fichier sous `KESH_DOCUMENTS_DIR`, vérifie +//! son SHA-256 contre `pdf_sha256`, rend ces octets — y compris pour une +//! facture annulée par un avoir après le gel ; +//! - facture `validated` **non figée** → rend dans la langue du **client**, +//! écrit le fichier, puis pose les quatre colonnes et l'audit +//! `invoice.pdf_frozen` dans une transaction (`invoices::freeze_pdf`). +//! +//! ⛔ Un PDF figé introuvable n'est **jamais** régénéré en silence : ce serait +//! fabriquer une pièce qui n'a pas été émise. Il répond 410, et seul le +//! refigeage explicite d'un administrateur ([`refreeze`]) produit un nouveau +//! document, tracé avec les deux empreintes. +//! +//! Les rendus ne sont pas reproductibles (date de création et `/ID` aléatoire +//! du PDF) : deux rendus concurrents produisent deux fichiers distincts. Celui +//! dont la pose échoue reste orphelin sur le disque — inoffensif, jamais servi. + +use std::path::{Path, PathBuf}; + +use kesh_db::entities::{Company, Invoice, Language}; +use kesh_db::errors::DbError; +use kesh_db::repositories::{contacts, invoices}; +use sha2::{Digest, Sha256}; + +use crate::document_storage::{self, ReadDocumentError}; +use crate::errors::AppError; +use crate::routes::invoice_email::resolve_language; +use crate::routes::invoice_pdf_service::{self, PdfDocument}; + +/// Ce dont le service a besoin, et **qui** agit — l'audit du gel nomme +/// l'acteur, quel que soit son rôle (arbitrage 8 : une pièce sortie de Kesh +/// est figée, qui que ce soit qui l'ait fait sortir). +pub struct PdfContext<'a> { + pub pool: &'a sqlx::MySqlPool, + pub i18n: &'a kesh_i18n::I18nBundle, + pub documents_dir: &'a Path, + /// Société **déjà autorisée** pour l'acteur (`get_company_for`) : le + /// service ne re-vérifie pas ce droit, il scope chaque lecture par elle. + pub company: &'a Company, + pub user_id: i64, + pub actor_api_key_id: Option, +} + +/// Le PDF émis d'une facture. +#[derive(Debug)] +pub struct IssuedPdf { + pub bytes: Vec, + /// Nom de fichier assaini, sans extension (cf. `RenderedInvoicePdf`). + pub filename_base: String, +} + +/// Issue d'une tentative de pose ([`pose`]). +#[derive(Debug)] +pub enum PoseOutcome { + /// La pose a eu lieu : ces octets sont désormais le document émis. + Posed(IssuedPdf), + /// Un rendu concurrent a figé avant nous : c'est **son** document qui est + /// servi, jamais le nôtre. + Adopted(IssuedPdf), + /// La facture a changé de version pendant le rendu : à rendre de nouveau. + Changed, +} + +/// Ce que l'appelant fait du PDF — il décide de ce qu'une facture **annulée** +/// figée peut encore donner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Usage { + /// Téléchargement : le PDF figé d'une facture annulée reste servi + /// (arbitrage 5) — c'est la pièce émise. + Download, + /// Envoi au client : seule une facture **validée** s'envoie. La garde est + /// tenue ici, sur la facture que le service relit, et non par l'appelant + /// seul (revue P2, R2-4 : une annulation entre les deux lectures passait). + Send, +} + +/// Le PDF émis de la facture `invoice_id` — le figeant s'il ne l'est pas. +/// +/// # Errors +/// - les erreurs de rendu inchangées (`InvoiceNotValidated` pour un brouillon +/// ou une facture annulée **jamais** figée, préconditions du PDF, …) ; +/// - [`AppError::InvoicePdfGone`] (410) si le fichier figé manque ; +/// - `Internal` (500) si son empreinte ne correspond plus, ou si le fichier +/// ne peut pas être écrit — jamais un rendu non figé présenté comme émis ; +/// - [`AppError::InvoiceChanged`] (409) si la facture change deux fois de +/// suite pendant le rendu ; [`AppError::InvoiceCancelled`] si un avoir +/// l'annule pendant le rendu. +pub async fn get_or_freeze( + ctx: &PdfContext<'_>, + invoice_id: i64, + usage: Usage, +) -> Result { + let invoice = load(ctx, invoice_id).await?; + if usage == Usage::Send && invoice.status != "validated" { + return Err(AppError::InvoiceNotValidated); + } + if is_frozen(&invoice) { + return serve_frozen(ctx, &invoice).await; + } + if invoice.status != "validated" { + return Err(AppError::InvoiceNotValidated); + } + + with_one_retry(|| render_and_pose(ctx, invoice_id, usage)).await +} + +/// Une seule nouvelle tentative : une pause des rappels, un règlement ou un +/// avoir concurrents dans la seconde du rendu la déclenchent ; une course qui +/// se répète répond 409 `INVOICE_CHANGED` plutôt que de boucler. +async fn with_one_retry(mut attempt: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for _ in 0..2 { + match attempt().await? { + PoseOutcome::Posed(pdf) | PoseOutcome::Adopted(pdf) => return Ok(pdf), + PoseOutcome::Changed => continue, + } + } + Err(AppError::InvoiceChanged) +} + +/// Un rendu dans la langue du client, puis sa pose. +/// +/// La facture est **relue à chaque tentative** (revue P2, F-L1) : la langue +/// se déduit de son client, qui a pu changer entre deux tentatives. Et si +/// `render_document` a lu une autre version que celle dont la langue est +/// tirée, la tentative est rejouée — un client changé dans l'intervalle +/// donnerait sinon ses données dans la langue de l'ancien. +/// +/// Une facture figée **entre deux tentatives** (par un rendu concurrent) est +/// adoptée telle quelle, sous la garde de l'usage (revue P3). +async fn render_and_pose( + ctx: &PdfContext<'_>, + invoice_id: i64, + usage: Usage, +) -> Result { + let invoice = load(ctx, invoice_id).await?; + if is_frozen(&invoice) { + return adopt(ctx, &invoice, usage).await; + } + let language = client_language(ctx, &invoice).await?; + let rendered = invoice_pdf_service::render_document( + ctx.pool, + ctx.i18n, + kesh_i18n::Locale::from(language.as_str()), + ctx.company, + invoice_id, + PdfDocument::Invoice, + ) + .await?; + if rendered.invoice_version != invoice.version { + return Ok(PoseOutcome::Changed); + } + pose( + ctx, + invoice_id, + rendered.invoice_version, + IssuedPdf { + bytes: rendered.bytes, + filename_base: rendered.filename_base, + }, + language, + usage, + ) + .await +} + +/// Écrit `rendered` sous `KESH_DOCUMENTS_DIR` et le pose comme document émis +/// de la facture, **si** elle est toujours `validated`, non figée, et à la +/// version `rendered_version` lue au rendu. +/// +/// Exposé pour les tests, qui y intercalent une dévalidation ou un gel +/// concurrent de façon déterministe ; le seul appelant applicatif est +/// [`get_or_freeze`]. +#[doc(hidden)] +pub async fn pose( + ctx: &PdfContext<'_>, + invoice_id: i64, + rendered_version: i32, + rendered: IssuedPdf, + language: Language, + usage: Usage, +) -> Result { + let (frozen, rendered) = store(ctx.documents_dir, rendered, language).await?; + let applied = invoices::freeze_pdf( + ctx.pool, + ctx.company.id, + invoice_id, + rendered_version, + &frozen, + ctx.user_id, + ctx.actor_api_key_id, + ) + .await?; + if applied { + tracing::info!( + company_id = ctx.company.id, + invoice_id, + pdf_sha256 = %frozen.sha256, + language = %frozen.language, + "PDF de facture figé" + ); + return Ok(PoseOutcome::Posed(rendered)); + } + + // Zéro ligne : relire pour savoir laquelle des trois gardes a refusé. + let after = load(ctx, invoice_id).await?; + if is_frozen(&after) { + return adopt(ctx, &after, usage).await; + } + match after.status.as_str() { + "validated" => Ok(PoseOutcome::Changed), + "cancelled" => Err(AppError::InvoiceCancelled), + _ => Err(AppError::InvoiceNotValidated), + } +} + +/// Refige le PDF d'une facture dont le fichier a disparu — geste +/// d'administrateur (arbitrage 7). Rend un **nouveau** document (langue +/// actuelle du client, données du moment) : ce n'est pas l'original, et +/// l'audit `invoice.pdf_refrozen` garde les deux empreintes. +/// +/// # Errors +/// - [`AppError::InvoicePdfNotFrozen`] : rien à refiger, le prochain rendu figera ; +/// - [`AppError::InvoicePdfPresent`] : le fichier existe, on ne le remplace pas ; +/// - [`AppError::InvoicePdfIntegrity`] : le fichier existe mais est altéré ; +/// - [`AppError::InvoiceCancelled`] : une facture annulée ne se rend plus ; +/// - les erreurs de rendu inchangées. +pub async fn refreeze(ctx: &PdfContext<'_>, invoice_id: i64) -> Result { + let invoice = load(ctx, invoice_id).await?; + let (Some(storage_path), Some(old_sha256)) = ( + invoice.pdf_storage_path.as_deref(), + invoice.pdf_sha256.clone(), + ) else { + return Err(AppError::InvoicePdfNotFrozen); + }; + match read_checked(ctx.documents_dir, storage_path, &old_sha256).await? { + FrozenFile::Intact(_) => return Err(AppError::InvoicePdfPresent), + FrozenFile::Altered => return Err(AppError::InvoicePdfIntegrity), + FrozenFile::Missing => {} + } + if invoice.status == "cancelled" { + return Err(AppError::InvoiceCancelled); + } + + let language = client_language(ctx, &invoice).await?; + let rendered = invoice_pdf_service::render_document( + ctx.pool, + ctx.i18n, + kesh_i18n::Locale::from(language.as_str()), + ctx.company, + invoice_id, + PdfDocument::Invoice, + ) + .await?; + let (frozen, rendered) = store( + ctx.documents_dir, + IssuedPdf { + bytes: rendered.bytes, + filename_base: rendered.filename_base, + }, + language, + ) + .await?; + let applied = invoices::refreeze_pdf( + ctx.pool, + ctx.company.id, + invoice_id, + &old_sha256, + &frozen, + ctx.user_id, + ctx.actor_api_key_id, + ) + .await?; + if applied { + tracing::warn!( + company_id = ctx.company.id, + invoice_id, + old_pdf_sha256 = %old_sha256, + pdf_sha256 = %frozen.sha256, + "PDF de facture refigé — nouveau document, l'original est perdu" + ); + return Ok(rendered); + } + + // Refusé : un avoir ou une dévalidation intercalés, ou un refigeage + // concurrent qui a gagné. Le statut d'abord : une dévalidation met + // l'empreinte à NULL, qui différerait aussi de l'ancienne (revue P3). + let after = load(ctx, invoice_id).await?; + match after.status.as_str() { + "cancelled" => Err(AppError::InvoiceCancelled), + "validated" if after.pdf_sha256.as_deref() != Some(old_sha256.as_str()) => { + Err(AppError::InvoicePdfPresent) + } + "validated" => Err(AppError::InvoiceChanged), + _ => Err(AppError::InvoiceNotValidated), + } +} + +/// Sert le document figé par un autre — sauf à un envoi, si la facture n'est +/// plus validée : la garde `Usage::Send` vaut aussi pour une facture relue en +/// cours de route (revue P3, un avoir intercalé après un gel concurrent). +async fn adopt( + ctx: &PdfContext<'_>, + invoice: &Invoice, + usage: Usage, +) -> Result { + if usage == Usage::Send && invoice.status != "validated" { + return Err(AppError::InvoiceNotValidated); + } + Ok(PoseOutcome::Adopted(serve_frozen(ctx, invoice).await?)) +} + +/// `true` si la facture porte un PDF figé (les quatre colonnes vont ensemble, +/// CHECK `chk_invoices_frozen_pdf`). +fn is_frozen(invoice: &Invoice) -> bool { + invoice.pdf_storage_path.is_some() +} + +async fn load(ctx: &PdfContext<'_>, invoice_id: i64) -> Result { + invoices::find_by_id(ctx.pool, ctx.company.id, invoice_id) + .await? + .ok_or(AppError::Database(DbError::NotFound)) +} + +/// La langue du client, sinon celle de l'installation — la règle de l'e-mail +/// (arbitrage 2). Un contact introuvable laisse `render_document` produire son +/// erreur habituelle : on rend alors dans la langue de l'installation. +async fn client_language(ctx: &PdfContext<'_>, invoice: &Invoice) -> Result { + Ok( + match contacts::find_by_id(ctx.pool, invoice.contact_id).await? { + Some(contact) => resolve_language(&contact, ctx.company), + None => ctx.company.instance_language, + }, + ) +} + +/// Sert le document figé, après en avoir vérifié l'empreinte. +async fn serve_frozen(ctx: &PdfContext<'_>, invoice: &Invoice) -> Result { + let (Some(storage_path), Some(sha256)) = ( + invoice.pdf_storage_path.as_deref(), + invoice.pdf_sha256.as_deref(), + ) else { + return Err(AppError::Internal(format!( + "facture {} : PDF figé incomplet", + invoice.id + ))); + }; + match read_checked(ctx.documents_dir, storage_path, sha256).await? { + FrozenFile::Intact(bytes) => Ok(IssuedPdf { + bytes, + filename_base: invoice_pdf_service::sanitize_filename( + invoice.invoice_number.as_deref().unwrap_or("facture"), + ), + }), + FrozenFile::Missing => Err(AppError::InvoicePdfGone { + sha256: sha256.to_string(), + // Une facture annulée ne se refige pas (`refreeze`). + refreezable: invoice.status == "validated", + }), + FrozenFile::Altered => { + tracing::error!( + company_id = ctx.company.id, + invoice_id = invoice.id, + storage_path, + expected_sha256 = sha256, + "PDF de facture figé ALTÉRÉ — empreinte différente, document non servi" + ); + Err(AppError::Internal(format!( + "facture {} : le PDF figé ne correspond plus à son empreinte", + invoice.id + ))) + } + } +} + +/// État du fichier d'un PDF figé. +enum FrozenFile { + Intact(Vec), + Missing, + Altered, +} + +/// Lit le fichier figé et compare son SHA-256 à `expected_sha256`. +async fn read_checked( + documents_dir: &Path, + storage_path: &str, + expected_sha256: &str, +) -> Result { + let dir: PathBuf = documents_dir.to_path_buf(); + let path = storage_path.to_string(); + let read = tokio::task::spawn_blocking(move || document_storage::read_document(&dir, &path)) + .await + .map_err(|e| AppError::Internal(format!("lecture du PDF figé : {e}")))?; + let bytes = match read { + Ok(bytes) => bytes, + Err(ReadDocumentError::NotFound) => return Ok(FrozenFile::Missing), + Err(e @ (ReadDocumentError::InvalidPath | ReadDocumentError::Io(_))) => { + return Err(AppError::Internal(format!( + "lecture du PDF figé {storage_path} : {e}" + ))); + } + }; + let actual = format!("{:x}", Sha256::digest(&bytes)); + if actual == expected_sha256 { + Ok(FrozenFile::Intact(bytes)) + } else { + Ok(FrozenFile::Altered) + } +} + +/// Écrit le rendu sous `KESH_DOCUMENTS_DIR` (`store_document` est synchrone et +/// fait `fsync` : hors du runtime). Un échec d'écriture est un 500 — rien +/// n'est servi. +async fn store( + documents_dir: &Path, + rendered: IssuedPdf, + language: Language, +) -> Result<(invoices::FrozenPdf, IssuedPdf), AppError> { + let dir = documents_dir.to_path_buf(); + let (meta, rendered) = tokio::task::spawn_blocking(move || { + let original = format!("facture-{}.pdf", rendered.filename_base); + document_storage::store_document(&dir, &rendered.bytes, "pdf", &original, "application/pdf") + .map(|meta| (meta, rendered)) + }) + .await + .map_err(|e| AppError::Internal(format!("écriture du PDF figé : {e}")))? + .map_err(|e| { + tracing::error!(error = %e, "écriture du PDF figé impossible (KESH_DOCUMENTS_DIR)"); + AppError::Internal(format!("écriture du PDF figé : {e}")) + })?; + Ok(( + invoices::FrozenPdf { + storage_path: meta.storage_path, + sha256: meta.sha256, + language: language.as_str().to_string(), + }, + rendered, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pdf(tag: u8) -> IssuedPdf { + IssuedPdf { + bytes: vec![tag], + filename_base: "F-1".into(), + } + } + + /// Une course qui se répète → 409, après exactement deux tentatives + /// (mutation : boucle sans borne, ou sans nouvelle tentative). + #[tokio::test] + async fn une_course_qui_se_repete_repond_invoice_changed() { + let mut calls = 0; + let res = with_one_retry(|| { + calls += 1; + async { Ok(PoseOutcome::Changed) } + }) + .await; + assert!(matches!(res, Err(AppError::InvoiceChanged)), "{res:?}"); + assert_eq!(calls, 2); + } + + /// Une seule course → la nouvelle tentative pose (mutation : aucune + /// nouvelle tentative, 409 dès la première). + #[tokio::test] + async fn une_course_unique_est_rattrapee_par_la_nouvelle_tentative() { + let mut calls = 0; + let res = with_one_retry(|| { + calls += 1; + let n = calls; + async move { + Ok(if n == 1 { + PoseOutcome::Changed + } else { + PoseOutcome::Posed(pdf(2)) + }) + } + }) + .await + .expect("posé à la seconde tentative"); + assert_eq!(res.bytes, vec![2]); + assert_eq!(calls, 2); + } + + /// Le document d'un rendu concurrent est servi tel quel, sans nouvelle + /// tentative. + #[tokio::test] + async fn le_document_adopte_est_servi_sans_nouvelle_tentative() { + let mut calls = 0; + let res = with_one_retry(|| { + calls += 1; + async { Ok(PoseOutcome::Adopted(pdf(7))) } + }) + .await + .expect("adopté"); + assert_eq!(res.bytes, vec![7]); + assert_eq!(calls, 1); + } +} diff --git a/crates/kesh-api/src/routes/mod.rs b/crates/kesh-api/src/routes/mod.rs index 77e331527..93c1e7913 100644 --- a/crates/kesh-api/src/routes/mod.rs +++ b/crates/kesh-api/src/routes/mod.rs @@ -24,6 +24,7 @@ pub mod invoice_email; pub mod invoice_pdf; pub mod invoice_pdf_service; pub mod invoices; +pub mod issued_invoice_pdf; pub mod journal_entries; pub mod limits; pub mod onboarding; diff --git a/crates/kesh-api/tests/admin_pat_denied_e2e.rs b/crates/kesh-api/tests/admin_pat_denied_e2e.rs index 893358617..d9b631cc4 100644 --- a/crates/kesh-api/tests/admin_pat_denied_e2e.rs +++ b/crates/kesh-api/tests/admin_pat_denied_e2e.rs @@ -43,7 +43,7 @@ const LIB_RS: &str = include_str!("../src/lib.rs"); const MARKER_BEGIN: &str = "KESH-ADMIN-ROUTES-BEGIN"; const MARKER_END: &str = "KESH-ADMIN-ROUTES-END"; -/// Les **25 constructeurs de méthode** du bloc `admin_routes`, un par couple +/// Les **28 constructeurs de méthode** du bloc `admin_routes`, un par couple /// (méthode, chemin). Les identifiants de chemin sont arbitraires : la couche /// répond **avant** le handler, donc aucune donnée n'a besoin d'exister. /// @@ -53,6 +53,8 @@ const MARKER_END: &str = "KESH-ADMIN-ROUTES-END"; const ADMIN_COUPLES: &[(&str, &str)] = &[ // Story 25-1a (#377) — la réinitialisation de démo rejoint le bloc admin. ("POST", "/api/v1/onboarding/reset"), + // Story 25-6-b (#387) — refiger le PDF d'une facture. + ("POST", "/api/v1/invoices/1/pdf/refreeze"), ("GET", "/api/v1/users"), ("POST", "/api/v1/users"), ("GET", "/api/v1/users/1"), @@ -308,7 +310,7 @@ fn block_uses_no_unlisted_route_constructor() { // Le compteur ne reconnaît que sept constructeurs ; `axum` en exporte // vingt-deux. Plutôt que d'énumérer les quinze autres, on asserte le // COMPLÉMENT — sinon `any(handler)` enregistrerait NEUF méthodes en - // laissant le compteur à 25 : la route serait protégée, mais absente de la + // laissant le compteur à 28 : la route serait protégée, mais absente de la // couverture, et rien ne le signalerait. let block = admin_block(); for forbidden in ["any", "on", "trace", "connect"] { @@ -367,7 +369,7 @@ fn no_route_enters_the_block_by_composition() { // assemblé — mais ses constructeurs `get(`/`post(` vivent dans un AUTRE // fichier, invisibles à `count_method_constructors`, qui ne lit que `lib.rs`. // - // Conséquence : le compteur reste à 25, `ADMIN_COUPLES` ne gagne pas le + // Conséquence : le compteur reste à 28, `ADMIN_COUPLES` ne gagne pas le // couple, et **aucun test HTTP n'exerce jamais cette route**. La protection // tient ; c'est le RAPPEL qui tombe — or c'est le rappel qui est l'objet de // cette story, et ce qui avait laissé 16 routes sur 19 sans garde. @@ -394,7 +396,7 @@ fn the_admin_guards_exist_once_each_and_live_inside_the_block() { // ⚠️ Le trou que ferme ce test a été DÉMONTRÉ : un SECOND routeur admin, // déclaré hors du bloc et mergé dans `protected` avec `require_admin_role` // mais SANS `require_not_pat`, échappe à la totalité du dispositif. Le - // compteur reste à 25, `admin_routes` apparaît toujours 2 fois, il y a + // compteur reste à 28, `admin_routes` apparaît toujours 2 fois, il y a // toujours 2 `.route_layer(` dans le bloc — et une route d'administration // redevient atteignable par un PAT. C'est **littéralement le mode d'échec // de #167**, rouvert par une autre porte. @@ -754,7 +756,7 @@ async fn error_code(resp: reqwest::Response) -> String { // Tests HTTP — le comportement // ============================================================================ -/// AC1, jambe `read-write` : les 25 couples rendent `API_KEY_ADMIN_FORBIDDEN`. +/// AC1, jambe `read-write` : les 28 couples rendent `API_KEY_ADMIN_FORBIDDEN`. /// /// ⚠️ On asserte le **code**, pas le statut. Trois gardes distinctes rendent /// `403` sur ces routes — le RBAC, le gate de portée, et la couche : un test @@ -780,9 +782,9 @@ async fn every_admin_couple_denies_a_read_write_pat(pool: MySqlPool) { /// AC1, jambe `read-only` — et elle se lit par couple, parce que le gate de /// portée répond **avant** la couche. /// -/// Les 5 `get` atteignent la couche ; les 20 méthodes mutantes sont arrêtées en +/// Les 5 `get` atteignent la couche ; les 23 méthodes mutantes sont arrêtées en /// amont par `require_auth` avec `API_KEY_READ_ONLY`, **qui existait avant cette -/// story**. Prescrire `403` partout ferait un test muet sur ces vingt-là : la +/// story**. Prescrire `403` partout ferait un test muet sur ces vingt-trois-là : la /// mutation « retirer la couche doit faire rougir » y est insatisfaisable. #[sqlx::test(migrations = "../kesh-db/test-schema")] async fn read_only_pat_is_stopped_by_the_right_guard_on_each_couple(pool: MySqlPool) { @@ -815,7 +817,8 @@ async fn read_only_pat_is_stopped_by_the_right_guard_on_each_couple(pool: MySqlP // 21 → 22 : Story 25-1a (#377) a fait entrer `POST /api/v1/onboarding/reset` // dans le bloc. Le nombre est codé en dur À DESSEIN — l'incrémenter est le // geste qui force à relire la route qu'on vient d'y ajouter. - (5, 22), + // 22 → 23 : Story 25-6-b (#387), `POST /api/v1/invoices/{id}/pdf/refreeze`. + (5, 23), "la répartition attendue entre la couche et le gate de portée a changé" ); } diff --git a/crates/kesh-api/tests/audit_route_registry.rs b/crates/kesh-api/tests/audit_route_registry.rs index 16f8ee31a..4117d838f 100644 --- a/crates/kesh-api/tests/audit_route_registry.rs +++ b/crates/kesh-api/tests/audit_route_registry.rs @@ -17,12 +17,12 @@ //! ⚠️ Comparer des NOMBRES ne détecterait pas une route retirée pendant qu'une //! autre est ajoutée : le compte resterait égal et la dérive invisible. Le test //! `admin_pat_denied_e2e` peut compter, lui, parce qu'il opère sur un bloc clos -//! entre marqueurs ; les 109 routes sont réparties dans tout le fichier. +//! entre marqueurs ; les 110 routes sont réparties dans tout le fichier. //! //! # Deux fichiers, deux volets //! -//! L'ensemble clos de l'INVENTAIRE est celui de `lib.rs` (109 routes) ; celui du -//! REGISTRE est plus large (112), car trois routes mutantes vivent dans +//! L'ensemble clos de l'INVENTAIRE est celui de `lib.rs` (110 routes) ; celui du +//! REGISTRE est plus large (113), car trois routes mutantes vivent dans //! `routes/test_endpoints.rs` et sont montées par un `nest()`. D'où : //! //! - volet « route absente du registre » → sur les **deux** fichiers, faute de @@ -108,6 +108,16 @@ const LIB_ROUTES: &[(&str, &str, Status)] = &[ ("post", "payment_batches::cancel_payment_batch", Traced), ("post", "invoices::validate_invoice_handler", Traced), ("post", "invoices::unvalidate_invoice_handler", Traced), + // Story 25-6-b (#387) — refiger le PDF d'une facture (audit + // `invoice.pdf_refrozen`, dans la transaction de `invoices::refreeze_pdf`). + // + // ⚠️ ANGLE MORT ASSUMÉ : `GET /api/v1/invoices/{id}/pdf` + // (`invoice_pdf::get_invoice_pdf`) ÉCRIT désormais — le premier rendu fige + // le document et trace `invoice.pdf_frozen` —, et `HEAD` aussi, axum le + // servant par le handler `get`. Ce registre ne balaie que les méthodes + // d'écriture : cette route mutante lui échappe. Son audit est tenu par + // `invoices::freeze_pdf`, qui ne pose rien sans sa trace. + ("post", "invoice_pdf::refreeze_invoice_pdf", Traced), ("post", "invoices::settle_invoice_handler", Traced), ("post", "invoices::cancel_invoice_settlement_handler", Traced), ("post", "supplier_invoices::cancel_supplier_invoice_settlement", Traced), @@ -450,13 +460,14 @@ fn the_registry_partition_is_what_the_story_declares() { .filter(|(_, _, s)| matches!(s, NoMatter(_))) .count(); - assert_eq!(LIB_ROUTES.len(), 109, "l'inventaire porte sur 109 routes"); + assert_eq!(LIB_ROUTES.len(), 110, "l'inventaire porte sur 110 routes"); assert_eq!(traced + exempt + no_matter, LIB_ROUTES.len()); assert_eq!( - traced, 91, + traced, 92, "73 tracées avant la 25-1b, plus ses 14, plus la dévalidation (25-2-b-1, #440), \ plus l'annulation d'un règlement client (25-3-a-1) et fournisseur (25-3-a-2, #414), \ - plus l'annulation d'un rapprochement (25-3-b, #418)" + plus l'annulation d'un rapprochement (25-3-b, #418), \ + plus le refigeage du PDF d'une facture (25-6-b, #387)" ); assert_eq!( exempt, 15, @@ -465,7 +476,7 @@ fn the_registry_partition_is_what_the_story_declares() { assert_eq!(no_matter, 3, "trois routes mutantes qui ne mutent rien"); assert_eq!( LIB_ROUTES.len() + TEST_ENDPOINT_ROUTES.len(), - 112, + 113, "le registre est plus large que l'inventaire, et c'est voulu" ); } diff --git a/crates/kesh-api/tests/inbox_import_e2e.rs b/crates/kesh-api/tests/inbox_import_e2e.rs index 86f3ad88f..7dfa02762 100644 --- a/crates/kesh-api/tests/inbox_import_e2e.rs +++ b/crates/kesh-api/tests/inbox_import_e2e.rs @@ -618,11 +618,16 @@ async fn import_field_too_long_returns_failed_not_500(pool: MySqlPool) { let app = spawn_app(pool.clone(), 25 * 1024 * 1024).await; // Nom créancier > 70 chars (QR tiers non conforme SIX 2.2) → 1406 → FIELD_TOO_LONG. let long_name = "X".repeat(100); - std::fs::write( - app.inbox.join("long.png"), - qr_invoice_png(dec!(20.00), &long_name), - ) - .unwrap(); + let png = qr_invoice_png(dec!(20.00), &long_name); + std::fs::write(app.inbox.join("long.png"), &png).unwrap(); + // Story 25-6-b (revue P1) : un fichier du même contenu existe déjà sous + // `KESH_DOCUMENTS_DIR` — un PDF figé, ou le justificatif d'une autre + // société. L'échec de cet import ne doit pas le supprimer. + use sha2::Digest; + let archived = app + .documents + .join(format!("{:x}.png", sha2::Sha256::digest(&png))); + std::fs::write(&archived, &png).unwrap(); let resp = app .client @@ -638,6 +643,10 @@ async fn import_field_too_long_returns_failed_not_500(pool: MySqlPool) { ); let body: Value = resp.json().await.unwrap(); assert_eq!(body["failed"][0]["errorCode"], "FIELD_TOO_LONG"); + assert!( + archived.exists(), + "un fichier archivé partagé (nommé par son contenu) ne doit jamais être supprimé" + ); } #[sqlx::test(migrations = "../kesh-db/test-schema")] diff --git a/crates/kesh-api/tests/invoice_frozen_pdf_e2e.rs b/crates/kesh-api/tests/invoice_frozen_pdf_e2e.rs new file mode 100644 index 000000000..f03f1e0da --- /dev/null +++ b/crates/kesh-api/tests/invoice_frozen_pdf_e2e.rs @@ -0,0 +1,1076 @@ +//! Story 25-6-b (#387) — le PDF d'une facture est figé : une pièce émise ne +//! change plus. +//! +//! Chaque test aurait échoué avant le patch (AC 7) : deux rendus de la même +//! facture n'avaient pas les mêmes octets (date de création et `/ID` du PDF), +//! le téléchargement suivait la langue de l'installation, et une modification +//! du client changeait la pièce déjà émise. +//! +//! ⚠️ Chaque test a **son propre** `KESH_DOCUMENTS_DIR` (patron +//! `inbox_import_e2e`) : plusieurs suppriment ou altèrent un fichier figé, ce +//! qui ne doit jamais toucher le répertoire partagé d'un autre test. + +mod common; + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::time::Duration; + +use chrono::{NaiveDate, TimeDelta}; +use kesh_api::auth::password::hash_password; +use kesh_api::config::Config; +use kesh_api::errors::AppError; +use kesh_api::mail::MockMailer; +use kesh_api::middleware::rate_limit::RateLimiter; +use kesh_api::routes::issued_invoice_pdf::{self, IssuedPdf, PdfContext, PoseOutcome, Usage}; +use kesh_api::{AppState, build_router}; +use kesh_db::entities::Language; +use kesh_db::entities::bank_account::NewBankAccount; +use kesh_db::entities::contact::{ContactType, NewContact, Salutation}; +use kesh_db::entities::credit_note::NewCreditNote; +use kesh_db::entities::invoice::{Invoice, NewInvoice, NewInvoiceLine}; +use kesh_db::entities::user::{NewUser, Role}; +use kesh_db::repositories::{bank_accounts, companies, contacts, credit_notes, invoices, users}; +use kesh_db::test_fixtures::seed_accounting_company; +use rust_decimal_macros::dec; +use serde_json::json; +use sha2::{Digest, Sha256}; +use sqlx::MySqlPool; + +const TEST_JWT_SECRET: &[u8] = b"test-secret-32-bytes-minimum-test-secret-padding"; +const TEST_ADMIN_PASSWORD: &str = "admin123"; +const ROLE_PASSWORD: &str = "role-test-password"; + +// ============================================================ +// Harness +// ============================================================ + +struct TestApp { + base_url: String, + client: reqwest::Client, + documents: PathBuf, +} + +impl TestApp { + fn url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } +} + +fn unique_dir(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("kesh-{prefix}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn load_i18n() -> Arc { + Arc::new( + kesh_i18n::I18nBundle::load( + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("kesh-i18n/locales") + .as_path(), + ) + .expect("load test i18n"), + ) +} + +/// `documents` : le `KESH_DOCUMENTS_DIR` de ce test — un chemin de FICHIER +/// le rend non inscriptible. +async fn spawn_app(pool: MySqlPool, mailer: MockMailer, documents: PathBuf) -> TestApp { + let mut config = Config::from_fields_for_test( + "mysql://test:test@localhost:3306/test".to_string(), + "admin".to_string(), + TEST_ADMIN_PASSWORD.to_string(), + String::from_utf8(TEST_JWT_SECRET.to_vec()).unwrap(), + TimeDelta::minutes(15), + TimeDelta::days(30), + TimeDelta::minutes(15), + TimeDelta::minutes(15), + 100, + TimeDelta::minutes(30), + 12, + ); + config.documents_dir = documents.to_string_lossy().to_string(); + let rate_limiter = RateLimiter::new(&config); + let i18n = load_i18n(); + kesh_api::errors::init_error_i18n(i18n.clone(), config.locale); + + let state = AppState { + pool, + config: Arc::new(config), + rate_limiter: Arc::new(rate_limiter), + rate_limiter_recovery: Arc::new(kesh_api::build_recovery_rate_limiter()), + rate_limiter_send_email: Arc::new(RateLimiter::with_thresholds( + 100, + Duration::from_secs(15 * 60), + Duration::from_secs(15 * 60), + )), + i18n, + users_exist: Arc::new(AtomicBool::new(true)), + mailer: Arc::new(mailer), + smtp_ready: true, + test_mock_mailer: None, + }; + + let app = build_router(state, "nonexistent-static-dir".to_string()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + + TestApp { + base_url: format!("http://{addr}"), + client: reqwest::Client::new(), + documents, + } +} + +async fn login(app: &TestApp, username: &str, password: &str) -> String { + let resp = app + .client + .post(app.url("/api/v1/auth/login")) + .json(&json!({ "username": username, "password": password })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "login {username}"); + let body: serde_json::Value = resp.json().await.unwrap(); + body["accessToken"].as_str().unwrap().to_string() +} + +struct Seeded { + company_id: i64, + admin_id: i64, + contact_id: i64, +} + +/// Société comptable + banque principale + contact PDF-ready, dans la langue +/// donnée (`None` : celle de l'installation, FR). +async fn seed(pool: &MySqlPool, language: Option) -> Seeded { + let seeded = seed_accounting_company(pool).await.expect("seed"); + bank_accounts::upsert_primary( + pool, + NewBankAccount { + company_id: seeded.company_id, + bank_name: "UBS".into(), + iban: "CH9300762011623852957".into(), + qr_iban: Some("CH4431999123000889012".into()), + is_primary: true, + }, + ) + .await + .unwrap(); + let contact = contacts::create( + pool, + seeded.admin_user_id, + NewContact { + company_id: seeded.company_id, + contact_type: ContactType::Personne, + name: "Pia Rutschmann".into(), + first_name: Some("Pia".into()), + last_name: Some("Rutschmann".into()), + is_client: true, + is_supplier: false, + address: None, + address_street: Some("Marktgasse".into()), + address_building: Some("28".into()), + address_postal_code: Some("9400".into()), + address_city: Some("Rorschach".into()), + address_country: Some("CH".into()), + email: Some("pia@example.ch".into()), + phone: None, + ide_number: None, + client_number: None, + default_payment_terms: None, + default_payment_terms_days: None, + language, + salutation: Salutation::Neutre, + }, + ) + .await + .unwrap(); + Seeded { + company_id: seeded.company_id, + admin_id: seeded.admin_user_id, + contact_id: contact.id, + } +} + +async fn seed_invoice(pool: &MySqlPool, s: &Seeded, n_lines: usize) -> i64 { + let lines = (0..n_lines) + .map(|i| NewInvoiceLine { + revenue_account_id: None, + description: format!("Ligne {}", i + 1), + quantity: dec!(1), + unit_price: dec!(100.00), + vat_rate: dec!(8.10), + }) + .collect(); + let (invoice, _) = invoices::create( + pool, + s.admin_id, + NewInvoice { + company_id: s.company_id, + contact_id: s.contact_id, + date: NaiveDate::from_ymd_opt(2026, 4, 14).unwrap(), + due_date: Some(NaiveDate::from_ymd_opt(2026, 5, 14).unwrap()), + payment_terms: Some("30 jours net".into()), + lines, + project_id: None, + }, + ) + .await + .unwrap(); + invoices::validate_invoice(pool, s.company_id, invoice.id, s.admin_id) + .await + .expect("validation"); + invoice.id +} + +async fn seed_user(pool: &MySqlPool, username: &str, role: Role, company_id: i64) -> i64 { + users::create( + pool, + NewUser { + username: username.to_string(), + password_hash: hash_password(ROLE_PASSWORD).unwrap(), + role, + active: true, + company_id, + email: None, + }, + ) + .await + .unwrap() + .id +} + +async fn get_pdf(app: &TestApp, token: &str, id: i64) -> reqwest::Response { + app.client + .get(app.url(&format!("/api/v1/invoices/{id}/pdf"))) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap() +} + +async fn pdf_bytes(app: &TestApp, token: &str, id: i64) -> Vec { + let resp = get_pdf(app, token, id).await; + assert_eq!(resp.status(), 200); + resp.bytes().await.unwrap().to_vec() +} + +async fn error_code(resp: reqwest::Response) -> String { + let body: serde_json::Value = resp.json().await.unwrap(); + body["error"]["code"] + .as_str() + .unwrap_or_default() + .to_string() +} + +async fn invoice(pool: &MySqlPool, s: &Seeded, id: i64) -> Invoice { + invoices::find_by_id(pool, s.company_id, id) + .await + .unwrap() + .unwrap() +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn frozen_file(app: &TestApp, inv: &Invoice) -> PathBuf { + app.documents.join(inv.pdf_storage_path.as_deref().unwrap()) +} + +async fn refreeze(app: &TestApp, token: &str, id: i64) -> reqwest::Response { + app.client + .post(app.url(&format!("/api/v1/invoices/{id}/pdf/refreeze"))) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap() +} + +// ============================================================ +// Le gel +// ============================================================ + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn deux_telechargements_rendent_les_memes_octets(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 2).await; + let version_avant = invoice(&pool, &s, id).await.version; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + let premier = pdf_bytes(&app, &token, id).await; + let second = pdf_bytes(&app, &token, id).await; + assert_eq!( + premier, second, + "le second téléchargement rend le document figé" + ); + + let inv = invoice(&pool, &s, id).await; + assert_eq!(inv.pdf_sha256.as_deref(), Some(sha256(&premier).as_str())); + assert_eq!(std::fs::read(frozen_file(&app, &inv)).unwrap(), premier); + assert_eq!(inv.version, version_avant, "le gel ne touche pas `version`"); + assert_eq!( + common::audit_actions(&pool, "invoice", id) + .await + .iter() + .filter(|a| *a == "invoice.pdf_frozen") + .count(), + 1, + "un seul gel, une seule trace" + ); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_pdf_est_dans_la_langue_du_client(pool: MySqlPool) { + let s = seed(&pool, Some(Language::De)).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + let pdf = pdf_bytes(&app, &token, id).await; + // L'installation est en français ; le client, en allemand. + assert_eq!( + invoice(&pool, &s, id).await.pdf_language.as_deref(), + Some("DE") + ); + // Revue P2 (F-M3) : la colonne ne suffit pas — le DOCUMENT est en allemand. + assert!( + pdf_contains(&pdf, "Zahlteil"), + "section de paiement en allemand" + ); + assert!(pdf_contains(&pdf, "Rechnung"), "titre en allemand"); + // ⚠️ Pas « Facture » : la communication de la QR (« Facture {numéro} ») + // est écrite en français quelle que soit la langue — défaut antérieur. + assert!( + !pdf_contains(&pdf, "Section paiement"), + "pas de libellé français de la section de paiement" + ); +} + +/// Le texte des PDF de `kesh-qrbill` est écrit en Helvetica WinAnsi, en chaînes +/// hexadécimales (`<48656C6C6F> Tj`) : on cherche le libellé sous cette forme. +fn pdf_contains(pdf: &[u8], text: &str) -> bool { + let hex: String = text.bytes().map(|b| format!("{b:02X}")).collect(); + String::from_utf8_lossy(pdf).contains(&hex) +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn l_email_joint_les_octets_du_telechargement_et_fige(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 2).await; + let mailer = MockMailer::new(); + let app = spawn_app(pool.clone(), mailer.clone(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + let resp = app + .client + .post(app.url(&format!("/api/v1/invoices/{id}/send-email"))) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "subject": "Facture", "body": "Bonjour" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + // Le premier envoi a figé. + assert!(invoice(&pool, &s, id).await.pdf_storage_path.is_some()); + + let piece_jointe = mailer.sent_emails()[0].attachment_bytes.clone(); + assert_eq!(pdf_bytes(&app, &token, id).await, piece_jointe); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn modifier_le_client_apres_le_gel_ne_change_pas_le_pdf(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + let avant = pdf_bytes(&app, &token, id).await; + + sqlx::query("UPDATE contacts SET address_city = 'St. Gallen', language = 'DE' WHERE id = ?") + .bind(s.contact_id) + .execute(&pool) + .await + .unwrap(); + + assert_eq!(pdf_bytes(&app, &token, id).await, avant); + assert_eq!( + invoice(&pool, &s, id).await.pdf_language.as_deref(), + Some("FR") + ); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_echec_smtp_laisse_la_facture_figee(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::failing(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + let resp = app + .client + .post(app.url(&format!("/api/v1/invoices/{id}/send-email"))) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "subject": "Facture", "body": "Bonjour" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 500); + + let inv = invoice(&pool, &s, id).await; + assert!(inv.emailed_at.is_none(), "non marquée envoyée"); + let fige = inv + .pdf_sha256 + .clone() + .expect("le gel survit à l'échec SMTP"); + assert_eq!(sha256(&pdf_bytes(&app, &token, id).await), fige); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn une_facture_a_dix_lignes_ne_fige_rien(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 10).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + let resp = get_pdf(&app, &token, id).await; + assert_eq!(resp.status(), 400); + assert_eq!(error_code(resp).await, "INVOICE_TOO_MANY_LINES_FOR_PDF"); + assert!(invoice(&pool, &s, id).await.pdf_storage_path.is_none()); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_repertoire_non_inscriptible_repond_500_sans_rien_figer(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + // Un FICHIER à la place du répertoire : `create_dir_all` échoue. + let blocker = unique_dir("documents-blocked").join("not-a-dir"); + std::fs::write(&blocker, b"x").unwrap(); + let app = spawn_app(pool.clone(), MockMailer::new(), blocker).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + assert_eq!(get_pdf(&app, &token, id).await.status(), 500); + assert!(invoice(&pool, &s, id).await.pdf_storage_path.is_none()); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_premier_telechargement_d_un_consultation_fige_et_le_nomme(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let lecteur = seed_user(&pool, "lecteur", Role::Consultation, s.company_id).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "lecteur", ROLE_PASSWORD).await; + + pdf_bytes(&app, &token, id).await; + assert!(invoice(&pool, &s, id).await.pdf_storage_path.is_some()); + let (actor_type, _, user_id, _) = + common::audit_actor(&pool, "invoice", id, "invoice.pdf_frozen").await; + assert_eq!((actor_type.as_str(), user_id), ("user", lecteur)); +} + +// ============================================================ +// L'intégrité +// ============================================================ + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_fichier_fige_supprime_repond_410(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + pdf_bytes(&app, &token, id).await; + let inv = invoice(&pool, &s, id).await; + std::fs::remove_file(frozen_file(&app, &inv)).unwrap(); + + let resp = get_pdf(&app, &token, id).await; + assert_eq!(resp.status(), 410); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "INVOICE_PDF_GONE"); + // Le message nomme le fichier manquant. + let message = body["error"]["message"].as_str().unwrap(); + assert!( + message.contains(&format!("{}.pdf", inv.pdf_sha256.as_deref().unwrap())), + "{body}" + ); + // Revue P2 (F-M1) : copié, le nom doit être exact — sans les marques + // d'isolation BiDi que Fluent pose autour d'une variable. + assert!( + !message.contains(['\u{2068}', '\u{2069}']), + "marques d'isolation dans le message : {message:?}" + ); + assert!( + message.contains("refiger"), + "le remède du refigeage est proposé" + ); + // Jamais régénéré en silence. + assert!(!frozen_file(&app, &inv).exists()); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_fichier_fige_altere_repond_500_sans_rien_servir(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + pdf_bytes(&app, &token, id).await; + let inv = invoice(&pool, &s, id).await; + std::fs::write(frozen_file(&app, &inv), b"%PDF-1.7 altered").unwrap(); + + let resp = get_pdf(&app, &token, id).await; + assert_eq!(resp.status(), 500); + assert!(!resp.bytes().await.unwrap().starts_with(b"%PDF")); +} + +// ============================================================ +// Le cycle de vie +// ============================================================ + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn devalider_detache_et_revalider_fige_un_nouveau_document(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + let ancien = pdf_bytes(&app, &token, id).await; + let inv = invoice(&pool, &s, id).await; + + invoices::unvalidate(&pool, s.company_id, id, s.admin_id, inv.version) + .await + .unwrap(); + assert!(invoice(&pool, &s, id).await.pdf_storage_path.is_none()); + // Le fichier reste sur le disque. + assert!(frozen_file(&app, &inv).exists()); + + invoices::validate_invoice(&pool, s.company_id, id, s.admin_id) + .await + .unwrap(); + let nouveau = pdf_bytes(&app, &token, id).await; + assert_ne!(sha256(&nouveau), sha256(&ancien), "un nouveau document"); + assert_eq!( + invoice(&pool, &s, id).await.pdf_sha256.as_deref(), + Some(sha256(&nouveau).as_str()) + ); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn une_facture_annulee_garde_son_pdf_fige(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + let emis = pdf_bytes(&app, &token, id).await; + + credit_notes::create_credit_note( + &pool, + NewCreditNote { + company_id: s.company_id, + invoice_id: id, + date: NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(), + }, + s.admin_id, + ) + .await + .unwrap(); + assert_eq!(invoice(&pool, &s, id).await.status, "cancelled"); + + assert_eq!(pdf_bytes(&app, &token, id).await, emis); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn une_facture_annulee_jamais_rendue_n_a_pas_de_pdf(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + credit_notes::create_credit_note( + &pool, + NewCreditNote { + company_id: s.company_id, + invoice_id: id, + date: NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(), + }, + s.admin_id, + ) + .await + .unwrap(); + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + let resp = get_pdf(&app, &token, id).await; + assert_eq!(resp.status(), 400); + assert_eq!(error_code(resp).await, "INVOICE_NOT_VALIDATED"); +} + +// ============================================================ +// Le service, aux points de course — déterministe +// ============================================================ + +async fn ctx_parts( + pool: &MySqlPool, + s: &Seeded, +) -> (kesh_db::entities::Company, Arc) { + let company = companies::find_by_id(pool, s.company_id) + .await + .unwrap() + .unwrap(); + (company, load_i18n()) +} + +/// Un rendu concurrent a figé avant nous : c'est **son** document qui est +/// servi, jamais le nôtre (mutation tuée : garde `pdf_storage_path IS NULL`). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn la_pose_perdante_sert_le_document_de_l_autre(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let (company, i18n) = ctx_parts(&pool, &s).await; + let documents = unique_dir("documents"); + let ctx = PdfContext { + pool: &pool, + i18n: &i18n, + documents_dir: &documents, + company: &company, + user_id: s.admin_id, + actor_api_key_id: None, + }; + let version = invoice(&pool, &s, id).await.version; + + let premier = issued_invoice_pdf::get_or_freeze(&ctx, id, Usage::Download) + .await + .unwrap(); + let autre = IssuedPdf { + bytes: b"%PDF-1.7 autre rendu".to_vec(), + filename_base: "x".into(), + }; + match issued_invoice_pdf::pose(&ctx, id, version, autre, Language::Fr, Usage::Download) + .await + .unwrap() + { + PoseOutcome::Adopted(pdf) => assert_eq!(pdf.bytes, premier.bytes), + other => panic!("attendu Adopted, obtenu {other:?}"), + } +} + +/// La facture a changé pendant le rendu : la pose est refusée et annoncée +/// comme telle, puis le service fige les **nouvelles** données (mutation +/// tuée : garde `version`). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_rendu_perime_est_refuse_puis_refait(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let (company, i18n) = ctx_parts(&pool, &s).await; + let documents = unique_dir("documents"); + let ctx = PdfContext { + pool: &pool, + i18n: &i18n, + documents_dir: &documents, + company: &company, + user_id: s.admin_id, + actor_api_key_id: None, + }; + let v_rendu = invoice(&pool, &s, id).await.version; + invoices::unvalidate(&pool, s.company_id, id, s.admin_id, v_rendu) + .await + .unwrap(); + invoices::validate_invoice(&pool, s.company_id, id, s.admin_id) + .await + .unwrap(); + + let rendu = IssuedPdf { + bytes: b"%PDF-1.7 ancien rendu".to_vec(), + filename_base: "x".into(), + }; + let outcome = issued_invoice_pdf::pose(&ctx, id, v_rendu, rendu, Language::Fr, Usage::Download) + .await + .unwrap(); + assert!(matches!(outcome, PoseOutcome::Changed), "{outcome:?}"); + assert!(invoice(&pool, &s, id).await.pdf_storage_path.is_none()); + + let pdf = issued_invoice_pdf::get_or_freeze(&ctx, id, Usage::Download) + .await + .unwrap(); + assert!(pdf.bytes.starts_with(b"%PDF-1.")); + assert_ne!(pdf.bytes, b"%PDF-1.7 ancien rendu".to_vec()); +} + +/// Dévalidée entre le rendu et la pose : rien n'est figé sur le brouillon. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn une_devalidation_intercalee_ne_laisse_rien_de_fige(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let (company, i18n) = ctx_parts(&pool, &s).await; + let documents = unique_dir("documents"); + let ctx = PdfContext { + pool: &pool, + i18n: &i18n, + documents_dir: &documents, + company: &company, + user_id: s.admin_id, + actor_api_key_id: None, + }; + let v = invoice(&pool, &s, id).await.version; + invoices::unvalidate(&pool, s.company_id, id, s.admin_id, v) + .await + .unwrap(); + + let rendu = IssuedPdf { + bytes: b"%PDF-1.7 rendu".to_vec(), + filename_base: "x".into(), + }; + let res = issued_invoice_pdf::pose(&ctx, id, v, rendu, Language::Fr, Usage::Download).await; + assert!(matches!(res, Err(AppError::InvoiceNotValidated)), "{res:?}"); + assert!(invoice(&pool, &s, id).await.pdf_storage_path.is_none()); +} + +/// Isolation : le PDF figé d'une autre société n'est jamais servi. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_pdf_fige_d_une_autre_societe_n_est_jamais_servi(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let (company, i18n) = ctx_parts(&pool, &s).await; + let documents = unique_dir("documents"); + let ctx = PdfContext { + pool: &pool, + i18n: &i18n, + documents_dir: &documents, + company: &company, + user_id: s.admin_id, + actor_api_key_id: None, + }; + issued_invoice_pdf::get_or_freeze(&ctx, id, Usage::Download) + .await + .unwrap(); + + let mut autre = company.clone(); + autre.id = company.id + 999; + let ctx_autre = PdfContext { + company: &autre, + ..ctx + }; + let res = issued_invoice_pdf::get_or_freeze(&ctx_autre, id, Usage::Download).await; + assert!( + matches!( + res, + Err(AppError::Database(kesh_db::errors::DbError::NotFound)) + ), + "{res:?}" + ); +} + +// ============================================================ +// Refiger (arbitrage 7) +// ============================================================ + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn refiger_apres_un_410_produit_un_nouveau_document_trace(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + pdf_bytes(&app, &token, id).await; + let avant = invoice(&pool, &s, id).await; + std::fs::remove_file(frozen_file(&app, &avant)).unwrap(); + assert_eq!(get_pdf(&app, &token, id).await.status(), 410); + + let resp = refreeze(&app, &token, id).await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!(body["pdfFrozenAt"].is_string(), "{body}"); + + let apres = invoice(&pool, &s, id).await; + assert_ne!(apres.pdf_sha256, avant.pdf_sha256); + assert_eq!( + sha256(&pdf_bytes(&app, &token, id).await), + apres.pdf_sha256.clone().unwrap() + ); + let details = common::audit_details(&pool, "invoice", id, "invoice.pdf_refrozen") + .await + .unwrap(); + assert_eq!(details["oldPdfSha256"], avant.pdf_sha256.unwrap()); + assert_eq!(details["pdfSha256"], apres.pdf_sha256.unwrap()); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn refiger_est_refuse_a_un_comptable(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + seed_user(&pool, "compta", Role::Comptable, s.company_id).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "compta", ROLE_PASSWORD).await; + assert_eq!(refreeze(&app, &token, id).await.status(), 403); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn refiger_une_facture_non_figee_repond_409(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + let resp = refreeze(&app, &token, id).await; + assert_eq!(resp.status(), 409); + assert_eq!(error_code(resp).await, "INVOICE_PDF_NOT_FROZEN"); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn refiger_un_fichier_present_repond_409(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + pdf_bytes(&app, &token, id).await; + let avant = invoice(&pool, &s, id).await.pdf_sha256; + + let resp = refreeze(&app, &token, id).await; + assert_eq!(resp.status(), 409); + assert_eq!(error_code(resp).await, "INVOICE_PDF_PRESENT"); + assert_eq!(invoice(&pool, &s, id).await.pdf_sha256, avant); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn refiger_un_fichier_altere_repond_409_integrite(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + pdf_bytes(&app, &token, id).await; + let inv = invoice(&pool, &s, id).await; + std::fs::write(frozen_file(&app, &inv), b"%PDF-1.7 altered").unwrap(); + + let resp = refreeze(&app, &token, id).await; + assert_eq!(resp.status(), 409); + assert_eq!(error_code(resp).await, "INVOICE_PDF_INTEGRITY"); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn refiger_une_facture_annulee_est_refuse(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + pdf_bytes(&app, &token, id).await; + credit_notes::create_credit_note( + &pool, + NewCreditNote { + company_id: s.company_id, + invoice_id: id, + date: NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(), + }, + s.admin_id, + ) + .await + .unwrap(); + let inv = invoice(&pool, &s, id).await; + std::fs::remove_file(frozen_file(&app, &inv)).unwrap(); + + let resp = refreeze(&app, &token, id).await; + assert_eq!(resp.status(), 400); + assert_eq!(error_code(resp).await, "INVOICE_CANCELLED"); +} + +/// Revue P1 (B1) : le service sert le PDF figé d'une facture annulée — pour le +/// téléchargement. L'envoi, lui, reste réservé à une facture validée : sans sa +/// garde, une facture annulée par un avoir repartait au client par l'API. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn une_facture_annulee_figee_ne_s_envoie_pas(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let mailer = MockMailer::new(); + let app = spawn_app(pool.clone(), mailer.clone(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + pdf_bytes(&app, &token, id).await; + credit_notes::create_credit_note( + &pool, + NewCreditNote { + company_id: s.company_id, + invoice_id: id, + date: NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(), + }, + s.admin_id, + ) + .await + .unwrap(); + + let resp = app + .client + .post(app.url(&format!("/api/v1/invoices/{id}/send-email"))) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "subject": "Facture", "body": "Bonjour" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + assert_eq!(error_code(resp).await, "INVOICE_NOT_VALIDATED"); + assert!(mailer.sent_emails().is_empty(), "rien n'est parti"); + assert!(invoice(&pool, &s, id).await.emailed_at.is_none()); +} + +/// Revue P2 (R2-2) : une facture ANNULÉE ne se refige pas — son 410 ne propose +/// que la restauration. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_410_d_une_facture_annulee_ne_propose_pas_de_refiger(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + pdf_bytes(&app, &token, id).await; + credit_notes::create_credit_note( + &pool, + NewCreditNote { + company_id: s.company_id, + invoice_id: id, + date: NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(), + }, + s.admin_id, + ) + .await + .unwrap(); + let inv = invoice(&pool, &s, id).await; + std::fs::remove_file(frozen_file(&app, &inv)).unwrap(); + + let resp = get_pdf(&app, &token, id).await; + assert_eq!(resp.status(), 410); + let body: serde_json::Value = resp.json().await.unwrap(); + let message = body["error"]["message"].as_str().unwrap(); + assert!( + message.contains(inv.pdf_sha256.as_deref().unwrap()), + "{body}" + ); + assert!( + !message.contains("refiger"), + "aucun refigeage promis : {message}" + ); +} + +/// Revue P2 (R2-4) : la garde d'envoi est tenue par le service, sur la facture +/// qu'il relit — un appelant qui n'aurait pas contrôlé le statut est couvert. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_service_refuse_l_envoi_d_une_facture_annulee_figee(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let (company, i18n) = ctx_parts(&pool, &s).await; + let documents = unique_dir("documents"); + let ctx = PdfContext { + pool: &pool, + i18n: &i18n, + documents_dir: &documents, + company: &company, + user_id: s.admin_id, + actor_api_key_id: None, + }; + issued_invoice_pdf::get_or_freeze(&ctx, id, Usage::Download) + .await + .unwrap(); + credit_notes::create_credit_note( + &pool, + NewCreditNote { + company_id: s.company_id, + invoice_id: id, + date: NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(), + }, + s.admin_id, + ) + .await + .unwrap(); + + // Le téléchargement sert la pièce émise ; l'envoi la refuse. + issued_invoice_pdf::get_or_freeze(&ctx, id, Usage::Download) + .await + .unwrap(); + let res = issued_invoice_pdf::get_or_freeze(&ctx, id, Usage::Send).await; + assert!(matches!(res, Err(AppError::InvoiceNotValidated)), "{res:?}"); +} + +/// Revue P2 (F-M2, arbitrage 8) : un gel déclenché par une clé d'API en lecture +/// est tracé AU NOM DE LA CLÉ, comme le disent les deux manuels. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_gel_par_une_cle_d_api_est_trace_au_nom_de_la_cle(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let app = spawn_app(pool.clone(), MockMailer::new(), unique_dir("documents")).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + let resp = app + .client + .post(app.url("/api/v1/settings/api-keys")) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "name": "lecture", "scope": "read" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let key = resp.json::().await.unwrap()["key"] + .as_str() + .unwrap() + .to_string(); + let key_id: i64 = sqlx::query_scalar("SELECT id FROM api_keys ORDER BY id DESC LIMIT 1") + .fetch_one(&pool) + .await + .unwrap(); + + pdf_bytes(&app, &key, id).await; + + assert!(invoice(&pool, &s, id).await.pdf_storage_path.is_some()); + let (actor_type, actor_key, user_id, _) = + common::audit_actor(&pool, "invoice", id, "invoice.pdf_frozen").await; + assert_eq!( + (actor_type.as_str(), actor_key, user_id), + ("api_key", Some(key_id), s.admin_id) + ); +} + +/// Revue P3 (L3) : la garde de l'envoi vaut aussi pour une facture RELUE en +/// cours de route — figée par un autre rendu puis annulée, elle n'est pas +/// adoptée pour un envoi. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_envoi_n_adopte_pas_le_gel_d_une_facture_annulee_entre_temps(pool: MySqlPool) { + let s = seed(&pool, None).await; + let id = seed_invoice(&pool, &s, 1).await; + let (company, i18n) = ctx_parts(&pool, &s).await; + let documents = unique_dir("documents"); + let ctx = PdfContext { + pool: &pool, + i18n: &i18n, + documents_dir: &documents, + company: &company, + user_id: s.admin_id, + actor_api_key_id: None, + }; + let version = invoice(&pool, &s, id).await.version; + // Un rendu concurrent fige, puis un avoir annule. + issued_invoice_pdf::get_or_freeze(&ctx, id, Usage::Download) + .await + .unwrap(); + credit_notes::create_credit_note( + &pool, + NewCreditNote { + company_id: s.company_id, + invoice_id: id, + date: NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(), + }, + s.admin_id, + ) + .await + .unwrap(); + + let rendu = || IssuedPdf { + bytes: b"%PDF-1.7 rendu de l'envoi".to_vec(), + filename_base: "x".into(), + }; + let res = issued_invoice_pdf::pose(&ctx, id, version, rendu(), Language::Fr, Usage::Send).await; + assert!(matches!(res, Err(AppError::InvoiceNotValidated)), "{res:?}"); + // Le téléchargement, lui, adopte la pièce émise. + let res = issued_invoice_pdf::pose(&ctx, id, version, rendu(), Language::Fr, Usage::Download) + .await + .unwrap(); + assert!(matches!(res, PoseOutcome::Adopted(_)), "{res:?}"); +} diff --git a/crates/kesh-api/tests/invoice_send_email_e2e.rs b/crates/kesh-api/tests/invoice_send_email_e2e.rs index fce37a43a..8af12b7d3 100644 --- a/crates/kesh-api/tests/invoice_send_email_e2e.rs +++ b/crates/kesh-api/tests/invoice_send_email_e2e.rs @@ -538,7 +538,8 @@ async fn contact_without_email_returns_400(pool: MySqlPool) { assert!(emailed_at.is_none(), "non marquée"); } -/// Facture draft → 400 INVOICE_NOT_VALIDATED (erreur héritée du service PDF). +/// Facture draft → 400 INVOICE_NOT_VALIDATED (garde du handler depuis la Story +/// 25-6-b — elle était auparavant héritée du rendu PDF). #[sqlx::test(migrations = "../kesh-db/test-schema")] async fn draft_invoice_returns_400_not_validated(pool: MySqlPool) { let (admin_id, company_id) = seed_base(&pool).await; diff --git a/crates/kesh-db/migrations.sha384 b/crates/kesh-db/migrations.sha384 index 04dda8d24..0222e05f6 100644 --- a/crates/kesh-db/migrations.sha384 +++ b/crates/kesh-db/migrations.sha384 @@ -83,3 +83,4 @@ 20260915000001 7cf8f78b3d9af607fcd2b52bb54f0190880c65df4062d3ce67a62f81bbdf5ef884a42533c579b1184684deaf75259d2b 20260917000001 ca59103a2ff33cde3ae613f14f6a1a54291315e9c91040ee4a393dee9180307d1b4b2a695f0ffa2f4ed28e5b40d0f023 20260930000001 9fb2d6d97dd32fa924ed44cc9660d02a7b5c059704a6605f4b61f0a2e507e8ef8dfe977b5d3dfef5cd3df3e3ef7d07ac +20261003000001 38638a4c36c2c823d6c149fa1ab21e2a4d4b44412b5d8796b9c5f65ce59aefa144f3ad9e690e63652730b9dab1073678 diff --git a/crates/kesh-db/migrations/20261003000001_invoices_frozen_pdf.sql b/crates/kesh-db/migrations/20261003000001_invoices_frozen_pdf.sql new file mode 100644 index 000000000..d0c8d0a94 --- /dev/null +++ b/crates/kesh-db/migrations/20261003000001_invoices_frozen_pdf.sql @@ -0,0 +1,35 @@ +-- Story 25-6-b (#387) — le PDF d'une facture est FIGÉ. +-- +-- Le PDF d'une facture était régénéré à chaque demande : une facture +-- réimprimée dans cinq ans n'aurait pas été celle remise au client. Il est +-- désormais figé au premier rendu après validation (téléchargement ou envoi), +-- dans la langue du client, et conservé sous `KESH_DOCUMENTS_DIR` sous le nom +-- de son empreinte SHA-256. Ces quatre colonnes le désignent. +-- +-- Arbitrage du Project Lead (2026-10-03) : des COLONNES sur `invoices`, pas une +-- table — une table neuve rendrait toutes les sauvegardes existantes non +-- importables (`post_restore.rs`, inventaire des tables), une colonne nullable +-- non (`check_schema_compat` n'exige que les colonnes NOT NULL sans défaut). +-- +-- Contrainte TOUT-OU-RIEN : les quatre colonnes sont nulles (facture non +-- figée), ou toutes renseignées, l'empreinte faisant 64 caractères et la langue +-- étant l'une des quatre de l'instance. +-- +-- DDL seul, AUCUNE donnée écrite : les factures existantes sont figées à leur +-- prochain rendu (triage P7 sans objet). Non-breaking (`ADD COLUMN` nullable, +-- ignoré par les anciens binaires, qui listent leurs colonnes) → PAS de bump +-- `kesh_version_min_required`. Ligne ajoutée à +-- `docs/migrations-idempotence-audit.md` (politique P5). + +ALTER TABLE invoices + ADD COLUMN pdf_storage_path VARCHAR(512) NULL, + ADD COLUMN pdf_sha256 CHAR(64) NULL, + ADD COLUMN pdf_frozen_at DATETIME(3) NULL, + ADD COLUMN pdf_language CHAR(2) NULL, + ADD CONSTRAINT chk_invoices_frozen_pdf CHECK ( + (pdf_storage_path IS NULL AND pdf_sha256 IS NULL AND pdf_frozen_at IS NULL AND pdf_language IS NULL) + OR (pdf_storage_path IS NOT NULL AND pdf_sha256 IS NOT NULL AND pdf_frozen_at IS NOT NULL + AND pdf_language IS NOT NULL + AND CHAR_LENGTH(pdf_sha256) = 64 + AND BINARY pdf_language IN (BINARY 'FR', BINARY 'DE', BINARY 'IT', BINARY 'EN')) + ); diff --git a/crates/kesh-db/src/entities/invoice.rs b/crates/kesh-db/src/entities/invoice.rs index 737bce86f..1a9720b4c 100644 --- a/crates/kesh-db/src/entities/invoice.rs +++ b/crates/kesh-db/src/entities/invoice.rs @@ -47,6 +47,14 @@ pub struct Invoice { /// Note optionnelle accompagnant la suspension (posée à la pause, remise à /// NULL à la reprise). pub dunning_paused_note: Option, + /// Story 25-6-b (#387) — le PDF **figé** : chemin relatif à + /// `KESH_DOCUMENTS_DIR`, empreinte SHA-256, instant du gel et langue. Les + /// quatre sont nuls (non figée) ou tous renseignés (CHECK + /// `chk_invoices_frozen_pdf`). Le gel ne touche pas `version`. + pub pdf_storage_path: Option, + pub pdf_sha256: Option, + pub pdf_frozen_at: Option, + pub pdf_language: Option, pub version: i32, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, diff --git a/crates/kesh-db/src/repositories/invoices.rs b/crates/kesh-db/src/repositories/invoices.rs index e131e78b1..dd5bb3263 100644 --- a/crates/kesh-db/src/repositories/invoices.rs +++ b/crates/kesh-db/src/repositories/invoices.rs @@ -46,14 +46,31 @@ use crate::util::search::{escape_boolean_ft, escape_like}; pub(crate) const LINE_COLUMNS: &str = "id, invoice_id, position, description, quantity, \ unit_price, vat_rate, line_total, revenue_account_id, created_at"; +/// Les colonnes de [`Invoice`], dans l'ordre de l'entité — **la seule liste** +/// (Story 25-6-b, #387). Elles étaient énumérées en dur à quatre endroits, et +/// un oubli échouait à l'exécution (`FromRow`), jamais à la compilation. Macro +/// et non `const` : `concat!` n'accepte que des littéraux, et les requêtes +/// constantes s'y composent. +macro_rules! invoice_columns { + () => { + "id, company_id, contact_id, invoice_number, status, date, due_date, payment_terms, \ + total_amount, journal_entry_id, paid_at, emailed_at, emailed_to, project_id, \ + dunning_paused_at, dunning_paused_note, pdf_storage_path, pdf_sha256, pdf_frozen_at, \ + pdf_language, version, created_at, updated_at" + }; +} + +/// [`invoice_columns!`] en `const`, pour les requêtes composées par `format!`. +pub(crate) const INVOICE_COLUMNS: &str = invoice_columns!(); + /// Toujours scopé par `company_id` (anti-IDOR multi-tenant). // pub(crate) : réutilisé par `repositories::credit_notes` (lock de la // facture d'origine) — une seule liste de colonnes à maintenir. -pub(crate) const FIND_INVOICE_SCOPED_SQL: &str = "SELECT id, company_id, contact_id, invoice_number, \ - status, date, due_date, payment_terms, total_amount, journal_entry_id, paid_at, \ - emailed_at, emailed_to, project_id, dunning_paused_at, dunning_paused_note, \ - version, created_at, updated_at \ - FROM invoices WHERE id = ? AND company_id = ?"; +pub(crate) const FIND_INVOICE_SCOPED_SQL: &str = concat!( + "SELECT ", + invoice_columns!(), + " FROM invoices WHERE id = ? AND company_id = ?" +); /// Snapshot JSON d'une facture (entête + lignes) pour l'audit log. fn invoice_snapshot_json(inv: &Invoice, lines: &[InvoiceLine]) -> serde_json::Value { @@ -93,6 +110,9 @@ fn invoice_snapshot_json(inv: &Invoice, lines: &[InvoiceLine]) -> serde_json::Va // P14 (review pass 3 A) : journal_entry_id tracé en audit pour // permettre de détecter une rupture du lien JE lors d'un mark_as_paid. "journalEntryId": inv.journal_entry_id, + // Story 25-6-b (#387) : l'empreinte du PDF figé. Au snapshot « avant » + // d'une dévalidation, c'est la trace du document détaché (AC 5). + "pdfSha256": inv.pdf_sha256, "version": inv.version, "lines": lines_json, }) @@ -779,6 +799,20 @@ pub async fn find_scoped_for_update_in_tx( .map_err(map_db_error) } +/// Retourne une facture par ID, sans ses lignes, scopée par `company_id`. +pub async fn find_by_id( + pool: &MySqlPool, + company_id: i64, + id: i64, +) -> Result, DbError> { + sqlx::query_as::<_, Invoice>(FIND_INVOICE_SCOPED_SQL) + .bind(id) + .bind(company_id) + .fetch_optional(pool) + .await + .map_err(map_db_error) +} + /// Retourne une facture par ID avec ses lignes, scopée par `company_id`. pub async fn find_by_id_with_lines( pool: &MySqlPool, @@ -1301,12 +1335,11 @@ pub async fn delete( ) -> Result<(), DbError> { let mut tx = pool.begin().await.map_err(map_db_error)?; - let current_opt = sqlx::query_as::<_, Invoice>( - "SELECT id, company_id, contact_id, invoice_number, status, date, due_date, \ - payment_terms, total_amount, journal_entry_id, paid_at, emailed_at, emailed_to, \ - project_id, dunning_paused_at, dunning_paused_note, version, created_at, updated_at \ - FROM invoices WHERE id = ? AND company_id = ? FOR UPDATE", - ) + let current_opt = sqlx::query_as::<_, Invoice>(concat!( + "SELECT ", + invoice_columns!(), + " FROM invoices WHERE id = ? AND company_id = ? FOR UPDATE" + )) .bind(id) .bind(company_id) .fetch_optional(&mut *tx) @@ -1572,9 +1605,16 @@ pub async fn unvalidate( // // ⚠️ `invoice_number` n'est PAS touché : c'est le critère du numéro // conservé. + // + // Story 25-6-b (#387, AC 5) : le PDF figé est DÉTACHÉ — les quatre + // colonnes repassent à `NULL` dans le même geste ; son empreinte reste + // au snapshot de l'audit. Le fichier, nommé par son empreinte, reste + // sur le disque. À la revalidation, le premier rendu fige un nouveau + // document. let rows = sqlx::query( "UPDATE invoices SET status = 'draft', journal_entry_id = NULL, \ - version = version + 1 \ + pdf_storage_path = NULL, pdf_sha256 = NULL, pdf_frozen_at = NULL, \ + pdf_language = NULL, version = version + 1 \ WHERE id = ? AND company_id = ? AND version = ? AND status = 'validated'", ) .bind(invoice_id) @@ -2438,6 +2478,171 @@ pub async fn mark_emailed( } } +/// Le PDF d'une facture à figer — ce que le service de `kesh-api` a écrit +/// sous `KESH_DOCUMENTS_DIR` (Story 25-6-b, #387). +#[derive(Debug, Clone)] +pub struct FrozenPdf { + /// Chemin relatif à `KESH_DOCUMENTS_DIR` (`{sha256}.pdf`). + pub storage_path: String, + /// SHA-256 hexadécimal (64 caractères) des octets écrits. + pub sha256: String, + /// Langue du rendu, `FR` / `DE` / `IT` / `EN` (CHECK `chk_invoices_frozen_pdf`). + pub language: String, +} + +/// Pose le PDF figé d'une facture **non encore figée** (Story 25-6-b, AC 2). +/// +/// Un seul `UPDATE` conditionnel, et l'audit `invoice.pdf_frozen` dans la même +/// transaction — l'empreinte n'existe jamais sans sa trace. Les trois gardes +/// sont toutes nécessaires : +/// +/// - `pdf_storage_path IS NULL` : un rendu concurrent a pu figer avant nous ; +/// on ne remplace jamais un document posé ; +/// - `status = 'validated'` : une dévalidation (ou un avoir) intercalée entre +/// le rendu et la pose laisserait sinon un PDF figé sur un brouillon ; +/// - `version = expected_version` : la version **lue au rendu** — une +/// séquence dévalidation → modification → revalidation passerait la seule +/// garde de statut et figerait des lignes périmées. +/// +/// ⚠️ Le gel **ne touche pas** `version` : une dévalidation ou un avoir lancés +/// depuis une fiche ouverte avant le téléchargement tomberaient sinon en 409. +/// +/// Retourne `true` si la pose a eu lieu, `false` si une garde l'a refusée — +/// à l'appelant de relire la facture pour savoir laquelle. +pub async fn freeze_pdf( + pool: &MySqlPool, + company_id: i64, + invoice_id: i64, + expected_version: i32, + pdf: &FrozenPdf, + user_id: i64, + actor_api_key_id: Option, +) -> Result { + let mut tx = pool.begin().await.map_err(map_db_error)?; + + let result = async { + let rows = sqlx::query( + "UPDATE invoices SET pdf_storage_path = ?, pdf_sha256 = ?, \ + pdf_frozen_at = NOW(3), pdf_language = ? \ + WHERE id = ? AND company_id = ? AND pdf_storage_path IS NULL \ + AND status = 'validated' AND version = ?", + ) + .bind(&pdf.storage_path) + .bind(&pdf.sha256) + .bind(&pdf.language) + .bind(invoice_id) + .bind(company_id) + .bind(expected_version) + .execute(&mut *tx) + .await + .map_err(map_db_error)? + .rows_affected(); + if rows != 1 { + return Ok(false); + } + + audit_log::insert_in_tx( + &mut tx, + NewAuditLogEntry::for_actor( + user_id, + actor_api_key_id, + "invoice.pdf_frozen", + "invoice", + invoice_id, + Some(serde_json::json!({ + "pdfSha256": pdf.sha256, + "pdfLanguage": pdf.language, + })), + ), + ) + .await?; + Ok(true) + } + .await; + + match result { + Ok(applied) => { + tx.commit().await.map_err(map_db_error)?; + Ok(applied) + } + Err(e) => { + let _ = tx.rollback().await; + Err(e) + } + } +} + +/// Remplace le PDF figé d'une facture dont le fichier a disparu — le geste +/// d'administrateur de l'arbitrage 7 (Story 25-6-b, AC 3-bis). +/// +/// Gardes : `pdf_sha256 = old_sha256` (deux refigeages concurrents, un seul +/// réussit) et `status = 'validated'` (un avoir intercalé refuse). L'audit +/// `invoice.pdf_refrozen` porte l'**ancienne** et la **nouvelle** empreinte, +/// dans la même transaction. Retourne `true` si le remplacement a eu lieu. +pub async fn refreeze_pdf( + pool: &MySqlPool, + company_id: i64, + invoice_id: i64, + old_sha256: &str, + pdf: &FrozenPdf, + user_id: i64, + actor_api_key_id: Option, +) -> Result { + let mut tx = pool.begin().await.map_err(map_db_error)?; + + let result = async { + let rows = sqlx::query( + "UPDATE invoices SET pdf_storage_path = ?, pdf_sha256 = ?, \ + pdf_frozen_at = NOW(3), pdf_language = ? \ + WHERE id = ? AND company_id = ? AND pdf_sha256 = ? \ + AND status = 'validated'", + ) + .bind(&pdf.storage_path) + .bind(&pdf.sha256) + .bind(&pdf.language) + .bind(invoice_id) + .bind(company_id) + .bind(old_sha256) + .execute(&mut *tx) + .await + .map_err(map_db_error)? + .rows_affected(); + if rows != 1 { + return Ok(false); + } + + audit_log::insert_in_tx( + &mut tx, + NewAuditLogEntry::for_actor( + user_id, + actor_api_key_id, + "invoice.pdf_refrozen", + "invoice", + invoice_id, + Some(serde_json::json!({ + "oldPdfSha256": old_sha256, + "pdfSha256": pdf.sha256, + "pdfLanguage": pdf.language, + })), + ), + ) + .await?; + Ok(true) + } + .await; + + match result { + Ok(applied) => { + tx.commit().await.map_err(map_db_error)?; + Ok(applied) + } + Err(e) => { + let _ = tx.rollback().await; + Err(e) + } + } +} + /// Charge jusqu'à `max_rows` factures validées filtrées (pour l'export CSV). /// /// Contrairement à [`list_by_company_paginated`], pas de LIMIT/OFFSET exposé : @@ -2508,14 +2713,11 @@ pub async fn list_all_by_company( pool: &MySqlPool, company_id: i64, ) -> Result, DbError> { - sqlx::query_as::<_, Invoice>( - "SELECT id, company_id, contact_id, invoice_number, status, date, due_date, \ - payment_terms, total_amount, journal_entry_id, paid_at, emailed_at, emailed_to, \ - project_id, dunning_paused_at, dunning_paused_note, version, created_at, updated_at \ - FROM invoices \ - WHERE company_id = ? \ - ORDER BY id", - ) + sqlx::query_as::<_, Invoice>(concat!( + "SELECT ", + invoice_columns!(), + " FROM invoices WHERE company_id = ? ORDER BY id" + )) .bind(company_id) .fetch_all(pool) .await diff --git a/crates/kesh-db/src/repositories/reconciliation.rs b/crates/kesh-db/src/repositories/reconciliation.rs index bcf9b03d2..856a8ca20 100644 --- a/crates/kesh-db/src/repositories/reconciliation.rs +++ b/crates/kesh-db/src/repositories/reconciliation.rs @@ -45,10 +45,9 @@ use crate::repositories::invoice_settlements::{ INVOICE_AMOUNT_DUE_DERIVED_SQL, amount_due_derived_joins, }; -/// Colonnes Invoice pour SELECT (cohérent FIND_INVOICE_SCOPED_SQL). -const INVOICE_COLUMNS: &str = "id, company_id, contact_id, invoice_number, status, date, \ - due_date, payment_terms, total_amount, journal_entry_id, paid_at, emailed_at, emailed_to, \ - project_id, dunning_paused_at, dunning_paused_note, version, created_at, updated_at"; +// Colonnes Invoice pour SELECT — la liste unique de `repositories::invoices` +// (Story 25-6-b). +use super::invoices::INVOICE_COLUMNS; /// Facture candidate à la réconciliation, accompagnée de son **reste dû** et /// de son **TTC**. diff --git a/crates/kesh-db/test-schema/0001_schema_squash.sql b/crates/kesh-db/test-schema/0001_schema_squash.sql index 014a18cca..e61ccb75a 100644 --- a/crates/kesh-db/test-schema/0001_schema_squash.sql +++ b/crates/kesh-db/test-schema/0001_schema_squash.sql @@ -1,6 +1,6 @@ -- SQUASH DU SCHÉMA DE TEST — Story 22-5 (#251). GÉNÉRÉ, NE PAS ÉDITER. -- Régénérer : scripts/regen-test-schema.sh --- Équivalent des 70 migrations de crates/kesh-db/migrations/, +-- Équivalent des 71 migrations de crates/kesh-db/migrations/, -- rejouées en UN batch DDL par base éphémère de test. -- -- Le garde-fou crates/kesh-db/tests/test_schema_guard.rs compare ce schéma @@ -711,6 +711,10 @@ CREATE TABLE `invoices` ( `emailed_to` varchar(320) DEFAULT NULL, `dunning_paused_at` datetime(6) DEFAULT NULL, `dunning_paused_note` varchar(500) DEFAULT NULL, + `pdf_storage_path` varchar(512) DEFAULT NULL, + `pdf_sha256` char(64) DEFAULT NULL, + `pdf_frozen_at` datetime(3) DEFAULT NULL, + `pdf_language` char(2) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_invoices_number` (`company_id`,`invoice_number`), KEY `idx_invoices_company_status` (`company_id`,`status`), @@ -729,7 +733,8 @@ CREATE TABLE `invoices` ( CONSTRAINT `chk_invoices_total_non_negative` CHECK (`total_amount` >= 0), CONSTRAINT `chk_invoices_validated_has_je` CHECK (`status` <> 'validated' or `journal_entry_id` is not null), CONSTRAINT `chk_invoices_paid_at_validated` CHECK (`paid_at` is null or `status` in ('validated','cancelled')), - CONSTRAINT `chk_invoices_paid_at_after_date` CHECK (`paid_at` is null or cast(`paid_at` as date) >= `date` - interval 1 day) + CONSTRAINT `chk_invoices_paid_at_after_date` CHECK (`paid_at` is null or cast(`paid_at` as date) >= `date` - interval 1 day), + CONSTRAINT `chk_invoices_frozen_pdf` CHECK (`pdf_storage_path` is null and `pdf_sha256` is null and `pdf_frozen_at` is null and `pdf_language` is null or `pdf_storage_path` is not null and `pdf_sha256` is not null and `pdf_frozen_at` is not null and `pdf_language` is not null and char_length(`pdf_sha256`) = 64 and cast(`pdf_language` as char charset binary) in (cast('FR' as char charset binary),cast('DE' as char charset binary),cast('IT' as char charset binary),cast('EN' as char charset binary))) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; DROP TABLE IF EXISTS `journal_entries`; diff --git a/crates/kesh-db/tests/invoice_frozen_pdf.rs b/crates/kesh-db/tests/invoice_frozen_pdf.rs new file mode 100644 index 000000000..282c7b8f8 --- /dev/null +++ b/crates/kesh-db/tests/invoice_frozen_pdf.rs @@ -0,0 +1,471 @@ +//! Story 25-6-b (#387) — les gardes de la pose du PDF figé, côté base. +//! +//! Le service de `kesh-api` rend le PDF puis le pose par +//! `invoices::freeze_pdf`, un `UPDATE` conditionnel. Ses trois gardes — +//! `pdf_storage_path IS NULL`, `status = 'validated'`, `version` — ne se +//! prouvent pas par une course (`tokio::join!` passerait aussi sans elles) : +//! chaque test ici **intercale** l'événement concurrent de façon déterministe, +//! puis pose. ⚠️ Chaque test nomme la mutation qu'il tue. + +use chrono::NaiveDate; +use kesh_db::entities::contact::{ContactType, NewContact, Salutation}; +use kesh_db::entities::invoice::{NewInvoice, NewInvoiceLine}; +use kesh_db::repositories::contacts; +use kesh_db::repositories::invoices::{self, FrozenPdf}; +use kesh_db::test_fixtures::seed_accounting_company; +use rust_decimal_macros::dec; +use sqlx::MySqlPool; + +struct Seeded { + company_id: i64, + user_id: i64, + invoice_id: i64, +} + +async fn seed_validated(pool: &MySqlPool) -> Seeded { + let seeded = seed_accounting_company(pool).await.expect("seed"); + let contact = contacts::create( + pool, + seeded.admin_user_id, + NewContact { + company_id: seeded.company_id, + contact_type: ContactType::Personne, + name: "Pia Rutschmann".into(), + first_name: None, + last_name: None, + is_client: true, + is_supplier: false, + address: None, + address_street: Some("Marktgasse".into()), + address_building: Some("28".into()), + address_postal_code: Some("9400".into()), + address_city: Some("Rorschach".into()), + address_country: Some("CH".into()), + email: None, + phone: None, + ide_number: None, + client_number: None, + default_payment_terms: None, + default_payment_terms_days: None, + language: None, + salutation: Salutation::Neutre, + }, + ) + .await + .expect("contact"); + let (invoice, _) = invoices::create( + pool, + seeded.admin_user_id, + NewInvoice { + company_id: seeded.company_id, + contact_id: contact.id, + date: NaiveDate::from_ymd_opt(2026, 4, 14).unwrap(), + due_date: None, + payment_terms: None, + lines: vec![NewInvoiceLine { + revenue_account_id: None, + description: "Conseil".into(), + quantity: dec!(1), + unit_price: dec!(100.00), + vat_rate: dec!(8.10), + }], + project_id: None, + }, + ) + .await + .expect("facture"); + invoices::validate_invoice(pool, seeded.company_id, invoice.id, seeded.admin_user_id) + .await + .expect("validation"); + Seeded { + company_id: seeded.company_id, + user_id: seeded.admin_user_id, + invoice_id: invoice.id, + } +} + +fn frozen(tag: char) -> FrozenPdf { + let sha256: String = std::iter::repeat_n(tag, 64).collect(); + FrozenPdf { + storage_path: format!("{sha256}.pdf"), + sha256, + language: "FR".into(), + } +} + +async fn version_of(pool: &MySqlPool, s: &Seeded) -> i32 { + invoices::find_by_id(pool, s.company_id, s.invoice_id) + .await + .unwrap() + .unwrap() + .version +} + +async fn audit_count(pool: &MySqlPool, s: &Seeded, action: &str) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE entity_type = 'invoice' AND entity_id = ? AND action = ?", + ) + .bind(s.invoice_id) + .bind(action) + .fetch_one(pool) + .await + .unwrap() +} + +#[sqlx::test(migrations = "./test-schema")] +async fn la_pose_fige_sans_toucher_la_version_et_trace_l_acteur(pool: MySqlPool) { + let s = seed_validated(&pool).await; + let v = version_of(&pool, &s).await; + + let applied = invoices::freeze_pdf( + &pool, + s.company_id, + s.invoice_id, + v, + &frozen('a'), + s.user_id, + None, + ) + .await + .unwrap(); + assert!(applied); + + let inv = invoices::find_by_id(&pool, s.company_id, s.invoice_id) + .await + .unwrap() + .unwrap(); + assert_eq!(inv.pdf_sha256.as_deref(), Some("a".repeat(64).as_str())); + assert_eq!(inv.pdf_language.as_deref(), Some("FR")); + assert!(inv.pdf_frozen_at.is_some()); + // B2 : le gel ne touche pas `version` (mutation : `version = version + 1`). + assert_eq!(inv.version, v); + + let (user_id, details): (i64, Vec) = sqlx::query_as( + "SELECT user_id, details_json FROM audit_log \ + WHERE entity_type = 'invoice' AND entity_id = ? AND action = 'invoice.pdf_frozen'", + ) + .bind(s.invoice_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(user_id, s.user_id); + let details: serde_json::Value = serde_json::from_slice(&details).unwrap(); + assert_eq!(details["pdfSha256"], "a".repeat(64)); + assert_eq!(details["pdfLanguage"], "FR"); +} + +/// Mutation tuée : garde `pdf_storage_path IS NULL` retirée — la seconde pose +/// remplacerait le document déjà émis. +#[sqlx::test(migrations = "./test-schema")] +async fn une_facture_deja_figee_ne_se_refige_pas(pool: MySqlPool) { + let s = seed_validated(&pool).await; + let v = version_of(&pool, &s).await; + assert!( + invoices::freeze_pdf( + &pool, + s.company_id, + s.invoice_id, + v, + &frozen('a'), + s.user_id, + None + ) + .await + .unwrap() + ); + + let second = invoices::freeze_pdf( + &pool, + s.company_id, + s.invoice_id, + v, + &frozen('b'), + s.user_id, + None, + ) + .await + .unwrap(); + assert!(!second, "la seconde pose doit être refusée"); + let inv = invoices::find_by_id(&pool, s.company_id, s.invoice_id) + .await + .unwrap() + .unwrap(); + assert_eq!(inv.pdf_sha256.as_deref(), Some("a".repeat(64).as_str())); + // Le refus n'écrit rien au journal. + assert_eq!(audit_count(&pool, &s, "invoice.pdf_frozen").await, 1); +} + +/// Mutation tuée : garde `status = 'validated'` retirée. La facture repasse en +/// brouillon **à version égale** — un `UPDATE` brut, pour isoler cette garde de +/// celle de version (une vraie dévalidation incrémente aussi `version`). +#[sqlx::test(migrations = "./test-schema")] +async fn rien_ne_se_fige_sur_un_brouillon(pool: MySqlPool) { + let s = seed_validated(&pool).await; + let v = version_of(&pool, &s).await; + let je: Option = sqlx::query_scalar("SELECT journal_entry_id FROM invoices WHERE id = ?") + .bind(s.invoice_id) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("UPDATE invoices SET status = 'draft', journal_entry_id = NULL WHERE id = ?") + .bind(s.invoice_id) + .execute(&pool) + .await + .unwrap(); + assert!(je.is_some()); + assert_eq!( + version_of(&pool, &s).await, + v, + "montage : version inchangée" + ); + + let applied = invoices::freeze_pdf( + &pool, + s.company_id, + s.invoice_id, + v, + &frozen('a'), + s.user_id, + None, + ) + .await + .unwrap(); + assert!(!applied); + let inv = invoices::find_by_id(&pool, s.company_id, s.invoice_id) + .await + .unwrap() + .unwrap(); + assert!(inv.pdf_storage_path.is_none()); +} + +/// Mutation tuée : garde `version` retirée. Dévalidation → modification → +/// revalidation dans la fenêtre du rendu : le statut est de nouveau +/// `validated`, seule la version dit que les lignes ont changé. +#[sqlx::test(migrations = "./test-schema")] +async fn un_rendu_perime_ne_se_fige_pas(pool: MySqlPool) { + let s = seed_validated(&pool).await; + let v_rendu = version_of(&pool, &s).await; + + let (draft, _) = invoices::unvalidate(&pool, s.company_id, s.invoice_id, s.user_id, v_rendu) + .await + .unwrap(); + sqlx::query("UPDATE invoice_lines SET description = 'Conseil (modifié)' WHERE invoice_id = ?") + .bind(s.invoice_id) + .execute(&pool) + .await + .unwrap(); + invoices::validate_invoice(&pool, s.company_id, s.invoice_id, s.user_id) + .await + .unwrap(); + assert_ne!(version_of(&pool, &s).await, v_rendu); + assert_eq!(draft.status, "draft"); + + let applied = invoices::freeze_pdf( + &pool, + s.company_id, + s.invoice_id, + v_rendu, + &frozen('a'), + s.user_id, + None, + ) + .await + .unwrap(); + assert!( + !applied, + "un rendu de l'ancienne version ne doit pas se figer" + ); + let inv = invoices::find_by_id(&pool, s.company_id, s.invoice_id) + .await + .unwrap() + .unwrap(); + assert!(inv.pdf_storage_path.is_none()); +} + +/// Isolation : une pose sous une autre société ne touche rien. +#[sqlx::test(migrations = "./test-schema")] +async fn la_pose_est_scopee_par_societe(pool: MySqlPool) { + let s = seed_validated(&pool).await; + let v = version_of(&pool, &s).await; + let applied = invoices::freeze_pdf( + &pool, + s.company_id + 999, + s.invoice_id, + v, + &frozen('a'), + s.user_id, + None, + ) + .await + .unwrap(); + assert!(!applied); +} + +/// AC 5 : la dévalidation détache, et l'audit porte l'empreinte détachée. +#[sqlx::test(migrations = "./test-schema")] +async fn la_devalidation_detache_le_pdf(pool: MySqlPool) { + let s = seed_validated(&pool).await; + let v = version_of(&pool, &s).await; + invoices::freeze_pdf( + &pool, + s.company_id, + s.invoice_id, + v, + &frozen('a'), + s.user_id, + None, + ) + .await + .unwrap(); + + let (after, _) = invoices::unvalidate(&pool, s.company_id, s.invoice_id, s.user_id, v) + .await + .unwrap(); + assert!(after.pdf_storage_path.is_none()); + assert!(after.pdf_sha256.is_none()); + assert!(after.pdf_frozen_at.is_none()); + assert!(after.pdf_language.is_none()); + + let details: Vec = sqlx::query_scalar( + "SELECT details_json FROM audit_log \ + WHERE entity_type = 'invoice' AND entity_id = ? AND action = 'invoice.unvalidated'", + ) + .bind(s.invoice_id) + .fetch_one(&pool) + .await + .unwrap(); + let details: serde_json::Value = serde_json::from_slice(&details).unwrap(); + assert_eq!(details["pdfSha256"], "a".repeat(64)); +} + +/// AC 1 : la contrainte tout-ou-rien rejette un état partiel, et une langue +/// hors des quatre. +#[sqlx::test(migrations = "./test-schema")] +async fn la_contrainte_rejette_un_etat_partiel(pool: MySqlPool) { + let s = seed_validated(&pool).await; + let partiel = sqlx::query("UPDATE invoices SET pdf_sha256 = ? WHERE id = ?") + .bind("a".repeat(64)) + .bind(s.invoice_id) + .execute(&pool) + .await; + assert!(partiel.is_err(), "empreinte sans chemin : refusé"); + + let courte = sqlx::query( + "UPDATE invoices SET pdf_storage_path = 'x.pdf', pdf_sha256 = 'abc', \ + pdf_frozen_at = NOW(3), pdf_language = 'FR' WHERE id = ?", + ) + .bind(s.invoice_id) + .execute(&pool) + .await; + assert!(courte.is_err(), "empreinte de 3 caractères : refusé"); + + let langue = sqlx::query( + "UPDATE invoices SET pdf_storage_path = 'x.pdf', pdf_sha256 = ?, \ + pdf_frozen_at = NOW(3), pdf_language = 'fr' WHERE id = ?", + ) + .bind("a".repeat(64)) + .bind(s.invoice_id) + .execute(&pool) + .await; + assert!(langue.is_err(), "langue en minuscules : refusé"); +} + +/// AC 3-bis : deux refigeages concurrents, un seul réussit ; l'audit porte +/// l'ancienne et la nouvelle empreinte. +#[sqlx::test(migrations = "./test-schema")] +async fn deux_refigeages_concurrents_un_seul_reussit(pool: MySqlPool) { + let s = seed_validated(&pool).await; + let v = version_of(&pool, &s).await; + invoices::freeze_pdf( + &pool, + s.company_id, + s.invoice_id, + v, + &frozen('a'), + s.user_id, + None, + ) + .await + .unwrap(); + let old = "a".repeat(64); + + let first = invoices::refreeze_pdf( + &pool, + s.company_id, + s.invoice_id, + &old, + &frozen('b'), + s.user_id, + None, + ) + .await + .unwrap(); + let second = invoices::refreeze_pdf( + &pool, + s.company_id, + s.invoice_id, + &old, + &frozen('c'), + s.user_id, + None, + ) + .await + .unwrap(); + assert!(first); + assert!(!second, "mutation tuée : garde `pdf_sha256 = old` retirée"); + + let inv = invoices::find_by_id(&pool, s.company_id, s.invoice_id) + .await + .unwrap() + .unwrap(); + assert_eq!(inv.pdf_sha256.as_deref(), Some("b".repeat(64).as_str())); + assert_eq!(audit_count(&pool, &s, "invoice.pdf_refrozen").await, 1); + let details: Vec = sqlx::query_scalar( + "SELECT details_json FROM audit_log \ + WHERE entity_type = 'invoice' AND entity_id = ? AND action = 'invoice.pdf_refrozen'", + ) + .bind(s.invoice_id) + .fetch_one(&pool) + .await + .unwrap(); + let details: serde_json::Value = serde_json::from_slice(&details).unwrap(); + assert_eq!(details["oldPdfSha256"], old); + assert_eq!(details["pdfSha256"], "b".repeat(64)); +} + +/// AC 3-bis : un refigeage n'aboutit pas sur une facture qui n'est plus +/// validée (mutation tuée : garde `status` retirée du refigeage). +#[sqlx::test(migrations = "./test-schema")] +async fn le_refigeage_refuse_une_facture_qui_n_est_plus_validee(pool: MySqlPool) { + let s = seed_validated(&pool).await; + let v = version_of(&pool, &s).await; + invoices::freeze_pdf( + &pool, + s.company_id, + s.invoice_id, + v, + &frozen('a'), + s.user_id, + None, + ) + .await + .unwrap(); + sqlx::query("UPDATE invoices SET status = 'cancelled' WHERE id = ?") + .bind(s.invoice_id) + .execute(&pool) + .await + .unwrap(); + + let applied = invoices::refreeze_pdf( + &pool, + s.company_id, + s.invoice_id, + &"a".repeat(64), + &frozen('b'), + s.user_id, + None, + ) + .await + .unwrap(); + assert!(!applied); +} diff --git a/crates/kesh-db/tests/migrations_upgrade_path.rs b/crates/kesh-db/tests/migrations_upgrade_path.rs index 6908f5ec0..c223a7807 100644 --- a/crates/kesh-db/tests/migrations_upgrade_path.rs +++ b/crates/kesh-db/tests/migrations_upgrade_path.rs @@ -35,9 +35,9 @@ async fn apply_migrations_up_to( assert!( n <= all.len(), "apply_migrations_up_to: n={} > total={} — vérifier que le calcul \ - `total - 36` (fenêtre d'upgrade, FRONTIÈRE figée à 34) reste \ + `total - 37` (fenêtre d'upgrade, FRONTIÈRE figée à 34) reste \ cohérent avec l'ajout de migrations futures. Si une migration a été ajoutée à \ - la branche, l'assertion `total == 70` du test upgrade_path_preserves_data \ + la branche, l'assertion `total == 71` du test upgrade_path_preserves_data \ doit également échouer, c'est son rôle : elle signale qu'il faut décider \ explicitement si la fenêtre s'élargit (bumper `total` seul) ou si la \ frontière doit rester à 34 (bumper `total` ET la fenêtre). Cf. garde-fou \ @@ -54,8 +54,8 @@ async fn apply_migrations_up_to( sub.run(pool).await } -/// AC #15a — cas générique upgrade path : `total - 36` migrations appliquées -/// (**34** à ce jour) + seed + `MIGRATOR.run()` final, qui applique les **36** +/// AC #15a — cas générique upgrade path : `total - 37` migrations appliquées +/// (**34** à ce jour) + seed + `MIGRATOR.run()` final, qui applique les **37** /// dernières. Assertion : seed préservé à travers la fenêtre d'upgrade. /// /// ⚠️ Les nombres ci-dessus se recomptent, ils ne se relisent pas — cf. le @@ -96,14 +96,15 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { // + audit_log_actor_label (Story 25-1a, #376) = 67, // + audit_log_company_id (Story 25-1c-zero, refs #378) = 68, // + journal_entry_number_sequences (Story 25-2-c, refs #381) = 69, - // + invoice_settings_rounding_account (Story 25-4-c3-a1, refs #476) = 70. + // + invoice_settings_rounding_account (Story 25-4-c3-a1, refs #476) = 70, + // + invoices_frozen_pdf (Story 25-6-b, refs #387) = 71. let total = kesh_db::MIGRATOR.migrations.len(); assert_eq!( - total, 70, - "70 migrations attendues (69 précédentes + Story 25-4-c3-a1 : invoice_settings_rounding_account)" + total, 71, + "71 migrations attendues (70 précédentes + Story 25-6-b : invoices_frozen_pdf)" ); - // Étape 1 : applique toutes les migrations sauf les 36 dernières. La + // Étape 1 : applique toutes les migrations sauf les 37 dernières. La // fenêtre d'upgrade démarre donc à la 35ᵉ, `20260614000001_vat_accounts_config` // (Story 18-1a), et court jusqu'à la dernière du dépôt. Ne pas ré-énumérer // ici les migrations de la fenêtre : une liste nominative se périme à chaque @@ -111,17 +112,17 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { // plus bas. Le seul nombre qui fait foi est celui du `total - N` ci-dessous. // // Note `total - N` : expression relative à la longueur totale, tandis que - // l'assertion `total == 70` ci-dessus est INTENTIONNELLEMENT codée en dur + // l'assertion `total == 71` ci-dessus est INTENTIONNELLEMENT codée en dur // pour fail-loud sur toute évolution non revue. À chaque migration ajoutée, // le mainteneur doit (1) bumper ce compte (2) incrémenter `N` du même pas, // de sorte que `total - N` — la frontière — reste **constant**. // // Frontière actuelle : **34**. Le test applique donc les 34 premières // migrations (jusqu'à `20260613000001_vat_rates_crud` incluse), seede des - // données, puis joue les 36 restantes comme « fenêtre d’upgrade ». + // données, puis joue les 37 restantes comme « fenêtre d’upgrade ». // - // ⚠️ `N` DOIT être incrémenté en même temps que `total`. Le laisser à 35 - // avec `total = 70` porterait la frontière à 35 : le test continuerait de + // ⚠️ `N` DOIT être incrémenté en même temps que `total`. Le laisser à 36 + // avec `total = 71` porterait la frontière à 35 : le test continuerait de // passer en testant une fenêtre plus étroite d'une migration. // Story 16-1a : 21 → 22, frontière inchangée (56 - 22 = 55 - 21 = 34). // Story 16-1a-bis : 22 → 23, frontière inchangée (57 - 23 = 34). @@ -167,16 +168,17 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { // voulu : « depuis un socle figé, jusqu'à la dernière du dépôt ». // Story 25-2-c : 34 → 35, frontière inchangée (69 - 35 = 34). // Story 25-4-c3-a1 : 35 → 36, frontière inchangée (70 - 36 = 34). + // Story 25-6-b : 36 → 37, frontière inchangée (71 - 37 = 34). // // ⚠️ J'ai d'abord porté `total` à 69 SANS toucher à ce soustracteur : le socle // est alors passé de 34 à 35 migrations, la fenêtre a glissé d'un cran, et le // test a rougi non plus sur son compteur mais sur `COUNT(accounts) : expected // 4, got 2` — un échec qui ne ressemble en rien à sa cause. *Les deux nombres // se bougent du même pas, et le commentaire ci-dessus le disait.* - let n_before_upgrade_window = total - 36; + let n_before_upgrade_window = total - 37; apply_migrations_up_to(&pool, n_before_upgrade_window) .await - .expect("apply_migrations_up_to(total - 36) failed"); + .expect("apply_migrations_up_to(total - 37) failed"); // Étape 2 : seed 1 company + 1 user + 2 accounts + 1 invoice + 1 contact. let company_id: i64 = sqlx::query_scalar( @@ -245,7 +247,7 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { .await .expect("INSERT invoice failed"); - // Étape 3 : appliquer les 36 migrations restantes via MIGRATOR.run(). + // Étape 3 : appliquer les 37 migrations restantes via MIGRATOR.run(). kesh_db::MIGRATOR .run(&pool) .await diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 3deb818bb..ae1590a07 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -622,6 +622,13 @@ invoice-pdf-qr-acceptance-point = Annahmestelle invoice-pdf-qr-separate-before-paying = Vor der Einzahlung abzutrennen invoice-pdf-error-invoice-not-validated = Die Rechnung muss validiert sein, bevor ein PDF erzeugt werden kann. +error-invoice-cancelled = Die Rechnung ist durch eine Gutschrift storniert: Ihr PDF kann nicht erstellt werden, und es wurde kein Dokument festgeschrieben. +error-invoice-pdf-gone = Das ausgestellte PDF dieser Rechnung ist nicht auffindbar: Die Datei { $sha256 }.pdf fehlt im Dokumentenverzeichnis (KESH_DOCUMENTS_DIR). Stellen Sie sie aus der Sicherung dieses Verzeichnisses wieder her; andernfalls kann ein Administrator die Rechnung neu festschreiben. +error-invoice-pdf-gone-cancelled = Das ausgestellte PDF dieser stornierten Rechnung ist nicht auffindbar: Die Datei { $sha256 }.pdf fehlt im Dokumentenverzeichnis (KESH_DOCUMENTS_DIR). Nur ihre Wiederherstellung aus der Sicherung dieses Verzeichnisses behebt das. +error-invoice-changed = Die Rechnung wurde während der Erstellung ihres PDF geändert. Versuchen Sie es erneut. +error-invoice-pdf-present = Das ausgestellte PDF dieser Rechnung ist vorhanden: Es gibt nichts neu festzuschreiben. +error-invoice-pdf-not-frozen = Diese Rechnung hat noch kein festgeschriebenes PDF: Der nächste Download schreibt es fest. +error-invoice-pdf-integrity = Die Datei des ausgestellten PDF wurde verändert: Ihr Fingerabdruck stimmt nicht mehr überein. Stellen Sie sie aus der Sicherung wieder her oder entfernen Sie sie aus dem Dokumentenverzeichnis, bevor Sie neu festschreiben. invoice-pdf-error-invoice-not-pdf-ready = Die Rechnung ist nicht bereit für die PDF-Erzeugung. invoice-pdf-error-pdf-generation-failed = PDF-Erzeugung fehlgeschlagen. Bitte später erneut versuchen. invoice-pdf-error-popup-blocked = Pop-up vom Browser blockiert — bitte Pop-ups für das PDF erlauben. @@ -1392,6 +1399,14 @@ invoice-send-email-confirm = E-Mail senden invoice-send-email-success = Rechnung per E-Mail gesendet invoice-send-email-error-empty = Betreff und Text der E-Mail dürfen nicht leer sein. invoice-detail-emailed-at-label = Gesendet am +invoice-detail-pdf-frozen-at-label = Dokument festgeschrieben am +invoice-pdf-refreeze-button = Dokument neu festschreiben +invoice-pdf-refreeze-title = Dokument neu festschreiben? +invoice-pdf-refreeze-body = Die Datei des ausgestellten PDF dieser Rechnung ist nicht auffindbar. Neu festschreiben erzeugt ein NEUES Dokument mit den heutigen Daten und der heutigen Sprache des Kunden: Es ist nicht das Original. Der Vorgang wird im Prüfprotokoll festgehalten, mit dem Fingerabdruck des alten und des neuen Dokuments. +invoice-pdf-refreeze-restore-first = Stellen Sie die Datei zuerst aus der Sicherung des Dokumentenverzeichnisses wieder her, falls sie dort vorhanden ist: Nur so erhalten Sie das Original zurück. Sind mehrere Rechnungen betroffen, prüfen Sie vor dem Neufestschreiben die Einbindung dieses Verzeichnisses. +invoice-pdf-refreeze-done = Dokument neu festgeschrieben. +invoice-pdf-refreeze-error = Das Dokument konnte nicht neu festgeschrieben werden. +invoice-pdf-refreeze-reload-failed = Dokument neu festgeschrieben, aber die Rechnung konnte nicht neu geladen werden: Laden Sie die Seite neu. contact-form-language = Korrespondenzsprache contact-form-language-inherited = Geerbt (Sprache der Instanz) contact-form-salutation = Anrede @@ -2046,6 +2061,8 @@ audit-log-action-invoice-unvalidated = Rechnung entvalidiert audit-log-action-invoice-cancelled = Rechnung storniert audit-log-action-invoice-deleted = Rechnung gelöscht audit-log-action-invoice-emailed = Rechnung per E-Mail gesendet +audit-log-action-invoice-pdf-frozen = Rechnungs-PDF festgeschrieben +audit-log-action-invoice-pdf-refrozen = Rechnungs-PDF neu festgeschrieben audit-log-action-invoice-paid = Rechnung bezahlt audit-log-action-invoice-partially-settled = Rechnung teilweise bezahlt audit-log-action-invoice-settlement-cancelled = Zahlung storniert diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index d6980d572..4c94a254f 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -622,6 +622,13 @@ invoice-pdf-qr-acceptance-point = Acceptance point invoice-pdf-qr-separate-before-paying = Separate before paying in invoice-pdf-error-invoice-not-validated = The invoice must be validated before generating a PDF. +error-invoice-cancelled = The invoice is cancelled by a credit note: its PDF cannot be produced, and no document was frozen. +error-invoice-pdf-gone = The issued PDF of this invoice cannot be found: the file { $sha256 }.pdf is missing from the documents directory (KESH_DOCUMENTS_DIR). Restore it from the backup of that directory; failing that, an administrator can refreeze the invoice. +error-invoice-pdf-gone-cancelled = The issued PDF of this cancelled invoice cannot be found: the file { $sha256 }.pdf is missing from the documents directory (KESH_DOCUMENTS_DIR). Only restoring it from the backup of that directory repairs it. +error-invoice-changed = The invoice was modified while its PDF was being prepared. Please try again. +error-invoice-pdf-present = The issued PDF of this invoice is present: there is nothing to refreeze. +error-invoice-pdf-not-frozen = This invoice has no frozen PDF yet: its next download will freeze it. +error-invoice-pdf-integrity = The issued PDF file has been altered: its fingerprint no longer matches. Restore it from the backup, or move it out of the documents directory before refreezing. invoice-pdf-error-invoice-not-pdf-ready = The invoice is not ready for PDF generation. invoice-pdf-error-pdf-generation-failed = PDF generation failed. Please try again later. invoice-pdf-error-popup-blocked = Pop-up blocked by the browser — please allow pop-ups to download the PDF. @@ -1392,6 +1399,14 @@ invoice-send-email-confirm = Send the email invoice-send-email-success = Invoice sent by email invoice-send-email-error-empty = The email subject and body cannot be empty. invoice-detail-emailed-at-label = Sent on +invoice-detail-pdf-frozen-at-label = Document frozen on +invoice-pdf-refreeze-button = Refreeze the document +invoice-pdf-refreeze-title = Refreeze the document? +invoice-pdf-refreeze-body = The issued PDF file of this invoice cannot be found. Refreezing produces a NEW document, with today's data and the client's current language: it is not the original. The action is recorded in the audit log, with the fingerprint of the old document and of the new one. +invoice-pdf-refreeze-restore-first = First restore the file from the backup of the documents directory, if it is there: it is the only way to recover the original. If several invoices are affected, check how that directory is mounted before refreezing. +invoice-pdf-refreeze-done = Document refrozen. +invoice-pdf-refreeze-error = The document could not be refrozen. +invoice-pdf-refreeze-reload-failed = Document refrozen, but the invoice could not be reloaded: please reload the page. contact-form-language = Correspondence language contact-form-language-inherited = Inherited (instance language) contact-form-salutation = Salutation @@ -2052,6 +2067,8 @@ audit-log-action-invoice-unvalidated = Invoice unvalidated audit-log-action-invoice-cancelled = Invoice cancelled audit-log-action-invoice-deleted = Invoice deleted audit-log-action-invoice-emailed = Invoice sent by e-mail +audit-log-action-invoice-pdf-frozen = Invoice PDF frozen +audit-log-action-invoice-pdf-refrozen = Invoice PDF refrozen audit-log-action-invoice-paid = Invoice paid audit-log-action-invoice-partially-settled = Invoice partially paid audit-log-action-invoice-settlement-cancelled = Settlement cancelled diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 9c6239854..f6db72f29 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -655,6 +655,13 @@ invoice-pdf-qr-separate-before-paying = A détacher avant le versement # Messages d'erreur PDF (6 clés — codes applicatifs + causes détaillées) invoice-pdf-error-invoice-not-validated = La facture doit être validée avant de pouvoir être générée en PDF. +error-invoice-cancelled = La facture est annulée par un avoir : son PDF ne peut pas être produit, et aucun document n'a été figé. +error-invoice-pdf-gone = Le PDF émis de cette facture est introuvable : le fichier { $sha256 }.pdf manque dans le répertoire des documents (KESH_DOCUMENTS_DIR). Restaurez-le depuis la sauvegarde de ce répertoire ; à défaut, un administrateur peut refiger la facture. +error-invoice-pdf-gone-cancelled = Le PDF émis de cette facture annulée est introuvable : le fichier { $sha256 }.pdf manque dans le répertoire des documents (KESH_DOCUMENTS_DIR). Seule sa restauration depuis la sauvegarde de ce répertoire le répare. +error-invoice-changed = La facture a été modifiée pendant la préparation de son PDF. Réessayez. +error-invoice-pdf-present = Le PDF émis de cette facture est présent : il n'y a rien à refiger. +error-invoice-pdf-not-frozen = Cette facture n'a pas encore de PDF figé : son prochain téléchargement le figera. +error-invoice-pdf-integrity = Le fichier du PDF émis a été altéré : son empreinte ne correspond plus. Restaurez-le depuis la sauvegarde, ou écartez-le du répertoire des documents avant de refiger. invoice-pdf-error-invoice-not-pdf-ready = La facture n'est pas prête pour la génération PDF. invoice-pdf-error-pdf-generation-failed = Échec de la génération du PDF. Réessayez ultérieurement. invoice-pdf-error-popup-blocked = Pop-up bloqué par le navigateur — autorisez les pop-ups pour télécharger le PDF. @@ -1489,6 +1496,14 @@ invoice-send-email-confirm = Envoyer l'e-mail invoice-send-email-success = Facture envoyée par e-mail invoice-send-email-error-empty = L'objet et le corps de l'e-mail ne peuvent pas être vides. invoice-detail-emailed-at-label = Envoyée le +invoice-detail-pdf-frozen-at-label = Document figé le +invoice-pdf-refreeze-button = Refiger le document +invoice-pdf-refreeze-title = Refiger le document ? +invoice-pdf-refreeze-body = Le fichier du PDF émis de cette facture est introuvable. Refiger produit un NOUVEAU document, avec les données et la langue du client d'aujourd'hui : ce n'est pas l'original. Le geste est tracé au journal d'audit, avec l'empreinte de l'ancien document et celle du nouveau. +invoice-pdf-refreeze-restore-first = Restaurez d'abord le fichier depuis la sauvegarde du répertoire des documents, s'il y existe : c'est le seul moyen de retrouver l'original. Si plusieurs factures sont dans ce cas, vérifiez le montage de ce répertoire avant de refiger. +invoice-pdf-refreeze-done = Document refigé. +invoice-pdf-refreeze-error = Le document n’a pas pu être refigé. +invoice-pdf-refreeze-reload-failed = Document refigé, mais la fiche n’a pas pu être relue : rechargez la page. contact-form-language = Langue de correspondance contact-form-language-inherited = Héritée (langue de l'instance) contact-form-salutation = Civilité @@ -2100,6 +2115,8 @@ audit-log-action-invoice-unvalidated = Facture dévalidée audit-log-action-invoice-cancelled = Facture annulée audit-log-action-invoice-deleted = Facture supprimée audit-log-action-invoice-emailed = Facture envoyée par e-mail +audit-log-action-invoice-pdf-frozen = PDF de facture figé +audit-log-action-invoice-pdf-refrozen = PDF de facture refigé audit-log-action-invoice-paid = Facture payée audit-log-action-invoice-partially-settled = Facture partiellement réglée audit-log-action-invoice-settlement-cancelled = Règlement annulé diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index fb1c9837a..a531bfc29 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -622,6 +622,13 @@ invoice-pdf-qr-acceptance-point = Punto di accettazione invoice-pdf-qr-separate-before-paying = Da staccare prima del versamento invoice-pdf-error-invoice-not-validated = La fattura deve essere convalidata prima di generare il PDF. +error-invoice-cancelled = La fattura è annullata da una nota di credito: il suo PDF non può essere prodotto e nessun documento è stato fissato. +error-invoice-pdf-gone = Il PDF emesso di questa fattura è introvabile: il file { $sha256 }.pdf manca nella directory dei documenti (KESH_DOCUMENTS_DIR). Ripristinatelo dal backup di questa directory; in alternativa, un amministratore può fissare di nuovo la fattura. +error-invoice-pdf-gone-cancelled = Il PDF emesso di questa fattura annullata è introvabile: il file { $sha256 }.pdf manca nella directory dei documenti (KESH_DOCUMENTS_DIR). Solo il suo ripristino dal backup di questa directory lo ripara. +error-invoice-changed = La fattura è stata modificata durante la preparazione del suo PDF. Riprovate. +error-invoice-pdf-present = Il PDF emesso di questa fattura è presente: non c'è nulla da fissare di nuovo. +error-invoice-pdf-not-frozen = Questa fattura non ha ancora un PDF fissato: il prossimo download lo fisserà. +error-invoice-pdf-integrity = Il file del PDF emesso è stato alterato: la sua impronta non corrisponde più. Ripristinatelo dal backup, oppure toglietelo dalla directory dei documenti prima di fissarlo di nuovo. invoice-pdf-error-invoice-not-pdf-ready = La fattura non è pronta per la generazione PDF. invoice-pdf-error-pdf-generation-failed = Generazione PDF fallita. Riprovare più tardi. invoice-pdf-error-popup-blocked = Pop-up bloccato dal browser — consentire i pop-up per scaricare il PDF. @@ -1392,6 +1399,14 @@ invoice-send-email-confirm = Invia l'e-mail invoice-send-email-success = Fattura inviata per e-mail invoice-send-email-error-empty = L'oggetto e il corpo dell'e-mail non possono essere vuoti. invoice-detail-emailed-at-label = Inviata il +invoice-detail-pdf-frozen-at-label = Documento fissato il +invoice-pdf-refreeze-button = Fissare di nuovo il documento +invoice-pdf-refreeze-title = Fissare di nuovo il documento? +invoice-pdf-refreeze-body = Il file del PDF emesso di questa fattura è introvabile. Fissarlo di nuovo produce un NUOVO documento, con i dati e la lingua del cliente di oggi: non è l'originale. L'operazione è registrata nel registro di controllo, con l'impronta del vecchio documento e quella del nuovo. +invoice-pdf-refreeze-restore-first = Ripristinate prima il file dal backup della directory dei documenti, se vi si trova: è l'unico modo per ritrovare l'originale. Se più fatture sono in questo caso, verificate il montaggio di questa directory prima di fissarle di nuovo. +invoice-pdf-refreeze-done = Documento fissato di nuovo. +invoice-pdf-refreeze-error = Non è stato possibile fissare di nuovo il documento. +invoice-pdf-refreeze-reload-failed = Documento fissato di nuovo, ma la scheda non ha potuto essere ricaricata: ricaricate la pagina. contact-form-language = Lingua di corrispondenza contact-form-language-inherited = Ereditata (lingua dell'istanza) contact-form-salutation = Titolo @@ -2047,6 +2062,8 @@ audit-log-action-invoice-unvalidated = Fattura annullata (convalida) audit-log-action-invoice-cancelled = Fattura annullata audit-log-action-invoice-deleted = Fattura eliminata audit-log-action-invoice-emailed = Fattura inviata per e-mail +audit-log-action-invoice-pdf-frozen = PDF della fattura fissato +audit-log-action-invoice-pdf-refrozen = PDF della fattura fissato di nuovo audit-log-action-invoice-paid = Fattura pagata audit-log-action-invoice-partially-settled = Fattura parzialmente pagata audit-log-action-invoice-settlement-cancelled = Pagamento annullato diff --git a/crates/kesh-reconciliation/src/matching.rs b/crates/kesh-reconciliation/src/matching.rs index aed947d07..ed0dbacbe 100644 --- a/crates/kesh-reconciliation/src/matching.rs +++ b/crates/kesh-reconciliation/src/matching.rs @@ -235,6 +235,10 @@ mod tests { project_id: None, dunning_paused_at: None, dunning_paused_note: None, + pdf_storage_path: None, + pdf_sha256: None, + pdf_frozen_at: None, + pdf_language: None, version: 1, created_at: NaiveDateTime::default(), updated_at: NaiveDateTime::default(), diff --git a/docs/manual/fr/admin-manual.pdf b/docs/manual/fr/admin-manual.pdf index 006da745b..af9b095d5 100644 Binary files a/docs/manual/fr/admin-manual.pdf and b/docs/manual/fr/admin-manual.pdf differ diff --git a/docs/manual/fr/admin-manual.tex b/docs/manual/fr/admin-manual.tex index 0de2e27b2..079c84cd8 100644 --- a/docs/manual/fr/admin-manual.tex +++ b/docs/manual/fr/admin-manual.tex @@ -738,7 +738,7 @@ \subsection{Variables d'environnement complètes}\label{sec:env-vars} \toprule \keshtableheader{Variable} & \keshtableheader{Description} & \keshtableheader{Défaut} \\ \midrule - KESH\_DOCUMENTS\_DIR & Archivage des justificatifs (\texttt{\{sha256\}.\{ext\}}, hors DB). & \texttt{/data/documents} \\ + KESH\_DOCUMENTS\_DIR & Archivage des justificatifs importés \textbf{et des PDF de factures émises} (\texttt{\{sha256\}.\{ext\}}, hors DB) --- \textbf{à sauvegarder}. & \texttt{/data/documents} \\ KESH\_INBOX\_DIR & Dossier inbox scruté à l'import. & \texttt{/data/inbox} \\ KESH\_INBOX\_MAX\_FILE\_BYTES & Taille max d'un fichier inbox (octets). Borne [1, 524288000]. & \texttt{26214400} \\ KESH\_INBOX\_MAX\_FILES\_PER\_RUN & Fichiers par déclenchement d'import. Borne [1, 10000]. & \texttt{200} \\ @@ -810,7 +810,11 @@ \subsubsection{Import de factures depuis un dossier} sous-dossier \texttt{failed/}. \item \texttt{KESH\_DOCUMENTS\_DIR} (défaut \texttt{/data/documents}) — le dossier d'archivage des justificatifs (copies des fichiers importés, nommées - \texttt{\{sha256\}.\{ext\}}). \textbf{Ne jamais} pointer ces deux dossiers + \texttt{\{sha256\}.\{ext\}}) \textbf{et des PDF de factures émises} : depuis la + v0.12.1, le PDF d'une facture est figé au premier téléchargement ou envoi et + conservé ici (\texttt{\{sha256\}.pdf}). Ce sont des \textbf{pièces à conserver}, + que la base ne contient pas : ce dossier \textbf{doit être sauvegardé avec la + base} (cf. \S\ref{sec:pdf-factures-sauvegarde}). \textbf{Ne jamais} pointer ces deux dossiers vers \texttt{/tmp} : leur contenu serait perdu au redémarrage du conteneur. \end{itemize} @@ -1350,7 +1354,7 @@ \subsection{Création des utilisateurs et rôles} \midrule Admin & Toutes les opérations d'une société : gestion des utilisateurs, paramètres de facturation, taux de TVA, plan comptable, exercices, sauvegarde/restauration complète, en plus de toutes les opérations Comptable. \\ Comptable & Saisie et validation d'écritures, factures (clients et fournisseurs), avoirs, règlements et paiements pain.001, import bancaire, réconciliation, import de factures, rapports et exports. \\ - Consultation & Lecture seule : consultation des écritures, factures, contacts, produits, rapports — aucune mutation. \\ + Consultation & Lecture seule : consultation des écritures, factures, contacts, produits, rapports — aucune mutation, à une exception près : le \textbf{premier téléchargement} du PDF d'une facture le \textbf{fige} et l'inscrit au journal d'audit, quel que soit le rôle. \\ \bottomrule \end{tabularx} @@ -1377,11 +1381,26 @@ \subsection{Stratégie recommandée} \begin{itemize} \item \textbf{Quotidienne} : \keshcommand{mariadb-dump} complet, conservé 30 jours, stocké en local + offsite (cloud). + \item \textbf{Quotidienne, avec la base} : copie du répertoire des documents (\texttt{KESH\_DOCUMENTS\_DIR}, monté depuis \texttt{KESH\_DOCUMENTS\_HOST\_DIR}) --- il porte les \textbf{PDF des factures émises}, que ni le dump ni l'export ne contiennent (cf. \S\ref{sec:pdf-factures-sauvegarde}). \item \textbf{Hebdomadaire} : snapshot du volume Docker \keshpath{/var/lib/docker/volumes/kesh\_db\_data} (gel cohérent via \keshcommand{docker compose stop} puis snapshot, ou via LVM/ZFS). \item \textbf{Mensuelle} : export ZIP de souveraineté (Story 9-2b) pour chaque company depuis l'interface utilisateur. \item \textbf{Annuelle} : archivage long terme conforme OLICo Art. 9 (support non-modifiable type CD-R, ou hash signé sur support modifiable WORM). \end{itemize} +\subsection{Les PDF des factures émises}\label{sec:pdf-factures-sauvegarde} + +Depuis la v0.12.1, le PDF d'une facture est \textbf{figé} au premier téléchargement ou envoi : Kesh l'écrit dans \texttt{KESH\_DOCUMENTS\_DIR} sous le nom \texttt{\{sha256\}.pdf} et n'en garde en base que la référence et l'empreinte. Ce fichier est la \textbf{copie de la pièce émise} --- celle qu'un contrôle demande ---, et il n'est \textbf{ni dans le dump MariaDB, ni dans le fichier \keshcommand{.keshbackup}}\footnote{L'inclusion des fichiers dans \keshcommand{.keshbackup} est suivie par l'issue \#503.}. D'où trois règles : + +\begin{itemize} + \item \textbf{Sauvegarder} le répertoire des documents \textbf{en même temps} que la base, et le \textbf{restaurer avec elle}. + \item Une base restaurée sans ses documents répond, pour chaque facture figée, un refus \texttt{410 INVOICE\_PDF\_GONE} dont le message nomme le fichier manquant. \textbf{Le remède est de restaurer ce fichier} depuis la sauvegarde du répertoire : c'est le seul moyen de retrouver l'original. Si les refus arrivent en série après une mise à jour, vérifier d'abord le \textbf{montage} du volume des documents. + \item À défaut de sauvegarde, un administrateur peut \textbf{refiger} la facture depuis sa fiche (bouton \emph{Refiger le document}, ou \texttt{POST /api/v1/invoices/\{id\}/pdf/refreeze}) : Kesh produit un \textbf{nouveau} document, avec les données du moment --- \textbf{ce n'est pas l'original} --- et inscrit au journal d'audit l'empreinte de l'ancien et du nouveau (\texttt{invoice.pdf\_refrozen}). Le geste est irréversible et refusé à une clé d'API. Une facture \textbf{annulée} ne peut pas être refigée : seule la restauration du fichier la répare. +\end{itemize} + +\begin{keshwarning} +\textbf{Un fichier altéré} (empreinte différente de celle enregistrée) n'est \textbf{jamais servi} : le téléchargement répond une erreur serveur et la journalise. Le refigeage le refuse aussi (\texttt{409 INVOICE\_PDF\_INTEGRITY}) : un fichier altéré se \textbf{diagnostique}, il ne se recouvre pas. Restaurer \texttt{\{empreinte\}.pdf} depuis la sauvegarde ; à défaut, \textbf{écarter} le fichier altéré (le déplacer hors du répertoire), ce qui ramène au cas du fichier manquant, puis refiger. +\end{keshwarning} + \subsection{Script de sauvegarde mariadb-dump} Voici un script bash de référence à exécuter via cron quotidien : @@ -1455,10 +1474,14 @@ \subsection{Restauration depuis sauvegarde} zcat kesh_20260520_020000.sql.gz | docker compose exec -T db \ mariadb -u root -p"${MARIADB_ROOT_PASSWORD}" -# 4. Redemarrer Kesh +# 4. Restaurer le repertoire des documents (PDF des factures emises, +# justificatifs) depuis SA sauvegarde, prise en meme temps que le dump +rsync -a /var/backups/kesh/documents_20260520/ ./documents/ + +# 5. Redemarrer Kesh docker compose start kesh-api -# 5. Verifier le fonctionnement +# 6. Verifier le fonctionnement curl http://localhost/health \end{lstlisting} @@ -1492,7 +1515,7 @@ \subsubsection{Hyper Backup — backup incrémental + offsite intégré} \item Lancer Hyper Backup → \textbf{+ Tâche de sauvegarde de données} → choisir la destination (ex. \emph{Synology C2 Backup} pour offsite managé, \emph{S3 Storage} pour AWS/Wasabi/Backblaze, \emph{Dossier local} pour HDD USB attaché au NAS). \item Cocher les dossiers à backup : \begin{itemize} - \item \textbf{Obligatoire} : \keshpath{/volume1/docker/kesh/} (compose + .env + tous les volumes via bind-mount si vous utilisez le pattern Synology recommandé). + \item \textbf{Obligatoire} : \keshpath{/volume1/docker/kesh/} (compose + .env + tous les volumes via bind-mount si vous utilisez le pattern Synology recommandé) --- \textbf{y compris le répertoire des documents} (\texttt{KESH\_DOCUMENTS\_HOST\_DIR}), qui porte les PDF des factures émises (\S\ref{sec:pdf-factures-sauvegarde}). À restaurer avec la base. \item \textbf{Optionnel} : volumes Docker nommés si vous n'utilisez pas le bind-mount (rare sur DSM) — accessibles via \keshpath{/volume1/@docker/volumes/kesh\_*}. \end{itemize} \item Cocher l'application \textbf{Container Manager} dans \emph{Applications} pour backup automatique de la configuration des projets. @@ -1580,7 +1603,7 @@ \subsubsection{Stratégie 3-2-1 sur DSM} \subsection{Export/import d'installation via l'interface Kesh}\label{sec:backup-ui-keshbackup} -Depuis la version v0.2, Kesh permet d'\textbf{exporter et de réimporter l'intégralité d'une installation} directement depuis l'interface web d'administration, sans accès SSH ni ligne de commande. L'export produit un fichier unique \keshcommand{.keshbackup} (conteneur compressé) contenant \textbf{toutes les données d'installation} : l'ensemble des sociétés, les utilisateurs, et les données système. Cette fonction est \textbf{réservée au rôle Administrateur} et \textbf{inaccessible via une clé API (PAT)} — les opérations d'infrastructure ne transitent jamais par une intégration externe. +Depuis la version v0.2, Kesh permet d'\textbf{exporter et de réimporter l'intégralité d'une installation} directement depuis l'interface web d'administration, sans accès SSH ni ligne de commande. L'export produit un fichier unique \keshcommand{.keshbackup} (conteneur compressé) contenant \textbf{toutes les données d'installation} : l'ensemble des sociétés, les utilisateurs, et les données système --- \textbf{à l'exception des fichiers} du répertoire des documents (justificatifs importés, PDF des factures émises), qui se sauvegardent à part (\S\ref{sec:pdf-factures-sauvegarde}, issue \#503). Cette fonction est \textbf{réservée au rôle Administrateur} et \textbf{inaccessible via une clé API (PAT)} — les opérations d'infrastructure ne transitent jamais par une intégration externe. \begin{keshnote} À ne pas confondre avec l'\textbf{export global per-société} (menu \textbf{Administration → Export global}, fichier CSV/ZIP) qui n'extrait les données comptables que d'\emph{une seule} entreprise pour transmission à un tiers. L'export/import \keshcommand{.keshbackup} décrit ici porte sur l'\textbf{installation complète} (toutes entreprises + comptes), pour la migration ou la restauration. @@ -1649,7 +1672,7 @@ \subsubsection{Quelle méthode de sauvegarde choisir ?} \midrule Hyper Backup DSM & Volume Docker / MariaDB complet (niveau système, cf. \ref{sec:backup-dsm}) & Sauvegarde \textbf{planifiée automatique} quotidienne en production (NAS Synology), avec rotation et offsite. \\ \keshcommand{mariadb-dump} CLI & Dump SQL d'une base (niveau base de données) & Sauvegarde \textbf{ponctuelle technique} ou scriptée, pour un opérateur disposant d'un accès SSH. \\ - Export/import \keshcommand{.keshbackup} (UI) & Installation Kesh complète, portable (niveau applicatif) & \textbf{Migration entre instances} ou restauration \textbf{self-service sans SSH}, déclenchée par un administrateur depuis l'interface. \\ + Export/import \keshcommand{.keshbackup} (UI) & Installation Kesh complète, portable (niveau applicatif) --- \textbf{sans les fichiers} du répertoire des documents (\S\ref{sec:pdf-factures-sauvegarde}) & \textbf{Migration entre instances} ou restauration \textbf{self-service sans SSH}, déclenchée par un administrateur depuis l'interface. \\ \bottomrule \end{tabularx} @@ -1771,7 +1794,7 @@ \subsection{Clés API (PAT) — accès programmatique}\label{sec:api-keys} \item \textbf{Administration} : plus largement, \textbf{aucune route réservée au rôle Administrateur n'est atteignable avec une clé API}, quel que soit le rôle de son créateur --- gestion des utilisateurs et des mots de passe, paramètres de facturation et de relance, taux de TVA, niveaux de rappel, modèles d'e-mail, coordonnées de l'entreprise, suppression de facture, export et import d'installation, réouverture d'exercice. La réponse est \texttt{403 API\_KEY\_ADMIN\_FORBIDDEN}. Ces opérations se font dans l'interface web. \textbf{Attention à la version} : cette fermeture n'est pas dans la v0.9.0 --- voir l'encadré \emph{« Dans quelle version ? »} plus bas. \item \textbf{Journal d'audit} : la \textbf{consultation du journal} --- la liste, le vocabulaire et l'export CSV (\texttt{/api/v1/audit-log*}) --- est elle aussi \textbf{fermée aux clés API}, y compris en lecture seule et y compris pour une clé créée par un Administrateur. ⚠️ \emph{La réponse porte ici un \textbf{autre} code~: \texttt{403 API\_KEY\_MANAGEMENT\_FORBIDDEN}, et non \texttt{API\_KEY\_ADMIN\_FORBIDDEN} --- le journal n'est pas une route d'administration. Le libellé parle de «~gestion de clés~» pour des raisons historiques~; sur ces trois routes, il signifie simplement qu'une clé n'y a pas accès. Ne cherchez pas le défaut dans la configuration de la clé.} Le journal d'audit se lit dans l'interface web, par un Comptable ou un Administrateur. \item ⚠️ \textbf{Ce qu'une clé \texttt{read-write} peut faire depuis la v0.12.1, et qu'elle ne pouvait pas avant} : \textbf{dévalider une facture}, donc \textbf{supprimer l'écriture comptable} que sa validation avait créée. La route n'est pas réservée à l'administrateur --- elle est ouverte au Comptable, et donc aux clés --- par un choix délibéré, aligné sur les API de facturation comparables. Les huit refus de la dévalidation s'y appliquent intégralement (exercice clos, verrou de période, facture réglée, créditée, relancée, envoyée, rapprochée, contre-passée), et chaque dévalidation est journalisée sous son propre nom. Si cette capacité vous paraît trop large pour une intégration, donnez-lui une clé en \emph{lecture seule}. - \item \textbf{Portée} : chaque clé est en \emph{lecture seule} (\texttt{read} --- méthodes GET/HEAD/OPTIONS) ou \emph{lecture-écriture} (\texttt{read-write} --- toutes les méthodes). La permission effective est l'intersection de la portée de la clé et du rôle de son créateur --- \textbf{et l'administration en est exclue dans tous les cas} (point \emph{Administration} ci-dessus), \textbf{de même que le journal d'audit} (point précédent). + \item \textbf{Portée} : chaque clé est en \emph{lecture seule} (\texttt{read} --- méthodes GET/HEAD/OPTIONS ; une lecture n'écrit rien, à une exception près : le \textbf{premier téléchargement} du PDF d'une facture --- y compris une simple requête \texttt{HEAD} --- le \textbf{fige} et l'inscrit au journal d'audit au nom de la clé) ou \emph{lecture-écriture} (\texttt{read-write} --- toutes les méthodes). La permission effective est l'intersection de la portée de la clé et du rôle de son créateur --- \textbf{et l'administration en est exclue dans tous les cas} (point \emph{Administration} ci-dessus), \textbf{de même que le journal d'audit} (point précédent). \item \textbf{Périmètre} : une clé est liée à \textbf{une seule entreprise} (\emph{company}) et agit au nom de son créateur (rôle relu en base à chaque requête --- désactiver le créateur invalide ses clés immédiatement). \item \textbf{Stockage} : seul un condensé SHA-256 de la clé est conservé en base (table \texttt{api\_keys}) ; le secret en clair n'est affiché qu'une seule fois, à la création. \item \textbf{Expiration / révocation} : une clé peut avoir une date d'expiration optionnelle ; la révocation prend effet immédiatement. diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index b6bf730c0..45feb4f94 100644 Binary files a/docs/manual/fr/user-manual.pdf and b/docs/manual/fr/user-manual.pdf differ diff --git a/docs/manual/fr/user-manual.tex b/docs/manual/fr/user-manual.tex index 001208afd..5c9435ce8 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -808,7 +808,7 @@ \subsubsection{Exemple : facture d'une agence web} \item Compte de réception : le compte bancaire QR-IBAN de l'agence. \end{itemize} -À la validation, Kesh attribue le numéro \texttt{F-2026-0042}, génère le PDF QR Bill, et crée l'écriture comptable automatique (Débit 1100 Débiteurs / Crédit 3200 Honoraires). +À la validation, Kesh attribue le numéro \texttt{F-2026-0042} et crée l'écriture comptable automatique (Débit 1100 Débiteurs / Crédit 3200 Honoraires). Le PDF QR Bill est produit et \textbf{figé} au premier téléchargement ou au premier envoi (cf. \S\ref{sec:pdf-fige}). \medskip \noindent\emph{Variante ventilée.} Si l'agence souhaite distinguer ses activités, elle affecte la ligne 1 au compte \texttt{3200 Honoraires} et la ligne 2 au compte \texttt{3400 Maintenance}. L'écriture générée porte alors \textbf{deux lignes de crédit distinctes} — Crédit 3200 pour CHF 4'500.00 et Crédit 3400 pour CHF 450.00 — au lieu d'un crédit unique. Le compte de résultat devient exploitable sans reclassement manuel a posteriori. Les lignes laissées vides suivent le compte de produit par défaut de la société et s'y regroupent — y compris celles qui viennent du catalogue, dont le compte a été vidé après la recopie. Si les lignes de la facture portent un taux de TVA, Kesh comptabilise aussi automatiquement la TVA due au compte de TVA (p.~ex. 2200) ; ici l'agence n'est pas assujettie, l'écriture se limite donc à CHF 4'950.00 (cf. \emph{Décompte TVA}). @@ -819,7 +819,7 @@ \subsection{Validation d'une facture} \begin{enumerate} \item \textbf{Brouillon} : modification libre, n'apparaît pas dans les rapports. Un brouillon n'a en général \emph{pas} de numéro --- sauf s'il vient d'être \textbf{dévalidé}, auquel cas il \textbf{conserve} celui qu'il portait, et le reprendra à la revalidation (cf. \S\ref{sec:suppression-facture}). - \item \textbf{Validée} : numéro définitif attribué, PDF QR Bill généré, écriture comptable créée automatiquement (Débit 1100 Débiteurs / Crédit 3000 Ventes + 2200 TVA), et \textbf{comptes de produit figés}. Une facture validée n'est plus modifiable : le compte de chaque ligne reste néanmoins \textbf{consultable} dans la colonne « Compte de produit » de l'écran de détail, où les lignes suivant le compte par défaut de la société l'affichent avec la mention \emph{(défaut)}. C'est le moyen de contrôler la ventilation sans avoir à ouvrir l'écriture comptable et à la relire à l'envers. + \item \textbf{Validée} : numéro définitif attribué, PDF QR Bill disponible (figé au premier téléchargement ou envoi), écriture comptable créée automatiquement (Débit 1100 Débiteurs / Crédit 3000 Ventes + 2200 TVA), et \textbf{comptes de produit figés}. Une facture validée n'est plus modifiable : le compte de chaque ligne reste néanmoins \textbf{consultable} dans la colonne « Compte de produit » de l'écran de détail, où les lignes suivant le compte par défaut de la société l'affichent avec la mention \emph{(défaut)}. C'est le moyen de contrôler la ventilation sans avoir à ouvrir l'écriture comptable et à la relire à l'envers. \item \textbf{Payée} : \textbf{soldée} par un ou plusieurs règlements --- enregistrés à la main depuis la fiche, ou par le rapprochement d'une transaction bancaire entrante (\S\ref{sec:reglement-client}). Une facture réglée en partie reste \emph{validée}, avec @@ -840,6 +840,8 @@ \subsection{Vos coordonnées sur la facture}\label{sec:coordonnees-emetteur} Les \textbf{avoirs} portent les mêmes coordonnées, à l'identique. +\textbf{Une facture déjà émise ne change plus} : vos coordonnées s'impriment sur les factures dont le PDF n'est pas encore figé. Celui d'une facture déjà téléchargée ou envoyée reste tel qu'il a été émis (cf. \S\ref{sec:pdf-fige}). + \begin{keshwarning} \textbf{Une coordonnée très longue est raccourcie à l'affichage} (suivie de «~\ldots~») pour ne pas empiéter sur le bloc de droite, qui porte le numéro et la date de la facture. La valeur complète reste intacte dans vos réglages. @@ -854,7 +856,7 @@ \subsection{Le numéro de client sur la facture}\label{sec:numero-client} Elle rend deux services : votre client rapproche la facture de son propre dossier fournisseur, et vous-même remontez d'une facture papier au contact en saisissant ce numéro dans la \textbf{recherche} du carnet d'adresses. -\textbf{Où le saisir} : sur la \textbf{fiche du contact}, champ \emph{Numéro de client}, sous le numéro IDE. La valeur apparaît ensuite sur toutes les factures adressées à ce contact, \textbf{avoirs compris}. +\textbf{Où le saisir} : sur la \textbf{fiche du contact}, champ \emph{Numéro de client}, sous le numéro IDE. La valeur apparaît ensuite sur toutes les factures adressées à ce contact dont le PDF n'est pas encore figé, \textbf{avoirs compris} --- une facture déjà émise ne change plus (cf. \S\ref{sec:pdf-fige}). \begin{keshnote} \textbf{Kesh ne génère aucun numéro de client}, et n'impose aucun format : \texttt{42}, \texttt{CLI-2026-00042} ou une référence dictée par votre client conviennent également. Le champ est facultatif ; un contact sans numéro n'affiche simplement aucune ligne sur le PDF, et rien ne bouge dans la mise en page. @@ -885,21 +887,39 @@ \subsection{Génération du PDF avec QR Bill} \keshscreenshot{facture-qrbill.png}{Exemple de PDF de facture avec section de paiement QR Bill 2.2 détachable en bas de page.} % TODO capture: PDF facture QR Bill (donnees factices) -À la validation, Kesh génère un PDF conforme au standard \href{https://www.six-group.com/en/products-services/banking-services/standardization/qr-bill.html}{QR Bill 2.2} (norme suisse 2020+) : +Le PDF d'une facture validée est conforme au standard \href{https://www.six-group.com/en/products-services/banking-services/standardization/qr-bill.html}{QR Bill 2.2} (norme suisse 2020+) : \begin{itemize} \item Format A4, page principale + section de paiement séparable en bas. \item QR code suisse contenant toutes les informations de paiement (créancier, débiteur, montant, IBAN, référence structurée, communication). - \item Section de paiement en français (modifiable selon la langue du client). + \item Facture et section de paiement dans la \textbf{langue de correspondance du client} (fiche contact --- sinon la langue de l'instance). \item Compatible scan par les e-banking suisses et applications mobiles (Raiffeisen, PostFinance, UBS, Credit Suisse, Migros Bank, etc.). \end{itemize} -Le PDF est téléchargeable depuis la facture validée — pour l'imprimer (envoi postal) ou l'archiver. Depuis la version v0.6, Kesh peut aussi l'\textbf{envoyer directement par e-mail} à votre client, sans passer par votre messagerie : voir la sous-section suivante. +Le PDF est téléchargeable depuis la facture validée — pour l'imprimer (envoi postal) ou l'archiver. Il est \textbf{identique d'un téléchargement à l'autre} : c'est le document émis, figé (cf. \S\ref{sec:pdf-fige}). Depuis la version v0.6, Kesh peut aussi l'\textbf{envoyer directement par e-mail} à votre client, sans passer par votre messagerie : voir la sous-section suivante. \begin{keshtip} La référence structurée du QR Bill est calculée selon la norme ISO 11649 (RF Reference) ou le format suisse QRR (Référence Kesh). Le choix du format dépend du compte bancaire de réception : les comptes IBAN suisses standard utilisent IS0 11649, les comptes QR-IBAN utilisent QRR. \end{keshtip} +\subsubsection{Le PDF émis est figé}\label{sec:pdf-fige} + +Le PDF d'une facture est produit \textbf{une seule fois} : au premier téléchargement ou au premier envoi par e-mail qui suit la validation, dans la langue de correspondance du client. Kesh le conserve alors tel quel, avec son \textbf{empreinte} (SHA-256), et c'est ce même fichier, octet pour octet, que rendent ensuite chaque téléchargement et chaque envoi. La fiche facture l'indique par la mention \emph{Document figé le} suivie de la date. + +\begin{itemize} + \item \textbf{Modifier ensuite} vos coordonnées, l'adresse, le numéro ou la langue du client, ou la mise en page, \textbf{ne change pas} une facture déjà émise : seules les factures non encore figées en tiennent compte. C'est ce qui permet de reproduire, des années plus tard, la copie exacte de la pièce envoyée. + \item \textbf{Qui que ce soit} qui télécharge le premier --- y compris un utilisateur en consultation ou une clé d'API en lecture --- fige le document : une pièce sortie de Kesh est figée. Le gel est inscrit au \textbf{journal d'audit}, avec l'empreinte et le nom de qui l'a déclenché. + \item Une facture \textbf{annulée par un avoir} garde le PDF qu'elle a émis, et la fiche continue de le proposer. Ce PDF ne porte pas de mention d'annulation : c'est l'avoir qui la porte. Une facture annulée dont le PDF n'a \textbf{jamais été figé} --- jamais téléchargée ni envoyée, ou envoyée seulement avant la v0.12.1 --- n'a pas de PDF. + \item Une facture validée \textbf{avant la version v0.12.1} est figée à son prochain téléchargement, avec les données du moment : ses PDF antérieurs n'avaient pas été conservés. ⚠️ Si elle avait déjà été envoyée, le document figé \textbf{n'est pas forcément celui que le client a reçu} --- la date de la mention \emph{Document figé le} est alors postérieure à celle de l'envoi. + \item La \textbf{restauration d'une sauvegarde} antérieure au gel ramène la facture à l'état non figé : son prochain téléchargement figera un \textbf{nouveau} document. L'ancien reste dans le répertoire des documents, et son empreinte au journal d'audit. + \item La \textbf{dévalidation} détache le document figé (son empreinte reste au journal d'audit) ; la revalidation en figera un nouveau au premier téléchargement. + \item Les \textbf{rappels}, eux, sont produits à chaque envoi (cf. \S\ref{sec:relances-user}) : seul le PDF de la facture est figé. +\end{itemize} + +\begin{keshwarning} +\textbf{Si le fichier figé a disparu} --- typiquement après une restauration qui n'a pas repris le répertoire des documents ---, le téléchargement est \textbf{refusé} avec un message qui nomme le fichier manquant (\texttt{.pdf}). Kesh ne le régénère jamais de lui-même : ce serait fabriquer une pièce qui n'a pas été émise. Le remède est de \textbf{restaurer ce fichier} depuis la sauvegarde du répertoire des documents --- c'est le seul moyen de retrouver l'original. À défaut, un \textbf{administrateur} voit apparaître sur la fiche, après ce refus, le bouton \emph{Refiger le document} : il produit un \textbf{nouveau} document avec les données et la langue du client d'aujourd'hui, \textbf{qui n'est pas l'original}, et le journal d'audit garde l'empreinte de l'ancien et du nouveau. Une facture annulée ne peut pas être refigée : seule la restauration du fichier la répare. +\end{keshwarning} + \subsection{Envoyer une facture par e-mail}\label{sec:envoi-email} \keshscreenshot{facture-envoi-email.png}{Modale d'envoi : destinataire verrouillé, objet et message pré-remplis dans la langue du client, modifiables avant l'envoi.} % TODO capture: modale envoi e-mail (facture validee, contact avec e-mail) @@ -919,7 +939,11 @@ \subsection{Envoyer une facture par e-mail}\label{sec:envoi-email} Les deux restent modifiables avant l'envoi : ce que vous voyez est ce qui part. \item \textbf{Pièce jointe} : la QR-facture PDF, attachée - automatiquement. + automatiquement --- le document \textbf{figé}, octet pour octet + celui du téléchargement (\S\ref{sec:pdf-fige}). Le premier envoi + d'une facture jamais téléchargée la fige, même si l'envoi échoue. + Si la langue du client a changé depuis le gel, le message suit la + nouvelle langue et la pièce jointe garde la sienne. \end{itemize} Après l'envoi, la fiche facture affiche \emph{Envoyée le} suivi de la date @@ -967,7 +991,8 @@ \subsubsection{Langue et civilité du contact, adresse de réponse} \begin{itemize} \item \textbf{Fiche contact} : le champ \emph{Langue de correspondance} (héritée de l'instance, ou FR/DE/IT/EN) détermine la langue de - l'e-mail \textbf{et} du PDF joint ; le champ \emph{Civilité} + l'e-mail \textbf{et} celle du PDF de la facture --- téléchargé ou + joint ---, figée au premier rendu (\S\ref{sec:pdf-fige}) ; le champ \emph{Civilité} (Neutre/Monsieur/Madame, contacts de type Personne) personnalise la formule d'appel. Une entreprise reçoit toujours la formule neutre. \item \textbf{Adresse de réponse} : \emph{Paramètres → Organisation → @@ -1050,7 +1075,7 @@ \subsection{Relancer les débiteurs}\label{sec:relances-user} \item Une \textbf{protection anti-double-envoi} empêche qu'un double-clic n'envoie deux fois le même rappel. Une facture arrivée au \textbf{dernier niveau} reste affichée avec le badge « Dernier niveau atteint » (poursuite à envisager) — elle ne disparaît jamais silencieusement. \end{itemize} -\paragraph{Ce que réclame un rappel.} Un rappel réclame ce que le client \textbf{doit encore} : le \textbf{reste dû} de la facture, après ses règlements partiels --- le montant total s'il n'a rien payé. Le courrier annonce ce reste, augmenté des frais de rappel s'il y en a ; à frais nuls, il ne parle pas de frais. Si un règlement est enregistré \textbf{entre l'ouverture de l'aperçu et l'envoi}, l'envoi est \textbf{refusé} : le courrier validé annoncerait un autre montant que la QR-facture jointe. Fermez puis rouvrez l'aperçu, qui reprend le nouveau reste. Le PDF joint n'est pas la facture réémise, c'est un \textbf{rappel} : il en porte le titre, rappelle le numéro de la facture d'origine et, sous le total, ce qui est \textbf{déjà réglé} puis le \textbf{reste à payer} (ces deux lignes n'apparaissent qu'après un règlement). Sa \textbf{QR-facture porte le reste dû}, avec la même référence que la facture : le rapprochement bancaire propose la facture pour un paiement de ce montant, même après des règlements partiels. Les \textbf{frais de rappel n'y sont jamais inclus} : ils figurent sur une ligne à part, avec la mention qu'ils ne sont pas compris dans le bulletin de versement. +\paragraph{Ce que réclame un rappel.} Un rappel réclame ce que le client \textbf{doit encore} : le \textbf{reste dû} de la facture, après ses règlements partiels --- le montant total s'il n'a rien payé. Le courrier annonce ce reste, augmenté des frais de rappel s'il y en a ; à frais nuls, il ne parle pas de frais. Si un règlement est enregistré \textbf{entre l'ouverture de l'aperçu et l'envoi}, l'envoi est \textbf{refusé} : le courrier validé annoncerait un autre montant que la QR-facture jointe. Fermez puis rouvrez l'aperçu, qui reprend le nouveau reste. Le PDF joint n'est pas la facture réémise, c'est un \textbf{rappel}, produit à chaque envoi --- il n'est pas figé comme le PDF de la facture (\S\ref{sec:pdf-fige}) : il en porte le titre, rappelle le numéro de la facture d'origine et, sous le total, ce qui est \textbf{déjà réglé} puis le \textbf{reste à payer} (ces deux lignes n'apparaissent qu'après un règlement). Sa \textbf{QR-facture porte le reste dû}, avec la même référence que la facture : le rapprochement bancaire propose la facture pour un paiement de ce montant, même après des règlements partiels. Les \textbf{frais de rappel n'y sont jamais inclus} : ils figurent sur une ligne à part, avec la mention qu'ils ne sont pas compris dans le bulletin de versement. \begin{keshwarning} Les frais de rappel ne sont \textbf{pas comptabilisés} par Kesh. Un client qui paie le montant du courrier (reste dû \emph{et} frais) ne verra pas son versement \textbf{proposé} au rapprochement, puisqu'il dépasse ce qui reste à payer ; saisi comme règlement manuel, il est \textbf{refusé comme trop-perçu}. Seul le montant de la QR-facture s'enregistre (issue \#401). @@ -1175,7 +1200,10 @@ \subsection{Supprimer ou dévalider une facture} \item facture \textbf{envoyée au client} par Kesh~: refus \textbf{sec}, que rien ne lève. Une facture que le client détient se corrige par un \textbf{avoir} (\S\ref{sec:avoirs}). ⚠️ Kesh ne connaît que ce qu'il a envoyé lui-même~: un PDF téléchargé puis transmis à la - main ne laisse aucune trace~; + main ne bloque pas la dévalidation. Son premier téléchargement est bien inscrit au + journal d'audit (gel du document, cf. \S\ref{sec:pdf-fige}), et la dévalidation + \textbf{détache} ce document~: celui que vous avez transmis reste valable, mais n'est plus + celui que Kesh conserve~; \item écriture \textbf{rapprochée} d'une transaction bancaire~; \item écriture d'un \textbf{exercice clos}~; \item écriture déjà \textbf{contre-passée}~; @@ -1856,7 +1884,10 @@ \subsection{Contenu du ZIP} ⚠️ Les \textbf{pièces justificatives} --- les fichiers eux-mêmes --- ne sont \textbf{pas encore} incluses. Le chemin de chaque pièce figure dans \texttt{imported\_supplier\_invoices.csv}, mais -le document doit être sauvegardé séparément. +le document doit être sauvegardé séparément. Il en va de même des \textbf{PDF des factures +émises} (\S\ref{sec:pdf-fige}) : \texttt{invoices.csv} porte leur empreinte, la date et la langue +du gel (colonnes \texttt{pdf\_sha256}, \texttt{pdf\_frozen\_at}, \texttt{pdf\_language}), +pas le document lui-même, qui se conserve avec le répertoire des documents de l'installation. \end{keshnote} \subsection{Génération} diff --git a/docs/migrations-idempotence-audit.md b/docs/migrations-idempotence-audit.md index 72874a565..874fb5988 100644 --- a/docs/migrations-idempotence-audit.md +++ b/docs/migrations-idempotence-audit.md @@ -14,7 +14,7 @@ Cet audit est **purement informationnel** : les fichiers `.sql` historiques ne s - **`tracked-by-sqlx`** — l'idempotence est garantie uniquement par le tracking `_sqlx_migrations`. Re-exécution manuelle hors sqlx échouerait avec un code d'erreur MariaDB précisé en justification (1050 table exists, 1060 duplicate column, 1061 duplicate key, 1091 index not found, etc.). - **`no`** — re-exécution échouerait toujours (non utilisé sur le repo actuel). -## Table d'audit (70 migrations) +## Table d'audit (71 migrations) > Une ligne par fichier de `crates/kesh-db/migrations/`, **triée par version croissante**. Toute nouvelle migration s'ajoute **à l'intérieur de ce tableau**, jamais en fin de fichier — cf. la note de la section « Maintenance future ». @@ -90,12 +90,13 @@ Cet audit est **purement informationnel** : les fichiers `.sql` historiques ne s | `20260915000001_audit_log_company_id.sql` | yes | `ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS company_id BIGINT NULL, ADD INDEX IF NOT EXISTS idx_audit_log_company_date (company_id, created_at)` + un `UPDATE … JOIN users` de backfill gardé `company_id IS NULL`. **Verdict `yes`** : le DDL est conditionnel — rejoué, il rend les notes 1060 et 1061 sans erreur — et le backfill est gardé, donc une re-exécution manuelle hors sqlx est **sans effet**. ⚠️ **ELLE ÉCRIT DES DONNÉES** ⇒ triée en **`EXEMPT_MIGRATIONS`** (garde-fou P7) avec `ExemptionBasis::PerishableSince(20260827000001)` : fondement de **parc** et non de fenêtre — aucune version publiée dans `[20260827000001 .. 20260915000001)`. **Pas** au registre de rejeu, par arbitrage du 2026-09-11 : ni `COALESCE` ni rejeu, `NULL` signifiant « société indéterminable », état permanent. `company_id` est un **pointeur logique sans FK**, rempli pour les entrées futures par sous-SELECT dans `insert_in_tx` ; elle prépare la consultation par société de la Story 25-1c (#378). **Non-breaking** (`ADD COLUMN` nullable + `ADD INDEX`) → pas de bump `kesh_version_min_required`, donc pas de bump de version Cargo (P1/P2/P2-bis) — Story 25-1c-zero, refs #378. | | `20260917000001_journal_entry_number_sequences.sql` | tracked-by-sqlx | `CREATE TABLE journal_entry_number_sequences` sans `IF NOT EXISTS` ; re-exécution hors sqlx échouerait erreur 1050. ⚠️ *C'est le DDL qui porte le verdict : l'`INSERT … SELECT` d'amorçage qui le suit n'est atteignable qu'une fois la table créée, donc jamais rejouable seul — à la différence de `20260909000001`, où un `UPDATE` nu et gardé décide du verdict `yes`.* **Le `MAX(entry_number) + 1` des écritures était l'EXCEPTION du dépôt** : `invoice_number_sequences` (20260417000001) et `credit_note_number_sequences` sont déjà des compteurs persistants de portée `(company_id, fiscal_year_id)` — exactement celle d'`entry_number`. Ferme la **réattribution** d'un numéro d'écriture (supprimer la dernière libérait son numéro pour une écriture au contenu différent — symptôme **muet**) ; le **trou** subsiste et reste explicable, assumé par un critère de la story. ⚠️ **ELLE ÉCRIT DES DONNÉES** (amorçage depuis l'existant, sans lequel le compteur repartirait de 1 et heurterait aussitôt l'`UNIQUE`) ⇒ triée en **`EXEMPT_MIGRATIONS`** (garde-fou P7), fondement **couverture** (`ExemptionBasis::Durable`) — et **non** `Hors fenêtre`, que le contrôle symétrique a refusé : cette migration *est* la fenêtre, et le marqueur est réservé à un fait de chronologie. La migration crée elle-même la table, donc un backup dépourvu de ses lignes est un backup dépourvu de la table — refusé au contrôle de couverture de `parse_and_verify`. Même motif que `20260428000001` (vat_rates). ⛔ **Sa création de table REFERME la fenêtre d'importabilité** et périme les deux entrées alors au registre (`20260828000001`, `20260910000001`), déplacées vers `RETIRED_BACKFILLS` et exemptées — le module annonçait ce cas : *« toute nouvelle table applicative aura le même effet »*. **Non-breaking** (`CREATE TABLE` d'une table qu'un binaire antérieur ignore, continuant de servir son `MAX + 1`) → pas de bump `kesh_version_min_required` ni de version Cargo (P1/P2/P2-bis) — Story 25-2-c, refs #381. | | `20260930000001_invoice_settings_rounding_account.sql` | tracked-by-sqlx | `ALTER TABLE company_invoice_settings ADD COLUMN default_rounding_account_id BIGINT NULL` + FK `fk_cis_rounding` (`ON DELETE RESTRICT`), sans `IF NOT EXISTS` ; re-exécution hors sqlx échouerait erreur 1060. **DDL seul, aucune donnée écrite** : les sociétés existantes désignent leur compte de différences d'arrondi dans *Paramètres → Facturation* (arbitrage du 2026-09-30) — triage P7 sans objet. Patron des comptes TVA (`20260614000001`). **Non-breaking** (`ADD COLUMN` nullable ignoré par un binaire antérieur, qui liste ses colonnes) → pas de bump `kesh_version_min_required` ni de version Cargo (P1/P2/P2-bis) — Story 25-4-c3-a1, refs #476. | +| `20261003000001_invoices_frozen_pdf.sql` | tracked-by-sqlx | `ALTER TABLE invoices ADD COLUMN pdf_storage_path`, `pdf_sha256`, `pdf_frozen_at`, `pdf_language` (nullables) + `ADD CONSTRAINT chk_invoices_frozen_pdf` (tout-ou-rien) sans `IF NOT EXISTS` ; re-exécution hors sqlx échouerait erreur 1060 (colonne dupliquée). DDL seul, aucune donnée écrite (Story 25-6-b, #387). | ## Statistiques -- **Total** : 70 migrations (26 historiques + 1 Story 10-2 + 1 Story v011-2 + 1 Story v014-1 + 2 Story 17-2a + 2 Story 17-4a + 1 Story 11-1 + 1 Story 18-1a + 1 Story 12-1 + 1 Story 12-2 + 1 Story 12-3 + 1 Story 12-5b + 1 Story 19-1 + 1 Story 19-3 + 1 Story 19-4 + 1 Story 19-5 + 3 #213 + 1 Story 20-1 + 3 Story 20-3b1 + 1 Story 21-1 + 2 Story 21-3 + 1 Story 21-5a + 1 Story 14-3a + 1 Story 16-1a + 1 Story 16-1a-bis + 1 Story 16-2a + 1 Story 16-3a + 1 Story 16-3b + 1 Story 22-1 + 1 Story 24-2 + 1 Story 24-3 + 1 Story 24-4a + 1 Story 24-4c + 1 Story 24-5 + 1 Story 25-1a + 1 Story 25-1c-zero + 1 Story 25-2-c + 1 Story 25-4-c3-a1). +- **Total** : 71 migrations (26 historiques + 1 Story 10-2 + 1 Story v011-2 + 1 Story v014-1 + 2 Story 17-2a + 2 Story 17-4a + 1 Story 11-1 + 1 Story 18-1a + 1 Story 12-1 + 1 Story 12-2 + 1 Story 12-3 + 1 Story 12-5b + 1 Story 19-1 + 1 Story 19-3 + 1 Story 19-4 + 1 Story 19-5 + 3 #213 + 1 Story 20-1 + 3 Story 20-3b1 + 1 Story 21-1 + 2 Story 21-3 + 1 Story 21-5a + 1 Story 14-3a + 1 Story 16-1a + 1 Story 16-1a-bis + 1 Story 16-2a + 1 Story 16-3a + 1 Story 16-3b + 1 Story 22-1 + 1 Story 24-2 + 1 Story 24-3 + 1 Story 24-4a + 1 Story 24-4c + 1 Story 24-5 + 1 Story 25-1a + 1 Story 25-1c-zero + 1 Story 25-2-c + 1 Story 25-4-c3-a1 + 1 Story 25-6-b). - **Idempotence `yes`** : 8 (`country_code`, `invoice_paid_at`, `bank_imports_relax_hash_unique`, `users_email`, `invoice_lines_revenue_account_backfill`, `closing_accounts_not_postable`, `audit_log_actor_label`, `audit_log_company_id`). -- **Idempotence `tracked-by-sqlx`** : 62 (toutes les autres). +- **Idempotence `tracked-by-sqlx`** : 63 (toutes les autres). - **Idempotence `no`** : 0. > Ces compteurs se **recomptent depuis le tableau**, ils ne s'incrémentent pas de confiance. Précédent : jusqu'à la Story 16-1a (2026-07-28), `tracked-by-sqlx` annonçait 45 pour 52 réelles — 11 lignes de migration des Epics 20/21 avaient été ajoutées **sous** la section « Maintenance future » au lieu du tableau, où elles ne se rendaient même pas en markdown et échappaient à tout comptage. Sept passes de `validate` avaient « re-vérifié » ce compteur sans le recompter. Lignes remontées et compteurs rétablis en revue de code de la 16-1a. diff --git a/docs/testing.md b/docs/testing.md index 980e5b864..b91db408b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -202,6 +202,12 @@ démarre quand même, `/health` répond `ok`, et l'échec ne se voit qu'à l'ex côté Playwright un simple `getByTestId('inbox-import-report')` introuvable. On cherche alors la panne dans le frontend, qui n'y est pour rien. +Depuis la Story 25-6-b (#387), **tout téléchargement de PDF de facture écrit dans +`KESH_DOCUMENTS_DIR`** (le PDF y est figé au premier rendu) : sans un répertoire inscriptible, il +répond `500` et toutes les specs qui téléchargent un PDF de facture rougissent +(`invoice-frozen-pdf`, `invoices`, `invoice-send-email`). Les fichiers figés persistent d'une +exécution à l'autre : une spec qui en dépend crée sa propre facture. + Sans ces deux variables, `inbox-import.spec.ts` rend **1 échec et 2 tests skippés** ; avec elles, **3 passés** — le round-trip complet de l'import de factures fournisseurs. Les deux se corrigent ensemble : `documents` n'apparaît qu'une fois `inbox` réglé, chaque erreur masquant la suivante. diff --git a/frontend/src/lib/features/invoices/invoices.api.ts b/frontend/src/lib/features/invoices/invoices.api.ts index 1fca69b36..9e78a4a6e 100644 --- a/frontend/src/lib/features/invoices/invoices.api.ts +++ b/frontend/src/lib/features/invoices/invoices.api.ts @@ -86,6 +86,14 @@ export async function unvalidateInvoice( return apiClient.post(`/api/v1/invoices/${id}/unvalidate`, { version }); } +/** + * Refige le PDF d'une facture dont le fichier a disparu (Story 25-6-b, #387) — + * **Admin**. Produit un nouveau document, pas l'original. Rend la facture à jour. + */ +export async function refreezeInvoicePdf(id: number): Promise { + return apiClient.post(`/api/v1/invoices/${id}/pdf/refreeze`, {}); +} + export async function getInvoiceSettings(): Promise { return apiClient.get('/api/v1/company/invoice-settings'); } diff --git a/frontend/src/lib/features/invoices/invoices.types.ts b/frontend/src/lib/features/invoices/invoices.types.ts index ad7aef1dd..1bdf97db3 100644 --- a/frontend/src/lib/features/invoices/invoices.types.ts +++ b/frontend/src/lib/features/invoices/invoices.types.ts @@ -81,6 +81,13 @@ export interface InvoiceResponse { /** Story 21-6a (D10) — rappels suspendus. `null` = rappels actifs. */ dunningPausedAt: string | null; dunningPausedNote: string | null; + /** + * Story 25-6-b (#387) — instant et langue (`FR`/`DE`/`IT`/`EN`) du PDF + * émis, figé au premier téléchargement ou envoi. `null` = jamais rendue + * depuis sa validation. + */ + pdfFrozenAt: string | null; + pdfLanguage: string | null; /** Calculé backend (P6 review pass 2). Source unique de vérité pour le badge « en retard ». */ isOverdue: boolean; /** diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts index 56787693d..ab79cc126 100644 --- a/frontend/src/lib/shared/i18n-keys.test.ts +++ b/frontend/src/lib/shared/i18n-keys.test.ts @@ -370,8 +370,29 @@ const ATTENDU = { // 12 appels (`grep -o "i18nMsg("`) : l'état d'échec de la tuile // (`homepage-bank-unavailable`) et l'écart non calculable d'un compte du grand // livre partagé (`homepage-bank-gap-unavailable`), deux clés neuves × 4 locales. - sitesTotal: 1767, - sitesNonResolus: 32, + // Story 25-6-b (#387) : **+9** (1758 → 1767 sur sa branche), recompté par ce test et par + // `grep -o "i18nMsg("` aux deux bornes de `invoices/[id]/+page.svelte` + // (69 → 78) — la mention « Document figé le », le bouton « Refiger le + // document » (deux sites : l'en-tête et le pied du dialogue), le titre, le + // corps et l'avertissement du dialogue, son « Annuler », le succès, et le + // repli générique du refus — neuf sites. Le bouton PDF, extrait dans un snippet pour servir aussi la facture + // annulée, n'ajoute aucun site : il n'est écrit qu'une fois. + // + // Story 25-6-b, revue P2 : **−2**, recompté par ce test et par + // `grep -o "i18nMsg("` aux deux bornes — fiche facture 78 → 76, fiche avoir + // 16 → 14, et le module partagé `shared/utils/pdf-error.ts` +2. Les deux + // fiches résolvaient chacune le refus de PDF (`i18nMsg(clé, err.message)` et + // le générique) ; elles délèguent désormais à `pdfErrorMessage`. + // + // Story 25-6-b, revue P3 : **+1** — l'avertissement « refigé, mais la + // fiche n'a pas pu être relue » de la fiche facture (76 → 77), clé neuve + // `invoice-pdf-refreeze-reload-failed` dans les 4 catalogues. + // + // Fusion de `main` (25-6-a) dans la 25-6-b, 2026-10-05 : les deux branches partaient de + // 1758 ; 25-6-a +9, 25-6-b +8 (+9 −2 +1) ⇒ **1775**. `sitesNonResolus` : 32 (25-6-a) + // − 1 (25-6-b, deux sites des fiches devenus un dans `pdf-error.ts`) ⇒ **31**. + sitesTotal: 1775, + sitesNonResolus: 31, relais: 6, sitesGabarit: 10, litterauxMin: 1050, diff --git a/frontend/src/lib/shared/i18n-repli-divergent-actif.test.ts b/frontend/src/lib/shared/i18n-repli-divergent-actif.test.ts index 7a88cb642..28198adc7 100644 --- a/frontend/src/lib/shared/i18n-repli-divergent-actif.test.ts +++ b/frontend/src/lib/shared/i18n-repli-divergent-actif.test.ts @@ -88,10 +88,6 @@ const TOLEREES: readonly { cle: string; motif: string }[] = [ cle: 'product-filter-search', motif: "même cause que `contact-filter-search-placeholder`", }, - { - cle: 'invoice-pdf-error-generic', - motif: "« Échec du téléchargement du PDF. » contre « Erreur lors du téléchargement du PDF » — synonymes, même échec", - }, { cle: 'dunning-edit', motif: "« Modifier » en bouton de ligne contre « Modifier le niveau » en titre de formulaire — le catalogue affiche la forme courte, perte de précision sans ambiguïté", diff --git a/frontend/src/lib/shared/utils/pdf-error.test.ts b/frontend/src/lib/shared/utils/pdf-error.test.ts new file mode 100644 index 000000000..2611d0632 --- /dev/null +++ b/frontend/src/lib/shared/utils/pdf-error.test.ts @@ -0,0 +1,48 @@ +/** + * `pdfErrorMessage` — Story 25-6-b, revue P2. + * + * ⚠️ Le mock d'`i18nMsg` honore un CATALOGUE résolu sans arguments, comme le + * vrai : une clé connue l'emporte sur le repli. Un mock qui rendrait toujours + * le repli (`err.message`) cacherait exactement le défaut que ce module ferme. + */ +import { describe, it, expect, vi } from 'vitest'; + +const CATALOGUE: Record = { + 'error-invoice-too-many-lines-for-pdf': 'La facture contient ⁨{ $count }⁩ lignes.', + 'error-invoice-pdf-gone': 'Le fichier ⁨{ $sha256 }⁩.pdf manque.', + 'invoice-pdf-error-invoice-not-pdf-ready': 'Facture pas prête pour le PDF.', + 'invoice-pdf-error-generic': 'Erreur PDF.', +}; +vi.mock('$lib/shared/utils/i18n.svelte', () => ({ + i18nMsg: (k: string, fallback: string) => CATALOGUE[k] ?? fallback, +})); + +import { pdfErrorMessage } from './pdf-error'; + +const err = (code: string, message: string) => ({ code, message, status: 400 }); + +describe('pdfErrorMessage', () => { + it('« trop de lignes » : le message du serveur, avec le nombre (mutation : clé du catalogue, `{ $count }` brut)', () => { + const msg = pdfErrorMessage(err('INVOICE_TOO_MANY_LINES_FOR_PDF', 'La facture contient 12 lignes.')); + expect(msg).toBe('La facture contient 12 lignes.'); + }); + + it('410 : le message du serveur, qui nomme le fichier', () => { + const msg = pdfErrorMessage(err('INVOICE_PDF_GONE', 'Le fichier abc.pdf manque.')); + expect(msg).toBe('Le fichier abc.pdf manque.'); + }); + + it('un code sans variable : la clé du catalogue', () => { + expect(pdfErrorMessage(err('INVOICE_NOT_PDF_READY', 'serveur'))).toBe( + 'Facture pas prête pour le PDF.', + ); + }); + + it('un code inconnu : le générique', () => { + expect(pdfErrorMessage(err('SOMETHING_NEW', 'serveur'))).toBe('Erreur PDF.'); + }); + + it('une erreur qui n’est pas une erreur d’API : le générique', () => { + expect(pdfErrorMessage(new Error('réseau'))).toBe('Erreur PDF.'); + }); +}); diff --git a/frontend/src/lib/shared/utils/pdf-error.ts b/frontend/src/lib/shared/utils/pdf-error.ts new file mode 100644 index 000000000..24d1f0263 --- /dev/null +++ b/frontend/src/lib/shared/utils/pdf-error.ts @@ -0,0 +1,46 @@ +/** + * Le message d'un refus de téléchargement de PDF — facture ou avoir. + * + * Partagé par les deux fiches (`invoices/[id]`, `credit-notes/[id]`), qui + * portaient chacune leur table : la même correction devait y être faite deux + * fois (Story 25-6-b, revue P2). + */ +import { isApiError } from '$lib/shared/utils/api-client'; +import { i18nMsg } from '$lib/shared/utils/i18n.svelte'; + +/** + * Codes de refus → clé FTL. Explicite et non construite depuis `err.code`, + * pour qu'un code inconnu du backend ne fabrique pas une clé inexistante — le + * mismatch serait silencieux. + */ +const PDF_ERROR_KEYS: Record = { + INVOICE_NOT_VALIDATED: 'invoice-pdf-error-invoice-not-validated', + INVOICE_NOT_PDF_READY: 'invoice-pdf-error-invoice-not-pdf-ready', + // Story 16-3a (#151) — sans cette entrée, le message retombe sur le + // générique et l'utilisateur ne sait pas QUOI raccourcir. + INVOICE_PDF_HEADER_OVERFLOW: 'error-invoice-pdf-header-overflow', + PDF_GENERATION_FAILED: 'invoice-pdf-error-pdf-generation-failed', + NOT_FOUND: 'invoice-pdf-error-not-found', + // Story 25-6-b (#387) — le PDF figé. + INVOICE_CHANGED: 'error-invoice-changed', + INVOICE_CANCELLED: 'error-invoice-cancelled', +}; + +/** + * ⛔ Codes dont le message porte une **variable** (`{ $count }`, `{ $sha256 }`). + * Le catalogue servi au frontend est résolu SANS arguments : la clé y rendrait + * la variable brute (« La facture contient { $count } lignes »). Le message du + * serveur, résolu avec ses arguments dans la même langue d'installation, est + * donc repris tel quel. + */ +const SERVER_RESOLVED_CODES = new Set(['INVOICE_TOO_MANY_LINES_FOR_PDF', 'INVOICE_PDF_GONE']); + +/** Le texte à afficher pour un échec de téléchargement de PDF. */ +export function pdfErrorMessage(err: unknown): string { + if (!isApiError(err)) { + return i18nMsg('invoice-pdf-error-generic', 'Erreur lors du téléchargement du PDF'); + } + if (SERVER_RESOLVED_CODES.has(err.code)) return err.message; + const key = PDF_ERROR_KEYS[err.code] ?? 'invoice-pdf-error-generic'; + return i18nMsg(key, err.message); +} diff --git a/frontend/src/routes/(app)/credit-notes/[id]/+page.svelte b/frontend/src/routes/(app)/credit-notes/[id]/+page.svelte index ca4d13496..145536da3 100644 --- a/frontend/src/routes/(app)/credit-notes/[id]/+page.svelte +++ b/frontend/src/routes/(app)/credit-notes/[id]/+page.svelte @@ -13,6 +13,7 @@ import { apiClient, isApiError } from '$lib/shared/utils/api-client'; import { notifyError } from '$lib/shared/utils/notify'; import { i18nMsg } from '$lib/shared/utils/i18n.svelte'; + import { pdfErrorMessage } from '$lib/shared/utils/pdf-error'; import { fetchAccounts } from '$lib/features/accounts/accounts.api'; import type { AccountResponse } from '$lib/features/accounts/accounts.types'; import { creditNoteRevenueAccountLabel } from '$lib/features/accounts/account-label'; @@ -75,23 +76,9 @@ return creditNoteRevenueAccountLabel(accounts, revenueAccountId); } - /** - * Whitelist explicite des codes d'erreur PDF, alignée sur celle de la fiche - * facture (`invoices/[id]/+page.svelte`). Explicite et non construite depuis - * `err.code`, pour qu'un code inconnu du backend ne fabrique pas une clé FTL - * inexistante — le mismatch serait silencieux. - * - * Les trois refus ci-dessous sont ceux que `map_qrbill_error` peut produire - * pour un avoir : l'avoir passe par la MÊME fonction de mapping que la - * facture (`credit_notes.rs:341`). - */ - const PDF_ERROR_KEYS: Record = { - INVOICE_NOT_PDF_READY: 'invoice-pdf-error-invoice-not-pdf-ready', - INVOICE_TOO_MANY_LINES_FOR_PDF: 'error-invoice-too-many-lines-for-pdf', - INVOICE_PDF_HEADER_OVERFLOW: 'error-invoice-pdf-header-overflow', - PDF_GENERATION_FAILED: 'invoice-pdf-error-pdf-generation-failed', - NOT_FOUND: 'invoice-pdf-error-not-found', - }; + // Le message d'un refus de PDF : `pdfErrorMessage`, partagé avec la fiche + // facture (l'avoir passe par la même fonction de mapping que la facture, + // `credit_notes.rs:341`). async function downloadPdf() { if (!creditNote) return; @@ -114,19 +101,7 @@ document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 5_000); } catch (err) { - if (isApiError(err)) { - // Story 16-3a (#151), passe 7 : sans ce remappage, `err.message` - // s'affichait tel quel — c'est-à-dire traduit CÔTÉ SERVEUR, dans la - // locale d'instance figée au démarrage, et non dans la langue - // d'interface de l'utilisateur. La page facture porte la même - // whitelist depuis la passe 3 ; le CHANGELOG promet que l'avoir se - // comporte « à l'identique ». `i18nMsg` retombe sur `err.message` - // pour tout code absent de la table, donc aucun message n'est perdu. - const key = PDF_ERROR_KEYS[err.code] ?? 'invoice-pdf-error-generic'; - notifyError(i18nMsg(key, err.message)); - } else { - notifyError(i18nMsg('invoice-pdf-error-generic', 'Échec du téléchargement du PDF.')); - } + notifyError(pdfErrorMessage(err)); } finally { pdfDownloading = false; } diff --git a/frontend/src/routes/(app)/invoices/[id]/+page.svelte b/frontend/src/routes/(app)/invoices/[id]/+page.svelte index 57433db55..1d74985f6 100644 --- a/frontend/src/routes/(app)/invoices/[id]/+page.svelte +++ b/frontend/src/routes/(app)/invoices/[id]/+page.svelte @@ -20,6 +20,7 @@ deleteInvoice, validateInvoice, unvalidateInvoice, + refreezeInvoicePdf, settleInvoice, listInvoiceSettlements, cancelInvoiceSettlement, @@ -27,6 +28,7 @@ sendInvoiceEmail, getInvoiceSettings, } from '$lib/features/invoices/invoices.api'; + import { pdfErrorMessage } from '$lib/shared/utils/pdf-error'; import { fetchAccounts } from '$lib/features/accounts/accounts.api'; import type { AccountResponse } from '$lib/features/accounts/accounts.types'; import type { InvoiceSettingsResponse } from '$lib/features/invoices/invoices.types'; @@ -654,19 +656,49 @@ let pdfDownloading = $state(false); - // D2 (review pass 1 G2 D) : whitelist explicite des codes d'erreur PDF - // — empêche la construction dynamique de clés FTL depuis err.code - // (potentiel mismatch silencieux si le backend renvoie un nouveau code). - const PDF_ERROR_KEYS: Record = { - INVOICE_NOT_VALIDATED: 'invoice-pdf-error-invoice-not-validated', - INVOICE_NOT_PDF_READY: 'invoice-pdf-error-invoice-not-pdf-ready', - INVOICE_TOO_MANY_LINES_FOR_PDF: 'error-invoice-too-many-lines-for-pdf', - // Story 16-3a (#151) — sans cette entrée, le message retombe sur le - // générique et l'utilisateur ne sait pas QUOI raccourcir. - INVOICE_PDF_HEADER_OVERFLOW: 'error-invoice-pdf-header-overflow', - PDF_GENERATION_FAILED: 'invoice-pdf-error-pdf-generation-failed', - NOT_FOUND: 'invoice-pdf-error-not-found', - }; + // Le message d'un refus de PDF : `pdfErrorMessage` (partagé avec la fiche + // avoir — Story 25-6-b, revue P2). + + // Story 25-6-b (#387, arbitrage 7) — le fichier du PDF figé a disparu (410). + // Un administrateur peut alors REFIGER : un nouveau document, pas l'original. + let pdfGone = $state(false); + let refreezeOpen = $state(false); + let refreezeSubmitting = $state(false); + let refreezeError = $state(''); + + /** Le bouton PDF : facture validée, ou annulée dont le document a été figé. */ + let canDownloadPdf = $derived( + invoice?.status === 'validated' || (invoice?.status === 'cancelled' && !!invoice.pdfFrozenAt), + ); + + async function confirmRefreeze() { + if (!invoice) return; + refreezeSubmitting = true; + refreezeError = ''; + try { + await refreezeInvoicePdf(invoice.id); + pdfGone = false; + refreezeOpen = false; + notifySuccess(i18nMsg('invoice-pdf-refreeze-done', 'Document refigé.')); + // La réponse du refigeage ne porte pas « Déjà réglé / Reste dû » : la + // fiche se relit (revue P2, F-L2). ⚠️ Hors du refigeage : un échec de + // cette relecture ne doit pas faire croire que le refigeage a échoué + // (revue P3) — la fiche garde alors son état précédent. + try { + invoice = await getInvoice(invoice.id); + } catch { + notifyError( + i18nMsg('invoice-pdf-refreeze-reload-failed', 'Document refigé, mais la fiche n’a pas pu être relue : rechargez la page.'), + ); + } + } catch (err) { + refreezeError = isApiError(err) + ? err.message + : i18nMsg('invoice-pdf-refreeze-error', 'Le document n’a pas pu être refigé.'); + } finally { + refreezeSubmitting = false; + } + } async function downloadPdf() { if (!invoice) return; @@ -674,6 +706,9 @@ try { const res = await apiClient.getBlob(`/api/v1/invoices/${invoice.id}/pdf`); const blob = await res.blob(); + // Un téléchargement qui aboutit dit que le fichier est là (restauré + // depuis la sauvegarde, par exemple) : plus rien à refiger. + pdfGone = false; if (blob.size === 0) { notifyError(i18nMsg('invoice-pdf-error-empty', 'Le PDF reçu est vide.')); return; @@ -693,16 +728,9 @@ // Revoke différé pour laisser le navigateur récupérer le blob. setTimeout(() => URL.revokeObjectURL(url), 5_000); } catch (err) { - if (isApiError(err)) { - // Pass 2 : remappage vers les clés FTL réellement présentes — - // `INVOICE_TOO_MANY_LINES_FOR_PDF` utilise la clé legacy - // `error-invoice-too-many-lines-for-pdf` (existante FR/DE/IT/EN - // avec les arguments {count}/{max}). - const key = PDF_ERROR_KEYS[err.code] ?? 'invoice-pdf-error-generic'; - notifyError(i18nMsg(key, err.message)); - } else { - notifyError(i18nMsg('invoice-pdf-error-generic', 'Erreur lors du téléchargement du PDF')); - } + // Le 410 nomme le fichier à restaurer et ouvre le refigeage. + if (isApiError(err) && err.code === 'INVOICE_PDF_GONE') pdfGone = true; + notifyError(pdfErrorMessage(err)); } finally { pdfDownloading = false; } @@ -727,6 +755,35 @@ Facture — Kesh +{#snippet pdfButtons()} + {#if invoice && canDownloadPdf} + + + {#if isAdmin && pdfGone && invoice.status === 'validated'} + + {/if} + {/if} +{/snippet} +
{/if} - + {@render pdfButtons()} {#if canManage} {#if featureFlags.smtpConfigured} +
+ + {@render pdfButtons()} + +
{/if}
@@ -938,6 +989,14 @@
{invoice.paidAt.slice(0, 10)}
{/if} + {#if invoice.pdfFrozenAt} +
+
+ {i18nMsg('invoice-detail-pdf-frozen-at-label', 'Document figé le')} +
+
{invoice.pdfFrozenAt.slice(0, 10)}
+
+ {/if} {#if invoice.emailedAt}
@@ -1070,6 +1129,57 @@ {/if}
+ + { + refreezeOpen = o; + if (!o) refreezeError = ''; + }} + > + + + + {i18nMsg('invoice-pdf-refreeze-title', 'Refiger le document ?')} + + +
+

+ {i18nMsg( + 'invoice-pdf-refreeze-body', + "Le fichier du PDF émis de cette facture est introuvable. Refiger produit un NOUVEAU document, avec les données et la langue du client d'aujourd'hui : ce n'est pas l'original. Le geste est tracé au journal d'audit, avec l'empreinte de l'ancien document et celle du nouveau.", + )} +

+
+ {i18nMsg( + 'invoice-pdf-refreeze-restore-first', + "Restaurez d'abord le fichier depuis la sauvegarde du répertoire des documents, s'il y existe : c'est le seul moyen de retrouver l'original. Si plusieurs factures sont dans ce cas, vérifiez le montage de ce répertoire avant de refiger.", + )} +
+
+ {#if refreezeError} +
+ {refreezeError} +
+ {/if} + + + + +
+
+ { diff --git a/frontend/src/routes/(app)/invoices/[id]/invoice-pdf-page.test.ts b/frontend/src/routes/(app)/invoices/[id]/invoice-pdf-page.test.ts new file mode 100644 index 000000000..73b18a487 --- /dev/null +++ b/frontend/src/routes/(app)/invoices/[id]/invoice-pdf-page.test.ts @@ -0,0 +1,330 @@ +/** + * La fiche facture, côté PDF émis — Story 25-6-b (#387). + * + * Ce que l'écran doit tenir : le bouton PDF d'une facture **annulée** n'existe + * que si son document a été figé ; la mention « Document figé le » ; et, sur un + * 410 `INVOICE_PDF_GONE`, le refigeage réservé à l'administrateur, qui + * **confirme** avant d'envoyer. + * + * ⚠️ Chaque test nomme la MUTATION qu'il attrape. Patron : + * `invoice-settlements-page.test.ts` — mocks hoistés AVANT l'import du composant. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, fireEvent, cleanup, waitFor } from "@testing-library/svelte"; +import type { InvoiceResponse } from "$lib/features/invoices/invoices.types"; + +vi.mock("$app/environment", () => ({ browser: true })); +vi.mock("$app/navigation", () => ({ goto: vi.fn() })); +vi.mock("$app/state", () => ({ + page: { params: { id: "5" }, url: new URL("http://localhost/invoices/5") }, +})); +// ⚠️ Le mock honore un CATALOGUE, comme le vrai `i18nMsg` : une clé connue +// l'emporte sur le repli, et le catalogue est résolu sans arguments (revue P1, +// M1 — un mock qui rendait toujours le repli cachait un `{ $sha256 }` affiché). +const CATALOGUE: Record = { + "error-invoice-pdf-gone": + "Le fichier \u2068{ $sha256 }\u2069.pdf manque dans le répertoire des documents.", +}; +vi.mock("$lib/shared/utils/i18n.svelte", () => ({ + i18nMsg: ( + k: string, + fallback: string, + args?: Record, + ) => { + const raw = CATALOGUE[k] ?? fallback; + return args + ? raw.replace(/\{\s*\$(\w+)\s*\}/g, (_, n) => String(args[n] ?? "")) + : raw; + }, +})); +const notifyErrorMock = vi.hoisted(() => vi.fn()); +vi.mock("$lib/shared/utils/notify", () => ({ + notifyError: (m: string) => notifyErrorMock(m), + notifySuccess: vi.fn(), + notifyWarning: vi.fn(), + notifyMissingFiscalYearOrFallback: vi.fn(() => false), +})); + +const auth = vi.hoisted(() => ({ + currentUser: { role: "Admin", username: "a", userId: "1" }, +})); +vi.mock("$lib/app/stores/auth.svelte", () => ({ authState: auth })); + +const getBlobMock = vi.hoisted(() => vi.fn()); +vi.mock("$lib/shared/utils/api-client", async (importOriginal) => { + const real = + await importOriginal(); + return { + ...real, + apiClient: { ...real.apiClient, getBlob: (url: string) => getBlobMock(url) }, + }; +}); + +const getInvoiceMock = vi.fn(); +const refreezeMock = vi.fn(); +vi.mock("$lib/features/invoices/invoices.api", () => ({ + getInvoice: (id: number) => getInvoiceMock(id), + refreezeInvoicePdf: (id: number) => refreezeMock(id), + listInvoiceSettlements: vi.fn(async () => []), + cancelInvoiceSettlement: vi.fn(), + deleteInvoice: vi.fn(), + validateInvoice: vi.fn(), + unvalidateInvoice: vi.fn(), + settleInvoice: vi.fn(), + getInvoiceEmailPreview: vi.fn(), + sendInvoiceEmail: vi.fn(), + getInvoiceSettings: vi.fn(async () => ({})), +})); +vi.mock("$lib/features/accounts/accounts.api", () => ({ + fetchAccounts: vi.fn(async () => []), +})); +vi.mock("$lib/features/bank-accounts/bank-accounts.api", () => ({ + listBankAccounts: vi.fn(async () => []), +})); +vi.mock("$lib/features/reminders/reminders.api", () => ({ + listReminderHistory: vi.fn(async () => []), + pauseDunning: vi.fn(), + resumeDunning: vi.fn(), +})); +vi.mock("$lib/features/projects/projects.api", () => ({ + listProjects: vi.fn(async () => []), +})); +vi.mock("$lib/features/credit-notes/credit-notes.api", () => ({ + createCreditNote: vi.fn(), +})); + +import Page from "./+page.svelte"; + +function invoice(partial: Partial = {}): InvoiceResponse { + return { + id: 5, + companyId: 1, + contactId: 1, + invoiceNumber: "F-2026-005", + status: "validated", + date: "2026-03-01", + dueDate: "2026-03-31", + paymentTerms: null, + totalAmount: "100.00", + totalTtc: "100.00", + vatBreakdown: [], + journalEntryId: 9, + paidAt: null, + emailedAt: null, + emailedTo: null, + projectId: null, + dunningPausedAt: null, + dunningPausedNote: null, + pdfFrozenAt: null, + pdfLanguage: null, + isOverdue: false, + amountSettled: "0.00", + amountDue: "100.00", + version: 3, + createdAt: "2026-03-01T00:00:00", + updatedAt: "2026-03-05T00:00:00", + lines: [], + ...partial, + }; +} + +const gone = { + code: "INVOICE_PDF_GONE", + message: "Le fichier abc123.pdf manque dans le répertoire des documents.", + status: 410, +}; + +beforeEach(() => { + auth.currentUser.role = "Admin"; +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe("fiche facture — bouton PDF d'une facture annulée", () => { + it("présent si le document a été figé (mutation : condition `validated` seule)", async () => { + getInvoiceMock.mockResolvedValue( + invoice({ status: "cancelled", pdfFrozenAt: "2026-03-02T10:00:00.000" }), + ); + const { findByTestId } = render(Page); + expect(await findByTestId("invoice-download-pdf")).toBeTruthy(); + }); + + it("absent si elle n'a jamais été figée (mutation : `cancelled` sans condition de gel)", async () => { + getInvoiceMock.mockResolvedValue(invoice({ status: "cancelled" })); + const { findByTestId, queryByTestId } = render(Page); + await findByTestId("invoice-detail"); + expect(queryByTestId("invoice-download-pdf")).toBeNull(); + }); +}); + +describe("fiche facture — mention du gel", () => { + it("« Document figé le » avec la date, quand le PDF est figé", async () => { + getInvoiceMock.mockResolvedValue( + invoice({ pdfFrozenAt: "2026-03-02T10:00:00.000" }), + ); + const { findByTestId } = render(Page); + expect((await findByTestId("invoice-pdf-frozen-at")).textContent).toBe( + "2026-03-02", + ); + }); + + it("aucune mention quand il ne l'est pas", async () => { + getInvoiceMock.mockResolvedValue(invoice()); + const { findByTestId, queryByTestId } = render(Page); + await findByTestId("invoice-detail"); + expect(queryByTestId("invoice-pdf-frozen-at")).toBeNull(); + }); +}); + +describe("fiche facture — refiger après un 410", () => { + it("un admin voit le bouton après le 410, confirme, et la fiche se met à jour (mutation : envoi sans confirmation)", async () => { + getInvoiceMock.mockResolvedValue( + invoice({ pdfFrozenAt: "2026-03-02T10:00:00.000" }), + ); + getBlobMock.mockRejectedValue(gone); + // La réponse du refigeage porte une AUTRE date que la relecture : si la + // fiche l'affichait, le test le verrait (mutation : pas de relecture). + refreezeMock.mockResolvedValue( + invoice({ pdfFrozenAt: "2026-09-09T08:00:00.000" }), + ); + // La fiche se RELIT après le refigeage (revue P2, F-L2) : c'est cette + // lecture, et non la réponse du refigeage, qui doit s'afficher. + getInvoiceMock + .mockResolvedValueOnce(invoice({ pdfFrozenAt: "2026-03-02T10:00:00.000" })) + .mockResolvedValueOnce( + invoice({ + pdfFrozenAt: "2026-04-01T08:00:00.000", + amountSettled: "40.00", + amountDue: "60.00", + }), + ); + const { findByTestId, getByTestId, queryByTestId } = render(Page); + + await fireEvent.click(await findByTestId("invoice-download-pdf")); + await fireEvent.click(await findByTestId("invoice-pdf-refreeze-button")); + // Rien n'est envoyé avant la confirmation, qui conseille de restaurer. + expect(refreezeMock).not.toHaveBeenCalled(); + expect( + getByTestId("invoice-pdf-refreeze-dialog").textContent, + ).toContain("Restaurez d'abord"); + await fireEvent.click(getByTestId("invoice-pdf-refreeze-confirm")); + + await waitFor(() => expect(refreezeMock).toHaveBeenCalledWith(5)); + await waitFor(() => expect(getInvoiceMock).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(getByTestId("invoice-pdf-frozen-at").textContent).toBe( + "2026-04-01", + ), + ); + await waitFor(() => + expect(queryByTestId("invoice-pdf-refreeze-button")).toBeNull(), + ); + }); + + it("pas de bouton avant le 410 (mutation : bouton toujours affiché à l'admin)", async () => { + getInvoiceMock.mockResolvedValue( + invoice({ pdfFrozenAt: "2026-03-02T10:00:00.000" }), + ); + const { findByTestId, queryByTestId } = render(Page); + await findByTestId("invoice-download-pdf"); + expect(queryByTestId("invoice-pdf-refreeze-button")).toBeNull(); + }); + + it("un Comptable ne le voit pas, même après le 410 (mutation : garde `isAdmin` retirée)", async () => { + auth.currentUser.role = "Comptable"; + getInvoiceMock.mockResolvedValue( + invoice({ pdfFrozenAt: "2026-03-02T10:00:00.000" }), + ); + getBlobMock.mockRejectedValue(gone); + const { findByTestId, queryByTestId } = render(Page); + await fireEvent.click(await findByTestId("invoice-download-pdf")); + await waitFor(() => expect(getBlobMock).toHaveBeenCalled()); + // Laisser le rejet se propager avant de conclure à l'absence. + await new Promise((r) => setTimeout(r, 0)); + expect(queryByTestId("invoice-pdf-refreeze-button")).toBeNull(); + }); + + it("un autre échec que le 410 n'ouvre pas le refigeage (mutation : tout échec pose `pdfGone`)", async () => { + getInvoiceMock.mockResolvedValue(invoice()); + getBlobMock.mockRejectedValue({ + code: "INVOICE_NOT_PDF_READY", + message: "Adresse incomplète.", + status: 400, + }); + const { findByTestId, queryByTestId } = render(Page); + await fireEvent.click(await findByTestId("invoice-download-pdf")); + await waitFor(() => expect(getBlobMock).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + expect(queryByTestId("invoice-pdf-refreeze-button")).toBeNull(); + }); + + it("le message du 410 nomme le fichier manquant (mutation : message tiré du catalogue, `{ $sha256 }` brut)", async () => { + getInvoiceMock.mockResolvedValue( + invoice({ pdfFrozenAt: "2026-03-02T10:00:00.000" }), + ); + getBlobMock.mockRejectedValue(gone); + const { findByTestId } = render(Page); + await fireEvent.click(await findByTestId("invoice-download-pdf")); + await waitFor(() => expect(notifyErrorMock).toHaveBeenCalled()); + const msg = notifyErrorMock.mock.calls[0][0] as string; + expect(msg).toContain("abc123.pdf"); + expect(msg).not.toContain("$sha256"); + }); + + it("pas de refigeage pour une facture annulée, même après le 410 (mutation : condition `validated` retirée)", async () => { + getInvoiceMock.mockResolvedValue( + invoice({ status: "cancelled", pdfFrozenAt: "2026-03-02T10:00:00.000" }), + ); + getBlobMock.mockRejectedValue(gone); + const { findByTestId, queryByTestId } = render(Page); + await fireEvent.click(await findByTestId("invoice-download-pdf")); + await waitFor(() => expect(notifyErrorMock).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + expect(queryByTestId("invoice-pdf-refreeze-button")).toBeNull(); + }); + + it("un téléchargement réussi après le 410 retire le bouton (mutation : `pdfGone` jamais remis à faux)", async () => { + getInvoiceMock.mockResolvedValue( + invoice({ pdfFrozenAt: "2026-03-02T10:00:00.000" }), + ); + getBlobMock + .mockRejectedValueOnce(gone) + .mockResolvedValueOnce({ blob: async () => new Blob(["%PDF-1.7"]) }); + const createObjectURL = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:x"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const { findByTestId, queryByTestId } = render(Page); + await fireEvent.click(await findByTestId("invoice-download-pdf")); + await findByTestId("invoice-pdf-refreeze-button"); + await fireEvent.click(await findByTestId("invoice-download-pdf")); + await waitFor(() => expect(createObjectURL).toHaveBeenCalled()); + await waitFor(() => + expect(queryByTestId("invoice-pdf-refreeze-button")).toBeNull(), + ); + }); + + it("le refigeage réussi mais la relecture échoue : le dialogue se ferme, pas de faux échec (mutation : relecture dans le même try)", async () => { + getInvoiceMock + .mockResolvedValueOnce(invoice({ pdfFrozenAt: "2026-03-02T10:00:00.000" })) + .mockRejectedValueOnce(new Error("réseau")); + getBlobMock.mockRejectedValue(gone); + refreezeMock.mockResolvedValue(invoice()); + const { findByTestId, getByTestId, queryByTestId } = render(Page); + await fireEvent.click(await findByTestId("invoice-download-pdf")); + await fireEvent.click(await findByTestId("invoice-pdf-refreeze-button")); + await fireEvent.click(getByTestId("invoice-pdf-refreeze-confirm")); + + await waitFor(() => expect(refreezeMock).toHaveBeenCalledWith(5)); + await waitFor(() => + expect(queryByTestId("invoice-pdf-refreeze-dialog")).toBeNull(), + ); + expect(queryByTestId("invoice-pdf-refreeze-button")).toBeNull(); + expect( + notifyErrorMock.mock.calls.some((c) => String(c[0]).includes("rechargez")), + ).toBe(true); + }); +}); diff --git a/frontend/src/routes/(app)/invoices/[id]/invoice-settlements-page.test.ts b/frontend/src/routes/(app)/invoices/[id]/invoice-settlements-page.test.ts index d707f4d23..028009bd0 100644 --- a/frontend/src/routes/(app)/invoices/[id]/invoice-settlements-page.test.ts +++ b/frontend/src/routes/(app)/invoices/[id]/invoice-settlements-page.test.ts @@ -98,6 +98,8 @@ function invoice(partial: Partial = {}): InvoiceResponse { projectId: null, dunningPausedAt: null, dunningPausedNote: null, + pdfFrozenAt: null, + pdfLanguage: null, isOverdue: false, amountSettled: "100.00", amountDue: "0.00", diff --git a/frontend/tests/e2e/invoice-frozen-pdf.spec.ts b/frontend/tests/e2e/invoice-frozen-pdf.spec.ts new file mode 100644 index 000000000..a839f8ca4 --- /dev/null +++ b/frontend/tests/e2e/invoice-frozen-pdf.spec.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { + seedTestState, + clearAuthStorage, + authedApiContext, + disposeContextSafe, +} from './helpers/test-state'; +import { + createContactWithAddressViaApi, + createAndValidateInvoiceViaApi, + ensurePrimaryBankAccountViaApi, +} from './helpers/api-fixtures'; + +/** + * Tests E2E — le PDF d'une facture est figé (Story 25-6-b, #387). + * + * Seul un E2E voit les octets traverser la frontière HTTP jusqu'au client. Les + * deux parcours : deux téléchargements rendent le même fichier ; une facture + * annulée par un avoir garde le PDF qu'elle a émis. + * + * ⚠️ Chaque test crée **sa propre** facture : les fichiers figés persistent + * sous `KESH_DOCUMENTS_DIR` (`/tmp/kesh-e2e/documents`) d'une exécution à + * l'autre, et une facture déjà figée par un run précédent ne prouverait rien. + */ + +test.beforeAll(async () => { + await seedTestState('with-company'); +}); + +test.afterEach(async ({ page }) => { + await clearAuthStorage(page); +}); + +async function login(page: import('@playwright/test').Page) { + await page.goto('/login'); + await page.fill('#username', 'admin'); + await page.fill('#password', 'admin123'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/'); +} + +function uniq(prefix: string): string { + return `${prefix} ${Date.now()}-${Math.floor(Math.random() * 1e6)}`; +} + +test.describe('PDF figé', () => { + test('deux téléchargements rendent le même fichier', async ({ page }) => { + await login(page); + // Le PDF exige un compte bancaire principal. + await ensurePrimaryBankAccountViaApi(page); + const contactId = await createContactWithAddressViaApi(page, uniq('PDF Figé')); + const invoiceId = await createAndValidateInvoiceViaApi(page, contactId); + + const ctx = await authedApiContext(page); + try { + const premier = await ctx.get(`/api/v1/invoices/${invoiceId}/pdf`); + expect(premier.status()).toBe(200); + const second = await ctx.get(`/api/v1/invoices/${invoiceId}/pdf`); + expect(second.status()).toBe(200); + const a = await premier.body(); + const b = await second.body(); + expect(a.subarray(0, 7).toString('utf8')).toMatch(/^%PDF-1\./); + // Avant la story, la date de création et l'`/ID` du PDF changeaient. + expect(b.equals(a)).toBe(true); + } finally { + await disposeContextSafe(ctx); + } + + // La fiche dit que le document est figé. + await page.goto(`/invoices/${invoiceId}`); + await expect(page.getByTestId('invoice-pdf-frozen-at')).toBeVisible(); + }); + + test('une facture annulée par un avoir garde son PDF émis', async ({ page }) => { + await login(page); + // Le PDF exige un compte bancaire principal. + await ensurePrimaryBankAccountViaApi(page); + const contactId = await createContactWithAddressViaApi(page, uniq('PDF Annulée')); + const invoiceId = await createAndValidateInvoiceViaApi(page, contactId); + + const ctx = await authedApiContext(page); + try { + const emis = await ctx.get(`/api/v1/invoices/${invoiceId}/pdf`); + expect(emis.status()).toBe(200); + const avant = await emis.body(); + + const cn = await ctx.post('/api/v1/credit-notes', { + data: { invoiceId, date: new Date().toISOString().slice(0, 10) }, + }); + expect(cn.ok(), `create credit note failed: ${cn.status()}`).toBeTruthy(); + const inv = await ctx.get(`/api/v1/invoices/${invoiceId}`); + expect((await inv.json()).status).toBe('cancelled'); + + const apres = await ctx.get(`/api/v1/invoices/${invoiceId}/pdf`); + expect(apres.status()).toBe(200); + expect((await apres.body()).equals(avant)).toBe(true); + } finally { + await disposeContextSafe(ctx); + } + + // Le bouton PDF reste sur la fiche de la facture annulée. + await page.goto(`/invoices/${invoiceId}`); + await expect(page.getByTestId('invoice-download-pdf')).toBeVisible(); + }); +});