diff --git a/CHANGELOG.md b/CHANGELOG.md index ca07fcb0..1d6e160a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,14 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **Sur la fiche d'une facture fournisseur, le bouton *Annuler la facture* s'affichait aussi pour un utilisateur en consultation**, qui ne pouvait que se heurter à un refus ; et un refus effaçait toute la fiche au profit d'un message d'erreur. Le bouton est désormais réservé aux comptables et administrateurs, et un refus s'affiche sous le bouton, la fiche restant visible. +- **Une facture créditée gardait sa TVA en « reste dû » ([#455](https://github.com/guycorbaz/kesh/issues/455)).** Le reste dû retranchait l'avoir **hors taxes** d'un total **TTC** : une facture de 100.— HT à 8,1 %, entièrement créditée, restait due de 8.10, alors que le grand livre, lui, avait soldé la créance. L'écran ne l'affichait que dans un cas rare, mais ⚠️ **l'API le rendait toujours** : `GET /api/v1/invoices/{id}`, ouverte aux clés API en lecture, renvoyait la TVA dans `amountDue` pour toute facture créditée. **Si une intégration lisait ce champ, ses chiffres étaient faux.** Le reste dû compte désormais l'avoir TTC, arrondi ligne par ligne comme son écriture. + +- **Un avoir pouvait viser une facture déjà réglée en partie ([#456](https://github.com/guycorbaz/kesh/issues/456)), et le compte du client devenait créditeur sans que rien ne le signale.** L'avoir était refusé sur une facture **payée**, mais pas sur une facture réglée **en partie** — qui, depuis le règlement partiel, n'est « payée » qu'une fois soldée. Comme l'avoir annule tout le montant de la facture, le client se retrouvait avec un crédit du montant déjà encaissé, que Kesh ne sait ni montrer, ni rembourser. L'avoir est désormais refusé dès qu'un règlement existe, avec un message qui dit quoi faire : annuler d'abord le règlement. *(Autoriser l'avoir sur une facture encaissée, avec le remboursement ou l'imputation qu'il appelle, est suivi par [#471](https://github.com/guycorbaz/kesh/issues/471).)* + ### Changed +- **Le bouton *Créer un avoir* s'affiche aussi sur une facture payée**, et c'est voulu. Il était masqué sur une facture payée mais pas sur une facture réglée en partie : l'écran ne couvrait que la moitié de la règle. Il reste désormais visible, et Kesh explique le refus dans le dialogue — comme le bouton *Dévalider*. + - **Supprimer une facture validée n'est plus possible — il faut la « dévalider » d'abord.** Jusqu'ici, le bouton *Supprimer* d'une facture validée effaçait la facture **et son écriture comptable** d'un seul geste. La destruction d'une écriture était donc un **effet de bord du mot « supprimer »** : rien, dans le vocabulaire de l'écran, ne disait qu'on touchait aux livres. Le geste existe toujours, mais il porte désormais son nom. Le bouton **« Dévalider »** repasse la facture en **brouillon**, supprime son écriture, et **conserve son numéro** — en la revalidant, elle reprend *le même*, sans consommer le compteur. Deux sorties s'ouvrent alors : corriger puis revalider, ou supprimer le brouillon. diff --git a/_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md b/_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md new file mode 100644 index 00000000..0f17fadb --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md @@ -0,0 +1,527 @@ +# Story 25.4-a : Le résiduel juste — l'avoir compté TTC, et refusé sur une facture réglée + +Status: done + +**Issues : [#455], [#456]**, qu'elle **ferme** : `closes #455, closes #456` dans le **titre ET le +corps** de la PR (squash ; un `refs` partout laisserait les issues ouvertes sans signal). + +**Mère : `25-4-propager-le-residuel.md`** (`split`) — l'inventaire vérifié et la raison du +découpage y sont. **Sœurs à venir** : 25-4-b (#416, agrégats et relances), 25-4-c (#420, +rapprochement), qui **réemploient la formule** que cette story corrige : ⛔ elle passe en premier. + +## Story + +En tant que comptable, +je veux que le montant « reste dû » d'une facture soit calculé dans la même unité que ce que le +grand livre a passé, et qu'un avoir ne puisse pas viser une facture déjà réglée en partie, +afin que le reste dû ne mente jamais — ni en gardant la TVA d'une facture créditée, ni en laissant +une créance devenir créditrice sans que rien ne le signale. + +## Le défaut, vérifié dans le code + +### #455 — l'avoir est soustrait HORS TAXES d'un total TTC + +`amount_due` (`crates/kesh-db/src/repositories/invoice_settlements.rs:131-145`) calcule +`TTC − avoir émis − Σ règlements` : + +- le TTC vient de `INVOICE_TTC_SUBQUERY_SQL` (`invoices.rs:161-162`) : + `Σ (line_total + ROUND(line_total × vat_rate / 100, 2))` sur `invoice_lines` ; +- l'avoir vient de `INVOICE_CREDITED_SUBQUERY_SQL` (`invoice_settlements.rs:51-52`) : + `SUM(cn.total_amount)` — et `credit_notes.total_amount` est **HT** + (`20260627000001_credit_notes.sql:19` : « `total_amount` stocké = HT (Σ line_total) » ; + `credit_notes.rs:514-529`, `.bind(total_ht)`). + +Or l'écriture de l'avoir crédite la créance du **TTC** (`generate_credit_note_journal_lines`, +`credit_notes.rs:217-223` : `credit: total_ht + total_vat`, la TVA arrondie **ligne par ligne** par +`kesh_core::accounting::vat::line_vat_amount`, `:194` et `:209`). Le grand livre et le résiduel ne parlent +donc pas la même unité : une facture de 100.— HT à 8,1 % entièrement créditée garde **8.10** de +reste dû. La forme jointe `INVOICE_CREDITED_DERIVED_JOIN_SQL` (`:56-58`) porte le même défaut. + +⚠️ **Pourquoi aucun test ne l'a vu** : les factures des tests de règlement sont à **0 % de TVA** +(`crates/kesh-db/tests/invoice_settlement.rs:32`), où HT = TTC ; et aucun test n'associe un avoir +et `amount_due`. + +⚠️ **Masqué à l'écran, exposé par l'API.** L'avoir est **total** et fait passer la facture à +`cancelled` (`credit_notes.rs:560-575`) ; le terme « avoir » n'est donc non nul que sur une facture +annulée. **L'écran** n'y affiche le reste dû que si `amountSettled > 0` +(`invoices/[id]/+page.svelte:1022-1041`) — l'état que #456 permet de produire. ⛔ **Mais l'API le +rend toujours** : `get_invoice` (`crates/kesh-api/src/routes/invoices.rs:621-642`) calcule +`amount_due` **sans condition de statut**, et `GET /invoices/{id}` est **ouverte aux clés API en +lecture** (`docs/api-external.md:211`). Un avoir émis sur une facture **jamais réglée** — geste +ordinaire, sans #456 — suffit donc, aujourd'hui, à faire rendre à une intégration tierce +`amountDue` = **la TVA** au lieu de `0`. *(Relevé en validation P1 ; la fiche mère disait « latent » +à tort.)* Et un `.keshbackup` de la v0.12.0 **peut contenir** l'état de #456 : l'import le +restaure. + +### #456 — un avoir peut viser une facture partiellement réglée + +`create_credit_note` (`credit_notes.rs:292-308`) refuse une facture `paid_at IS NOT NULL` (garde +AC2bis) — **et rien d'autre**. Depuis la 24-2, `paid_at` n'est posé qu'**au solde** : une facture +réglée en partie a une ligne `invoice_settlements`, un reste dû positif, `paid_at` à `NULL` — elle +passe. + +Conséquence comptable : la créance reçoit **D TTC** (vente), **C réglé** (règlement), **C TTC** +(avoir) — elle finit **créditrice du montant encaissé**, sans flux de remboursement. C'est le solde +contre nature que la garde AC2bis voulait empêcher, atteint par un chemin qu'elle ne voit pas. La +facture passe ensuite à `cancelled`, et la ligne de règlement **survit** sur une facture annulée — +où `settlement_cancel_blocker` la déclare inannulable (`SettlementCancelBlocker::InvoiceCredited`, +`invoice_settlements_write.rs:327-333`). + +⛔ **Le patron de la bonne garde existe déjà** : la dévalidation lit « **l'existence d'une ligne** +`invoice_settlements` **OU** `paid_at IS NOT NULL` — les deux, jamais l'une seule » +(`UnvalidationBlocker::Settled`, `errors.rs:134-160` ; `invoices.rs:1430-1448`). + +⚠️ **Un test encode le défaut comme attendu** : la fixture `monter` +(`crates/kesh-db/tests/invoice_settlement.rs:937-1019`) règle 40 sur 100 puis appelle +`create_credit_note` avec `.expect("avoir après règlement partiel — le vrai chemin l'accepte")`. + +⚠️ **Le frontend masque le bouton sur une facture payée, pas sur une facture partiellement +réglée** : `invoices/[id]/+page.svelte:836` n'exige que `!invoice.paidAt`. Le bouton voisin +« Dévalider » a **retiré** cette condition pour cette raison même (commentaire `:864-871`) et laisse +le serveur refuser avec un motif nommé ; « Créer un avoir » n'a pas été aligné. + +## ✅ Arbitrage du 2026-09-27 — le refus reste, sa levée a son issue + +Guy : *« si on peut créer un avoir pour une facture payée, il faudrait aussi pouvoir le faire pour +une facture partiellement payée »* — les deux cas sont traités **de la même façon**, et, pour +l'instant, **dans le sens du refus** : un avoir sur facture encaissée laisse au client un crédit que +Kesh ne sait ni montrer, ni rembourser, ni imputer. La levée du refus est tracée par **[#471]**. Le +message de refus (AC 12) n'a donc pas à promettre une fonction à venir ; le manuel (AC 17) renvoie à +la limite existante « avoir sur facture déjà encaissée ». + +⚠️ **Ne pas contester l'arbitrage en revue** : en contester la mise en œuvre. + +## Acceptance Criteria + +### Volet 1 — l'avoir compté TTC (#455) + +**AC 1** — `INVOICE_CREDITED_SUBQUERY_SQL` rend le **TTC** de l'avoir émis : +`Σ (cl.line_total + ROUND(cl.line_total × cl.vat_rate / 100, 2))` sur `credit_note_lines cl` jointe à +`credit_notes cn` (`cn.id = cl.credit_note_id`), filtrée `cn.invoice_id = i.id AND +cn.status = 'issued'`, `COALESCE(…, 0)`. **Même formule d'arrondi, ligne par ligne**, que +`INVOICE_TTC_SUBQUERY_SQL` et que `line_vat_amount` : c'est ce qui la fait concorder avec +l'écriture. + +**AC 2** — `INVOICE_CREDITED_DERIVED_JOIN_SQL` rend la même grandeur sous forme de table dérivée +(alias `cnt`, colonne `credited`), agrégée par `invoice_id`. + +**AC 3** — Les doc-comments des deux constantes disent **l'unité** (TTC, arrondi ligne par ligne) et +**pourquoi pas `credit_notes.total_amount`** (HT, miroir de `invoices.total_amount`) ; celui +d'`amount_due` dit que ses trois termes sont TTC. + +**AC 4** — ⛔ **Aucune migration, aucune colonne.** Le résiduel se **calcule** (24-2, D3) ; stocker +un TTC d'avoir recréerait un chiffre qui peut diverger. `credit_notes.total_amount` reste HT et +n'est pas renommé. + +**AC 5** — **DRY** : la formule « TTC d'une ligne » (`line_total + ROUND(line_total * vat_rate / 100, +2)`) n'est pas recopiée une troisième et une quatrième fois à la main. Elle est factorisée en **une +seule source** dont dérivent les quatre constantes (TTC facture scalaire et jointe, avoir scalaire +et joint) — par exemple une macro `macro_rules!` rendant un littéral via `concat!`, les constantes +restant des `&'static str`. Si la factorisation s'avère impossible sans perdre le caractère +`const`, le test de parité de l'AC 6 en tient lieu **et le doc-comment le dit**. + +**AC 6** — **Le test de parité promis par `invoice_settlements.rs:27-29`, et qui n'existe pas**, +est écrit : pour un jeu de factures qui couvre **sans règlement / un règlement / deux règlements / +avoir émis / avoir brouillon / deux taux de TVA dont 8,1 %**, la forme scalaire et la forme jointe +rendent **la même valeur, facture par facture**, pour le réglé (`st.settled`) **et** pour l'avoir +(`cnt.credited`). ⚠️ Patron : `crates/kesh-db/tests/invoice_ttc_parity.rs`. + +**AC 7** — ⛔ **Concordance avec le grand livre** : pour une facture multi-taux (dont 8,1 %) +créditée, le montant d'avoir que rend la sous-requête est **égal au crédit porté au compte de +créance par l'écriture de l'avoir** (`credit_notes.journal_entry_id` → ligne de crédit du compte +`default_receivable_account_id`). C'est l'assertion qui aurait attrapé #455 ; une comparaison au +seul HT ou à une valeur recalculée en Rust **ne suffit pas**. + +**AC 8** — Une facture validée à 8,1 %, **non réglée, créditée** : `amount_due` = **0** (et non la +TVA) — **au repository ET à la frontière HTTP** : `GET /api/v1/invoices/{id}` rend `amountDue` **présent et non `null`** +(`null` veut dire « non calculé », `routes/invoices.rs:242-248` ; ⛔ aucun `unwrap_or`, aucune +valeur par défaut dans l'assertion), sérialisé en +**chaîne** (sérialisation par défaut de `rust_decimal`, `kesh-api/Cargo.toml:39`), dont la valeur **parsée en +`Decimal`** vaut zéro — ⛔ ni comparaison de chaîne (l'échelle suit le calcul SQL : `"0.0000"` et +`"0.00"` sont également justes), ni `f64` (patron à ne pas suivre : +`invoice_echeancier_e2e.rs:636-641`). Même règle pour `amountSettled`. Une facture validée à 8,1 %, **réglée de 40 puis créditée** (état **monté en SQL**, cf. +AC 13) : `amount_due` = **−40**, le montant encaissé en trop sur une vente annulée — et non +`TVA − 40`. ⚠️ **Pas d'écrêtage à zéro** : le doc-comment d'`amount_due` l'interdit déjà. + +### Volet 2 — l'avoir refusé sur une facture réglée (#456) + +**AC 9** — `create_credit_note` refuse une facture qui porte **au moins une ligne +`invoice_settlements` OU** `paid_at IS NOT NULL` — **les deux, jamais l'une seule**, comme +`UnvalidationBlocker::Settled`. La lecture des règlements se fait **après** le verrou de la facture +(`FOR UPDATE`, première instruction de la transaction, `credit_notes.rs:280-289`) et **dans** la +transaction ; elle est **verrouillante** (`… FOR UPDATE` ou `LOCK IN SHARE MODE`). ⛔ Leçon de la +25-3-b : sous REPEATABLE READ, une lecture non verrouillante fige l'instantané — ne pas l'ajouter +**avant** le verrou. + +**AC 10** — Le refus est une **variante dédiée** de `DbError`, pas `IllegalStateTransition` — même +motif que `InvoiceNotUnvalidatable` (`errors.rs:453`, raison écrite `:119-130`) : le générique ne dit ni ce qui bloque ni +quoi faire. Elle porte l'identifiant du premier règlement trouvé (`Option`, `None` si seul +`paid_at` est posé) et le numéro de la facture. Code machine **`CREDIT_NOTE_INVOICE_SETTLED`**, +HTTP **409**, ajouté à `DbError::error_code()` (`errors.rs:641`) et à toute garde qui énumère les +codes. + +**AC 11** — **Le cas « payée » passe par la même variante** : AC2bis cesse de rendre le générique. +Une seule garde, un seul message, qu'elle soit déclenchée par `paid_at` ou par une ligne de +règlement. + +**AC 12** — Message, **quatre locales**, clé **`error-credit-note-blocked-settled`**, mapping dans +`kesh-api/src/errors.rs` sur le patron de `error-invoice-unvalidate-blocked-settled` +(`:2528-2530`) : fr « Cette facture porte un règlement, même partiel : annulez-le d'abord pour +pouvoir émettre un avoir. » Registre du glossaire (`docs/i18n-glossaire.md` § Registre : de = +*Sie*, it = 2ᵉ personne du singulier, en neutre). `details` : `{ "invoiceId", "settlementId" }`. + +**AC 13** — La fixture `monter` (`invoice_settlement.rs:937-1019`) cesse d'obtenir l'état +`InvoiceCredited` **par le chemin que 25-4-a ferme**, avec un commentaire qui dit que l'état n'est +**plus atteignable par l'application** depuis 25-4-a, mais **reste possible** par l'import d'une +sauvegarde antérieure — ce qui justifie de garder `SettlementCancelBlocker::InvoiceCredited`. +⛔ Ne **pas** supprimer ce motif ni ses tests. Ses doc-comments, qui le présentent comme un chemin +normal (« créditée par un avoir après ce règlement … pas une anomalie », `errors.rs:214-217` ; +« Hors `validated`, c'est donc un avoir », `invoice_settlements_write.rs:327-330`), disent +désormais que l'état n'est plus atteignable **que par l'import d'une sauvegarde antérieure**. + +⚠️ **`monter` sert quinze cas, pas un** : `la_precedence_de_l_annulation_lecture_et_ecriture` +(`:1056-1064`) l'appelle avec les cinq motifs seuls **et les dix paires** (`RANGS`, +`assert_eq!(cas.len(), 15)`), dont **quatre combinent `InvoiceCredited`** avec un motif monté +**après** lui dans le même corps (clôture d'exercice, rapprochement bancaire, compte archivé, +aucun exercice ouvert). Le montage doit rester compatible avec ces quatre suites, et **les quinze +cas restent verts**. + +⚠️ **Un avoir `issued` ne s'insère pas en SQL nu** : `chk_credit_notes_issued_has_je` exige +`credit_note_number` **et** `journal_entry_id` non nuls (`20260627000001_credit_notes.sql:53-54`), +donc une écriture de contre-passation valide. **Gabarit recommandé**, qui garde les **deux +écritures produites par les vrais chemins** et ne touche en SQL qu'un rattachement : régler (vrai +chemin) ; **détacher** la ligne de règlement vers une facture auxiliaire validée de la même société +(`UPDATE invoice_settlements SET invoice_id = ?`) ; créer l'avoir (vrai chemin, désormais accepté) ; +**rattacher** la ligne à la facture d'origine. La facture auxiliaire est créée **dans `monter`** +par le helper existant `validated_invoice` (même société, montant quelconque), n'est jamais réglée +ni créditée, et ne sert qu'à porter la ligne le temps de l'avoir ; aucun motif ne la lit. Seule +contrainte d'unicité d'`invoice_settlements` : `uq_invoice_settlements_entry (journal_entry_id)` +(`20260827000001_invoice_settlements.sql:65`) — le détachement ne la touche pas. ⚠️ Pendant le +détachement, l'écriture de règlement reste celle de la facture d'origine : aucun motif de +`settlement_cancel_blocker` ne doit être évalué entre les deux `UPDATE`. Si le gabarit se révèle +impraticable, **arrêter et demander** : le repli naturel — construire l'écriture d'avoir avec les +lignes de `generate_credit_note_journal_lines` — est **impossible depuis un test**, la fonction +étant privée (`credit_notes.rs:187`), et la rendre publique pour un test n'est pas un choix du +développeur. La facture auxiliaire est datée **dans l'exercice seedé** (`d − 20` suffit) : c'est ce +qui garde valide le cas « aucun exercice ouvert ». + +**AC 14** — Frontend : le bouton « Créer un avoir » (`invoices/[id]/+page.svelte:836`) **suit le +précédent du bouton « Dévalider »** (`:864-871`) : la condition `!invoice.paidAt` est **retirée**, le +bouton s'affiche pour `canManage`, et **le serveur refuse avec le motif nommé**, que le dialogue +affiche déjà (`creditNoteError = err.message`, `:101`). ⛔ L'écran **ne rejoue pas** la règle métier : +une condition client qui en couvre une moitié laisse l'utilisateur sans rien à lire, et une qui la +couvre entière se désynchronise à la première évolution du serveur. La garde étant désormais +**unique** côté serveur (AC 11), le motif « payée » et le motif « réglée en partie » s'affichent de +la même façon. Le commentaire de `:864-871` est étendu aux deux boutons, ou dupliqué au-dessus du +second. ⚠️ Le commentaire du bloc Suspendre/Reprendre (`:842-844`) affirme que ces boutons sont +« masqués, comme les boutons voisins « Créer un avoir » / « Supprimer » » : il devient faux et se +corrige dans le même patch. + +⚠️ **Changement visible sur une facture entièrement payée** : le bouton, masqué jusqu'ici, apparaît ; +l'utilisateur ouvre le dialogue, confirme, et lit le refus nommé (AC 12) dans le dialogue même +(`creditNoteError`, `:77`, `:101`). C'est le comportement de « Dévalider » depuis le 2026-09-19, et +le CHANGELOG le dit. + +**AC 15** — Rien n'est **réparé** dans les données existantes : pas de migration, pas de rejeu +(`CLAUDE.md` : aucune donnée de production à protéger ; précédent 24-2 D7). Les états hérités +restent **visibles** — reste dû négatif sur la fiche, motif `InvoiceCredited` à l'annulation du +règlement — et c'est ce que les textes disent (AC 17). + +### Volet 3 — tests, textes + +**AC 16** — Tests, au minimum (noms indicatifs) : + +| Test | Prouve | +|---|---| +| `credited_amount_is_ttc_and_matches_the_ledger` | AC 1, AC 7 — facture multi-taux, avoir émis : sous-requête = crédit créance de l'écriture d'avoir | +| `amount_due_of_a_credited_invoice_is_zero` | AC 8 — 8,1 %, non réglée : `0`, pas `8.10` | +| e2e API `get_credited_invoice_reports_zero_amount_due` | AC 8 — la surface exposée aux clés API | +| `amount_due_after_settlement_then_legacy_credit_is_minus_settled` | AC 8 — état hérité monté en SQL : `−40` | +| `settled_and_credited_forms_are_at_parity` | AC 6 | +| `credit_note_refused_on_partially_settled_invoice` | AC 9, AC 10 — **rien écrit** : aucun avoir, facture `validated`, `version` inchangée, solde de créance inchangé, séquence d'avoir non consommée | +| `credit_note_refused_on_paid_invoice` (**existant, réécrit**) | AC 11 — nouvelle variante, plus `IllegalStateTransition` | +| `credit_note_accepted_after_the_settlement_is_cancelled` | la promesse du message : annuler le règlement rouvre l'avoir | +| `credit_note_waits_for_a_concurrent_settlement` | AC 9 — **entrelacement** : une transaction verrouille la facture et insère un règlement ; `create_credit_note` lancé en parallèle attend, puis **refuse** après le commit. Patron : la sonde d'entrelacement de la 25-3-b | +| e2e API `credit_note_on_partially_settled_invoice_is_409` | la frontière HTTP : 409, `CREDIT_NOTE_INVOICE_SETTLED`, message fr | +| Vitest fiche facture | AC 14 — bouton **présent** sur une facture réglée (en partie **et** en totalité) ; au refus, le dialogue affiche le message du serveur | + +⛔ **Chaque test porte au moins une facture à TVA non nulle.** À 0 %, HT = TTC et #455 est +invisible — c'est ainsi qu'il est passé. + +⛔ **Mutations à exécuter et à consigner** (résultat **observé**, pas supposé) : (m1) revenir à +`SUM(cn.total_amount)` dans la forme scalaire ; (m2) idem dans la forme jointe seule ; (m3) retirer +la lecture des règlements de la garde (ne garder que `paid_at`) ; (m4) retirer `paid_at` (ne garder +que la ligne) ; (m5) lire les règlements **avant** le verrou, non verrouillant ; (m6) remettre +`!invoice.paidAt` sur le bouton ; (m7) mapper la variante sur le générique `ILLEGAL_STATE_TRANSITION`. Chacune doit faire rougir au moins un test. + +**AC 17** — Manuels et notes (`docs/manual/fr/user-manual.tex`, PDF régénéré et **contrôlé +aplati**) : +- `:1040` et `:2073` : « un avoir est refusé sur une facture **marquée payée** » devient « … sur une + facture **réglée, même en partie** » — annuler d'abord le règlement ; +- § *Avoirs* (`\label{sec:avoirs}`, `:1007`) : le refus et son motif sont nommés à côté du refus + « compte de produit archivé » ; +- `:963` et `:1455` (« la facture a été créditée par un avoir… ») : précisés comme **cas hérité** + — une facture créditée avant la v0.12.1, ou restaurée d'une sauvegarde antérieure ; +- `CHANGELOG.md` `[0.12.1]` *Fixed* : les deux défauts, en langage d'utilisateur — et, pour #455, + **nommer l'API** : `GET /invoices/{id}` rendait la TVA en `amountDue` sur une facture créditée ; + une intégration tierce qui le lisait doit le savoir. + +⚠️ **Greper le symptôme, pas la phrase** : `grep -rniE "payée|encaissée|réglée" docs/manual/fr/*.tex` +restreint aux lignes qui parlent d'avoir, plus le PDF aplati. Les manuels DE/IT/EN sont des stubs. + +## Tasks / Subtasks + +- [x] **T1 — formule de l'avoir TTC** (AC 1-5) : factoriser la formule de ligne, réécrire les deux + constantes d'avoir, doc-comments. +- [x] **T2 — parité et concordance** (AC 6-8) : nouveau fichier de test sur le patron + `invoice_ttc_parity.rs`. ⚠️ Le helper `validated_invoice` (`invoice_settlement.rs:32`) est + **à 0 % en dur** et passe une écriture de vente **au HT** : les factures à 8,1 % se montent + par le **vrai chemin de validation**, sur le patron de `create_and_validate` + (`credit_notes_repository.rs`) ; l'état hérité, par le gabarit de l'AC 13. +- [x] **T3 — garde de l'avoir** (AC 9-11) : lecture verrouillante des règlements après le verrou, + variante `DbError`, `error_code()`. +- [x] **T4 — API et i18n** (AC 12) : mapping, clé ×4 ; e2e 409. +- [x] **T5 — fixture `monter`** (AC 13) : gabarit « détacher, créditer, rattacher », commentaire ; + **les quinze cas** de la précédence restent verts, dont les quatre paires avec `InvoiceCredited`. +- [x] **T6 — frontend** (AC 14) : condition du bouton retirée, commentaire, Vitest. +- [x] **T7 — tests et mutations** (AC 16) : m1 à **m7** exécutées, résultats consignés. +- [x] **T8 — textes** (AC 17) : manuel, PDF aplati, CHANGELOG. +- [x] **T9 — gates** : backend complet (base remise à zéro), frontend complet, E2E complet. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- ⛔ **Ne pas « corriger » `credit_notes.total_amount` en TTC.** Il est HT par construction, miroir + de `invoices.total_amount`, et d'autres lecteurs le consomment comme tel (export, PDF, liste des + avoirs). Le défaut est dans **le lecteur**, pas dans la colonne. +- ⛔ **Ne pas calculer le TTC de l'avoir en Rust** puis le soustraire : `amount_due` doit rester + **une requête**, et la forme jointe est destinée aux agrégats de 25-4-b (pas de N+1). +- ⛔ **Ne pas supprimer `SettlementCancelBlocker::InvoiceCredited`** : l'import d'un `.keshbackup` + v0.12.0 peut ramener l'état qu'il décrit. +- ⚠️ **Ne pas lire `cn.total_amount` × taux** : un avoir multi-taux n'a pas un taux, et l'arrondi + doit être **par ligne** pour concorder avec l'écriture (`line_vat_amount`). + +### Où regarder + +| Fichier | Rôle | +|---|---| +| `crates/kesh-db/src/repositories/invoice_settlements.rs:24-58, 122-145` | les quatre constantes, `amount_due` | +| `crates/kesh-db/src/repositories/invoices.rs:155-190` | `INVOICE_TTC_SUBQUERY_SQL` / `…_DERIVED_JOIN_SQL`, `total_ttc` | +| `crates/kesh-db/src/repositories/credit_notes.rs:187-257, 260-330, 510-575` | écriture de l'avoir, garde AC2bis, `total_amount`, bascule `cancelled` | +| `crates/kesh-db/src/errors.rs:119-175, 205-260, 300-470, 641-670` | `UnvalidationBlocker`, `SettlementCancelBlocker` (doc d'`InvoiceCredited` `:214-217`), `DbError`, `error_code()` | +| `crates/kesh-db/src/repositories/invoice_settlements_write.rs:60-72, 320-333` | verrou de `settle_invoice` ; rang 1 de `settlement_cancel_blocker` (commentaire `:327-330`, AC 13) | +| `crates/kesh-db/src/repositories/invoices.rs:1430-1448` | la garde de dévalidation, patron de l'AC 9 | +| `crates/kesh-api/src/errors.rs:2515-2560, 2741-2748` | mapping `InvoiceNotUnvalidatable`, générique | +| `crates/kesh-api/src/routes/credit_notes.rs:183-205` | handler de création | +| `crates/kesh-db/tests/invoice_ttc_parity.rs` | patron du test de parité | +| `crates/kesh-db/tests/credit_notes_repository.rs:267-299` | `credit_note_refused_on_paid_invoice` | +| `crates/kesh-db/tests/invoice_settlement.rs:32, 116-125, 937-1019` | helper à 0 %, `solde()`, `monter` | +| `frontend/src/routes/(app)/invoices/[id]/+page.svelte:90-104, 836, 864-871, 1022-1041` | dialogue d'avoir (`err.message` `:101`), bouton, précédent « Dévalider », reste dû | +| `crates/kesh-api/src/routes/invoices.rs:621-642` | `get_invoice` : `amountDue` rendu sans condition de statut | + +### Courses + +Les trois chemins qui écrivent un règlement ou un avoir, et ce qui les sérialise : + +| Chemin | Verrou sur la facture | +|---|---| +| `settle_invoice` (`invoice_settlements_write.rs:60-72`) | `FOR UPDATE`, première lecture | +| `create_credit_note` (`credit_notes.rs:280-289`) | `FOR UPDATE`, première lecture | +| `accept_one_invoice` (`kesh-api/src/routes/reconciliation.rs`) | **aucun** — lecture simple, puis `UPDATE invoices … AND version = ? AND status = 'validated'` (`:1490-1500`) | + +Le troisième se sérialise par **verrou optimiste** : si l'avoir passe d'abord, la facture est +`cancelled` et l'`UPDATE` du rapprochement n'affecte aucune ligne. Si le rapprochement passe +d'abord, son `UPDATE` tient le verrou de ligne jusqu'au commit ; `create_credit_note` l'attend sur +son `FOR UPDATE`, puis **doit** voir le règlement — d'où l'exigence d'une lecture verrouillante +(AC 9). ⚠️ Vérifier en dev que l'échec de l'`UPDATE` du rapprochement sur 0 ligne rend bien un +`FailedProposal` et annule tout (test existant ou à ajouter). + +### Gardes-fous du dépôt + +- **Aucune migration** : P1-P8 ne s'arment pas. Si une migration devient nécessaire, arrêter et + demander. +- **Gate complet** : la story touche des repositories — § *Exception `kesh-db`* : gate complet même + en cours de boucle de revue. +- **i18n** : une clé ×4 côté serveur ; `sitesTotal` ne bouge que si le frontend ajoute un + `i18nMsg(` — AC 14 n'en ajoute pas. + +### References + +- 24-2, D3 (le résiduel se calcule) et D4 (`paid_at` projection du solde) — + `24-2-encaissement-client.md:120-160`. +- 25-3-b, leçon REPEATABLE READ — mémoire `etat-epic-25`. +- Glossaire et registre — `docs/i18n-glossaire.md`. + +## Questions pour Guy + +Aucune qui bloque cette story. La question de la QR-facture jointe aux relances (Q1 de la mère) +bloque **25-4-b**, pas celle-ci. + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5. + +### Debug Log References + +- `settled_and_credited_forms_are_at_parity` attendait **huit** factures ; il y en a **sept** (six du jeu + + la facture auxiliaire du gabarit). Erreur de décompte dans le test, pas dans le code — corrigée, + et l'assertion anti-vacuité garde des valeurs **non nulles** des deux côtés. +- `SUM(next_number)` rend un `DECIMAL` sous MariaDB : `CAST(… AS SIGNED)` dans l'empreinte du test de + refus. +- Un doc-comment égaré (« Paramètres de recherche, tri et pagination. ») précédait + `INVOICE_TTC_SUBQUERY_SQL` : remis au-dessus d'`InvoiceListQuery`, qu'il décrit. +- ⚠️ **Relevé hors périmètre, non corrigé** : la clé voisine `error-invoice-unvalidate-blocked-settled` + (it-CH) dit « annullatelo » — forme *voi*, contraire au registre du glossaire (2ᵉ personne du + singulier). La nouvelle clé suit le glossaire (« annullalo »). + +### Completion Notes List + +- **Formule du TTC d'une ligne, source unique** (AC 5) : macro `line_ttc_sql!` (`invoices.rs`), + rendant un littéral via `concat!` ; les **quatre** constantes en dérivent (TTC facture scalaire et + jointe, avoir scalaire et joint), qui restent des `&'static str`. +- **Avoir compté TTC** (AC 1-4) : les deux constantes d'avoir somment `credit_note_lines` (jointes à + `credit_notes`, `status = 'issued'`), arrondi ligne par ligne ; doc-comments sur l'unité et sur + `total_amount` HT. Aucune migration. +- **Parité et concordance** (AC 6-8) : `tests/invoice_amount_due_parity.rs`, 4 tests, **tous à TVA + non nulle** ; factures par le vrai chemin de validation ; l'état hérité par le gabarit « détacher, + créditer, rattacher ». Le crédit de l'avoir égale le crédit de la créance **dans l'écriture** + (1207.71 sur trois lignes à 8,1 % / 2,6 % / sous-centime), et le test s'assure que `total_amount` + (HT) en diffère — sans quoi il ne distinguerait rien. +- **Garde de l'avoir** (AC 9-11) : lecture **verrouillante** des règlements, **après** le verrou de la + facture ; une ligne **ou** `paid_at` ; variante `DbError::CreditNoteBlockedBySettlement` + (`CREDIT_NOTE_INVOICE_SETTLED`), qui remplace aussi le générique du cas « payée ». +- **API et i18n** (AC 12) : 409, message suffixé du numéro de facture, `details { invoiceId, + settlementId }` ; clé `error-credit-note-blocked-settled` ×4. +- **Fixture `monter`** (AC 13) : gabarit « détacher, créditer, rattacher » ; les **quinze cas** de la + précédence restent verts. Doc-comments de `InvoiceCredited` et du rang 1 : état hérité. +- **Frontend** (AC 14) : condition `!invoice.paidAt` retirée du bouton, commentaires Suspendre/Reprendre + corrigé ; `data-testid` sur le bouton, la confirmation et l'erreur du dialogue. +- **Textes** (AC 17) : manuel (refus nommé en § Avoirs, deux limites corrigées pour couvrir le règlement + partiel — l'une « réglée, même en partie », l'autre « déjà encaissée, même en partie », deux motifs d'annulation de règlement précisés comme **cas hérité**), PDF régénéré et + contrôlé aplati (0 `??`, six phrases de contrôle présentes, « marquée payée » absente) ; CHANGELOG + *Fixed* ×2 (dont **l'API** pour #455) et *Changed* (le bouton). Grep large du symptôme « avoir sur + facture payée » : aucun autre site. + +**Tests ajoutés** (recomptés depuis la source, `main` → arbre de travail) : `invoice_amount_due_parity` +0 → 4, `credit_notes_repository` 6 → 9 (dont `credit_note_refused_on_paid_invoice` réécrit), +`invoice_echeancier_e2e` 11 → 13, Vitest `invoice-settlements-page.test.ts` 2 → 5. + +**Mutations** (résultat **observé**, chaque fichier restauré après) : + +| Mutation | Tests rouges | +|---|---| +| m1 — avoir scalaire en `SUM(cn.total_amount)` | 4 / 4 de `invoice_amount_due_parity` | +| m2 — avoir joint seul en `SUM(total_amount)` | `settled_and_credited_forms_are_at_parity` | +| m3 — garde sur `paid_at` seul | `…refused_on_partially_settled_invoice`, `…accepted_after_the_settlement_is_cancelled`, `…waits_for_a_concurrent_settlement` | +| m4 — garde sur la ligne seule | `credit_note_refused_on_paid_invoice` | +| m5 — lecture avant le verrou, non verrouillante | `credit_note_waits_for_a_concurrent_settlement` | +| m6 — `!invoice.paidAt` remis sur le bouton | 2 Vitest (bouton sur facture payée ; refus dans le dialogue) | +| m7 — code mappé sur `ILLEGAL_STATE_TRANSITION` | `credit_note_on_partially_settled_invoice_is_409` | + +### Gates + +- Backend : base remise à zéro (tmpfs + migrations + seed), `scripts/test-fast.sh` (fmt + clippy + + nextest) **2484 / 2484** (2475 + 9). +- Frontend : `check` 0 erreur (27 avertissements), `lint-i18n-ownership` PASS, `test:unit` + **826 / 826** (823 + 3), build OK. +- E2E : `kesh_e2e` reconstruite, montage complet (`smtpConfigured:true`, répertoires inbox et documents), + frontend buildé après le dernier patch, run à **22:52 UTC** : **222 passés / 10 échoués / 19 + ignorés**. Les 7 de KF-029 ; les 3 autres — `product-revenue-account:133`, `sidebar-navigation:75` + (pollution documentée) et ⚠️ `accounts.spec.ts:185` (**non documenté**) — passent **rejoués seuls, + 18 / 18**, et ne touchent aucun écran de la story. Trois pollutions dans un même run, là où la + baseline en compte une : à surveiller, sans régression de la story. + +### File List + +| Fichier | Nature | +|---|---| +| `crates/kesh-db/src/repositories/invoices.rs` | macro `line_ttc_sql!`, constantes TTC recomposées, doc-comment replacé | +| `crates/kesh-db/src/repositories/invoice_settlements.rs` | avoir TTC (scalaire, joint), doc d'`amount_due` | +| `crates/kesh-db/src/repositories/credit_notes.rs` | garde : lecture verrouillante des règlements | +| `crates/kesh-db/src/errors.rs` | `CreditNoteBlockedBySettlement`, `error_code()`, doc d'`InvoiceCredited` | +| `crates/kesh-db/src/repositories/invoice_settlements_write.rs` | commentaire du rang 1 | +| `crates/kesh-api/src/errors.rs` | mapping 409 | +| `crates/kesh-i18n/locales/{fr,de,it,en}-CH/messages.ftl` | `error-credit-note-blocked-settled` | +| `crates/kesh-db/tests/invoice_amount_due_parity.rs` | **nouveau** — parité, concordance, zéro, hérité | +| `crates/kesh-db/tests/credit_notes_repository.rs` | refus payée réécrit ; partiel, réouverture, entrelacement | +| `crates/kesh-db/tests/invoice_settlement.rs` | `monter` : gabarit « détacher, créditer, rattacher » | +| `crates/kesh-api/tests/invoice_echeancier_e2e.rs` | e2e : `amountDue` nul, 409 | +| `frontend/src/routes/(app)/invoices/[id]/+page.svelte` | bouton, commentaires, `data-testid` | +| `frontend/src/routes/(app)/invoices/[id]/invoice-settlements-page.test.ts` | 3 Vitest | +| `docs/manual/fr/user-manual.tex` / `.pdf` | avoirs, limites, cas hérité | +| `CHANGELOG.md` | *Fixed* ×2, *Changed* | + +## Change Log + +- **2026-09-27** — **revue de code P1** (Sonnet, diff `ab9b9448..9bf2bb2b`, prompt + `25-4-a-review-prompt-p1.md`) — **0 au-dessus de LOW, 1 LOW** : le compte rendu attribuait la + formule « réglée, même en partie » aux deux limites du manuel, la seconde dit « déjà encaissée, + même en partie » — compte rendu corrigé, le manuel était juste. Les huit axes exercés avec preuve : + les constantes TTC facture **octet pour octet identiques** à leur forme d'avant, l'entrelacement ne + peut pas passer à vide (le motif ne correspond qu'à la requête verrouillante de l'avoir), tous les + lecteurs d'`amount_due` inchangés et resserrés, PDF aplati, i18n, Vitest, décomptes recomptés. + Non exercé : la ré-exécution des gates et des mutations (interdite à la lentille). **Revue close en + une passe ; story `done`.** +- **2026-09-27** — **dev** (Opus 5.5) : formule du TTC de ligne factorisée (`line_ttc_sql!`), avoir + compté TTC, garde verrouillante de l'avoir sur facture réglée, variante `CREDIT_NOTE_INVOICE_SETTLED` + ×4 locales, fixture `monter` par le gabarit, bouton aligné sur « Dévalider », manuel et CHANGELOG. + +9 tests Rust, +3 Vitest ; **7 / 7 mutations tuées**. Gates : backend 2484/2484, frontend 826/826, + E2E 222/10/19 sans régression (7 KF-029 + 3 pollutions, rejouées seules vertes). +- **2026-09-27** — **validation P4 ciblée** (Sonnet, diff `1344b3a2..b18f7cd2`, prompt + `25-4-a-validate-prompt-p4.md`) — rapporte **1 MEDIUM, 3 LOW**, tous confirmés et corrigés. + **MEDIUM reclassé LOW** par l'orchestrateur : `invoice_settlements_write.rs` manquait au tableau + « Où regarder », mais l'AC 13 qui prescrit son édition le **nomme avec sa ligne** — une omission de + navigation, sans risque qu'une prescription soit perdue. LOW : citation `:326-329` → `:327-330` ; + plage d'`errors.rs` étendue à `SettlementCancelBlocker` ; commentaire de la ligne mère de + `sprint-status` resté à trois stories. Le gabarit de l'AC 13, l'AC 8 et l'AC 14 revérifiés sans + défaut ; la sérialisation en chaîne confirmée dans la source de `rust_decimal` 1.42.1. + ⛔ **Boucle de validation CLOSE en 4 passes** : `1H/3M/1L → 1M → 2M/5L → 0 > LOW` (après + reclassement), rotation Sonnet → Haiku → Opus → Sonnet, deux passes ciblées ; toutes les + corrections ont porté sur la spec, aucune sur du code. **Prête pour le dev.** +- **2026-09-27** — Arbitrage de Guy : le refus de l'avoir sur facture réglée (en tout ou en partie) reste ; sa levée est tracée par #471. +- **2026-09-27** — **validation P3 ciblée** (Opus, diff aplati des remédiations P1-P2, prompt + `25-4-a-validate-prompt-p3.md`) — **2 MEDIUM, 5 LOW**, tous de **propagation** des corrections P1, + tous confirmés par grep. MEDIUM : T7 disait encore « m1 à m6 » (m7 ajoutée) ; l'AC 14 rendait faux + le commentaire Suspendre/Reprendre (`:842-844`) et l'AC 13 laissait deux doc-comments décrire + l'avoir après règlement comme un chemin normal. LOW : « non nul » pour « non `null` » (et + `unwrap_or` interdit) ; repli de l'AC 13 impossible (fonction privée) — remplacé par « arrêter et + demander », date de la facture auxiliaire précisée ; cause de la sérialisation en chaîne ; + fiche mère et `sprint-status` en retard d'une passe ; T2 ne disait pas comment monter une facture + à 8,1 %. Le gabarit « détacher, créditer, rattacher » a été **exécuté mentalement** contre + `create_credit_note`, le rang 1 et les quatre paires : il tient. Référence de l'AC 10 corrigée + (`errors.rs:453`). Aucun code de production touché par ces corrections. +- **2026-09-27** — **validation P2** (Haiku, diff aplati, prompt `25-4-a-validate-prompt-p2.md`) — + rapporte 3 MEDIUM et 1 LOW ; **un seul MEDIUM retenu** : l'AC 13 ne disait pas d'où vient la facture + auxiliaire du gabarit — précisé (helper `validated_invoice`, contrainte d'unicité vérifiée, aucun + motif évalué pendant le détachement). **Écartés** : deux « MEDIUM » reprochent au **code** de ne pas + encore faire ce que la fiche prescrit (bouton `:836`, test e2e absent) — ce n'est pas un défaut de + spec ; le LOW (deux formulations du manuel) est déjà couvert par l'AC 17. ⚠️ **Repris par + l'orchestrateur**, la priorité 3 que la passe n'a pas tranchée : `amountDue` est une **chaîne** à + l'échelle du calcul SQL — l'AC 8 impose désormais `null` interdit et une comparaison **en + `Decimal`**, ni chaîne ni `f64` ; et la priorité 2 : ce que voit l'utilisateur sur une facture + entièrement payée (bouton désormais visible, refus nommé dans le dialogue) est écrit à l'AC 14. + La passe affirmait aussi que `accept_one_invoice` ne crée pas de règlement — faux + (`invoice_settlements::create_in_tx`, `reconciliation.rs:1416-1428`) ; sans conséquence sur la fiche. +- **2026-09-26** — **validation P1** (Sonnet, prompt `25-4-a-validate-prompt-p1.md`) — **1 HIGH, + 3 MEDIUM, 1 LOW**, tous confirmés dans le code avant correction. **HIGH** : le récit « #455 + latent » était faux — `get_invoice` rend `amountDue` sans condition de statut, et + `GET /invoices/{id}` est ouverte aux clés API : une facture créditée à TVA non nulle y rend la + TVA ; récit corrigé ici et dans la fiche mère, AC 8 étendu à la frontière HTTP, e2e ajouté, + CHANGELOG à nommer l'API. **MEDIUM** : `DbError::code()` n'existe pas — c'est `error_code()` ; + AC 14 contredisait le précédent « Dévalider » qu'il citait — il le suit désormais (bouton + visible, le serveur refuse avec le motif nommé, m7 ajoutée) ; AC 13 ignorait que `monter` sert + quinze cas dont quatre paires avec `InvoiceCredited`, et qu'un avoir `issued` exige une écriture + (`chk_credit_notes_issued_has_je`) — gabarit « détacher, créditer, rattacher » prescrit. **LOW** : + `err.message` est à `:101`, non `:98`. Axes tous exercés ; non exercé : la sémantique REPEATABLE + READ n'a pas été mesurée (raisonnée). +- **2026-09-26** — Créée (Opus 5.5) après découpage de la 25-4. Inventaire vérifié par `grep -nF` + sur chaque site cité. Deux faits hors des issues : #455 est **latent** sur les données neuves et + ne se voit que dans l'état que #456 produit — d'où leur réunion ; et la fixture `monter` encode + #456 comme attendu. + +[#471]: https://github.com/guycorbaz/kesh/issues/471 +[#455]: https://github.com/guycorbaz/kesh/issues/455 +[#456]: https://github.com/guycorbaz/kesh/issues/456 diff --git a/_bmad-output/implementation-artifacts/25-4-a-review-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-a-review-prompt-p1.md new file mode 100644 index 00000000..f19f3516 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-a-review-prompt-p1.md @@ -0,0 +1,51 @@ +# Prompt — revue de code P1, Story 25-4-a (le résiduel juste) + +*Versionné le 2026-09-27. **Une lentille** (Sonnet), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-propager-le-residuel`. Diff à relire : +`git diff ab9b9448 9bf2bb2b` (commit de dev, après la validation close). Fiche : +`_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md` (AC, Dev Notes, Dev Agent Record). +Issues : `gh issue view 455`, `gh issue view 456`. Règles : `CLAUDE.md`. **Arbitrages de Guy** (fiche +§ Arbitrage, fiche mère `25-4-propager-le-residuel.md`) : ne pas les contester. + +## Axes — tous obligatoires + +1. **SQL** : les quatre constantes produites par `line_ttc_sql!` (`invoices.rs`, + `invoice_settlements.rs`) — développe-les à la main et vérifie le texte exact (espaces, alias, + `\` de continuation dans un `concat!`), l'égalité **octet pour octet** des deux constantes TTC + facture avec leur forme d'avant (`git show ab9b9448:crates/kesh-db/src/repositories/invoices.rs`), + et l'agrégation de la forme jointe de l'avoir (GROUP BY, jointure, filtre `issued`). +2. **La garde** (`credit_notes.rs`) : ordre verrou → lecture verrouillante, scoping `company_id`, + chemins d'erreur, rien écrit en cas de refus. Les tests prouvent-ils ce qu'ils annoncent, ou + peuvent-ils passer à vide (surtout l'entrelacement : que se passe-t-il si + `attendre_une_requete_en_cours` voit une AUTRE requête que celle de l'avoir) ? +3. **Tous les lecteurs** des grandeurs changées : `grep -rn "INVOICE_CREDITED\|amount_due\|INVOICE_TTC" crates/` + — un appelant change-t-il de valeur sans test ? Et tout appelant de `create_credit_note`, ou qui + matchait `IllegalStateTransition` pour le cas « payée » (API, frontend, tests) ? +4. **La fixture `monter`** : le gabarit tient-il pour les quinze cas ? Un motif lit-il la facture + auxiliaire ? +5. **Le manuel dit-il vrai ?** `docs/manual/fr/user-manual.tex` — les passages modifiés (§ Avoirs, les + deux limites, les deux « cas hérité ») contre le code ; **le PDF aplati** + (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '`, vers + `/tmp/claude-1000/-home-gcorbaz-devel-kesh/ca5ce2e2-67a3-4eeb-817f-c2de35620a1c/scratchpad/`), + `??` compris. Le renvoi `\S\ref{sec:reglement-client}` pointe-t-il où il faut ? Le CHANGELOG + décrit-il juste (l'API, le bouton) ? +6. **i18n** : la clé ×4 — sens, registre du glossaire (`docs/i18n-glossaire.md` § Registre), terme + « avoir » du glossaire. +7. **Frontend** : le bouton, les commentaires, les `data-testid` ; les Vitest tuent-ils vraiment m6 ? +8. **Dev Agent Record** : chaque décompte (tests, mutations, gates) **recompté** depuis la source. + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL / HIGH / MEDIUM / LOW), fichier:ligne, **preuve** (commande et + résultat, code lu), correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base — +`scripts/prepare-release.sh`, `scripts/regen-test-schema.sh`, `scripts/install-hooks.sh`, +`scripts/test-fast.sh`, `scripts/mem-guard.sh`, `make`, `latexmk`, tout `git commit`/`push`/`add`/ +`stash`/`reset`/`rebase`/`checkout`/`switch`/`worktree`, `sqlx migrate`, `cargo test`/`nextest`, +`npm run`, `npx playwright`. Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, +`pdftotext` vers le scratchpad, `cargo check`, `cargo expand` s'il est installé. diff --git a/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p1.md new file mode 100644 index 00000000..03a3ca11 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p1.md @@ -0,0 +1,50 @@ +# Prompt — validation P1, Story 25-4-a (le résiduel juste) + +*Versionné le 2026-09-26. **Une lentille** (Sonnet), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-propager-le-residuel`. Fiche à valider : +`_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md`. Fiche mère (inventaire, +découpage) : `25-4-propager-le-residuel.md`. Issues : `gh issue view 455`, `gh issue view 456`. +Contexte de la grandeur : `24-2-encaissement-client.md` § D3, D4, AC 5-7. Règles du dépôt : +`CLAUDE.md`. Checklist BMAD : `.claude/skills/bmad-create-story/checklist.md`. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme — relis le code. +2. **La formule** : la sous-requête TTC de l'avoir prescrite (AC 1-2) concorde-t-elle réellement, + centime par centime, avec l'écriture de l'avoir (`generate_credit_note_journal_lines`, + `line_vat_amount` — lis `kesh-core`, sens d'arrondi de `ROUND` MariaDB vs Rust) ? Et avec + `INVOICE_TTC_SUBQUERY_SQL` ? Un écart d'un centime possible est un finding. +3. **La garde** (AC 9-11) : inventorie **tous** les chemins qui créent un règlement ou un avoir + (pars du symptôme : `grep -rn "INSERT INTO invoice_settlements\|create_credit_note\|INSERT INTO credit_notes" crates/`), + et leur sérialisation (verrou pessimiste ou optimiste). La fiche en nomme trois : en manque-t-il ? + Le raisonnement REPEATABLE READ de § Courses est-il juste ? +4. **L'état hérité** : l'import d'une sauvegarde ramène-t-il vraiment l'état (règlement + avoir) ? + Les textes prescrits (AC 17) le disent-ils juste ? D'autres lecteurs de `amount_due` ou des + constantes changent-ils de valeur avec l'AC 1 (inventaire : `grep -rn "INVOICE_CREDITED\|amount_due" crates/ frontend/src`) ? +5. **Les tests** (AC 16) : chacun prouve-t-il ce qu'il annonce, ou peut-il passer à vide ? Les + mutations m1-m6 sont-elles tuables par les tests listés ? Une mutation que rien ne tuerait est + un finding. +6. **Le manuel** : les lignes citées (AC 17) existent-elles et disent-elles ce qu'on veut changer ? + Contrôle aussi le **PDF aplati** (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '`, + vers `/tmp/claude-1000/-home-gcorbaz-devel-kesh/ca5ce2e2-67a3-4eeb-817f-c2de35620a1c/scratchpad/`). + Une autre phrase du manuel ou de l'admin-manual, de l'api-external, du README, promet-elle + l'ancien comportement ? +7. **Le découpage** (fiche mère) : quelque chose de 25-4-a appartient-il en fait à b ou c, ou + l'inverse ? La latence de #455 est-elle correctement établie ? + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL / HIGH / MEDIUM / LOW), l'endroit exact de la fiche, **la + preuve** (commande et résultat, code lu), la correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un « 0 finding » + sans elle ne compte pas. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; n'exécute aucune commande qui écrit dans le dépôt ou dans une +base — `scripts/prepare-release.sh`, `scripts/regen-test-schema.sh`, `scripts/install-hooks.sh`, +`scripts/test-fast.sh`, `scripts/mem-guard.sh`, `make`, `latexmk`, tout `git commit`/`push`/`add`/ +`stash`/`reset`/`rebase`/`checkout`/`switch`/`worktree`, `sqlx migrate`, `cargo test`/`nextest`, +`npm run`, `npx playwright`. Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, +`pdftotext` vers le scratchpad, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p2.md new file mode 100644 index 00000000..f6a2662a --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p2.md @@ -0,0 +1,66 @@ +# Prompt — validation P2, Story 25-4-a (le résiduel juste) + +*Versionné le 2026-09-26. **Une lentille** (Haiku), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-propager-le-residuel`. Fiche à valider : +`_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md`. Fiche mère (inventaire, +découpage) : `25-4-propager-le-residuel.md`. Issues : `gh issue view 455`, `gh issue view 456`. +Contexte de la grandeur : `24-2-encaissement-client.md` § D3, D4, AC 5-7. Règles du dépôt : +`CLAUDE.md`. Checklist BMAD : `.claude/skills/bmad-create-story/checklist.md`. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme — relis le code. +2. **La formule** : la sous-requête TTC de l'avoir prescrite (AC 1-2) concorde-t-elle réellement, + centime par centime, avec l'écriture de l'avoir (`generate_credit_note_journal_lines`, + `line_vat_amount` — lis `kesh-core`, sens d'arrondi de `ROUND` MariaDB vs Rust) ? Et avec + `INVOICE_TTC_SUBQUERY_SQL` ? Un écart d'un centime possible est un finding. +3. **La garde** (AC 9-11) : inventorie **tous** les chemins qui créent un règlement ou un avoir + (pars du symptôme : `grep -rn "INSERT INTO invoice_settlements\|create_credit_note\|INSERT INTO credit_notes" crates/`), + et leur sérialisation (verrou pessimiste ou optimiste). La fiche en nomme trois : en manque-t-il ? + Le raisonnement REPEATABLE READ de § Courses est-il juste ? +4. **L'état hérité** : l'import d'une sauvegarde ramène-t-il vraiment l'état (règlement + avoir) ? + Les textes prescrits (AC 17) le disent-ils juste ? D'autres lecteurs de `amount_due` ou des + constantes changent-ils de valeur avec l'AC 1 (inventaire : `grep -rn "INVOICE_CREDITED\|amount_due" crates/ frontend/src`) ? +5. **Les tests** (AC 16) : chacun prouve-t-il ce qu'il annonce, ou peut-il passer à vide ? Les + mutations m1-m6 sont-elles tuables par les tests listés ? Une mutation que rien ne tuerait est + un finding. +6. **Le manuel** : les lignes citées (AC 17) existent-elles et disent-elles ce qu'on veut changer ? + Contrôle aussi le **PDF aplati** (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '`, + vers `/tmp/claude-1000/-home-gcorbaz-devel-kesh/ca5ce2e2-67a3-4eeb-817f-c2de35620a1c/scratchpad/`). + Une autre phrase du manuel ou de l'admin-manual, de l'api-external, du README, promet-elle + l'ancien comportement ? +7. **Le découpage** (fiche mère) : quelque chose de 25-4-a appartient-il en fait à b ou c, ou + l'inverse ? La latence de #455 est-elle correctement établie ? + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL / HIGH / MEDIUM / LOW), l'endroit exact de la fiche, **la + preuve** (commande et résultat, code lu), la correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un « 0 finding » + sans elle ne compte pas. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; n'exécute aucune commande qui écrit dans le dépôt ou dans une +base — `scripts/prepare-release.sh`, `scripts/regen-test-schema.sh`, `scripts/install-hooks.sh`, +`scripts/test-fast.sh`, `scripts/mem-guard.sh`, `make`, `latexmk`, tout `git commit`/`push`/`add`/ +`stash`/`reset`/`rebase`/`checkout`/`switch`/`worktree`, `sqlx migrate`, `cargo test`/`nextest`, +`npm run`, `npx playwright`. Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, +`pdftotext` vers le scratchpad, `cargo check`. + +## Contexte P1 — à vérifier, pas à re-signaler + +La passe 1 a trouvé 1 HIGH (récit « #455 latent » faux : `get_invoice` rend `amountDue` aux clés +API), 3 MEDIUM (`error_code()` et non `code()` ; AC 14 aligné sur le précédent « Dévalider » ; +AC 13, `monter` sert quinze cas et un avoir `issued` exige une écriture) et 1 LOW. Les corrections +sont dans `git diff 0f4b9ec0 HEAD -- _bmad-output/implementation-artifacts/25-4-*.md` — **diff unique +aplati, à lire en priorité** : une correction introduit souvent le défaut suivant. + +**Tes priorités** : (1) le **gabarit « détacher, créditer, rattacher »** de l'AC 13 — est-il +réalisable (contraintes d'unicité et FK d'`invoice_settlements`, `company_id`, écriture de +règlement qui pointe l'ancienne facture ?) et compatible avec les quatre paires combinées ? +(2) l'AC 14 révisé — le dialogue d'avoir affiche-t-il vraiment le message du serveur, et que +voit l'utilisateur sur une facture **entièrement** payée, où le bouton était masqué jusqu'ici ? +(3) l'e2e `get_credited_invoice_reports_zero_amount_due` — la forme JSON exacte de `amountDue` +(sérialisation de `Decimal`) pour que l'assertion ne soit pas fragile. diff --git a/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p3.md new file mode 100644 index 00000000..d26bf42e --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p3.md @@ -0,0 +1,49 @@ +# Prompt — validation P3 CIBLÉE, Story 25-4-a (le résiduel juste) + +*Versionné le 2026-09-27. **Une lentille** (Opus), contexte frais. Passe **ciblée** (CLAUDE.md +§ « La passe ciblée ») : la P2 n'a trouvé qu'un défaut **né de la correction P1** ; ce qu'il reste à +relire est la remédiation, pas la story entière.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-propager-le-residuel`. Fiche : +`_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md`. **Périmètre** : le diff aplati +des remédiations P1 et P2 — +`git diff 0f4b9ec0 451e2cc2 -- _bmad-output/implementation-artifacts/25-4-a-residuel-juste.md _bmad-output/implementation-artifacts/25-4-propager-le-residuel.md` +(ignore les paragraphes de la fiche mère qui consignent les arbitrages de Guy sur 25-4-b : **ne pas +les contester**). + +## La lentille : chasseur de régressions de remédiation + +Pour chaque passage réécrit en P1 ou P2 : + +1. **Est-il vrai contre le code ?** Relis les sites qu'il cite : `get_invoice` + (`crates/kesh-api/src/routes/invoices.rs`), `docs/api-external.md`, le bouton et le dialogue + d'avoir (`frontend/src/routes/(app)/invoices/[id]/+page.svelte`), la fixture `monter` et + `la_precedence_de_l_annulation_lecture_et_ecriture` (`crates/kesh-db/tests/invoice_settlement.rs`), + la migration `20260827000001_invoice_settlements.sql`, `settlement_cancel_blocker` + (`invoice_settlements_write.rs`), `kesh-api/Cargo.toml` (`serde-str`). +2. **Le gabarit « détacher, créditer, rattacher » (AC 13)** : exécute-le **mentalement** pas à pas + contre `create_credit_note` et les quatre paires combinées de `monter`. Une FK, une contrainte, + un audit, un contrôle de `company_id`, une lecture de `invoice_settlements` par + `create_credit_note` ou par les motifs montés **après** peut-il le casser ? L'écriture de + règlement porte-t-elle quelque part l'identifiant de la facture d'origine (description, audit, + `invoice_settlements` seul) ? +3. **AC 14 révisé** : cohérent avec l'AC 11, l'AC 12, les tests de l'AC 16 et la mutation m6/m7 ? + Un test de l'AC 16 porte-t-il encore l'ancienne logique (« bouton absent ») ? +4. **AC 8 révisé** : l'assertion prescrite peut-elle passer à vide (`null`, champ absent) ? +5. **Propagation** : chaque correction a-t-elle été reportée partout où le symptôme était écrit + (tâches T1-T9, tableau « Où regarder », Change Log, fiche mère, `sprint-status.yaml`) ? Greppe la + **valeur** (`code()`, `:98`, `937-990`, `latent`, `masqué`), pas la formulation. + +## Ce que tu rends + +- **Findings** : sévérité, endroit exact, **preuve** (commande et résultat, code lu), correction. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base — +`scripts/prepare-release.sh`, `scripts/regen-test-schema.sh`, `scripts/install-hooks.sh`, +`scripts/test-fast.sh`, `scripts/mem-guard.sh`, `make`, `latexmk`, tout `git commit`/`push`/`add`/ +`stash`/`reset`/`rebase`/`checkout`/`switch`/`worktree`, `sqlx migrate`, `cargo test`/`nextest`, +`npm run`, `npx playwright`. Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, +`cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p4.md new file mode 100644 index 00000000..047bdafb --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-a-validate-prompt-p4.md @@ -0,0 +1,58 @@ +# Prompt — validation P4 CIBLÉE, Story 25-4-a (le résiduel juste) + +*Versionné le 2026-09-27. **Une lentille** (Sonnet), contexte frais. Passe **ciblée** (CLAUDE.md +§ « La passe ciblée ») : la P3 n'a trouvé que des défauts **de propagation des corrections P1** ; ce qu'il reste à +relire est la remédiation, pas la story entière.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-propager-le-residuel`. Fiche : +`_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md`. **Périmètre** : le diff aplati +des remédiations P3 et de l'arbitrage du 2026-09-27 — +`git diff 1344b3a2 b18f7cd2 -- _bmad-output/implementation-artifacts/25-4-a-residuel-juste.md _bmad-output/implementation-artifacts/25-4-propager-le-residuel.md` +(ignore les paragraphes de la fiche mère qui consignent les arbitrages de Guy sur 25-4-b : **ne pas +les contester**). + +## La lentille : chasseur de régressions de remédiation + +Pour chaque passage réécrit en P1 ou P2 : + +1. **Est-il vrai contre le code ?** Relis les sites qu'il cite : `get_invoice` + (`crates/kesh-api/src/routes/invoices.rs`), `docs/api-external.md`, le bouton et le dialogue + d'avoir (`frontend/src/routes/(app)/invoices/[id]/+page.svelte`), la fixture `monter` et + `la_precedence_de_l_annulation_lecture_et_ecriture` (`crates/kesh-db/tests/invoice_settlement.rs`), + la migration `20260827000001_invoice_settlements.sql`, `settlement_cancel_blocker` + (`invoice_settlements_write.rs`), `kesh-api/Cargo.toml` (`serde-str`). +2. **Le gabarit « détacher, créditer, rattacher » (AC 13)** : exécute-le **mentalement** pas à pas + contre `create_credit_note` et les quatre paires combinées de `monter`. Une FK, une contrainte, + un audit, un contrôle de `company_id`, une lecture de `invoice_settlements` par + `create_credit_note` ou par les motifs montés **après** peut-il le casser ? L'écriture de + règlement porte-t-elle quelque part l'identifiant de la facture d'origine (description, audit, + `invoice_settlements` seul) ? +3. **AC 14 révisé** : cohérent avec l'AC 11, l'AC 12, les tests de l'AC 16 et la mutation m6/m7 ? + Un test de l'AC 16 porte-t-il encore l'ancienne logique (« bouton absent ») ? +4. **AC 8 révisé** : l'assertion prescrite peut-elle passer à vide (`null`, champ absent) ? +5. **Propagation** : chaque correction a-t-elle été reportée partout où le symptôme était écrit + (tâches T1-T9, tableau « Où regarder », Change Log, fiche mère, `sprint-status.yaml`) ? Greppe la + **valeur** (`code()`, `:98`, `937-990`, `latent`, `masqué`), pas la formulation. + +## Ce que tu rends + +- **Findings** : sévérité, endroit exact, **preuve** (commande et résultat, code lu), correction. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base — +`scripts/prepare-release.sh`, `scripts/regen-test-schema.sh`, `scripts/install-hooks.sh`, +`scripts/test-fast.sh`, `scripts/mem-guard.sh`, `make`, `latexmk`, tout `git commit`/`push`/`add`/ +`stash`/`reset`/`rebase`/`checkout`/`switch`/`worktree`, `sqlx migrate`, `cargo test`/`nextest`, +`npm run`, `npx playwright`. Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, +`cargo check`. + +## Complément P4 + +Le diff contient aussi l'**arbitrage du 2026-09-27** (refus de l'avoir maintenu, levée tracée par +#471 ; #384 ramenée en 25-4-d) : **ne pas le contester**, mais vérifier que la fiche 25-4-a reste +cohérente avec lui (AC 12, AC 17, hors périmètre de la fiche mère), et que la fiche de l'epic et +`sprint-status.yaml` disent la même chose. Vérifie aussi que les corrections P3 (T7 « m1 à m7 », +commentaire `:842-844`, doc-comments `errors.rs:214-217` et `invoice_settlements_write.rs:326-329`, +repli « arrêter et demander », T2 et le montage à 8,1 %) sont justes et ne contredisent rien. diff --git a/_bmad-output/implementation-artifacts/25-4-propager-le-residuel.md b/_bmad-output/implementation-artifacts/25-4-propager-le-residuel.md new file mode 100644 index 00000000..65f7e7dc --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-propager-le-residuel.md @@ -0,0 +1,151 @@ +# Story 25.4 : Propager le résiduel — découpée en 25-4-a / 25-4-b / 25-4-c / 25-4-d + +Status: split + +**Issues : [#416], [#420]** (fiche de l'epic) — **et [#455], [#456]**, rattachées ici le 2026-09-26 : +les quatre portent sur **la même grandeur**, le montant restant dû d'une facture client, et les +quatre sont au jalon « Vague 1 », que l'Epic 25 doit mettre à zéro. + +## Pourquoi découper — § *Règle de splitting préventif* + +Inventaire des sites fait le 2026-09-26 (trois lectures du code, chaque site revérifié par +`grep -nF`, cf. § ci-dessous). Les quatre issues réunies touchent **six modules** : `kesh-db` +(résiduel, avoirs, rapprochement, échéancier), `kesh-report` (balance âgée), +`kesh-reconciliation` (score), `kesh-api` (routes factures, rapprochement, relances, erreurs), +`kesh-i18n`, `frontend` (fiche facture, échéancier, dialogue de règlement). Le seuil est **5** : la +règle impose le découpage **avant** `bmad-create-story`. + +Le découpage (quatre stories depuis le 2026-09-27) suit l'ordre des dépendances, non celui des issues : + +| Story | Issues | Ce qu'elle fait | Pourquoi à ce rang | +|---|---|---|---| +| **25-4-a** — le résiduel juste | [#455], [#456] | la formule canonique soustrait l'avoir **TTC** ; un avoir est refusé sur une facture **réglée, même en partie** ; le test de parité promis et jamais écrit | ⛔ **les deux suivantes réemploient la formule** : la propager avant de la corriger propagerait le défaut | +| **25-4-b** — le résiduel aux agrégats | [#416] | balance âgée, totaux de l'échéancier, **colonne « reste dû »** de l'échéancier et de son CSV, dialogue de règlement pré-rempli, **montant réclamé par les relances** | le cœur de #416 ; première utilisatrice des formes jointes | +| **25-4-c** — le résiduel au rapprochement | [#420] | filtre des candidats, score, re-score à l'acceptation, montant affiché dans la proposition | indépendante de 25-4-b, mais du même socle | +| **25-4-d** — solder le reste | [#384] | imputer l'écart d'un règlement partiel — **perte sur débiteur**, escompte, frais bancaires — pour clore une facture partiellement réglée ; la part de **TVA** réduit la TVA due, seule la part HT va au compte de perte | ramenée de la 25-6 le 2026-09-27 (Guy) : elle repose entièrement sur le résiduel juste de 25-4-a | + +## Ce que l'inventaire a trouvé, et que les issues ne disaient pas + +1. ⛔ **#420 a DEUX sites, et le premier est avant le score.** `find_unpaid_invoices_for_window` + (`crates/kesh-db/src/repositories/reconciliation.rs:115`) filtre les candidats par + `HAVING total_ttc BETWEEN ? - ? AND ? + ?` : le virement du solde d'une facture partiellement + réglée n'est **même pas candidat**. Corriger `amount_score` seul (`matching.rs:68`) ne changerait + rien. Troisième site : le re-score à l'acceptation, `reconciliation.rs:1196` + (`invoices::total_ttc`). +2. ⛔ **Les relances réclament le TTC complet.** `render_reminder` + (`crates/kesh-api/src/routes/invoice_email.rs:334`) : `total_due = ttc + other_fees + level_fee`. + Une facture de 1 000.— réglée à 900.— est relancée pour 1 000.— plus les frais. Aucune issue ne le + nomme ; c'est **le même symptôme** que #416 — la grandeur agrégée n'est pas le résiduel —, sur un + document **envoyé au client**. Rattaché à 25-4-b. +3. ⚠️ **La relance joint le PDF de la facture**, dont la QR-facture porte le TTC + (`invoice_email.rs:1140-1166`, `invoice_pdf_service::render`). Un client qui paie la relance en + scannant la QR paie **à nouveau le montant entier**. Question posée à Guy (Q1 ci-dessous). +4. ⚠️ **#455 est masqué à l'écran — mais pas à l'API.** *(Corrigé en validation P1 de 25-4-a : la + première rédaction disait « latent ».)* L'avoir est **total** et fait passer la facture à + `cancelled` (`credit_notes.rs:560-575`) ; le terme « avoir » n'est donc non nul que sur une + facture annulée. L'**écran** n'y affiche le reste dû que si `amountSettled > 0` — l'état que #456 + permet de produire. Mais `GET /invoices/{id}`, ouverte aux clés API, rend `amountDue` **sans + condition de statut** : sur toute facture créditée à TVA non nulle, il vaut la TVA. Les deux + issues se corrigent ensemble parce qu'elles portent sur la même grandeur et le même état. +5. ⚠️ **Les formes jointes existent et ne servent nulle part.** `INVOICE_SETTLED_DERIVED_JOIN_SQL` + et `INVOICE_CREDITED_DERIVED_JOIN_SQL` (`invoice_settlements.rs:40`, `:56`) n'ont aucun appelant, + et le « test de parité » annoncé par le commentaire des lignes 27-29 **n'existe pas**. 25-4-b sera + leur premier appelant : la parité doit être prouvée **avant**. +6. **Pourquoi aucun test n'a vu #455** : toutes les factures des tests de règlement sont à **0 % de + TVA** (`crates/kesh-db/tests/invoice_settlement.rs:32`), où HT = TTC. +7. ⚠️ **Un test encode #456 comme attendu** : la fixture `monter` + (`crates/kesh-db/tests/invoice_settlement.rs:937-1019`) règle 40 sur 100 puis crée un avoir avec + `.expect("avoir après règlement partiel — le vrai chemin l'accepte")`. Il **décrit le défaut** ; + il se corrige, il ne se contourne pas. + +## ✅ Arbitrages du 2026-09-27 — l'avoir sur facture encaissée, et la perte + +- **L'avoir reste refusé sur une facture réglée, en tout ou en partie** (25-4-a). Un avoir sur + facture encaissée est un geste normal, mais il laisse au client un **crédit** que Kesh ne sait ni + montrer, ni rembourser, ni imputer : l'autoriser seul reproduirait la dette muette de #456. Sa + levée est tracée par **[#471]** (crédit visible, remboursement ou imputation). +- **#384 est ramenée dans la 25-4, en 25-4-d**, pour clore une facture partiellement réglée dont le + solde ne sera pas payé (perte sur débiteur), ou réglée sous déduction d'un escompte ou de frais. + ⚠️ La part de TVA du montant passé en perte **réduit la TVA due** (correction de l'impôt sur + créance irrécouvrable) ; seule la part HT va au compte de perte. + +## Hors périmètre des quatre stories + +- **Le côté fournisseur** : une facture fournisseur se règle en une fois, il n'y a pas de résiduel + partiel (#416, § Portée). +- **L'avoir partiel et le remboursement** — v0.2, inchangé. + +## Questions pour Guy + +**Q1 — la QR-facture jointe à une relance.** Elle porte le TTC complet. Sur une facture +partiellement réglée, un client qui paie par la QR paie deux fois la part déjà réglée. Trois +issues : (a) la QR de la relance porte le **reste dû** — le PDF joint n'est alors plus tout à fait +la facture envoyée à l'origine, ce qui touche #387 (l'archivage du PDF) ; (b) la relance d'une +facture partiellement réglée joint un PDF **sans montant dans la QR** (la norme le permet : le +client saisit le montant) ; (c) laisser tel quel et le dire dans le texte de la relance. Ma +recommandation : (b), qui ne ment pas et ne modifie pas la facture. **À trancher avant 25-4-b.** + +### ✅ Arbitrage du 2026-09-26 (Q1) — (b), et le reste à payer dit au client + +*« b, mais indiquer au client ce qu'il reste à payer. »* (Guy) + +Pour 25-4-b, donc : + +- la relance d'une facture **partiellement réglée** joint un PDF dont la QR-facture **ne porte pas + de montant** — le client le saisit ; une facture sans règlement garde sa QR à montant ; +- la relance **dit ce qu'il reste à payer** : `{totalDue}` devient **reste dû + frais**, et non plus + TTC + frais (`invoice_email.rs:334`) — c'est le montant que le client doit saisir. + +- ✅ **Le reste à payer figure AUSSI sur le PDF joint**, à côté de la QR sans montant (Guy, + 2026-09-26) : *« sinon le client ne peut pas savoir ce qu'il reste à payer sans recherche et + calculs »*. Le PDF d'une relance de facture partiellement réglée n'est donc plus exactement la + facture d'origine — à articuler en spécification avec #387 (l'archivage du PDF envoyé). + +- ✅ **Recadrage (Guy, 2026-09-27)** : *« en fait c'est un rappel pour un montant partiel. »* Le + document joint à une relance de facture partiellement réglée n'est pas la facture réémise : c'est + **un rappel pour le montant restant**. L'objection « le PDF n'est plus la facture d'origine » + tombe donc — ce n'est pas la facture —, et #387 ne s'en trouve pas touché. ⚠️ **Point ouvert + posé à Guy** : dès lors, la QR du rappel doit-elle porter le **reste à payer** plutôt que rester + sans montant ? +- ✅ **Tranché (Guy, 2026-09-27)** : *« si c'est un rappel pour facture impayée, on met le montant + total de la facture, et si c'est un montant partiel, on met le montant à payer : on traite les + deux types de rappel de la même manière. »* Une seule règle : **la QR du rappel porte le reste + dû** — qui vaut le TTC sur une facture sans règlement. L'option (b) « sans montant » est + **abandonnée**. Guy suggère aussi de rappeler **le numéro de la facture d'origine** : la QR le + porte déjà (référence QRR dérivée de la facture, ou message non structuré = numéro, + `invoice_pdf_service.rs:218-250`) et **doit le garder à l'identique** — c'est ce que lit le + rapprochement ; le PDF du rappel le nomme en toutes lettres, avec montant initial, déjà réglé, + reste à payer. +- ✅ **Les frais restent configurables, et à zéro rien ne s'affiche** (Guy, 2026-09-27) : *« ils + doivent être configurables : si 0, ne rien afficher. »* Les frais sont déjà réglés par niveau de + rappel (`dunning_levels.fee_amount`) ; le PDF et le texte du rappel n'ont **aucune ligne de + frais** quand le montant est nul — ni « frais : 0.00 », ni total qui les mentionne. +- ⚠️ **Point ouvert pour la spécification de 25-4-b — les frais de rappel.** Le texte réclame + aujourd'hui `TTC + frais` (`invoice_email.rs:334`), mais les frais ne semblent **pas + comptabilisés** (aucune écriture dans `invoice_reminders.rs`, à vérifier). Si la QR portait + « reste dû + frais », le virement du client **dépasserait le reste dû** et serait refusé comme + trop-perçu, au rapprochement comme au règlement manuel (`RECONCILIATION_OVERPAYMENT`). À établir + avant de fixer le montant de la QR. + +⚠️ **Ne pas contester l'arbitrage en revue** : en contester la mise en œuvre. + +## Change Log + +- **2026-09-27** — Arbitrages : avoir sur facture réglée refusé en 25-4-a, levée tracée par #471 ; #384 ramenée en 25-4-d. +- **2026-09-27** — Frais de rappel : configurables, aucune ligne affichée à zéro. +- **2026-09-27** — Q1 retranchée : la QR du rappel porte le reste dû (le TTC s'il n'y a aucun règlement) ; numéro de facture conservé ; point ouvert sur les frais. +- **2026-09-27** — Recadrage de Q1 : le document est un rappel pour un montant partiel, pas la facture réémise. +- **2026-09-26** — Complément de Q1 : le reste à payer figure aussi sur le PDF joint. +- **2026-09-26** — Q1 tranchée par Guy : QR sans montant pour une facture partiellement réglée, et le reste à payer dit au client (rattaché à 25-4-b). +- **2026-09-26** — Découpée avant spécification (§ *Règle de splitting préventif*, six modules), + sur inventaire vérifié. #455 et #456 rattachées (même grandeur, même jalon). Deux sites trouvés + hors des issues : le filtre SQL des candidats du rapprochement (#420), et le montant des relances + (rattaché à 25-4-b). 25-4-a spécifiée dans la foulée. + +[#384]: https://github.com/guycorbaz/kesh/issues/384 +[#471]: https://github.com/guycorbaz/kesh/issues/471 +[#387]: https://github.com/guycorbaz/kesh/issues/387 +[#416]: https://github.com/guycorbaz/kesh/issues/416 +[#420]: https://github.com/guycorbaz/kesh/issues/420 +[#455]: https://github.com/guycorbaz/kesh/issues/455 +[#456]: https://github.com/guycorbaz/kesh/issues/456 diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index cfbc52ae..ea88fb5a 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -1,3 +1,5 @@ +# last_updated: 2026-09-27 (1) (25-4-d creee depuis #384 ; #471 ouverte ; 25-4-a validee P3.) +# last_updated: 2026-09-26 (5) (25-4 DECOUPEE en a/b/c ; 25-4-a creee, #455/#456 rattachees ; #467 #468 #470 mergees.) # last_updated: 2026-09-26 (4) (**25-3-c DONE** — revue close, gates verts, non poussee.) # last_updated: 2026-09-26 (3) (25-3-c IMPLEMENTEE, en review ; revue a posteriori de la 25-5-a close dans le worktree.) # last_updated: 2026-09-26 (2) (25-3-c : Q1 tranchee — reglement detache.) @@ -352,11 +354,15 @@ development_status: 25-3-c-annuler-facture-fournisseur: done # 2026-09-26 **REVUE CLOSE EN 1 PASSE** (0 > LOW, Sonnet x3) ; non poussee ; PR `closes #454` titre ET corps, sur accord. **IMPLEMENTEE** — gates 2472/2472, 783/783, E2E 220/10/19 sans regression ; prochaine etape : code review. # 2026-09-26 **SPEC VALIDEE en 2 passes** (3 MED -> 0 > LOW). **SPECIFIEE** — `25-3-c-annuler-facture-fournisseur.md`. Geste par le socle (autorite `SupplierPurchase`), queue commune sur l'ecriture d'ACHAT, lot en dernier rang ; payee : reglement DETACHE (colonnes remises a NULL, ecriture libre, lien garde par l'audit) — **Q1 tranchee par Guy le 2026-09-26**. Aucune migration. Prochaine etape : validate. # 2026-09-24 NEE d'un arbitrage de Guy — une facture fournisseur s'annule DANS TOUS LES CAS, sauf si l'exercice de son ecriture d'achat est CLOS (l'exercice, pas le verrou de periode) ; payee, son reglement RESTE au grand livre et REDEVIENT A LETTRER (Epic 15 : un paiement doit correspondre a une facture, au besoin creee pour lui — « comme bexio »). Reecrit `cancel`, donc absorbe [#454]. Derriere la 25-3-a. 25-3-b-annuler-rapprochement: done # 2026-09-25 DONE — revue close en passe 2 (Sonnet 1 MED -> Haiku 0) ; gates : backend 2463/2463, frontend 777/777, E2E sans regression. Non poussee. — SPEC VALIDEE (boucle close en passe 4 : 2 HIGH -> 2 MED -> 2 MED -> 3 LOW ; pas de decoupage, arbitrage de Guy). Q1-Q3 tranchees. — [#418] closes — annuler un rapprochement bancaire. ⛔ NE JAMAIS compter sur le `ON DELETE SET NULL` de `matched_entry_id` : supprimer l'ecriture ferait disparaitre le lien EN SILENCE, le mode d'echec meme que la story repare. ⚠️ Le chemin d'ACCEPTATION n'a aucune fonction `kesh-db` a mirer : il est ecrit en SQL brut a **cinq** sites de `routes/reconciliation.rs`, dont **un seul** porte le verrou optimiste. Quand la transaction porte une facture, appelle le geste de la 25-3-a. Derriere elle. 25-3-annuler-reglement-et-rapprochement: split # [#414] [#418] — **l'Epic 24 a livre l'encaissement et le rapprochement ; il n'a PAS livre le moyen de les DEFAIRE.** Une erreur d'imputation est aujourd'hui incorrigible autrement qu'a la main — et le gel de la 24-4b interdit precisement la main. ⚠️ *C'est le prix d'une story qui ferme une porte sans ouvrir la suivante : le gel est juste, et il rend cette story-ci necessaire.* - 25-4-propager-le-residuel: backlog # [#416] [#420] — la 24-2 a introduit le reglement PARTIEL ; le residuel n'est propage ni a la balance agee ni a l'echeancier, et **le score de rapprochement compare au TTC et non au residuel** : le virement du solde d'une facture partiellement reglee **score 0**. + 25-4-a-residuel-juste: done # 2026-09-27 REVUE CLOSE EN 1 PASSE (0 > LOW, 1 LOW corrige) ; rien de pousse — PR closes #455 #456 sur accord. # IMPLEMENTEE — 7/7 mutations tuees ; gates 2484/2484, 826/826, E2E 222/10/19 sans regression ; prochaine etape : code review. # 2026-09-26 [#455] [#456] — l'avoir soustrait HT d'un TTC (masque a l'ecran, mais rendu par GET /invoices/{id} aux cles API : amountDue = la TVA sur une facture creditee) ; un avoir accepte sur une facture partiellement reglee rend la creance creditrice. CREEE ; validation : P1 Sonnet 1H/3M/1L -> P2 Haiku 1M retenu -> P3 ciblee Opus 2M/5L -> P4 ciblee Sonnet 0 > LOW (1M reclasse LOW) : VALIDEE en 4 passes. Arbitrage 2026-09-27 : refus maintenu, levee tracee par #471. + 25-4-b-residuel-aux-agregats: backlog # [#416] + montant des RELANCES (TTC complet, trouve a l'inventaire) — Q1 TRANCHEE 2026-09-27 : la QR du rappel porte le RESTE DU (= TTC sans reglement), reste a payer sur le PDF et dans le texte ; point ouvert : frais de rappel non comptabilises vs trop-percu. + 25-4-c-residuel-au-rapprochement: backlog # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. + 25-4-d-solder-le-reste: backlog # [#384] ramenee de la 25-6 le 2026-09-27 (Guy) — perte sur debiteur, escompte, frais : clore une facture partiellement reglee ; la part TVA reduit la TVA due. + 25-4-propager-le-residuel: split # 2026-09-26 DECOUPEE (six modules > 5) en 25-4-a / 25-4-b / 25-4-c, puis 25-4-d (#384, 2026-09-27) ; #455 et #456 rattachees. # [#416] [#420] — la 24-2 a introduit le reglement PARTIEL ; le residuel n'est propage ni a la balance agee ni a l'echeancier, et **le score de rapprochement compare au TTC et non au residuel** : le virement du solde d'une facture partiellement reglee **score 0**. 25-5-a-export-souverainete: done # 2026-09-26 **MERGEE (PR #452, #386 fermee) SANS REVUE DE CODE** ; revue a posteriori CLOSE en 3 passes sur `story/25-5-a-revue-code` (4 HIGH P1, 1 HIGH orchestrateur P2 — colonnes manquantes, garde de colonnes —, P3 ciblee test seul) ; gates 2466/2466, E2E 221/8/19 sans regression ; reports #465, #466. # 2026-09-23 **SPECIFIEE et VALIDEE en 1 passe** (0 CRITICAL, 4 HIGH, 3 MEDIUM, 3 LOW, tous corriges). [#386] — issue du DECOUPAGE de la 25-5 (arbitrage de Guy) : #385 (Balance vs Bilan) et #386 n'ont en commun que le mot « etat », et touchent des modules disjoints. ⛔ **Trois faits qui corrigent l'issue** : 21 tables absentes et non 10 ; le trou s'est CREUSE depuis son ecriture (`invoice_settlements`, Epic 24) ; et SIX repositories sur huit n'ont aucune lecture exhaustive — le vrai cout, absent de l'issue. Arbitrage de Guy : l'export CSV ne porte que les donnees COMPTABLES (11 dedans), les backups portent tout ; 10 exclusions AVEC motif ecrit, dont 3 tables de SECRETS. Les justificatifs (fichiers) partent en story separee sur la SAUVEGARDE. ⛔ La vraie livraison est la GARDE D'EXHAUSTIVITE (patron D4-ter + P7), non les 11 exports : trois epics ont rouvert le trou en silence. ⚠️ Pieges releves par la validation : le nombre 19 code en dur a SEPT endroits dont deux `debug_assert_eq!` qui PANIQUENT ; `audit_log` n'a AUCUN dump exhaustif (ses fonctions sont bornees, la route rejette au-dela de 10 000 lignes) ; `csv_sanitize` existe et n'est pas employee par cet export ; deux sites de manuel DEJA faux (un dossier `reports/` invente, un « 38 » pour 39). 25-5-b-balance-solde-ouverture: backlog # [#385] — la **Balance des comptes ne concorde pas avec le Bilan**, il manque la colonne « solde d'ouverture » (III.4). Detachee de la 25-5 le 2026-09-23 : voir 25-5-a. 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: backlog # [#388] [#389] [#384] [#387] — 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-tableau-de-bord-et-dettes: backlog # [#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*. 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/_bmad-output/planning-artifacts/epic-25-vague1-suite.md b/_bmad-output/planning-artifacts/epic-25-vague1-suite.md index 95bded48..1802055b 100644 --- a/_bmad-output/planning-artifacts/epic-25-vague1-suite.md +++ b/_bmad-output/planning-artifacts/epic-25-vague1-suite.md @@ -211,6 +211,11 @@ propagé aux rapports agrégés — balance âgée et échéancier ([#416]) — rapprochement compare au TTC et non au résiduel**, si bien que le virement du solde d'une facture partiellement réglée **score 0** ([#420]). +⚠️ **Découpée le 2026-09-26 en 25-4-a / b / c, puis d** : [#455] et [#456] (même grandeur, même +jalon) y sont rattachées, et **[#384] y est ramenée de la 25-6 le 2026-09-27** (arbitrage de Guy) +en 25-4-d — solder le reste d'une facture partiellement réglée repose sur le résiduel juste. Détail : +`25-4-propager-le-residuel.md`. + ### 25-5 — Les états et l'export **Issues : [#385], [#386].** La **Balance des comptes ne concorde pas avec le Bilan** — il @@ -225,8 +230,7 @@ tient*. - La tuile **« Dernières écritures » n'appelle rien** et affiche toujours « Aucune écriture » ([#388]) — *une tuile qui est un décor*. - Le **« solde bancaire » est le solde comptable**, donc faux ([#389]). -- **Imputer l'écart d'un règlement partiel** — escompte, frais bancaires, perte sur débiteur - ([#384]). +- ~~**Imputer l'écart d'un règlement partiel**~~ — [#384] **ramenée en 25-4-d** le 2026-09-27. - **Le PDF d'une facture n'est jamais archivé**, il est régénéré à la volée ([#387], IV.9) — un document réémis n'est pas le document envoyé. diff --git a/crates/kesh-api/src/errors.rs b/crates/kesh-api/src/errors.rs index 61ecab28..a93bc1f6 100644 --- a/crates/kesh-api/src/errors.rs +++ b/crates/kesh-api/src/errors.rs @@ -2563,6 +2563,36 @@ impl IntoResponse for AppError { }); (StatusCode::CONFLICT, Json(body)).into_response() } + // Story 25-4-a (#456) — l'avoir refusé sur une facture réglée, + // même en partie. ⛔ Un code et un message propres : l'ancienne + // garde rendait `ILLEGAL_STATE_TRANSITION`, dont le texte n'était + // que journalisé. Le numéro de la facture est suffixé, comme + // pour la dévalidation. + DbError::CreditNoteBlockedBySettlement { + invoice_id, + settlement_id, + invoice_number, + } => { + let base = t( + "error-credit-note-blocked-settled", + "Cette facture porte un règlement, même partiel : annulez-le d'abord pour pouvoir émettre un avoir.", + ); + let message = match invoice_number.as_deref() { + Some(numero) => format!("{base} ({numero})"), + None => base, + }; + let body = serde_json::json!({ + "error": { + "code": "CREDIT_NOTE_INVOICE_SETTLED", + "message": message, + "details": { + "invoiceId": invoice_id, + "settlementId": settlement_id, + }, + } + }); + (StatusCode::CONFLICT, Json(body)).into_response() + } // Story 25-3-a-1 (#414) — l'annulation d'un règlement refusée. // // ⚠️ Seuls les rangs que le GESTE refuse lui-même arrivent ici diff --git a/crates/kesh-api/tests/invoice_echeancier_e2e.rs b/crates/kesh-api/tests/invoice_echeancier_e2e.rs index ab43533a..99cffc3f 100644 --- a/crates/kesh-api/tests/invoice_echeancier_e2e.rs +++ b/crates/kesh-api/tests/invoice_echeancier_e2e.rs @@ -728,3 +728,116 @@ async fn a_closed_fiscal_year_is_announced_and_refused(pool: MySqlPool) { let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["error"]["code"], "FISCAL_YEAR_CLOSED", "got {body:?}"); } + +// --- Story 25-4-a (#455, #456) — le résiduel juste, à la frontière HTTP -------- + +async fn post_credit_note(app: &TestApp, token: &str, invoice_id: i64) -> reqwest::Response { + app.client + .post(app.url("/api/v1/credit-notes")) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "invoiceId": invoice_id, "date": "2026-05-10" })) + .send() + .await + .unwrap() +} + +/// Lit un montant rendu par l'API : **présent, non `null`, chaîne**, parsé en +/// `Decimal` — ni comparaison de chaîne (l'échelle suit le calcul SQL), ni +/// `f64`. `null` voudrait dire « non calculé » : ce serait un échec. +fn montant(v: &serde_json::Value, champ: &str) -> rust_decimal::Decimal { + let s = v[champ] + .as_str() + .unwrap_or_else(|| panic!("`{champ}` doit être une chaîne présente, reçu {v:?}")); + s.parse::() + .unwrap_or_else(|e| panic!("`{champ}` = {s:?} n'est pas un décimal : {e}")) +} + +/// #455 — une facture à 8,1 % créditée, jamais réglée : `GET /invoices/{id}` +/// — ouverte aux clés API en lecture — rend un reste dû **nul**, et non la TVA. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn get_credited_invoice_reports_zero_amount_due(pool: MySqlPool) { + let (company_id, admin_id) = seed_base(&pool).await; + let contact_id = seed_contact(&pool, company_id, admin_id).await; + let (id, _v) = create_validated_invoice( + &pool, + company_id, + contact_id, + admin_id, + NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(), + dec!(100.00), + ) + .await; + + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + assert_eq!(post_credit_note(&app, &token, id).await.status(), 201); + + let resp = app + .client + .get(app.url(&format!("/api/v1/invoices/{id}"))) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let v: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(v["status"], "cancelled", "anti-vacuité : l'avoir est passé"); + assert_eq!( + montant(&v, "amountDue"), + rust_decimal::Decimal::ZERO, + "et non 8.10 — got {v:?}" + ); + assert_eq!(montant(&v, "amountSettled"), rust_decimal::Decimal::ZERO); +} + +/// #456 — un avoir sur une facture réglée en partie est refusé en **409**, avec +/// son code et un message qui dit quoi faire. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn credit_note_on_partially_settled_invoice_is_409(pool: MySqlPool) { + let (company_id, admin_id) = seed_base(&pool).await; + let contact_id = seed_contact(&pool, company_id, admin_id).await; + let (id, _v) = create_validated_invoice( + &pool, + company_id, + contact_id, + admin_id, + NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(), + dec!(100.00), + ) + .await; + let caisse = caisse_id(&pool, company_id).await; + + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + let settle = app + .client + .post(app.url(&format!("/api/v1/invoices/{id}/settlements"))) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ + "settlementType": "internal_account", + "accountId": caisse, + "amount": "40.00", + "settledOn": "2026-04-15" + })) + .send() + .await + .unwrap(); + assert_eq!(settle.status(), 200); + + let resp = post_credit_note(&app, &token, id).await; + assert_eq!(resp.status(), 409); + let v: serde_json::Value = resp.json().await.unwrap(); + assert_eq!( + v["error"]["code"], "CREDIT_NOTE_INVOICE_SETTLED", + "got {v:?}" + ); + let message = v["error"]["message"].as_str().unwrap(); + assert!( + message.starts_with("Cette facture porte un règlement, même partiel"), + "message fr attendu, reçu {message:?}" + ); + assert_eq!(v["error"]["details"]["invoiceId"], id); + assert!(v["error"]["details"]["settlementId"].as_i64().is_some()); +} diff --git a/crates/kesh-db/src/errors.rs b/crates/kesh-db/src/errors.rs index 48326cf1..402d0b39 100644 --- a/crates/kesh-db/src/errors.rs +++ b/crates/kesh-db/src/errors.rs @@ -212,9 +212,13 @@ pub enum SettlementCancelBlocker { /// rapprochement à annuler. Coupe court : aucun rang suivant ne s'évalue. BankTransactionNotReconciled, /// La facture a été **créditée** par un avoir après ce règlement. Le - /// règlement est alors un paiement **à lettrer** (Epic 15), pas une - /// anomalie — il ne s'annule pas. ⚠️ Le statut `cancelled` d'une facture - /// ne naît en production que de l'avoir (`credit_notes.rs`). + /// règlement est alors un paiement **à lettrer** (Epic 15) — il ne + /// s'annule pas. ⚠️ Le statut `cancelled` d'une facture ne naît en + /// production que de l'avoir (`credit_notes.rs`). + /// + /// ⚠️ **État hérité depuis la Story 25-4-a (#456)** : un avoir est refusé + /// sur une facture réglée, même en partie. L'état n'est plus atteignable + /// que par l'**import d'une sauvegarde antérieure** — d'où ce motif, gardé. InvoiceCredited, /// Tête **fournisseur** (Story 25-3-a-2) : la facture n'est pas `paid` — il /// n'y a pas de règlement à annuler. ⚠️ Coupe court par construction : une @@ -460,6 +464,29 @@ pub enum DbError { document_label: Option, }, + /// Un avoir ne peut pas viser cette facture : elle porte un **règlement**, + /// même partiel (Story 25-4-a, #456). + /// + /// ⛔ **Les deux conditions, jamais l'une seule** : une ligne + /// `invoice_settlements` **ou** `paid_at` posé — comme + /// [`UnvalidationBlocker::Settled`]. Depuis la 24-2, `paid_at` n'est posé + /// qu'au solde : la garde qui ne lisait que lui laissait passer une facture + /// réglée en partie, et l'avoir, qui contre-passe tout le TTC, rendait la + /// créance **créditrice** du montant encaissé. + /// + /// Conflit d'état → HTTP **409** `CREDIT_NOTE_INVOICE_SETTLED`. Remplace le + /// générique `IllegalStateTransition` de l'ancienne garde AC2bis, qui ne + /// disait ni ce qui bloquait ni quoi faire. + #[error("Avoir refusé : la facture porte un règlement")] + CreditNoteBlockedBySettlement { + invoice_id: i64, + /// Le premier règlement trouvé ; `None` quand seul `paid_at` est posé + /// (facture soldée avant la 24-2, sans ligne de règlement). + settlement_id: Option, + /// Le **numéro** de la facture, que l'utilisateur connaît. + invoice_number: Option, + }, + /// Le règlement ne peut pas être annulé (Story 25-3-a-1, #414). /// /// Conflit d'état → HTTP **409**, avec le code canonique du @@ -655,6 +682,7 @@ impl DbError { Self::AccountRoleInvalidForType { .. } => "ACCOUNT_ROLE_INVALID_FOR_TYPE", Self::InvalidRevenueAccounts(_) => "INVOICE_LINE_REVENUE_ACCOUNT_INVALID", Self::CreditNoteRevenueAccountsArchived(_) => "CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED", + Self::CreditNoteBlockedBySettlement { .. } => "CREDIT_NOTE_INVOICE_SETTLED", // ⚠️ Le code EXPOSÉ est celui du `ReversalBlocker` (huit valeurs) ; // celui-ci n'est que le repli générique du mapping structuré. Self::EntryNotReversable { .. } => "ENTRY_NOT_REVERSABLE", diff --git a/crates/kesh-db/src/repositories/credit_notes.rs b/crates/kesh-db/src/repositories/credit_notes.rs index a0b164a7..cf79168e 100644 --- a/crates/kesh-db/src/repositories/credit_notes.rs +++ b/crates/kesh-db/src/repositories/credit_notes.rs @@ -297,16 +297,39 @@ pub async fn create_credit_note( inv.status ))); } - // AC2bis : pas d'avoir sur une facture déjà encaissée (v0.2, pas de - // flux de remboursement — éviterait un solde débiteur négatif). - Some(inv) if inv.paid_at.is_some() => { - return Err(DbError::IllegalStateTransition( - "impossible de créer un avoir sur une facture déjà payée".into(), - )); - } Some(inv) => inv, }; + // AC2bis, refondue par la Story 25-4-a (#456) : pas d'avoir sur une + // facture ENCAISSÉE, même en partie. L'avoir contre-passe tout le TTC ; + // sur une facture réglée, la créance deviendrait créditrice du montant + // encaissé, et Kesh n'a ni remboursement ni imputation pour le dénouer + // (#471 porte la levée de ce refus). + // + // ⛔ **Une ligne de règlement OU `paid_at`** — jamais `paid_at` seul : + // depuis la 24-2, il n'est posé qu'au solde. + // + // ⛔ **Lecture VERROUILLANTE, et après le verrou de la facture** (leçon + // de la 25-3-b) : sous REPEATABLE READ, une lecture simple fige + // l'instantané. Un règlement commité pendant que nous attendions le + // verrou de la facture doit être vu ici. + let settlement: Option = sqlx::query_scalar( + "SELECT id FROM invoice_settlements \ + WHERE invoice_id = ? AND company_id = ? ORDER BY id LIMIT 1 FOR UPDATE", + ) + .bind(invoice_id) + .bind(company_id) + .fetch_optional(&mut *tx) + .await + .map_err(map_db_error)?; + if settlement.is_some() || invoice.paid_at.is_some() { + return Err(DbError::CreditNoteBlockedBySettlement { + invoice_id, + settlement_id: settlement, + invoice_number: invoice.invoice_number.clone(), + }); + } + // AC3 : une seule note de crédit par facture (UNIQUE(invoice_id) en DB ; // check explicite pour une erreur métier claire plutôt qu'une FK 1062). let existing: Option = diff --git a/crates/kesh-db/src/repositories/invoice_settlements.rs b/crates/kesh-db/src/repositories/invoice_settlements.rs index d2c85887..0261a9b0 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements.rs @@ -20,13 +20,14 @@ const COLUMNS: &str = "id, company_id, invoice_id, journal_entry_id, amount, set use crate::entities::{InvoiceSettlement, NewInvoiceSettlement}; use crate::errors::{DbError, map_db_error}; +use crate::repositories::invoices::line_ttc_sql; /// Forme **scalaire par facture** du total réglé — sous-requête corrélée. /// **Prérequis : alias `i` sur `invoices`.** /// /// Miroir exact de [`INVOICE_SETTLED_DERIVED_JOIN_SQL`], qui en est la forme /// agrégée. Les deux doivent rester d'accord : c'est ce que vérifie le test de -/// parité. +/// parité (`tests/invoice_amount_due_parity.rs`). /// /// ⚠️ Même discipline que `INVOICE_TTC_SUBQUERY_SQL` (`invoices.rs`) : la forme /// corrélée sert **une** facture, jamais une liste — elle y serait ré-évaluée @@ -40,22 +41,44 @@ pub const INVOICE_SETTLED_SUBQUERY_SQL: &str = pub const INVOICE_SETTLED_DERIVED_JOIN_SQL: &str = "LEFT JOIN (SELECT invoice_id, SUM(amount) AS settled \ FROM invoice_settlements GROUP BY invoice_id) st ON st.invoice_id = i.id"; -/// Forme **scalaire par facture** de l'avoir émis — `0` s'il n'y en a pas. -/// **Prérequis : alias `i` sur `invoices`.** +/// Forme **scalaire par facture** de l'avoir émis, **TTC** — `0` s'il n'y en +/// a pas. **Prérequis : alias `i` sur `invoices`.** +/// +/// ⛔ **TTC, arrondi ligne par ligne** — la grandeur que l'écriture de l'avoir +/// porte au crédit de la créance (`credit_notes::generate_credit_note_journal_lines` : +/// `total_ht + total_vat`, TVA par [`kesh_core::accounting::vat::line_vat_amount`]). +/// Le résiduel soustrait ce terme d'un TTC : il doit être dans la même unité. +/// **Jamais `credit_notes.total_amount`**, qui est **HT** (miroir de +/// `invoices.total_amount`) : le lire ici laissait la TVA d'une facture +/// créditée en reste dû (#455, Story 25-4-a). /// /// ⚠️ **Seul un avoir `issued` compte.** Les statuts sont /// `draft / issued / cancelled` (`chk_credit_notes_status`) : un brouillon n'a /// pas d'écriture et n'éteint donc rien. `credit_notes.invoice_id` est /// `NOT NULL UNIQUE` — au plus un avoir par facture, la somme est donc une /// commodité de forme, pas un cumul réel. -pub const INVOICE_CREDITED_SUBQUERY_SQL: &str = "(SELECT COALESCE(SUM(cn.total_amount), 0) FROM credit_notes cn \ - WHERE cn.invoice_id = i.id AND cn.status = 'issued')"; +pub const INVOICE_CREDITED_SUBQUERY_SQL: &str = concat!( + "(SELECT COALESCE(SUM(", + line_ttc_sql!("cl."), + "), 0) FROM credit_note_lines cl \ + INNER JOIN credit_notes cn ON cn.id = cl.credit_note_id \ + WHERE cn.invoice_id = i.id AND cn.status = 'issued')" +); -/// Forme **agrégat multi-factures** de l'avoir émis — alias `cnt`. -/// **Prérequis : alias `i` sur `invoices`.** -pub const INVOICE_CREDITED_DERIVED_JOIN_SQL: &str = "LEFT JOIN (SELECT invoice_id, SUM(total_amount) AS credited \ - FROM credit_notes WHERE status = 'issued' GROUP BY invoice_id) cnt \ - ON cnt.invoice_id = i.id"; +/// Forme **agrégat multi-factures** de l'avoir émis, **TTC** — table dérivée +/// à joindre (alias `cnt`), puis `COALESCE(cnt.credited, 0)` côté requête +/// externe. **Prérequis : alias `i` sur `invoices`.** +/// +/// Miroir exact de [`INVOICE_CREDITED_SUBQUERY_SQL`] — même unité, même +/// arrondi ; les deux sont tenues d'accord par le test de parité +/// (`tests/invoice_amount_due_parity.rs`). +pub const INVOICE_CREDITED_DERIVED_JOIN_SQL: &str = concat!( + "LEFT JOIN (SELECT cn.invoice_id, SUM(", + line_ttc_sql!("cl."), + ") AS credited FROM credit_note_lines cl \ + INNER JOIN credit_notes cn ON cn.id = cl.credit_note_id \ + WHERE cn.status = 'issued' GROUP BY cn.invoice_id) cnt ON cnt.invoice_id = i.id" +); /// Enregistre un règlement dans la transaction courante. /// @@ -121,6 +144,10 @@ where /// Ce qui reste dû sur une facture : `TTC − avoir émis − Σ règlements`. /// +/// ⛔ **Trois termes TTC.** Le TTC de la facture et celui de l'avoir sont +/// arrondis ligne par ligne par la même formule (`line_ttc_sql!`) ; les +/// règlements sont des montants encaissés, donc TTC par nature. +/// /// ⚠️ **Calculé, jamais stocké.** C'est la seule source de vérité du « combien /// reste-t-il ? », et elle est adossée aux mêmes données que le grand livre. /// diff --git a/crates/kesh-db/src/repositories/invoice_settlements_write.rs b/crates/kesh-db/src/repositories/invoice_settlements_write.rs index 6b7d692f..415b747c 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements_write.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements_write.rs @@ -328,6 +328,9 @@ pub async fn settlement_cancel_blocker_unlinking( // `validated` ou `cancelled` : l'encaissement exige `validated`, la // dévalidation refuse une facture réglée, et `cancelled` ne naît en // production que de l'avoir. Hors `validated`, c'est donc un avoir. + // ⚠️ Depuis la Story 25-4-a (#456), l'avoir est refusé sur une facture + // réglée : ce rang ne se déclenche plus que sur un état HÉRITÉ, ramené par + // l'import d'une sauvegarde antérieure. if status != "validated" { return Ok(Some((SettlementCancelBlocker::InvoiceCredited, None, None))); } diff --git a/crates/kesh-db/src/repositories/invoices.rs b/crates/kesh-db/src/repositories/invoices.rs index b62c4e68..526bbc5a 100644 --- a/crates/kesh-db/src/repositories/invoices.rs +++ b/crates/kesh-db/src/repositories/invoices.rs @@ -147,7 +147,32 @@ pub enum PausedFilter { NotPaused, } -/// Paramètres de recherche, tri et pagination. +/// **Source unique** de la formule SQL du TTC d'UNE ligne (facture ou avoir) — +/// Story 25-4-a (#455). Rend un **littéral** (`concat!`), pour que les +/// constantes qui la composent restent des `&'static str`. +/// +/// `$p` est le préfixe de colonne : `"l."`, `"cl."`, ou `""` dans une table +/// dérivée sans alias. Arrondi **par ligne**, half-away-from-zero : miroir de +/// `kesh_core::accounting::vat::line_vat_amount`, que l'écriture de vente +/// comme celle de l'avoir emploient. +/// +/// ⛔ Ne pas recopier la formule à la main : la TVA de l'avoir a été soustraite +/// au HT pendant des mois (#455) parce que la grandeur « avoir » n'empruntait +/// pas cette formule-ci. +macro_rules! line_ttc_sql { + ($p:literal) => { + concat!( + $p, + "line_total + ROUND(", + $p, + "line_total * ", + $p, + "vat_rate / 100, 2)" + ) + }; +} +pub(crate) use line_ttc_sql; + /// Forme **scalaire par facture** du TTC canonique (#246, Story 21-2a) — /// sous-requête corrélée. **Prérequis : alias `i` sur `invoices`.** /// @@ -158,8 +183,11 @@ pub enum PausedFilter { /// /// `pub` (PAS `pub(crate)`) : consommée par `repositories::reconciliation` /// (21-2b) et par `kesh-report` (balance âgée 21-7) — source de vérité unique. -pub const INVOICE_TTC_SUBQUERY_SQL: &str = "(SELECT COALESCE(SUM(l.line_total + ROUND(l.line_total * l.vat_rate / 100, 2)), 0) \ - FROM invoice_lines l WHERE l.invoice_id = i.id)"; +pub const INVOICE_TTC_SUBQUERY_SQL: &str = concat!( + "(SELECT COALESCE(SUM(", + line_ttc_sql!("l."), + "), 0) FROM invoice_lines l WHERE l.invoice_id = i.id)" +); /// Forme **agrégat multi-factures** du TTC canonique (#246) — table dérivée à /// joindre (alias `lt`), puis `SUM(COALESCE(lt.ttc, 0))` côté requête externe. @@ -168,9 +196,11 @@ pub const INVOICE_TTC_SUBQUERY_SQL: &str = "(SELECT COALESCE(SUM(l.line_total + /// Choix de la table dérivée plutôt que la corrélée dans le `SUM()` externe : /// raison de **performance** (la corrélée serait ré-évaluée par ligne et par /// `CASE`), pas un interdit SQL (MariaDB accepte les deux — vérifié). -pub const INVOICE_TTC_DERIVED_JOIN_SQL: &str = "LEFT JOIN (SELECT invoice_id, \ - SUM(line_total + ROUND(line_total * vat_rate / 100, 2)) AS ttc \ - FROM invoice_lines GROUP BY invoice_id) lt ON lt.invoice_id = i.id"; +pub const INVOICE_TTC_DERIVED_JOIN_SQL: &str = concat!( + "LEFT JOIN (SELECT invoice_id, SUM(", + line_ttc_sql!(""), + ") AS ttc FROM invoice_lines GROUP BY invoice_id) lt ON lt.invoice_id = i.id" +); /// TTC canonique d'UNE facture (#246) via la forme scalaire — pour les /// call-sites qui n'ont pas les lignes chargées (ex. re-score de la @@ -189,6 +219,7 @@ where .map_err(map_db_error) } +/// Paramètres de recherche, tri et pagination. #[derive(Debug, Clone, Default)] pub struct InvoiceListQuery { pub search: Option, diff --git a/crates/kesh-db/tests/credit_notes_repository.rs b/crates/kesh-db/tests/credit_notes_repository.rs index f5f97df7..0c16ade8 100644 --- a/crates/kesh-db/tests/credit_notes_repository.rs +++ b/crates/kesh-db/tests/credit_notes_repository.rs @@ -10,9 +10,9 @@ use chrono::NaiveDate; use kesh_db::entities::contact::{ContactType, NewContact}; -use kesh_db::entities::{NewCreditNote, NewInvoice, NewInvoiceLine}; +use kesh_db::entities::{NewCreditNote, NewInvoice, NewInvoiceLine, SettlementChoice}; use kesh_db::errors::DbError; -use kesh_db::repositories::{contacts, credit_notes, invoices}; +use kesh_db::repositories::{contacts, credit_notes, invoice_settlements_write, invoices}; use kesh_db::test_fixtures::{SeededCompany, seed_accounting_company}; use rust_decimal::Decimal; use rust_decimal_macros::dec; @@ -290,9 +290,18 @@ async fn credit_note_refused_on_paid_invoice(pool: MySqlPool) { ) .await .unwrap_err(); + // Story 25-4-a (#456) : la garde AC2bis rend sa variante dédiée, plus le + // générique — le motif « payée » et le motif « réglée en partie » sont un + // seul et même refus. `settlement_id` est `None` : seul `paid_at` est posé. assert!( - matches!(err, DbError::IllegalStateTransition(_)), - "refus facture payée" + matches!( + err, + DbError::CreditNoteBlockedBySettlement { + settlement_id: None, + .. + } + ), + "refus facture payée — reçu {err:?}" ); } @@ -423,3 +432,225 @@ async fn credit_note_inherits_project_and_nets_to_zero(pool: MySqlPool) { .unwrap(); assert_eq!(debit, credit, "net par projet doit être 0 après l'avoir"); } + +// ─── Story 25-4-a (#456) — pas d'avoir sur une facture réglée, même en partie ─── + +async fn settle(pool: &MySqlPool, seeded: &SeededCompany, invoice_id: i64, amount: Decimal) -> i64 { + let out = invoice_settlements_write::settle_invoice( + pool, + seeded.admin_user_id, + seeded.company_id, + invoice_id, + SettlementChoice::InternalAccount { + account_id: seeded.accounts["1000"], + }, + amount, + d(2026, 6, 20), + ) + .await + .expect("règlement"); + sqlx::query_scalar("SELECT id FROM invoice_settlements WHERE journal_entry_id = ?") + .bind(out.journal_entry_id) + .fetch_one(pool) + .await + .expect("ligne de règlement") +} + +async fn try_credit( + pool: &MySqlPool, + seeded: &SeededCompany, + invoice_id: i64, +) -> Result { + credit_notes::create_credit_note( + pool, + NewCreditNote { + company_id: seeded.company_id, + invoice_id, + date: d(2026, 7, 1), + }, + seeded.admin_user_id, + ) + .await +} + +/// Ce qu'un refus ne doit pas avoir touché : avoirs, statut et version de la +/// facture, solde de la créance, séquence de numéros d'avoir. +async fn empreinte( + pool: &MySqlPool, + seeded: &SeededCompany, + invoice_id: i64, +) -> (i64, String, i32, Decimal, i64) { + let avoirs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_notes WHERE company_id = ?") + .bind(seeded.company_id) + .fetch_one(pool) + .await + .unwrap(); + let (status, version): (String, i32) = + sqlx::query_as("SELECT status, version FROM invoices WHERE id = ?") + .bind(invoice_id) + .fetch_one(pool) + .await + .unwrap(); + let creance: Decimal = sqlx::query_scalar( + "SELECT COALESCE(SUM(debit) - SUM(credit), 0) FROM journal_entry_lines WHERE account_id = ?", + ) + .bind(seeded.accounts["1100"]) + .fetch_one(pool) + .await + .unwrap(); + let sequence: i64 = sqlx::query_scalar( + "SELECT CAST(COALESCE(SUM(next_number), 0) AS SIGNED) FROM credit_note_number_sequences WHERE company_id = ?", + ) + .bind(seeded.company_id) + .fetch_one(pool) + .await + .unwrap(); + (avoirs, status, version, creance, sequence) +} + +/// AC 9, AC 10 — une facture réglée en partie (`paid_at` NULL) est refusée, et +/// **rien n'est écrit**. C'est le chemin que l'ancienne garde laissait passer. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_refused_on_partially_settled_invoice(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00))]).await; + let sid = settle(&pool, &seeded, invoice_id, dec!(40.00)).await; + + let paid_at: Option = + sqlx::query_scalar("SELECT paid_at FROM invoices WHERE id = ?") + .bind(invoice_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + paid_at.is_none(), + "anti-vacuité : la facture n'est PAS soldée" + ); + + let avant = empreinte(&pool, &seeded, invoice_id).await; + let err = try_credit(&pool, &seeded, invoice_id).await.unwrap_err(); + match &err { + DbError::CreditNoteBlockedBySettlement { + invoice_id: iid, + settlement_id, + invoice_number, + } => { + assert_eq!(*iid, invoice_id); + assert_eq!(*settlement_id, Some(sid)); + assert!(invoice_number.is_some(), "le numéro est rendu"); + } + other => panic!("attendu CreditNoteBlockedBySettlement, reçu {other:?}"), + } + assert_eq!(err.error_code(), "CREDIT_NOTE_INVOICE_SETTLED"); + assert_eq!( + empreinte(&pool, &seeded, invoice_id).await, + avant, + "rien n'est écrit" + ); +} + +/// La promesse du message : annuler le règlement rouvre l'avoir. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_accepted_after_the_settlement_is_cancelled(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00))]).await; + let sid = settle(&pool, &seeded, invoice_id, dec!(40.00)).await; + assert!(try_credit(&pool, &seeded, invoice_id).await.is_err()); + + invoice_settlements_write::cancel_settlement( + &pool, + seeded.admin_user_id, + seeded.company_id, + invoice_id, + sid, + ) + .await + .expect("annulation du règlement"); + + try_credit(&pool, &seeded, invoice_id) + .await + .expect("l'avoir passe une fois le règlement annulé"); +} + +/// AC 9 — ⛔ **entrelacement** : un règlement commité PENDANT que l'avoir +/// attend le verrou de la facture doit être vu. +/// +/// 1. une transaction verrouille la facture et y rattache un règlement, sans +/// valider ; +/// 2. l'avoir démarre et attend au `FOR UPDATE` de la facture ; +/// 3. la transaction est validée. +/// +/// La garde lit les règlements **après** le verrou, par une lecture +/// verrouillante : elle voit la ligne et refuse. Lue **avant** le verrou, en +/// lecture simple, elle aurait vu « aucun règlement » et laissé passer. +/// +/// Le règlement vient du vrai chemin, sur une facture auxiliaire : seul son +/// rattachement se fait dans la transaction concurrente. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_waits_for_a_concurrent_settlement(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00))]).await; + let parking = create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00))]).await; + let sid = settle(&pool, &seeded, parking, dec!(40.00)).await; + + // (1) Le règlement concurrent, non validé, qui tient la facture. + let mut concurrent = pool.begin().await.unwrap(); + sqlx::query("SELECT id FROM invoices WHERE id = ? FOR UPDATE") + .bind(invoice_id) + .execute(&mut *concurrent) + .await + .unwrap(); + sqlx::query("UPDATE invoice_settlements SET invoice_id = ? WHERE id = ?") + .bind(invoice_id) + .bind(sid) + .execute(&mut *concurrent) + .await + .unwrap(); + + // (2) L'avoir démarre et attend. + let p = pool.clone(); + let (company_id, user_id) = (seeded.company_id, seeded.admin_user_id); + let avoir = tokio::spawn(async move { + credit_notes::create_credit_note( + &p, + NewCreditNote { + company_id, + invoice_id, + date: d(2026, 7, 1), + }, + user_id, + ) + .await + }); + let vue = kesh_db::test_fixtures::attendre_une_requete_en_cours( + &pool, + &["FROM invoices", "FOR UPDATE"], + || avoir.is_finished(), + ) + .await; + if !vue { + panic!("l'avoir a fini sans attendre le verrou : {:?}", avoir.await); + } + + // (3) Le règlement concurrent est validé. + concurrent.commit().await.unwrap(); + + let result = avoir.await.expect("tâche d'avoir"); + assert!( + matches!( + result, + Err(DbError::CreditNoteBlockedBySettlement { + settlement_id: Some(s), + .. + }) if s == sid + ), + "l'avoir devait attendre le règlement puis refuser — reçu {:?}", + result.map(|c| c.credit_note.id) + ); +} diff --git a/crates/kesh-db/tests/invoice_amount_due_parity.rs b/crates/kesh-db/tests/invoice_amount_due_parity.rs new file mode 100644 index 00000000..79523b62 --- /dev/null +++ b/crates/kesh-db/tests/invoice_amount_due_parity.rs @@ -0,0 +1,311 @@ +//! Le résiduel juste — Story 25-4-a (#455, #456). +//! +//! `amount_due` = `TTC − avoir émis − Σ règlements`. Trois garanties : +//! +//! 1. **L'avoir est compté TTC** (#455) : le terme « avoir » vaut le crédit que +//! l'écriture de l'avoir porte à la créance — pas `credit_notes.total_amount`, +//! qui est HT. C'est l'assertion de **concordance avec le grand livre** qui le +//! prouve, pas une valeur recalculée à côté. +//! 2. **Les formes scalaire et jointe sont d'accord**, facture par facture, pour le +//! réglé comme pour l'avoir — le test de parité que le doc-comment +//! d'`INVOICE_SETTLED_SUBQUERY_SQL` annonçait sans qu'il existe. Les formes +//! jointes n'avaient aucun appelant ; 25-4-b en sera le premier. +//! 3. **Chaque facture porte une TVA non nulle.** À 0 %, HT = TTC et #455 est +//! invisible — c'est ainsi qu'il a passé tous les tests de la 24-2. +//! +//! Pré-requis : MariaDB démarré (`sqlx::test` crée une DB éphémère par test). + +use chrono::NaiveDate; +use kesh_db::entities::contact::{ContactType, NewContact}; +use kesh_db::entities::{NewCreditNote, NewInvoice, NewInvoiceLine, SettlementChoice}; +use kesh_db::repositories::invoice_settlements::{ + INVOICE_CREDITED_DERIVED_JOIN_SQL, INVOICE_CREDITED_SUBQUERY_SQL, + INVOICE_SETTLED_DERIVED_JOIN_SQL, INVOICE_SETTLED_SUBQUERY_SQL, amount_due, +}; +use kesh_db::repositories::{contacts, credit_notes, invoice_settlements_write, invoices}; +use kesh_db::test_fixtures::{SeededCompany, seed_accounting_company}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use sqlx::MySqlPool; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +async fn make_contact(pool: &MySqlPool, seeded: &SeededCompany) -> i64 { + contacts::create( + pool, + seeded.admin_user_id, + NewContact { + company_id: seeded.company_id, + contact_type: ContactType::Entreprise, + name: "Client Résiduel".into(), + first_name: None, + last_name: None, + is_client: true, + is_supplier: false, + address: Some("Rue 1\n1000 Lausanne".into()), + address_street: None, + address_building: None, + address_postal_code: None, + address_city: None, + address_country: None, + email: None, + phone: None, + ide_number: None, + client_number: None, + default_payment_terms: Some("30".into()), + default_payment_terms_days: None, + language: None, + salutation: kesh_db::entities::contact::Salutation::Neutre, + }, + ) + .await + .expect("contact") + .id +} + +/// Une facture validée par le **vrai chemin** (écriture de vente TVA comprise) — +/// jamais par le helper à 0 % de `invoice_settlement.rs`. +async fn validated( + pool: &MySqlPool, + seeded: &SeededCompany, + contact_id: i64, + lines: &[(Decimal, Decimal)], +) -> i64 { + let new = NewInvoice { + company_id: seeded.company_id, + contact_id, + date: d(2026, 6, 15), + due_date: None, + payment_terms: None, + lines: lines + .iter() + .map(|(rate, price)| NewInvoiceLine { + revenue_account_id: None, + description: "Ligne".into(), + quantity: dec!(1), + unit_price: *price, + vat_rate: *rate, + }) + .collect(), + project_id: None, + }; + let (inv, _) = invoices::create(pool, seeded.admin_user_id, new) + .await + .expect("create invoice"); + invoices::validate_invoice(pool, seeded.company_id, inv.id, seeded.admin_user_id) + .await + .expect("validate invoice"); + inv.id +} + +async fn settle(pool: &MySqlPool, seeded: &SeededCompany, invoice_id: i64, amount: Decimal) -> i64 { + invoice_settlements_write::settle_invoice( + pool, + seeded.admin_user_id, + seeded.company_id, + invoice_id, + SettlementChoice::InternalAccount { + account_id: seeded.accounts["1000"], + }, + amount, + d(2026, 6, 20), + ) + .await + .expect("règlement") + .journal_entry_id +} + +async fn credit( + pool: &MySqlPool, + seeded: &SeededCompany, + invoice_id: i64, +) -> credit_notes::IssuedCreditNote { + credit_notes::create_credit_note( + pool, + NewCreditNote { + company_id: seeded.company_id, + invoice_id, + date: d(2026, 7, 1), + }, + seeded.admin_user_id, + ) + .await + .expect("avoir") +} + +/// L'état hérité **règlement puis avoir**, que 25-4-a rend inatteignable par +/// l'application mais qu'un `.keshbackup` v0.12.0 peut ramener. +/// +/// Gabarit « détacher, créditer, rattacher » (AC 13) : les deux écritures +/// viennent des **vrais chemins** ; seul le rattachement de la ligne de +/// règlement passe en SQL, le temps de l'avoir. +async fn settle_then_legacy_credit( + pool: &MySqlPool, + seeded: &SeededCompany, + contact_id: i64, + invoice_id: i64, + amount: Decimal, +) { + settle(pool, seeded, invoice_id, amount).await; + let parking = validated(pool, seeded, contact_id, &[(dec!(8.10), dec!(10.00))]).await; + sqlx::query("UPDATE invoice_settlements SET invoice_id = ? WHERE invoice_id = ?") + .bind(parking) + .bind(invoice_id) + .execute(pool) + .await + .expect("détacher"); + credit(pool, seeded, invoice_id).await; + sqlx::query("UPDATE invoice_settlements SET invoice_id = ? WHERE invoice_id = ?") + .bind(invoice_id) + .bind(parking) + .execute(pool) + .await + .expect("rattacher"); +} + +async fn credited_scalar(pool: &MySqlPool, invoice_id: i64) -> Decimal { + sqlx::query_scalar(&format!( + "SELECT {INVOICE_CREDITED_SUBQUERY_SQL} FROM invoices i WHERE i.id = ?" + )) + .bind(invoice_id) + .fetch_one(pool) + .await + .expect("avoir scalaire") +} + +/// AC 1, AC 7 — ⛔ le terme « avoir » du résiduel égale le crédit que +/// l'écriture de l'avoir porte à la créance. Multi-taux, arrondis limites. +#[sqlx::test(migrations = "./test-schema")] +async fn credited_amount_is_ttc_and_matches_the_ledger(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, &seeded).await; + let invoice_id = validated( + &pool, + &seeded, + contact, + &[ + (dec!(8.10), dec!(1000.00)), + (dec!(2.60), dec!(123.45)), + (dec!(8.10), dec!(0.05)), + ], + ) + .await; + + let issued = credit(&pool, &seeded, invoice_id).await; + let receivable = seeded.accounts["1100"]; + let ledger_credit: Decimal = issued + .journal_entry + .lines + .iter() + .filter(|l| l.account_id == receivable) + .map(|l| l.credit) + .sum(); + + // 1000 + 81.00 ; 123.45 + 3.21 ; 0.05 + 0.00 (sous-centime) = 1207.71 + assert_eq!(ledger_credit, dec!(1207.71), "crédit créance de l'avoir"); + assert_eq!(credited_scalar(&pool, invoice_id).await, ledger_credit); + assert_ne!( + issued.credit_note.total_amount, ledger_credit, + "le montant stocké est HT : s'il égalait le crédit, le test ne distinguerait rien" + ); +} + +/// AC 8 — facture créditée, jamais réglée : il ne reste rien à payer. +#[sqlx::test(migrations = "./test-schema")] +async fn amount_due_of_a_credited_invoice_is_zero(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, &seeded).await; + let invoice_id = validated(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00))]).await; + credit(&pool, &seeded, invoice_id).await; + + assert_eq!( + amount_due(&pool, invoice_id).await.unwrap(), + Decimal::ZERO, + "et non 8.10, la TVA (#455)" + ); +} + +/// AC 8 — l'état hérité : 40 encaissés sur une vente annulée. Le reste dû vaut +/// −40, un trop-perçu visible — pas `TVA − 40`, et pas écrêté à zéro. +#[sqlx::test(migrations = "./test-schema")] +async fn amount_due_after_settlement_then_legacy_credit_is_minus_settled(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, &seeded).await; + let invoice_id = validated(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00))]).await; + settle_then_legacy_credit(&pool, &seeded, contact, invoice_id, dec!(40.00)).await; + + assert_eq!(amount_due(&pool, invoice_id).await.unwrap(), dec!(-40.00)); +} + +/// AC 6 — la forme scalaire et la forme jointe rendent la même valeur, facture +/// par facture, pour le réglé et pour l'avoir. Jeu : sans règlement, un +/// règlement, deux règlements, avoir émis, avoir brouillon, état hérité. +#[sqlx::test(migrations = "./test-schema")] +async fn settled_and_credited_forms_are_at_parity(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, &seeded).await; + let lines = [(dec!(8.10), dec!(100.00)), (dec!(2.60), dec!(33.33))]; + + let _plain = validated(&pool, &seeded, contact, &lines).await; + let one = validated(&pool, &seeded, contact, &lines).await; + settle(&pool, &seeded, one, dec!(40.00)).await; + let two = validated(&pool, &seeded, contact, &lines).await; + settle(&pool, &seeded, two, dec!(40.00)).await; + settle(&pool, &seeded, two, dec!(12.34)).await; + let credited = validated(&pool, &seeded, contact, &lines).await; + credit(&pool, &seeded, credited).await; + let legacy = validated(&pool, &seeded, contact, &lines).await; + settle_then_legacy_credit(&pool, &seeded, contact, legacy, dec!(25.00)).await; + + // Avoir BROUILLON : sans écriture, il n'éteint rien — monté en SQL, aucun + // chemin applicatif ne produisant de brouillon (single-step, DC5). + let draft_target = validated(&pool, &seeded, contact, &lines).await; + let cn = sqlx::query( + "INSERT INTO credit_notes (company_id, invoice_id, contact_id, date, status, total_amount) \ + VALUES (?, ?, ?, ?, 'draft', 133.33)", + ) + .bind(seeded.company_id) + .bind(draft_target) + .bind(contact) + .bind(d(2026, 7, 1)) + .execute(&pool) + .await + .expect("avoir brouillon"); + sqlx::query( + "INSERT INTO credit_note_lines (credit_note_id, position, description, quantity, unit_price, vat_rate, line_total) \ + VALUES (?, 1, 'Ligne', 1, 100.00, 8.10, 100.00)", + ) + .bind(cn.last_insert_id()) + .execute(&pool) + .await + .expect("ligne d'avoir brouillon"); + + let rows: Vec<(i64, Decimal, Decimal, Decimal, Decimal)> = sqlx::query_as(&format!( + "SELECT i.id, {INVOICE_SETTLED_SUBQUERY_SQL}, COALESCE(st.settled, 0), \ + {INVOICE_CREDITED_SUBQUERY_SQL}, COALESCE(cnt.credited, 0) \ + FROM invoices i {INVOICE_SETTLED_DERIVED_JOIN_SQL} {INVOICE_CREDITED_DERIVED_JOIN_SQL} \ + WHERE i.company_id = ? ORDER BY i.id" + )) + .bind(seeded.company_id) + .fetch_all(&pool) + .await + .expect("parité"); + + // Six factures du jeu, plus la facture auxiliaire du gabarit. + assert_eq!(rows.len(), 7, "le jeu entier est lu : {rows:?}"); + for (id, settled_s, settled_j, credited_s, credited_j) in &rows { + assert_eq!(settled_s, settled_j, "réglé, facture {id}"); + assert_eq!(credited_s, credited_j, "avoir, facture {id}"); + } + // Anti-vacuité : la parité porte sur des valeurs NON nulles des deux côtés. + let by_id = |x: i64| rows.iter().find(|r| r.0 == x).unwrap(); + assert_eq!(by_id(two).1, dec!(52.34)); + assert_eq!(by_id(credited).3, dec!(142.30)); // 100 + 8.10 ; 33.33 + 0.87 + assert_eq!( + by_id(draft_target).3, + Decimal::ZERO, + "un brouillon n'éteint rien" + ); +} diff --git a/crates/kesh-db/tests/invoice_settlement.rs b/crates/kesh-db/tests/invoice_settlement.rs index 0f01479f..f14d1f21 100644 --- a/crates/kesh-db/tests/invoice_settlement.rs +++ b/crates/kesh-db/tests/invoice_settlement.rs @@ -974,6 +974,25 @@ async fn monter(pool: &MySqlPool, motifs: &[SettlementCancelBlocker]) -> (Seeded .await; if motifs.contains(&SettlementCancelBlocker::InvoiceCredited) { + // ⚠️ **État HÉRITÉ, plus atteignable par l'application** depuis la + // Story 25-4-a (#456) : un avoir est désormais refusé sur une facture + // réglée, même en partie. Il reste possible par l'**import d'une + // sauvegarde antérieure** — c'est ce qui justifie de garder le motif + // `InvoiceCredited` et ce montage. + // + // Gabarit « détacher, créditer, rattacher » : les deux écritures + // (règlement, avoir) viennent des VRAIS chemins ; seul le rattachement + // de la ligne de règlement passe en SQL, le temps de l'avoir. La + // facture auxiliaire est datée dans l'exercice seedé, que le cas + // « aucun exercice ouvert » garde valide. + let parking = + validated_invoice(pool, &seeded, dec!(10.00), d - chrono::Duration::days(20)).await; + sqlx::query("UPDATE invoice_settlements SET invoice_id = ? WHERE id = ?") + .bind(parking) + .bind(sid) + .execute(pool) + .await + .expect("détacher le règlement"); credit_notes::create_credit_note( pool, kesh_db::entities::NewCreditNote { @@ -984,7 +1003,13 @@ async fn monter(pool: &MySqlPool, motifs: &[SettlementCancelBlocker]) -> (Seeded seeded.admin_user_id, ) .await - .expect("avoir après règlement partiel — le vrai chemin l'accepte"); + .expect("avoir sur la facture détachée de son règlement"); + sqlx::query("UPDATE invoice_settlements SET invoice_id = ? WHERE id = ?") + .bind(inv_id) + .bind(sid) + .execute(pool) + .await + .expect("rattacher le règlement"); } if motifs.contains(&SettlementCancelBlocker::MatchedBankTransaction) { match_to_bank( diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 3a86241f..6c151ed9 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -24,6 +24,7 @@ error-journal-entry-linked-to-invoice = Diese Buchung stammt aus einer freigegeb error-check-constraint = Ungültiger Wert error-illegal-state = Unzulässiger Statusübergang error-invoice-unvalidate-blocked-settled = Diese Rechnung weist eine — auch nur teilweise — Zahlung auf: stornieren Sie diese zuerst. +error-credit-note-blocked-settled = Diese Rechnung weist eine — auch nur teilweise — Zahlung auf: Stornieren Sie diese zuerst, um eine Gutschrift ausstellen zu können. error-invoice-unvalidate-blocked-credited = Diese Rechnung wurde durch eine Gutschrift korrigiert, die bereits die Korrektur darstellt. error-invoice-unvalidate-blocked-reminders = Diese Rechnung hat eine Mahnhistorie: deren Löschung würde den Nachweis des Inkassos vernichten. error-invoice-unvalidate-blocked-emailed = Diese Rechnung wurde dem Kunden zugestellt: korrigieren Sie sie mit einer Gutschrift. diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index fe0684f0..92ffd928 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -24,6 +24,7 @@ error-journal-entry-linked-to-invoice = This journal entry was generated by a va error-check-constraint = Invalid value error-illegal-state = Illegal state transition error-invoice-unvalidate-blocked-settled = This invoice carries a payment, even a partial one: cancel it first. +error-credit-note-blocked-settled = This invoice carries a payment, even a partial one: cancel it first to be able to issue a credit note. error-invoice-unvalidate-blocked-credited = This invoice is credited by a credit note, which is already the correction. error-invoice-unvalidate-blocked-reminders = This invoice has a reminder history: removing it would erase the proof of collection. error-invoice-unvalidate-blocked-emailed = This invoice was sent to the client: correct it with a credit note. diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 8aaa3a6a..9e47a49e 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -24,6 +24,7 @@ error-journal-entry-linked-to-invoice = Cette écriture comptable a été géné error-check-constraint = Valeur invalide error-illegal-state = Transition d'état interdite error-invoice-unvalidate-blocked-settled = Cette facture porte un règlement, même partiel : annulez-le d'abord. +error-credit-note-blocked-settled = Cette facture porte un règlement, même partiel : annulez-le d'abord pour pouvoir émettre un avoir. error-invoice-unvalidate-blocked-credited = Cette facture est créditée par un avoir, qui en est déjà la correction. error-invoice-unvalidate-blocked-reminders = Cette facture a un historique de rappels : le dévalider effacerait la preuve du recouvrement. error-invoice-unvalidate-blocked-emailed = Cette facture a été envoyée au client : corrigez-la par un avoir. diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 5cb50f27..06f35274 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -24,6 +24,7 @@ error-journal-entry-linked-to-invoice = Questa registrazione contabile è stata error-check-constraint = Valore non valido error-illegal-state = Transizione di stato non consentita error-invoice-unvalidate-blocked-settled = Questa fattura presenta un pagamento, anche parziale: annullatelo prima. +error-credit-note-blocked-settled = Questa fattura presenta un pagamento, anche parziale: annullalo prima per poter emettere una nota di credito. error-invoice-unvalidate-blocked-credited = Questa fattura è stornata da una nota di credito, che ne è già la correzione. error-invoice-unvalidate-blocked-reminders = Questa fattura ha uno storico di solleciti: eliminarlo cancellerebbe la prova del recupero. error-invoice-unvalidate-blocked-emailed = Questa fattura è stata inviata al cliente: correggetela con una nota di credito. diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index f402efac..139a7469 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 f4586c39..25dde525 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -961,7 +961,9 @@ \subsection{Enregistrer et annuler un règlement}\label{sec:reglement-client} \begin{itemize} \item \textbf{la facture a été créditée} par un avoir~: le règlement est alors un paiement - \emph{à lettrer} à une autre facture --- il ne s'annule pas~; + \emph{à lettrer} à une autre facture --- il ne s'annule pas. Ce cas ne se présente plus + que sur une facture créditée \emph{avant} la version 0.12.1, ou restaurée d'une sauvegarde + antérieure~: un avoir est désormais refusé sur une facture réglée (\S\ref{sec:avoirs})~; \item \textbf{l'exercice du règlement est clôturé}~: un administrateur doit d'abord \textbf{rouvrir} l'exercice~; \item \textbf{le règlement est rapproché} d'une transaction bancaire~: il faut d'abord @@ -1016,6 +1018,16 @@ \subsection{Avoirs et notes de crédit} L'avoir est consultable dans \emph{Avoirs} (menu \emph{Quotidien}), avec un \textbf{PDF « Avoir »} téléchargeable (mention « Avoir », référence à la facture d'origine, sans section QR de paiement). Le \emph{Décompte TVA} de la période tient compte de l'annulation (la TVA de la facture annulée est exclue). +\begin{keshwarning} +\textbf{Avoir refusé : « la facture porte un règlement ».} Un avoir annule \emph{toute} la facture. Si +le client en a déjà réglé une partie, l'avoir lui laisserait un crédit du montant encaissé, que +Kesh ne sait encore ni afficher, ni rembourser, ni imputer sur une autre facture. L'avoir est donc +refusé dès qu'un règlement existe --- même partiel ---, et Kesh le dit dans le dialogue. +\textbf{Annulez d'abord le règlement} depuis la fiche de la facture (\S\ref{sec:reglement-client}), +puis émettez l'avoir. Si le client ne paiera simplement pas le solde, ce n'est pas un avoir qu'il +faut, mais passer le reste en perte --- fonction prévue. +\end{keshwarning} + \begin{keshwarning} \textbf{Avoir refusé : « compte de produit archivé ».} Si une ligne de la facture impute un compte de produit qui a été \textbf{archivé depuis la validation}, Kesh refuse d'émettre l'avoir et nomme la ligne et le compte concernés. @@ -1037,7 +1049,7 @@ \subsection{Avoirs et notes de crédit} \end{keshwarning} \begin{keshnote} -En version actuelle, l'avoir est \textbf{total} (il annule la facture entière). L'avoir \emph{partiel} et le remboursement d'une facture \emph{déjà encaissée} (un avoir est refusé sur une facture marquée payée) sont prévus pour une version ultérieure. Les paiements sortants par fichier \texttt{pain.001.001.09} sont, eux, \textbf{disponibles} (cf. \S\ref{sec:paiements-fournisseurs}). +En version actuelle, l'avoir est \textbf{total} (il annule la facture entière). L'avoir \emph{partiel} et le remboursement d'une facture \emph{déjà encaissée} (un avoir est refusé sur une facture \textbf{réglée, même en partie}) sont prévus pour une version ultérieure. Les paiements sortants par fichier \texttt{pain.001.001.09} sont, eux, \textbf{disponibles} (cf. \S\ref{sec:paiements-fournisseurs}). \end{keshnote} \subsection{Supprimer ou dévalider une facture} @@ -1453,7 +1465,8 @@ \subsection{Annuler un rapprochement}\label{sec:annuler-rapprochement} \begin{itemize} \item \textbf{la facture a été créditée} par un avoir~: son règlement est un paiement - \emph{à lettrer}, il ne s'annule pas~; + \emph{à lettrer}, il ne s'annule pas --- cas hérité d'une version antérieure à la 0.12.1 + ou d'une sauvegarde restaurée~; \item \textbf{l'exercice de l'écriture du rapprochement est clôturé}~: un administrateur doit d'abord le rouvrir. ⚠️ C'est l'exercice du \textbf{paiement} qui compte, jamais celui de la facture~: une facture de décembre dont l'exercice est clôturé, payée en @@ -2070,7 +2083,7 @@ \subsection{Nouveautés et limites (v0.3.0)} \begin{itemize} \item Mono-devise CHF (multi-devises prévu pour une version ultérieure). \item Décompte TVA au format officiel AFC / e-décompte ESTV (les montants du décompte Kesh sont à reporter manuellement sur le formulaire AFC). - \item Avoir \emph{partiel} et avoir sur facture \emph{déjà encaissée} (l'avoir total sur facture non payée est, lui, disponible — cf. \S\ref{sec:avoirs}). + \item Avoir \emph{partiel} et avoir sur facture \emph{déjà encaissée}, même en partie (l'avoir total sur une facture sans aucun règlement est, lui, disponible — cf. \S\ref{sec:avoirs}). \item Sélecteur de langue dans l'interface (la langue est fixée à l'onboarding ; le changement en cours de session est prévu pour une version ultérieure). \item Pas de signature électronique qualifiée (QES) sur les exports (verdict (b) de la recherche réglementaire, prévu Epic 14). \item Format PDF standard, non PDF/A (migration prévue selon ISO 19005). diff --git a/frontend/src/routes/(app)/invoices/[id]/+page.svelte b/frontend/src/routes/(app)/invoices/[id]/+page.svelte index fb992f87..c4c2ce6a 100644 --- a/frontend/src/routes/(app)/invoices/[id]/+page.svelte +++ b/frontend/src/routes/(app)/invoices/[id]/+page.svelte @@ -833,15 +833,26 @@ Voir l'écriture comptable {/if} - {#if canManage && !invoice.paidAt} - {/if} {#if canManage && !invoice.paidAt} + à un rappel) : les boutons Suspendre/Reprendre sont masqués (review + P1 BH1). ⚠️ « Créer un avoir » ne l'est plus depuis la Story 25-4-a : + il s'affiche, et le serveur refuse avec le motif nommé. --> {#if invoice.dunningPausedAt === null} - 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 52f6cf4e..d707f4d2 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 @@ -1,5 +1,6 @@ /** - * La fiche facture, côté annulation d'un règlement — Story 25-3-a-1 (#414). + * La fiche facture, côté annulation d'un règlement — Story 25-3-a-1 (#414) — + * et côté avoir sur une facture réglée — Story 25-4-a (#456). * * Ce que le composant de liste ne peut pas prouver seul : que la fiche * **confirme** avant d'envoyer, **relit** facture et règlements après succès, et @@ -70,8 +71,9 @@ vi.mock("$lib/features/reminders/reminders.api", () => ({ vi.mock("$lib/features/projects/projects.api", () => ({ listProjects: vi.fn(async () => []), })); +const createCreditNoteMock = vi.fn(); vi.mock("$lib/features/credit-notes/credit-notes.api", () => ({ - createCreditNote: vi.fn(), + createCreditNote: (req: unknown) => createCreditNoteMock(req), })); import Page from "./+page.svelte"; @@ -176,3 +178,35 @@ describe("fiche facture — annuler un règlement", () => { expect(await findByText("F-2026-005", { exact: false })).toBeTruthy(); }); }); + +describe("fiche facture — créer un avoir sur une facture réglée (Story 25-4-a)", () => { + it("le bouton s'affiche sur une facture ENTIÈREMENT payée (mutation m6 : `!invoice.paidAt` remis)", async () => { + const { findByTestId } = render(Page); + expect(await findByTestId("invoice-credit-note-button")).toBeTruthy(); + }); + + it("le bouton s'affiche sur une facture réglée EN PARTIE, sans `paidAt`", async () => { + getInvoiceMock.mockResolvedValue( + invoice({ paidAt: null, amountSettled: "40.00", amountDue: "60.00" }), + ); + const { findByTestId } = render(Page); + expect(await findByTestId("invoice-credit-note-button")).toBeTruthy(); + }); + + it("le refus du serveur s'affiche DANS le dialogue, avec son motif (le serveur tranche, l'écran le dit)", async () => { + createCreditNoteMock.mockRejectedValue({ + code: "CREDIT_NOTE_INVOICE_SETTLED", + message: + "Cette facture porte un règlement, même partiel : annulez-le d'abord pour pouvoir émettre un avoir. (F-2026-005)", + status: 409, + }); + const { findByTestId, getByTestId } = render(Page); + + await fireEvent.click(await findByTestId("invoice-credit-note-button")); + await fireEvent.click(getByTestId("invoice-credit-note-confirm")); + + await waitFor(() => expect(createCreditNoteMock).toHaveBeenCalledTimes(1)); + const erreur = await findByTestId("invoice-credit-note-error"); + expect(erreur.textContent).toContain("annulez-le d'abord"); + }); +});