diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6e160a7..8eff70add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **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).)* +- **La balance âgée et l'échéancier comptaient le montant facturé, et non ce que le client doit encore ([#416](https://github.com/guycorbaz/kesh/issues/416)).** Depuis le règlement partiel, une facture de 1'000.— réglée à 900.— y pesait toujours 1'000.—. ⚠️ La balance âgée est l'outil qui dit **qui relancer et pour combien** : elle surévaluait chaque créance de tout ce qui avait déjà été encaissé, sans que rien ne le signale, et ne concordait plus avec le compte clients du grand livre. Elle répartit désormais le **reste dû**, dans la tranche de l'échéance de la facture — un acompte ne rajeunit pas la créance. L'échéancier montre une colonne **« Reste dû »** à côté du total, le statut **« Partiellement payée »**, des totaux au reste dû, et son dialogue de règlement se **pré-remplit** au reste dû ; l'export CSV porte la même colonne. Le manuel dit à quelles conditions le total de la balance âgée égale le solde du compte clients. + ### 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*. diff --git a/_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md b/_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md index 0f17fadba..a258d6203 100644 --- a/_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md +++ b/_bmad-output/implementation-artifacts/25-4-a-residuel-juste.md @@ -456,6 +456,13 @@ Claude Opus 5.5. ## Change Log +- **2026-09-27** — ⚠️ **Rectification après livraison** (relevée en validation de 25-4-b1, P3-P4) : + l'AC 13 dit l'état « avoir après règlement partiel » atteignable **« que par l'import d'une + sauvegarde antérieure »**. C'est **faux par omission** : la **0.12.0 publiée** accepte cet avoir + (`git show v0.12.0:crates/kesh-db/src/repositories/credit_notes.rs:302`), si bien qu'une + installation **mise à jour sur place** peut le porter. Lire : « des données antérieures à la + 0.12.1, restaurées ou mises à jour ». Les quatre commentaires de code qui reprenaient la + formulation sont rectifiés par 25-4-b1 (T0) ; le manuel disait déjà juste. - **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, diff --git a/_bmad-output/implementation-artifacts/25-4-b1-residuel-aux-agregats.md b/_bmad-output/implementation-artifacts/25-4-b1-residuel-aux-agregats.md new file mode 100644 index 000000000..b75a5ff88 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b1-residuel-aux-agregats.md @@ -0,0 +1,467 @@ +# Story 25.4-b1 : Le résiduel aux agrégats — balance âgée et échéancier + +Status: done + +**Issue : [#416]** — cette story en livre la partie **agrégats** ; la partie **rappels** est la +25-4-b2. ⛔ **La PR de b1 porte `refs #416`, pas `closes`** : l'issue ne se ferme qu'avec b2. + +**Mère : `25-4-propager-le-residuel.md`** (`split`). **Sœur livrée : 25-4-a** (PR #472, #455 et #456) — +elle a rendu la formule juste (avoir compté TTC) et posé le test de parité des formes jointes ; +cette story en est le **premier appelant**. ⚠️ Branche **empilée** sur celle de 25-4-a : à rebaser +sur `main` après le merge de #472. + +## Story + +En tant que comptable, +je veux que la balance âgée et l'échéancier montrent ce que chaque client **doit encore**, et non +ce que la facture valait à l'émission, +afin de relancer les bons clients pour les bons montants, et que la balance âgée concorde avec le +compte débiteurs du grand livre. + +## Le défaut, vérifié dans le code + +Depuis la 24-2, une facture se règle en plusieurs fois ; le reste dû se **calcule** (24-2, D3). Les +écrans **unitaires** le savent (fiche facture : badge « partiellement payée », « Reste dû »). Les +écrans **agrégés**, non — ils totalisent le **TTC** : + +| Site | Ce qu'il somme | +|---|---| +| Balance âgée — `crates/kesh-report/src/aged_receivables.rs:111-131` (`generate`) | `COALESCE(lt.ttc, 0)` dans les cinq tranches et le total | +| Totaux de l'échéancier — `crates/kesh-db/src/repositories/invoices.rs:882-900` (`due_dates_summary`) | `unpaid_total`, `overdue_total` en `lt.ttc` ; le commentaire `:890-892` les dit « montants dus » — faux depuis la 24-2 | +| Lignes de l'échéancier — `list_by_company_paginated` (`:815-821`) | `total_ttc` seul ; **aucun** reste dû par ligne | +| Export CSV de l'échéancier — `list_for_export` (`:2456-2461`), `routes/invoices.rs:1463-1465` | colonne « Total » = TTC, commentée « montant dû » ; statut sans « partiel » (`:1443-1449`) | +| Page échéancier — `frontend/src/routes/(app)/invoices/due-dates/+page.svelte` | colonne Total = `totalTtc` (`:453`), `statusOf` sans `partial` (`:306-314`), dialogue `amountDue={null}` (`:493-507`) | + +Une facture de 1 000.— réglée à 900.— pèse **1 000.—** partout, au lieu de 100.—. ⚠️ La balance âgée +est un **instrument de décision** (qui relancer, pour combien) et doit **concorder avec le compte +débiteurs** du grand livre, juste depuis la 24-2 : les deux divergent aujourd'hui exactement du +montant des règlements partiels, et **aucun test ne compare** les deux (vérifié : aucun test de +`kesh-report` ne lit un solde de compte). + +⚠️ **Les formes jointes du résiduel n'ont aucun appelant** (`INVOICE_SETTLED_DERIVED_JOIN_SQL`, +`INVOICE_CREDITED_DERIVED_JOIN_SQL`) ; 25-4-a en a prouvé la parité avec les formes scalaires. La +24-2 (D3) a prescrit la **jointure dérivée** pour les listes et agrégats : la forme corrélée y +serait réévaluée par ligne — un N+1 déguisé. + +## Acceptance Criteria + +### Volet 1 — la grandeur, une seule fois + +**AC 1** — Une constante (ou une paire) canonique porte le **reste dû sous forme jointe** : les trois +tables dérivées (`lt` TTC, `cnt` avoir, `st` réglé) et l'expression +`COALESCE(lt.ttc, 0) − COALESCE(cnt.credited, 0) − COALESCE(st.settled, 0)`. Elle vit à côté +d'`amount_due` (`invoice_settlements.rs`), avec un doc-comment qui la dit **miroir** de la forme +scalaire. ⛔ Aucun site de cette story ne réécrit l'expression à la main. + +**AC 2** — Le **test de parité** de 25-4-a (`tests/invoice_amount_due_parity.rs`) est étendu : pour +chaque facture du jeu, l'expression jointe de l'AC 1 = `amount_due` scalaire. + +### Volet 2 — la balance âgée + +**AC 3** — Les cinq tranches et le total de `aged_receivables::generate` somment le **reste dû** +(AC 1), non le TTC. Les tranches restent assises sur `i.due_date` : ⛔ **un règlement partiel ne +rajeunit pas la créance restante**. + +**AC 4** — Le périmètre des factures ne change pas : `status = 'validated' AND paid_at IS NULL` +(une facture soldée a `paid_at`, 24-2 D4). ~~Le `HAVING total <> 0` reste, et porte désormais sur le +reste dû.~~ Le `HAVING` garde un contact dès qu'**une** de ses factures a un reste dû non nul. +⚠️ Un reste dû **négatif** (trop-perçu hérité) n'est **pas écrêté** : il apparaît, en +négatif — le doc-comment d'`amount_due` l'exige. +*(Rectifié le 2026-09-27, passe 1 de revue de code : la phrase barrée contredisait la suivante. Sur +le total du contact, un trop-perçu hérité compensant une facture ouverte faisait sortir le contact +entier — et le négatif n'apparaissait pas.)* + +**AC 5** — ⛔ **Invariant de concordance** (test) : sur une société dont le compte débiteurs n'est mû +que par des factures, des règlements et des avoirs — factures sans règlement, partiellement réglée, +soldée, créditée —, le **total de la balance âgée = solde du compte 1100 au grand livre** +(`general_ledger` ou somme `debit − credit`). C'est l'assertion que #416 réclame et qui n'existe +pour aucun écran. + +⚠️ **Portée de l'invariant — énoncée par une RÈGLE, pas par une liste.** *(Réécrite en validation +P3 : trois passes ont chacune trouvé des chemins que la liste précédente omettait — quatre en P3. +Une énumération de formes est ouverte par nature ; `CLAUDE.md` § « Inventorier les sites NON +RÉSOLUS ».)* + +**La règle** : l'égalité vaut **si et seulement si** le compte débiteurs n'est mouvementé **que** par +les écritures de **vente**, de **règlement client** et d'**avoir** que Kesh passe pour des factures +**validées**, toutes **datées au plus tard à la date d'arrêté** (`as_of`), et **n'a jamais changé** +dans les réglages de facturation. Les **contre-passations** de ces mouvements (annulation d'un +règlement, d'un rapprochement de facture) restent **dans** la règle : elles inversent exactement +les mêmes comptes et montants, pendant que le reste dû remonte d'autant. Tout autre mouvement l'en +écarte. *Exemples*, non exhaustifs, tous +vérifiés atteignables : solde d'ouverture ; écriture saisie au journal ; rapprochement bancaire hors +facture — manuel, ventilé, par règle (`post_manual`, `post_split`, `accept_one_split`, +`accept_one_rule`) ; règlement d'une facture **fournisseur** par compte interne imputé au compte +débiteurs (compensation) ; changement du compte débiteurs par défaut (et l'avoir qui, depuis, crédite +le nouveau compte — **défaut produit, #473**) ; règlement client imputé au compte débiteurs lui-même +(**#474**) ; **données antérieures à la 0.12.1** — factures marquées payées sans écriture par +l'ancien `mark_as_paid` (avant 0.12.0), facture annulée par un avoir après règlement partiel +(**0.12.0 publiée**, `git show v0.12.0:…/credit_notes.rs:302`) — qu'elles viennent d'une +**restauration** ou d'une **mise à jour sur place**. + +Le test de l'AC 5 se place **dans** la règle : les quatre états **vivants**, et un `as_of` +postérieur ou égal à toutes les dates de pièces. ⛔ Il n'y ajoute aucun cas hors règle. Le +doc-comment de `generate` énonce la règle, pas la liste des exemples. + +**AC 6** — Doc-comment de `generate` (`:95-99` : « Montants = TTC dérivé ») et libellés : la balance +âgée montre le **reste dû** ; l'écran (`AgedReceivablesView.svelte`) et son CSV gardent leur +structure — seules les valeurs changent. Si un libellé visible dit « TTC » ou « total facturé », +il devient « reste dû » (4 locales). + +### Volet 3 — l'échéancier + +**AC 7** — `due_dates_summary` : `unpaid_total` et `overdue_total` somment le **reste dû** ; le +commentaire `:890-892` dit vrai. + +**AC 8** — `InvoiceListItem` porte `amount_settled` et `amount_due` (`Decimal`), projetés par **les +deux SELECT** qui le désérialisent — `list_by_company_paginated` **et** `list_for_export` (⚠️ le +doc-comment `:263-269` le rappelle : un oubli échoue au **runtime**). Calculés par la **forme +jointe** (AC 1) ; `total_ttc` reste. ⛔ Pas de N+1 : le nombre de requêtes d'une page ne dépend pas +du nombre de lignes (invariant 7 de la 24-2). + +**AC 9** — `InvoiceListItemResponse` (`routes/invoices.rs:333-364`) expose `amountSettled` et +`amountDue` (camelCase, chaîne décimale). ⚠️ Même discipline que la fiche +(`routes/invoices.rs:242-248`) : **toujours calculés** dans une liste, jamais `None` par défaut. Le +type frontend (`invoices.types.ts`) suit. + +**AC 10** — Page échéancier : +- une colonne **« Reste dû »** (`amountDue`) à côté du Total TTC, qui reste ; +- le statut de ligne rend **`partial`** quand `paidAt` est nul et `amountSettled > 0` — même règle + que la fiche (`invoices/[id]/+page.svelte:424-438`), idéalement **la même fonction** partagée ; +- le résumé montre les totaux de l'AC 7 (les libellés disent « reste dû » s'ils disaient autre + chose) ; +- le dialogue de règlement reçoit **`amountDue={inv.amountDue}`** : pré-rempli, et sa garde client + de trop-perçu (`SettleInvoiceDialog.svelte:108-112`) redevient active depuis l'échéancier. Le + commentaire `:493-499` qui explique pourquoi il ne l'était pas est retiré. + +**AC 11** — Export CSV de l'échéancier : une colonne **« Reste dû »** après « Total » (clé d'en-tête +×4, `CSV_HEADER_KEYS` / `CSV_HEADER_FALLBACKS`) ; le statut rend « partiellement payée » +(`payment-status-partial`, clé existante) ; le commentaire `:1463-1464` « TTC (montant dû) » est +corrigé. + +### Volet 4 — tests, textes + +**AC 12** — Tests, au minimum : + +| Test | Prouve | +|---|---| +| parité étendue (AC 2) | forme jointe du reste dû = `amount_due` | +| `aged_uses_amount_due_not_ttc` | facture 8,1 % de 1 081.— réglée 900.— : pèse 181.— dans sa tranche | +| `aged_partial_payment_keeps_original_bucket` | la tranche suit `due_date`, pas la date du règlement | +| `aged_total_matches_receivable_ledger` | AC 5 | +| `due_dates_summary_totals_are_amount_due` | AC 7 | +| `list_items_carry_amount_due` (repo **et** e2e HTTP) | AC 8-9, les deux SELECT ; la valeur traverse la frontière | +| export CSV : colonne reste dû, statut partiel | AC 11 | +| Vitest échéancier | colonne, statut `partial`, dialogue pré-rempli | +| **E2E** `invoices_echeancier.spec.ts` | le cas de la 24-3 (`:147-158`) cesse de **saisir** le montant : il vérifie qu'il est **pré-rempli** au reste dû, sur une facture partiellement réglée | + +⛔ **Chaque facture de test porte une TVA non nulle** (leçon de 25-4-a). ⛔ Mutations à exécuter et +consigner : TTC remis dans une tranche de la balance âgée — **et TTC remis dans le seul total** (doit +faire rougir `aged_total_matches_receivable_ledger`) ; `amount_due` retiré de `list_for_export` +seul ; `partial` retiré de `statusOf` ; `amountDue={null}` remis. + +**AC 13** — Textes (`docs/manual/fr/user-manual.tex`, PDF **contrôlé aplati**), § Balance âgée +(`\label{sec:balance-agee}`) — **quatre** passages, tous au TTC aujourd'hui : +- `:1576` « encours débiteur (le total dû, TVA comprise) » → le **reste dû**, après règlements + partiels et avoirs ; +- `:1578`, légende de la capture : « encours débiteur **TTC** » → « reste dû » ; +- `:1580-1581` : « La colonne « Non échu » **garantit** que le total général réconcilie avec le + solde du compte clients » — vrai **seulement** dans la règle de l'AC 5. La phrase devient, par la + règle et non par une liste : *« Le total général réconcilie avec le solde du compte clients tant + que ce compte n'est mouvementé que par les factures, leurs règlements et leurs avoirs (y compris + leurs annulations). Toute autre écriture qui le touche l'en écarte : solde de départ, écriture + saisie au journal, virement bancaire affecté à ce compte sans passer par une facture, paiement + d'une facture fournisseur compensé sur ce compte, ou règlement dont la contrepartie est ce compte + lui-même. De même un changement du compte clients dans les réglages de facturation, ou des données + antérieures à la version 0.12.1, restaurées ou mises à jour. »* ; +- `:1587-1588`, note : « le **total dû TTC** de chaque facture, jamais le montant hors taxe » → « le + **reste dû** de chaque facture, jamais le montant hors taxe ni le total facturé ». + +⚠️ Greper le symptôme (`TTC`, `total dû`) dans **toute** la section et dans le PDF aplati, pas +seulement ces lignes. Puis : § Échéancier (`:927`) décrit la colonne et le pré-remplissage. +CHANGELOG `[0.12.1]` *Fixed*. README : rien, sauf si la ligne v0.12.1 cite la balance âgée. + +## Tasks / Subtasks + +- [x] **T0 — rectifier la portée de l'état hérité posée par 25-4-a** : **cinq** sites disent + l'état « avoir après règlement partiel » atteignable **seulement par l'import d'une + sauvegarde** — quatre commentaires, `errors.rs:221`, `invoice_settlements_write.rs:333`, + `tests/invoice_amount_due_parity.rs:139`, `tests/invoice_settlement.rs:980`, et **leur source**, + la fiche `25-4-a-residuel-juste.md` (AC 13, `:180`), rectifiée par une note datée plutôt que + réécrite (story livrée). Faux : la **0.12.0 + publiée** accepte cet avoir, une installation **mise à jour sur place** le porte. Écrire « des + données antérieures à la 0.12.1, restaurées ou mises à jour ». *(Le manuel de 25-4-a dit + déjà « avant la version 0.12.1, ou restaurée » : juste.)* +- [x] **T1 — la grandeur jointe** (AC 1-2). +- [x] **T2 — balance âgée** (AC 3-6), dont l'invariant de concordance. +- [x] **T3 — échéancier, backend** (AC 7-9, AC 11). +- [x] **T4 — échéancier, frontend** (AC 10), i18n. +- [x] **T5 — tests et mutations** (AC 12). +- [x] **T6 — textes** (AC 13). +- [x] **T7 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. + +### Review Findings + +*Passe 1 de `bmad-code-review` (2026-09-27) — Blind Hunter, Edge Case Hunter, Acceptance Auditor, tous +Sonnet, sur `bd06e789..87cbc8b4`. Brut : 4 + 4 + 2 findings ; après dédoublonnage et tri : 3 patch, +2 defer, 3 écartés.* + +- [x] [Review][Patch] **MEDIUM — `HAVING total <> 0` fait disparaître un client dont les restes dus se compensent** [crates/kesh-report/src/aged_receivables.rs:149] — la grandeur sommée admet désormais le négatif ; une facture ouverte de 100.— et un trop-perçu hérité de −100.— chez le même contact donnent `total = 0` et la ligne entière sort du résultat, contrairement à ce que promet la spec (« il apparaît, en négatif »). Atteignable seulement par données héritées (les trois chemins d'écriture refusent le trop-perçu ou annulent la facture — tracé par l'Edge Case Hunter). Convergé Blind + Edge. +- [x] [Review][Patch] **MEDIUM — commentaire devenu faux : « la fiche est la seule surface où le résiduel est calculé »** [crates/kesh-api/src/routes/invoices.rs:638] — la story ajoute l'échéancier (liste, export, résumé) et la balance âgée. Symptôme grepé (`seule surface`) : aucun autre site. +- [x] [Review][Patch] **LOW — aucun test des agrégats après annulation d'un règlement** [crates/kesh-db/tests/invoice_amount_due_parity.rs] — `cancel_settlement` n'est exercé que sur la fiche ; ni l'échéancier ni la balance âgée ne sont vérifiés après retrait d'un règlement. +- [x] [Review][Defer] **`as_of` borne les tranches, pas les règlements** [crates/kesh-report/src/aged_receivables.rs:109] — deferred : limite écrite dans le doc-comment de `generate`, la route fixe `as_of` à aujourd'hui ; ne mord que si un appelant passait une date passée. +- [x] [Review][Defer] **trois tests préexistants déstructurent `seed_base` dans le mauvais ordre** [crates/kesh-api/tests/invoice_echeancier_e2e.rs] — deferred, pre-existing : déjà relevé au Debug Log, verts parce que les deux identifiants valent 1. + +*Passe 2 (2026-09-27) — les trois lentilles, Haiku ×3, sur le diff aplati `bd06e789..03951962`, +protocole complet (la passe 1 avait changé une règle métier). Brut : 8 + 0 + 0 ; après vérification +`grep -nF` : **0 au-dessus de LOW**.* + +- [x] [Review][Patch] **LOW — assertion d'anti-vacuité sans message** [crates/kesh-db/tests/invoice_amount_due_parity.rs:338] + +Écartés en passe 2 — **quatre faux positifs Haiku réfutés par `grep -nF`** : « CRITICAL » compteur +i18n +1 pour deux clés (la clé d'en-tête CSV n'est lue que côté backend — aucun `i18nMsg` du frontend +ne l'appelle, `grep -rnF echeancier-csv-header-amount-due frontend/src` vide) ; `id="settle-amount"` +prétendu absent (`SettleInvoiceDialog.svelte:213`) ; ordre de `seed_base` prétendu douteux (elle rend +`(seeded.admin_user_id, seeded.company_id)`, `invoice_echeancier_e2e.rs:113`) ; arrondi prétendu perdu +par `toFixed(2)` (tous les termes sont à deux décimales en amont, « 68.1000 » n'est que l'échelle +SQL). Plus : affirmation de T0 sur la 0.12.0 (fait établi par la spec) et trois remarques sans +défaut. ⚠️ L'Edge Case Hunter rendait 0 finding en **8 appels d'outils**, sans déclarer le manuel, les +locales ni le frontend : axes repris par l'orchestrateur — `{due}` jamais NULL (trois `COALESCE`, +`invoice_settlements.rs:106`), deux clés présentes dans les 4 locales, PDF aplati conforme au `.tex` +sur les dix passages « reste dû ». Rien trouvé. + +Écartés en passe 1 : pré-remplissage négatif du dialogue de règlement (Blind + Edge — tranché hors périmètre par +la spec, et la garde `n <= 0` bloque la soumission) ; deux remarques LOW de l'Acceptance Auditor +(piège de décompte `i18nMsg(` dans un commentaire, parité d'export couverte par un autre fichier que +celui nommé) — constats sans défaut. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- ⛔ **Ne pas stocker le reste dû** (24-2, D3). +- ⛔ **Ne pas utiliser `amount_due` scalaire dans une boucle** sur les lignes d'une liste : N+1. +- ⛔ **Ne pas retirer `total_ttc`** de la liste : l'échéancier affiche les deux, et d'autres écrans le + lisent. +- ⚠️ **Double calcul du TTC dans les listes** : `total_ttc` y vient de la forme **corrélée** + (`invoices.rs:819`, `:2459`), et la forme jointe de l'AC 1 recalcule `lt.ttc`. Il est **permis** — + et préférable — de dériver `total_ttc` de `lt.ttc` dans ces deux SELECT : `invoice_ttc_parity.rs` + prouve déjà l'égalité des deux formes. +- ⚠️ **Pré-remplissage à reste dû ≤ 0** : `SettleInvoiceDialog` affiche alors une erreur client dès + l'ouverture (`:104-112`). Comportement **déjà présent** sur la fiche + (`invoices/[id]/+page.svelte:1241`) ; un reste dû ≤ 0 sur une facture `validated` sans `paid_at` + n'existe que par un état hérité. Ne pas le corriger ici ; ne pas l'aggraver. +- **Montage E2E** du cas « partiellement réglée » : régler une partie par l'API + (`authedApiContext(page)`, patron `invoices-settlement-cancel.spec.ts:46`), puis ouvrir le dialogue + depuis l'échéancier. +- ⚠️ **Le tri** « TotalAmount » de l'échéancier trie sur `i.total_amount` (HT) alors que la colonne + affiche le TTC — défaut **préexistant**, hors périmètre ; ne pas l'aggraver, le signaler. + +### Où regarder + +| Fichier | Rôle | +|---|---| +| `crates/kesh-db/src/repositories/invoice_settlements.rs` | constantes jointes, `amount_due` | +| `crates/kesh-report/src/aged_receivables.rs:95-175` | requête, doc | +| `crates/kesh-report/tests/aged_receivables.rs` | tests existants (leur facture « payée » est montée par `UPDATE paid_at`) | +| `crates/kesh-db/src/repositories/invoices.rs:246-275, 800-830, 882-930, 2436-2470` | `InvoiceListItem`, liste, résumé, export | +| `crates/kesh-api/src/routes/invoices.rs:333-364, 1364-1470` | DTO de liste, export CSV | +| `frontend/src/routes/(app)/invoices/due-dates/+page.svelte` | page | +| `frontend/src/routes/(app)/invoices/[id]/+page.svelte:424-438` | `paymentStatus()` de la fiche, à partager | +| `frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte:35-112` | pré-remplissage, garde client | +| `frontend/tests/e2e/invoices_echeancier.spec.ts:100-160, 200-201` | cas de la 24-3 ; « la colonne Total affiche le TTC (montant dû) » | +| `crates/kesh-report/src/general_ledger.rs` | solde d'un compte, pour l'AC 5 | + +### Gardes-fous du dépôt + +- Aucune migration. +- Repositories touchés : **gate complet même en cours de boucle** (§ Exception `kesh-db`). +- i18n : les nouvelles clés et tout `i18nMsg(` ajouté bougent `sitesTotal` — recompter. + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5. + +### Debug Log References + +- La mutation « reste dû retiré du seul export » a d'abord **échoué à compiler** (argument nommé + `due` devenu inutilisé dans le `format!`) : mutation invalide, refaite en gardant l'argument + (`({due}) * 0 + COALESCE(lt.ttc, 0)`) — elle est alors tuée. +- Le reste dû arrive de l'API à l'échelle du calcul SQL (« 68.1000 ») ; `SettleInvoiceDialog` + le recopiait tel quel dans le champ montant — **aussi sur la fiche**, depuis la 24-2. Le + pré-remplissage passe désormais par `Big(amountDue).toFixed(2)`. +- ⚠️ **Relevé, non corrigé** : dans `invoice_echeancier_e2e.rs`, trois tests préexistants + déstructurent `seed_base` en `(company_id, admin_id)` alors qu'il rend `(admin_id, company_id)` + — ils passent parce que les deux identifiants valent 1 dans une base neuve. Les tests de 25-4-a, + qui avaient copié ce patron, sont rectifiés ici. + +### Completion Notes List + +- **T0** : les quatre commentaires de 25-4-a qui bornaient l'état hérité à l'import disent + désormais « données antérieures à la 0.12.1, restaurées ou mises à jour sur place » ; la fiche + de 25-4-a porte sa note datée (validation P4). +- **La grandeur jointe** (AC 1-2) : `amount_due_derived_joins()` (les trois tables dérivées ; une + fonction, `concat!` ne sachant pas assembler des constantes), `INVOICE_AMOUNT_DUE_DERIVED_SQL`, + `INVOICE_AMOUNT_SETTLED_DERIVED_SQL` ; la parité de 25-4-a étendue — reste dû joint = scalaire, + facture par facture, reste dû négatif compris. +- **Balance âgée** (AC 3-6) : les cinq tranches et le total somment le reste dû, tranches sur + `due_date` ; doc-comments (module et `generate`) énoncent la **règle** de concordance et + l'absence de filtre des règlements par `as_of`. +- **Échéancier, serveur** (AC 7-9, 11) : résumé au reste dû ; `InvoiceListItem` porte + `amount_settled` / `amount_due`, projetés par **les deux** SELECT (liste et export) ; `total_ttc` + y vient désormais de `lt.ttc` (permis par les Dev Notes) ; DTO camelCase ; export CSV : colonne + « Reste dû » (clé ×4) et statut « Partiellement payée ». +- **Échéancier, écran** (AC 10) : `paymentStatusOf` partagé par la fiche et l'échéancier ; + colonne « Reste dû » (clé ×4, `sitesTotal` 1750 → 1751 recompté) ; dialogue pré-rempli, garde + client de trop-perçu rendue active ; commentaires qui disaient « TTC (montant dû) » corrigés. +- **Textes** (AC 13) : les **quatre** passages de la § Balance âgée (dont la réserve, par la + règle) et la § Échéancier ; PDF régénéré et **contrôlé aplati** (0 `??`, six phrases de contrôle + présentes, trois formules « TTC » absentes) ; CHANGELOG *Fixed*. Grep du symptôme sur le dépôt : + seule la ligne v0.7.0 publiée du README, légitime. + +**Tests ajoutés** (recomptés, branche de 25-4-a → arbre de travail) : `invoice_amount_due_parity` +4 → 6, `kesh-report/tests/aged_receivables` 4 → 7, `invoice_echeancier_e2e` 13 → 15, Vitest +`invoice-helpers.test.ts` 12 → 16, `due-dates-page.test.ts` 0 → 4 (nouveau). E2E +`invoices_echeancier.spec.ts` : le parcours de la 24-3 **vérifie** le montant pré-rempli au lieu +de le saisir, et un cas « réglée en partie » est ajouté. + +**Mutations** (observées, fichiers restaurés) : + +| Mutation | Tests rouges | +|---|---| +| TTC dans une tranche de la balance âgée | `aged_uses_amount_due_not_ttc` | +| TTC dans le seul total | `aged_total_matches_receivable_ledger`, `aged_uses_amount_due_not_ttc` | +| reste dû retiré du seul export | `list_items_carry_amount_due` | +| TTC dans le résumé de l'échéancier | `due_dates_summary_totals_are_amount_due` | +| `partial` retiré de `paymentStatusOf` | 2 Vitest (helper, page) | +| `amountDue={null}` remis | Vitest « dialogue pré-rempli » | + +### Gates + +- Backend : base remise à zéro, `scripts/test-fast.sh` **2491 / 2491** (2484 + 7). +- Frontend : `check` 0 erreur (27 avertissements), `lint-i18n-ownership` PASS, `test:unit` + **834 / 834** (826 + 8), build OK. +- **Après la boucle de revue** (dernier commit) : base remise à zéro, `scripts/test-fast.sh` + **2494 / 2494** (2484 + 10) ; frontend `check` 0 erreur, `lint-i18n-ownership` PASS, `test:unit` + **834 / 834**, build OK ; E2E sur binaire recompilé et `kesh_e2e` reconstruite **225 / 8 / 19**, + les mêmes huit que ci-dessous. +- E2E (avant revue) : sur `kesh_e2e` reconstruite, **225 passed / 8 failed / 19 skipped** — les 7 KF-029 (#97) + et `product-revenue-account.spec.ts:133`, victime de pollution déjà répertoriée + (`docs/testing.md` § « Les échecs attendus ») et **verte rejouée seule** (deux fois, 4/4). + ⚠️ Un **premier run** avait rendu 214/19 : les 7 KF-029 et **12 échecs hors liste**, tous sur le + même symptôme — `/login` rendant la page SvelteKit « Erreur 500 — Internal Error » avant le + formulaire, sans aucune erreur côté backend. **Rejoués seuls, 12/12 verts** ; non reproduit au + run suivant. Symptôme non documenté, non tracé à ce jour. + +### File List + +| Fichier | Nature | +|---|---| +| `crates/kesh-db/src/repositories/invoice_settlements.rs` | grandeur jointe | +| `crates/kesh-db/src/repositories/invoices.rs` | résumé, `InvoiceListItem`, deux SELECT | +| `crates/kesh-report/src/aged_receivables.rs` | requête, doc | +| `crates/kesh-api/src/routes/invoices.rs` | DTO, export CSV | +| `crates/kesh-i18n/locales/{fr,de,it,en}-CH/messages.ftl` | `echeancier-csv-header-amount-due`, `due-dates-column-amount-due` | +| `crates/kesh-db/src/errors.rs`, `invoice_settlements_write.rs` | T0 | +| `crates/kesh-db/tests/invoice_amount_due_parity.rs` | parité étendue, résumé, listes ; T0 | +| `crates/kesh-db/tests/invoice_settlement.rs` | T0 | +| `crates/kesh-report/tests/aged_receivables.rs` | 3 tests | +| `crates/kesh-api/tests/invoice_echeancier_e2e.rs` | 2 e2e ; ordre de `seed_base` rectifié (tests 25-4-a) | +| `frontend/src/lib/features/invoices/invoice-helpers.ts` / `.test.ts` | `paymentStatusOf` | +| `frontend/src/lib/features/invoices/invoices.types.ts` | `amountSettled`, `amountDue` | +| `frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte` | pré-remplissage au centime | +| `frontend/src/routes/(app)/invoices/[id]/+page.svelte` | statut partagé | +| `frontend/src/routes/(app)/invoices/due-dates/+page.svelte` / `due-dates-page.test.ts` | colonne, statut, dialogue | +| `frontend/src/lib/shared/i18n-keys.test.ts` | `sitesTotal` 1751 | +| `frontend/tests/e2e/invoices_echeancier.spec.ts` | pré-remplissage, cas partiel | +| `docs/manual/fr/user-manual.tex` / `.pdf` | balance âgée, échéancier | +| `CHANGELOG.md` | *Fixed* | + +## Change Log + +- **2026-09-27** — **Revue de code CLOSE en 2 passes, story `done`.** Tendance : passe 1 `2M/1L` + (Sonnet ×3) → passe 2 **0 au-dessus de LOW** (Haiku ×3, protocole complet, diff aplati ; 1 LOW + corrigé dans un test, 4 faux positifs réfutés par `grep -nF`). La dernière remédiation ne touche + aucune ligne de production. Gates complets verts au dernier commit (§ *Gates*). + ⚠️ **Un gate complet perdu, et pourquoi** : il a rougi sur `aged_compensating_…` avec le message de + la mutation, `git diff HEAD` vide. La mutation avait été défaite par `mv` d'une copie `cp` datée + d'AVANT son build : source juste mais plus ancienne que le binaire, cargo n'a pas recompilé. + `touch` puis gate rejoué : vert. Le gate ciblé et clippy de la passe 1 avaient été lancés AVANT la + mutation, ils restent valables. +- **2026-09-27** — **Revue de code, passe 1** (Blind Hunter, Edge Case Hunter, Acceptance Auditor — + Sonnet ×3, `bd06e789..87cbc8b4`) — **2 MEDIUM, 1 LOW à corriger**, 2 différés, 3 écartés (§ *Review + Findings*). Corrigés : + - le `HAVING` de la balance âgée porte sur le **nombre de factures à reste dû non nul**, plus sur le + total du contact (`aged_receivables.rs`) ; l'AC 4 est rectifié, sa phrase contradictoire barrée. + Test `aged_compensating_legacy_overpayment_keeps_the_contact` — ⛔ **mutation vérifiée** : avec + l'ancien `HAVING total <> 0`, il rougit seul (« Alpha ne doit pas disparaître »), les 8 autres + restent verts ; + - le commentaire de `get_invoice` (`routes/invoices.rs`) ne prétend plus que la fiche est la seule + surface calculant le résiduel ; symptôme `seule surface` grepé sur `crates/`, `frontend/src` et le + manuel : aucun autre site ; + - règlement annulé : `aged_cancelled_settlement_no_longer_counts` et + `cancelled_settlement_leaves_the_aggregates` (liste, export, résumé), chacun avec un témoin avant + annulation. + Tests, recomptés (`#[sqlx::test]`) de `87cbc8b4` à ce commit : `aged_receivables.rs` 7 → 9, + `invoice_amount_due_parity.rs` 6 → 7, soit **+3** ; de `bd06e789` : +10 backend. Manuel : rien ne + décrivait la sélection des contacts, pas de régénération. **Gate ciblé** : fmt et clippy workspace + verts, `binary(aged_receivables) | binary(invoice_amount_due_parity)` **16/16** ; gate complet au + dernier commit de la boucle. +- **2026-09-27** — **T7 tenu, story en `review`.** Commit de sauvegarde `87cbc8b4` après un crash de + la station (intégrité vérifiée : `git fsck`, fins de fichiers, fmt, clippy, `check`). Gate E2E : + cf. § *Gates* — premier run pollué (12 hors liste, verts seuls), second conforme à la baseline. +- **2026-09-27** — Signal MED→MED arbitré par Guy : **pas de découpage** (« continue ainsi »). Dev lancé. +- **2026-09-27** — **validation P5 ciblée** (Haiku, diff `5480b4f8..5824c048`, prompt + `25-4-b1-validate-prompt-p5.md`) — **0 finding**, preuves recopiées (grep des cinq sites de T0, + garde de la 0.12.0) et **concordantes avec les greps de l'orchestrateur**. ⛔ **Boucle close en 5 + passes** : `2M/3L → 0 (+1M orchestrateur) → 3M/3L → 1M/3L → 0`, rotation Sonnet → Haiku → Opus → + Sonnet → Haiku, trois passes ciblées ; toutes les corrections sur la spec, aucune sur du code. + ⚠️ **Signal de découpage à remonter à Guy** : quatre passes consécutives au niveau MEDIUM + (§ *Règle de splitting préventif*, critère de sévérité). Toutes portaient sur **une seule + phrase** — la portée de l'invariant de l'AC 5 —, close par le passage d'une liste à une règle. +- **2026-09-27** — **validation P4 ciblée** (Sonnet, prompt `25-4-b1-validate-prompt-p4.md`) — **1 + MEDIUM, 3 LOW**. MEDIUM : un **cinquième** site de la formulation fausse — la fiche de 25-4-a + elle-même (AC 13, `:180`), source des quatre commentaires ; ajouté à T0, rectifiée par une note + datée. LOW : la règle ne disait pas que les contre-passations (annulation de règlement ou de + rapprochement) restent dans sa portée — ajouté ; mutation dédiée au test de l'AC 5 ; réserve du + manuel reformulée pour ne plus pouvoir se lire comme visant les règlements ordinaires. Éprouvée + contre la dévalidation, l'annulation de règlement et de rapprochement, la facture soldée et le + reste dû négatif : **la règle tient**. +- **2026-09-27** — **validation P3 ciblée** (Opus, prompt `25-4-b1-validate-prompt-p3.md`, inventaire + de **tous** les chemins qui écrivent une ligne d'écriture) — **3 MEDIUM, 3 LOW** : **quatre chemins + oubliés** par la portée de l'AC 5 — règlement fournisseur par compte interne imputé au compte + débiteurs ; changement du compte débiteurs par défaut, que l'avoir suit alors que la vente ne le + suit pas ; données héritées plus larges (`mark_as_paid` sans écriture) et **pas seulement par + import** (la 0.12.0 publiée accepte l'avoir après règlement partiel) ; règlement client imputé au + compte débiteurs lui-même. LOW : deux handlers de rapprochement non cités ; `as_of` non borné. + ⛔ **Troisième passe consécutive à trouver des cas hors d'une liste** : la portée est **réécrite + par une règle**, avec des exemples non exhaustifs ; le test se place dans la règle, `as_of` + postérieur à toutes les pièces ; la réserve du manuel suit la même forme. **Deux défauts produit + sortis en issues** : #473 (l'avoir crédite le compte débiteurs des réglages, pas celui de la + vente), #474 (règlement client sur le compte débiteurs lui-même). **T0 ajoutée** : quatre + commentaires de 25-4-a bornaient à tort l'état hérité à l'import. +- **2026-09-27** — **validation P2** (Haiku, prompt `25-4-b1-validate-prompt-p2.md`) — rapporte **0 + finding**. ⚠️ **Non pris pour argent comptant** : la passe affirmait le CHANGELOG « à créer » (il + existe) et n'a cité aucun des fichiers qu'on lui demandait de lire pour éprouver la portée de + l'AC 5. **Repris par l'orchestrateur** : la portée oubliait un chemin — la **ventilation** et la + **règle** de rapprochement bancaire (`accept_one_split`, `accept_one_rule`), dont la contrepartie + est un compte libre, donc possiblement le compte débiteurs, sans facture. **1 MEDIUM** ajouté, + corrigé (portée à quatre cas, réserve du manuel étendue). Vérifié aussi : le filtre « payées » + de l'échéancier existe (`due-dates/+page.svelte:45`) — ses lignes montreront un reste dû nul, + cohérent. +- **2026-09-27** — **validation P1** (Sonnet, prompt `25-4-b1-validate-prompt-p1.md`) — **2 MEDIUM, + 3 LOW**, confirmés dans le code et le manuel. MEDIUM : l'invariant balance âgée = grand livre + (AC 5) était énoncé sans portée — l'état hérité de 25-4-a (facture `cancelled` créditée après + règlement) et les soldes d'ouverture le rompent ; portée écrite, test borné aux états vivants, et + le manuel, qui **garantit** la concordance sans réserve, recevra la réserve. MEDIUM : l'AC 13 ne + citait qu'un des quatre passages « TTC » de la section Balance âgée — les trois autres ajoutés. + LOW (Dev Notes) : double calcul du TTC dans les listes (dériver `total_ttc` de `lt.ttc` permis) ; + pré-remplissage à reste dû ≤ 0 (préexistant, hors périmètre) ; montage E2E du cas partiel. + L'inventaire des sites au TTC, refait depuis le symptôme, n'a trouvé **aucun site orphelin** + (b1, b2, 25-4-c ou légitimement TTC). +- **2026-09-27** — Créée (Opus 5.5) après découpage de la 25-4-b (arbitrages Q1 ⇒ six modules). + Sites revérifiés dans le code ; 25-4-a en est le socle. + +[#416]: https://github.com/guycorbaz/kesh/issues/416 diff --git a/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p1.md new file mode 100644 index 000000000..63c3960ec --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p1.md @@ -0,0 +1,52 @@ +# Prompt — validation P1, Story 25-4-b1 (le résiduel aux agrégats) + +*Versionné le 2026-09-27. **Une lentille** (Sonnet), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b-residuel-aux-agregats` (empilée sur la 25-4-a, +PR #472). Fiche à valider : `_bmad-output/implementation-artifacts/25-4-b1-residuel-aux-agregats.md`. +Mère : `25-4-propager-le-residuel.md` (arbitrages de Guy : **ne pas les contester**). Sœur livrée : +`25-4-a-residuel-juste.md`. Issue : `gh issue view 416`. Contexte : `24-2-encaissement-client.md` +§ D3, D4, invariants. Règles : `CLAUDE.md`. Checklist : `.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. +2. **Inventaire des sites qui somment le TTC là où le reste dû est la grandeur** — pars du symptôme, + pas de la liste de la fiche : `grep -rn "lt.ttc\|total_ttc\|totalTtc\|INVOICE_TTC" crates/ frontend/src`. + Pour chaque site, dis s'il est dans la b1, dans la b2 (rappels), ou légitimement au TTC. Un site + oublié est au moins MEDIUM. +3. **L'invariant de concordance (AC 5)** : est-il vrai en théorie ? Liste ce qui meut le compte + 1100 hors factures (écritures manuelles, soldes d'ouverture, contre-passations, annulation de + règlement, avoir, facture annulée avec règlement hérité) et vérifie que le montage prescrit les + exclut ou les inclut à bon escient. Le « périmètre `validated AND paid_at IS NULL` » rend-il la + concordance possible (factures `cancelled` à reste dû négatif hérité ?). +4. **Le N+1 (AC 8)** : la liste utilise aujourd'hui la forme **corrélée** du TTC + (`INVOICE_TTC_SUBQUERY_SQL`) ; la fiche prescrit la forme jointe pour le reste dû — cohérence, + coût, et risque de divergence entre `total_ttc` (corrélé) et `amount_due` (joint) sur une même + ligne. +5. **Le frontend** : `statusOf` / `paymentStatus` partageables ? Le dialogue pré-rempli : que se + passe-t-il pour une facture à reste dû négatif ou nul ? Le filtre « payées » de l'échéancier + montre-t-il des factures dont `amountDue` = 0 ? +6. **Les tests** (AC 12) : chacun prouve-t-il ce qu'il annonce ? L'E2E modifié de la 24-3 : le + montage (facture partiellement réglée) est-il faisable depuis Playwright ? Les mutations sont- + elles tuables ? +7. **Le manuel** : lignes citées, et **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/`). + D'autres textes (admin-manual, api-external, README, website) promettent-ils le TTC ? +8. **Le découpage b1/b2** : quelque chose de b1 appartient-il à b2, ou l'inverse ? La PR de b1 en + `refs #416` est-elle juste ? + +## 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`, +`pdftotext` vers le scratchpad, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p2.md new file mode 100644 index 000000000..e9e71f2ce --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p2.md @@ -0,0 +1,65 @@ +# Prompt — validation P2, Story 25-4-b1 (le résiduel aux agrégats) + +*Versionné le 2026-09-27. **Une lentille** (Haiku), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b-residuel-aux-agregats` (empilée sur la 25-4-a, +PR #472). Fiche à valider : `_bmad-output/implementation-artifacts/25-4-b1-residuel-aux-agregats.md`. +Mère : `25-4-propager-le-residuel.md` (arbitrages de Guy : **ne pas les contester**). Sœur livrée : +`25-4-a-residuel-juste.md`. Issue : `gh issue view 416`. Contexte : `24-2-encaissement-client.md` +§ D3, D4, invariants. Règles : `CLAUDE.md`. Checklist : `.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. +2. **Inventaire des sites qui somment le TTC là où le reste dû est la grandeur** — pars du symptôme, + pas de la liste de la fiche : `grep -rn "lt.ttc\|total_ttc\|totalTtc\|INVOICE_TTC" crates/ frontend/src`. + Pour chaque site, dis s'il est dans la b1, dans la b2 (rappels), ou légitimement au TTC. Un site + oublié est au moins MEDIUM. +3. **L'invariant de concordance (AC 5)** : est-il vrai en théorie ? Liste ce qui meut le compte + 1100 hors factures (écritures manuelles, soldes d'ouverture, contre-passations, annulation de + règlement, avoir, facture annulée avec règlement hérité) et vérifie que le montage prescrit les + exclut ou les inclut à bon escient. Le « périmètre `validated AND paid_at IS NULL` » rend-il la + concordance possible (factures `cancelled` à reste dû négatif hérité ?). +4. **Le N+1 (AC 8)** : la liste utilise aujourd'hui la forme **corrélée** du TTC + (`INVOICE_TTC_SUBQUERY_SQL`) ; la fiche prescrit la forme jointe pour le reste dû — cohérence, + coût, et risque de divergence entre `total_ttc` (corrélé) et `amount_due` (joint) sur une même + ligne. +5. **Le frontend** : `statusOf` / `paymentStatus` partageables ? Le dialogue pré-rempli : que se + passe-t-il pour une facture à reste dû négatif ou nul ? Le filtre « payées » de l'échéancier + montre-t-il des factures dont `amountDue` = 0 ? +6. **Les tests** (AC 12) : chacun prouve-t-il ce qu'il annonce ? L'E2E modifié de la 24-3 : le + montage (facture partiellement réglée) est-il faisable depuis Playwright ? Les mutations sont- + elles tuables ? +7. **Le manuel** : lignes citées, et **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/`). + D'autres textes (admin-manual, api-external, README, website) promettent-ils le TTC ? +8. **Le découpage b1/b2** : quelque chose de b1 appartient-il à b2, ou l'inverse ? La PR de b1 en + `refs #416` est-elle juste ? + +## 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`, +`pdftotext` vers le scratchpad, `cargo check`. + +## Contexte P1 — à vérifier, pas à re-signaler + +La P1 a trouvé : l'invariant AC 5 sans portée (état hérité `cancelled`, soldes d'ouverture) et +trois passages « TTC » du manuel absents de l'AC 13 ; corrigés dans `git diff cfd2a779 73f72e8d` +(**diff unique, à lire en priorité**). Vérifie que ces corrections sont justes et complètes : +la portée de l'AC 5 énumère-t-elle **tout** ce qui meut le compte 1100 hors factures de Kesh +(lis `crates/kesh-db/src/repositories/opening_balances*.rs`, les contre-passations, l'import +fournisseurs n'y touche pas ?) ; les quatre passages du manuel sont-ils les seuls de la section et +du document (greppe `TTC`, `total dû`, `encours` dans le `.tex` ET le PDF aplati) ? + +⛔ Pour chaque finding, cite la ligne exacte lue dans le fichier ACTUEL, et vérifie ton affirmation +par `grep -nF` avant de l'écrire. diff --git a/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p3.md new file mode 100644 index 000000000..e1b97c6ed --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p3.md @@ -0,0 +1,43 @@ +# Prompt — validation P3 CIBLÉE, Story 25-4-b1 (le résiduel aux agrégats) + +*Versionné le 2026-09-27. **Une lentille** (Opus), contexte frais. Passe **ciblée** : la P2 (Haiku) +a rendu « 0 finding » sans exercer l'axe qu'on lui confiait ; l'orchestrateur l'a repris et a +trouvé un chemin oublié. Ce qu'il reste à relire : la **portée de l'AC 5**, et elle seule, contre le +code.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b-residuel-aux-agregats`. Fiche : +`_bmad-output/implementation-artifacts/25-4-b1-residuel-aux-agregats.md`, AC 5 (bloc « Portée de +l'invariant ») et AC 13 (réserve du manuel). Diff des remédiations : `git diff cfd2a779 58a874ab`. + +## La lentille : inventaire des sites NON RÉSOLUS + +L'AC 5 affirme : *total de la balance âgée = solde du compte débiteurs au grand livre*, sauf quatre +cas énumérés. ⛔ **Ne vérifie pas que les quatre cas sont vrais — cherche le cinquième.** + +1. **Inventorie l'ensemble clos de ce qui écrit une ligne d'écriture** (`journal_entry_lines`) : + pars du symptôme — `grep -rn "create_in_tx\|journal_entries::create\|INSERT INTO journal_entry_lines" crates/ --include=*.rs` + hors tests. Pour **chaque** chemin, dis s'il peut imputer le compte débiteurs (le compte + `default_receivable_account_id` ou le compte de la ligne de débit d'une vente), et, s'il le + peut, s'il est (a) une facture, un règlement ou un avoir **visibles** de la balance âgée, + (b) un des quatre cas de la portée, ou (c) **un cas oublié**. +2. Pense aux chemins **de clôture et de réouverture d'exercice**, à la **dévalidation** d'une + facture, à l'**annulation d'un règlement**, à la **contre-passation** manuelle d'une écriture de + vente ou de règlement depuis sa fiche, à l'**import** de pièces, au **rapprochement** annulé. +3. Pour chaque cas oublié : sévérité, preuve (code lu), et formulation à ajouter à la portée. +4. La réserve prescrite pour le manuel (AC 13, `:1580-1581`) couvre-t-elle alors tous les cas, en + mots d'utilisateur ? + +## Ce que tu rends + +- **Findings** : sévérité, endroit, **preuve**, correction. +- ⛔ **La liste des chemins inventoriés** (fichier:fonction → classement a/b/c) — c'est elle qui + prouve l'axe — **et des axes non exercés.** + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base — +`scripts/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-b1-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p4.md new file mode 100644 index 000000000..bd2491b17 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p4.md @@ -0,0 +1,43 @@ +# Prompt — validation P4 CIBLÉE, Story 25-4-b1 (le résiduel aux agrégats) + +*Versionné le 2026-09-27. **Une lentille** (Sonnet), contexte frais. Passe **ciblée** sur la +remédiation de la P3 : la portée de l'AC 5 a été **réécrite par une règle** après trois passes qui +trouvaient chacune des cas hors de la liste précédente.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b-residuel-aux-agregats`. Fiche : +`_bmad-output/implementation-artifacts/25-4-b1-residuel-aux-agregats.md`. Diff à relire : +`git diff 72255481 HEAD -- _bmad-output/implementation-artifacts/25-4-b1-residuel-aux-agregats.md`. +Issues sorties : `gh issue view 473`, `gh issue view 474`. + +## La lentille + +1. **La règle est-elle juste ?** Trop large (elle exclut un cas où l'égalité tient) ou trop étroite + (un mouvement qu'elle autorise rompt l'égalité) ? Éprouve-la contre : la **dévalidation** d'une + facture (l'écriture de vente disparaît), l'**annulation d'un règlement** (contre-passation + + suppression de la ligne), l'**annulation d'un rapprochement** de facture, une facture **soldée** + (`paid_at`) — hors du périmètre de la balance âgée mais ses mouvements restent au grand livre et + s'annulent-ils exactement ? —, un **reste dû négatif** sur une facture validée. +2. **« n'a jamais changé dans les réglages »** : formulation testable ? Le test peut-il la tenir ? +3. **Le test prescrit** (quatre états vivants, `as_of` ≥ toutes les pièces) : prouve-t-il la règle ou + passe-t-il par construction ? Quelle mutation de la requête de la balance âgée le ferait rougir ? +4. **T0** : les quatre sites cités existent-ils aux lignes dites, et la correction prescrite est-elle + juste (`git show v0.12.0:crates/kesh-db/src/repositories/credit_notes.rs`) ? En existe-t-il un + cinquième (greppe la **valeur** : `keshbackup`, `sauvegarde antérieure`, `import d'une sauvegarde` + dans `crates/`, `docs/`, les fiches 25-4-*) ? +5. **La réserve du manuel** : exacte, lisible par un utilisateur, cohérente avec la règle ? +6. **Propagation** : le mot « quatre » (cas), les références aux handlers, #473/#474 — cohérents + partout (fiche, fiche mère, `sprint-status.yaml`) ? + +## Ce que tu rends + +- **Findings** : sévérité, endroit, **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-b1-validate-prompt-p5.md b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p5.md new file mode 100644 index 000000000..9394a3640 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b1-validate-prompt-p5.md @@ -0,0 +1,34 @@ +# Prompt — validation P5 CIBLÉE, Story 25-4-b1 + +*Versionné le 2026-09-27. **Une lentille** (Haiku), contexte frais. Passe ciblée sur la seule +remédiation de la P4.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b-residuel-aux-agregats`. Diff **unique** à +relire : `git diff 5480b4f8 5824c048` (deux fiches : `25-4-b1-residuel-aux-agregats.md`, +`25-4-a-residuel-juste.md`). + +## Ce que tu vérifies + +1. **T0** liste cinq sites : chacun existe-t-il à la ligne dite, avec la formulation dite ? Commande + obligatoire, à recopier dans ton rapport : `grep -rnF "sauvegarde antérieure" crates/ _bmad-output/implementation-artifacts/25-4-a-residuel-juste.md` + et `grep -rnF "keshbackup" crates/`. +2. **La phrase ajoutée à la règle** (contre-passations dans la portée) : juste ? Lis + `invoice_settlements_write.rs` (`cancel_settlement_in_tx`) avant de répondre. +3. **La réserve du manuel reformulée** : chacun de ses cas correspond-il à un cas réel de la règle ? + En manque-t-il un par rapport à la règle ? +4. **La note datée dans la fiche 25-4-a** : exacte (`git show v0.12.0:crates/kesh-db/src/repositories/credit_notes.rs | grep -n "paid_at.is_some"`) ? + +⛔ Une fiche `ready-for-dev` décrit du code **à écrire** : ne signale pas que le code ne le fait pas +encore. ⛔ Pour chaque finding, recopie la **sortie** de la commande qui le prouve. + +## Ce que tu rends + +Findings (sévérité, endroit, preuve recopiée, correction) et **la liste des axes exercés et non +exercés**. + +## Interdits + +⛔ Aucune écriture dans le dépôt ; aucune commande mutante — `scripts/*.sh`, `make`, `latexmk`, +`git commit`/`push`/`add`/`stash`/`reset`/`rebase`/`checkout`/`switch`/`worktree`, `sqlx migrate`, +`cargo test`/`nextest`, `npm run`, `npx playwright`. Autorisés : lecture, `grep`, `git show`/`diff`, +`gh issue view`. diff --git a/_bmad-output/implementation-artifacts/25-4-propager-le-residuel.md b/_bmad-output/implementation-artifacts/25-4-propager-le-residuel.md index 65f7e7dcb..44de9114b 100644 --- a/_bmad-output/implementation-artifacts/25-4-propager-le-residuel.md +++ b/_bmad-output/implementation-artifacts/25-4-propager-le-residuel.md @@ -20,7 +20,9 @@ Le découpage (quatre stories depuis le 2026-09-27) suit l'ordre des dépendance | 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-b** — ⚠️ **découpée le 2026-09-27** en **b1** et **b2** : les arbitrages de Q1 ont ajouté les rappels (texte, QR, PDF, frais), et le tout touchait six modules | [#416] | — | — | +| **25-4-b1** — le résiduel aux agrégats | [#416] | balance âgée, totaux et **colonne « reste dû »** de l'échéancier et de son CSV, statut « partiellement payée » dans l'échéancier, dialogue de règlement **pré-rempli** ; formes jointes du résiduel, invariant balance âgée ↔ grand livre | le cœur de #416 ; première utilisatrice des formes jointes | +| **25-4-b2** — le résiduel aux rappels | [#416] | le rappel réclame le **reste dû** (texte, QR, PDF avec montant initial / déjà réglé / reste), frais configurables et masqués à zéro ; question des frais non comptabilisés | les arbitrages Q1 ; repose sur la grandeur de b1 | | **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 | @@ -131,6 +133,7 @@ Pour 25-4-b, donc : ## Change Log +- **2026-09-27** — 25-4-b découpée en b1 (agrégats) / b2 (rappels) : six modules une fois les arbitrages Q1 ajoutés. - **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. diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index edfa49828..4876d9b9b 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -139,3 +139,8 @@ Pass 1 Opus 4.8 × 3 reviewers (Blind Hunter + Edge Case Hunter + Acceptance Aud ## Deferred from: code review of 25-5-a-export-souverainete (2026-09-26) - L'export de souveraineté lit ses trente tables sans instantané commun (défaut antérieur, aggravé par la 25-5-a) — tracé par **#465**, source de vérité. + +## Deferred from: code review of 25-4-b1-residuel-aux-agregats (2026-09-27) + +- **La balance âgée déduit tous les règlements, quelle que soit `as_of`** (`crates/kesh-report/src/aged_receivables.rs`, `generate`). Depuis la 25-4-b1 le montant sommé est le reste dû, qui soustrait les règlements et avoirs **existants** sans comparer leur date à `as_of` : une balance « au jour J » passé serait sous-évaluée. **Différé** : la limite est écrite dans le doc-comment de `generate`, et la seule route appelante fixe `as_of` à aujourd'hui. À reprendre si une balance âgée historique (audit, clôture) est demandée. +- **Trois tests préexistants de `invoice_echeancier_e2e.rs` déstructurent `seed_base` en `(company_id, admin_id)`** alors qu'elle rend `(admin_id, company_id)`. Ils passent parce que les deux valent 1 sur une base neuve. **Différé** : préexistant, relevé au Debug Log de la 25-4-b1 ; les deux tests hérités de la 25-4-a ont été rectifiés. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index ea88fb5a9..439e699d8 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -1,3 +1,4 @@ +# last_updated: 2026-09-27 (2) (PR #472 ouverte pour 25-4-a ; 25-4-b decoupee b1/b2 ; 25-4-b1 creee.) # 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.) @@ -354,8 +355,10 @@ 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-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-a-residuel-juste: done # 2026-09-27 REVUE CLOSE EN 1 PASSE ; PR #472 OUVERTE (closes #455 #456), attend « merge ». # 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-b1-residuel-aux-agregats: done # 2026-09-27 revue CLOSE en 2 passes (2M/1L -> 0 >LOW), gates complets verts (2494, 834, E2E 225/8). # [#416, refs] balance agee + echeancier (totaux, colonne reste du, CSV, statut partiel, dialogue pre-rempli) + invariant balance agee = grand livre. CREEE ; validation P1 Sonnet 2M/3L -> P2 Haiku 0 (repris : 1M) -> P3 ciblee Opus 3M/3L : portee de l'invariant REECRITE PAR UNE REGLE ; issues #473 #474 ouvertes -> P4 1M -> P5 0 : VALIDEE en 5 passes. Signal MED->MED : pas de decoupage (Guy, 2026-09-27). Branche EMPILEE sur 25-4-a. + 25-4-b2-residuel-aux-rappels: backlog # [#416, closes] le rappel reclame le reste du (texte, QR, PDF montant initial / deja regle / reste), frais configurables masques a zero ; point ouvert : frais non comptabilises. + 25-4-b-residuel-aux-agregats: split # 2026-09-27 DECOUPEE en b1 (agregats) / b2 (rappels) — six modules apres les arbitrages Q1. # [#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**. diff --git a/crates/kesh-api/src/routes/invoices.rs b/crates/kesh-api/src/routes/invoices.rs index 913684d7a..c23166bb9 100644 --- a/crates/kesh-api/src/routes/invoices.rs +++ b/crates/kesh-api/src/routes/invoices.rs @@ -343,6 +343,11 @@ pub struct InvoiceListItemResponse { pub total_amount: Decimal, /// TTC canonique (#246, Story 21-2a) — colonne SQL calculée de la liste. pub total_ttc: Decimal, + /// Story 25-4-b1 (#416) — total réglé et **reste dû**, **toujours + /// calculés** dans une liste (forme jointe) : jamais `null` ici, à la + /// différence de la fiche où `None` veut dire « non calculé ». + pub amount_settled: Decimal, + pub amount_due: Decimal, pub paid_at: Option, /// Story 21-6a (D10) — suspension des rappels, alimente le badge /// « suspendu » de la liste des factures et le filtre `paused`. @@ -379,6 +384,8 @@ impl From for InvoiceListItemResponse { payment_terms: i.payment_terms, total_amount: i.total_amount, total_ttc: i.total_ttc, + amount_settled: i.amount_settled, + amount_due: i.amount_due, paid_at: i.paid_at, dunning_paused_at: i.dunning_paused_at, dunning_paused_note: i.dunning_paused_note, @@ -628,8 +635,11 @@ pub async fn get_invoice( invoices::find_by_id_with_lines(&state.pool, current_user.company_id, id) .await? .ok_or(AppError::Database(DbError::NotFound))?; - // Story 24-2 (#371) — la fiche est la seule surface où le résiduel est - // calculé. ⚠️ Une requête de plus, sur UNE facture : pas de N+1 possible ici. + // Story 24-2 (#371) — la fiche calcule le résiduel par la forme SCALAIRE. + // ⚠️ Une requête de plus, sur UNE facture : pas de N+1 possible ici. Les + // agrégats (échéancier : liste, export, résumé ; balance âgée) le calculent + // par la forme JOINTE `amount_due_derived_joins` depuis la Story 25-4-b1 — + // les deux sont tenues à parité par `invoice_amount_due_parity.rs`. let settled = kesh_db::repositories::invoice_settlements::amount_settled(&state.pool, id) .await .map_err(AppError::Database)?; @@ -1332,12 +1342,13 @@ pub async fn cancel_invoice_settlement_handler( } /// Clés FTL des en-têtes CSV (locale = `companies.accounting_language`). -const CSV_HEADER_KEYS: [&str; 7] = [ +const CSV_HEADER_KEYS: [&str; 8] = [ "echeancier-csv-header-number", "echeancier-csv-header-date", "echeancier-csv-header-due-date", "echeancier-csv-header-contact", "echeancier-csv-header-total", + "echeancier-csv-header-amount-due", "echeancier-csv-header-payment-status", "echeancier-csv-header-paid-at", ]; @@ -1347,12 +1358,13 @@ const CSV_HEADER_KEYS: [&str; 7] = [ // interdit la recopie. Comportement identique ; ses tests unitaires, qui // n'existaient pas, ont été écrits à l'extraction (`util.rs`). -const CSV_HEADER_FALLBACKS: [&str; 7] = [ +const CSV_HEADER_FALLBACKS: [&str; 8] = [ "Numéro", "Date", "Date d'échéance", "Client", "Total", + "Reste dû", "Statut paiement", "Date paiement", ]; @@ -1440,8 +1452,12 @@ pub async fn export_due_dates_csv_handler( for inv in rows { // B3 (review pass 1 G2 B) : utilise le helper centralisé. + // Story 25-4-b1 (#416) : « partiellement payée » — même règle que la + // fiche : pas de `paid_at`, mais un règlement. let payment_status_key = if inv.paid_at.is_some() { "payment-status-paid" + } else if inv.amount_settled > Decimal::ZERO { + "payment-status-partial" } else if is_invoice_overdue(&inv.status, inv.paid_at, inv.due_date, today) { "payment-status-overdue" } else { @@ -1460,9 +1476,12 @@ pub async fn export_due_dates_csv_handler( format_date(&inv.date), inv.due_date.as_ref().map(format_date).unwrap_or_default(), csv_sanitize(inv.contact_name.clone()), - // #246 (Story 21-2a) : colonne Total = TTC (montant dû), - // cohérente avec les KPI du summary — pas le HT comptable. + // #246 (Story 21-2a) : colonne Total = TTC émis — pas le HT + // comptable. ⚠️ Ce n'est PAS le montant dû depuis la 24-2 : la + // colonne suivante le porte (Story 25-4-b1), cohérente avec les + // KPI du résumé. format_money(&inv.total_ttc), + format_money(&inv.amount_due), // B19 (review pass 2 G2 B) : défense en profondeur — la valeur // vient d'un fichier FTL contrôlé, mais une compromission // (clé locale altérée) ne doit pas ouvrir un vecteur d'injection. diff --git a/crates/kesh-api/tests/invoice_echeancier_e2e.rs b/crates/kesh-api/tests/invoice_echeancier_e2e.rs index 99cffc3fa..b568b0369 100644 --- a/crates/kesh-api/tests/invoice_echeancier_e2e.rs +++ b/crates/kesh-api/tests/invoice_echeancier_e2e.rs @@ -756,7 +756,7 @@ fn montant(v: &serde_json::Value, champ: &str) -> rust_decimal::Decimal { /// — 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 (admin_id, company_id) = seed_base(&pool).await; let contact_id = seed_contact(&pool, company_id, admin_id).await; let (id, _v) = create_validated_invoice( &pool, @@ -795,7 +795,7 @@ async fn get_credited_invoice_reports_zero_amount_due(pool: MySqlPool) { /// 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 (admin_id, company_id) = seed_base(&pool).await; let contact_id = seed_contact(&pool, company_id, admin_id).await; let (id, _v) = create_validated_invoice( &pool, @@ -841,3 +841,102 @@ async fn credit_note_on_partially_settled_invoice_is_409(pool: MySqlPool) { assert_eq!(v["error"]["details"]["invoiceId"], id); assert!(v["error"]["details"]["settlementId"].as_i64().is_some()); } + +// --- Story 25-4-b1 (#416) — l'échéancier porte le reste dû, à la frontière HTTP -- + +/// Une facture de 108.10 (100.— à 8,1 %) réglée de 40.— en espèces, par HTTP. +async fn partially_settled_invoice( + pool: &MySqlPool, + app: &TestApp, + token: &str, + admin_id: i64, + company_id: i64, +) -> i64 { + 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 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); + id +} + +/// AC 8-9 — la liste de l'échéancier rend `amountSettled` et `amountDue` par +/// ligne, et le résumé somme le reste dû. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn due_dates_list_carries_amount_due(pool: MySqlPool) { + let (admin_id, company_id) = seed_base(&pool).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + let id = partially_settled_invoice(&pool, &app, &token, admin_id, company_id).await; + + let resp = app + .client + .get(app.url("/api/v1/invoices/due-dates")) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let v: serde_json::Value = resp.json().await.unwrap(); + let item = v["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["id"] == id) + .unwrap_or_else(|| panic!("facture {id} absente : {v:?}")); + assert_eq!(montant(item, "totalTtc"), dec!(108.10)); + assert_eq!(montant(item, "amountSettled"), dec!(40.00)); + assert_eq!(montant(item, "amountDue"), dec!(68.10)); + assert_eq!( + montant(&v["summary"], "unpaidTotal"), + dec!(68.10), + "résumé : {v:?}" + ); +} + +/// AC 11 — l'export CSV porte une colonne « Reste dû » et le statut « partiellement +/// payée ». +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn due_dates_csv_has_amount_due_and_partial_status(pool: MySqlPool) { + let (admin_id, company_id) = seed_base(&pool).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + let _id = partially_settled_invoice(&pool, &app, &token, admin_id, company_id).await; + + let resp = app + .client + .get(app.url("/api/v1/invoices/due-dates/export.csv")) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let text = String::from_utf8_lossy(&resp.bytes().await.unwrap()).to_string(); + let mut lines = text.trim_start_matches('\u{feff}').lines(); + let header = lines.next().unwrap(); + assert!(header.contains(";Total;Reste dû;"), "en-tête : {header}"); + let row = lines.next().expect("une ligne"); + assert!(row.contains(";108.10;68.10;"), "ligne : {row}"); + assert!(row.contains("Partiellement payée"), "statut : {row}"); +} diff --git a/crates/kesh-db/src/errors.rs b/crates/kesh-db/src/errors.rs index 402d0b396..c50f64a64 100644 --- a/crates/kesh-db/src/errors.rs +++ b/crates/kesh-db/src/errors.rs @@ -217,8 +217,11 @@ pub enum SettlementCancelBlocker { /// 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é. + /// sur une facture réglée, même en partie. L'état n'est plus produit par + /// l'application, mais des **données antérieures à la 0.12.1** peuvent le + /// porter — la 0.12.0 publiée acceptait cet avoir —, qu'elles soient + /// **restaurées** d'une sauvegarde ou **mises à jour sur place**. 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 diff --git a/crates/kesh-db/src/repositories/invoice_settlements.rs b/crates/kesh-db/src/repositories/invoice_settlements.rs index 0261a9b08..8dd1a2481 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements.rs @@ -80,6 +80,34 @@ pub const INVOICE_CREDITED_DERIVED_JOIN_SQL: &str = concat!( WHERE cn.status = 'issued' GROUP BY cn.invoice_id) cnt ON cnt.invoice_id = i.id" ); +/// Les trois tables dérivées du **reste dû sous forme jointe** — TTC (`lt`), +/// avoir émis (`cnt`), réglé (`st`) — à placer après `FROM invoices i …` +/// (Story 25-4-b1, #416). **Prérequis : alias `i` sur `invoices`.** +/// +/// ⛔ Forme des **listes et agrégats** : la forme scalaire +/// ([`amount_due`]) y serait réévaluée par ligne — un N+1 déguisé (24-2, D3). +/// Les deux formes sont tenues d'accord par `tests/invoice_amount_due_parity.rs`. +/// +/// Une fonction et non une constante : les trois jointures sont elles-mêmes des +/// constantes, que `concat!` ne sait pas assembler. +pub fn amount_due_derived_joins() -> String { + format!( + "{} {INVOICE_CREDITED_DERIVED_JOIN_SQL} {INVOICE_SETTLED_DERIVED_JOIN_SQL}", + crate::repositories::invoices::INVOICE_TTC_DERIVED_JOIN_SQL + ) +} + +/// Le **reste dû** d'une ligne, sur les tables de +/// [`amount_due_derived_joins`] : `TTC − avoir émis − Σ règlements`, trois +/// termes TTC. Miroir exact de [`amount_due`]. ⛔ Ne pas le réécrire à la main +/// dans une requête : c'est ainsi que les agrégats ont sommé le TTC pendant +/// un mois après la 24-2 (#416). +pub const INVOICE_AMOUNT_DUE_DERIVED_SQL: &str = + "(COALESCE(lt.ttc, 0) - COALESCE(cnt.credited, 0) - COALESCE(st.settled, 0))"; + +/// Le **total réglé** d'une ligne, sur les tables de [`amount_due_derived_joins`]. +pub const INVOICE_AMOUNT_SETTLED_DERIVED_SQL: &str = "COALESCE(st.settled, 0)"; + /// Enregistre un règlement dans la transaction courante. /// /// ⚠️ **Ne pose PAS `paid_at`** : c'est à l'appelant de le faire, et seulement diff --git a/crates/kesh-db/src/repositories/invoice_settlements_write.rs b/crates/kesh-db/src/repositories/invoice_settlements_write.rs index 415b747c2..02d4185d7 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements_write.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements_write.rs @@ -329,8 +329,9 @@ pub async fn settlement_cancel_blocker_unlinking( // 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. + // réglée : ce rang ne se déclenche plus que sur un état HÉRITÉ — des données + // antérieures à la 0.12.1 (la 0.12.0 acceptait cet avoir), restaurées ou + // mises à jour sur place. 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 526bbc5aa..e131e78b1 100644 --- a/crates/kesh-db/src/repositories/invoices.rs +++ b/crates/kesh-db/src/repositories/invoices.rs @@ -32,6 +32,9 @@ use crate::entities::audit_log::NewAuditLogEntry; use crate::entities::invoice::{Invoice, InvoiceLine, InvoiceUpdate, NewInvoice, NewInvoiceLine}; use crate::errors::{DbError, UnvalidationBlocker, map_db_error}; use crate::repositories::audit_log; +use crate::repositories::invoice_settlements::{ + INVOICE_AMOUNT_DUE_DERIVED_SQL, INVOICE_AMOUNT_SETTLED_DERIVED_SQL, amount_due_derived_joins, +}; use crate::repositories::journal_entries; use crate::util::search::{escape_boolean_ft, escape_like}; @@ -254,9 +257,16 @@ pub struct InvoiceListItem { pub due_date: Option, pub payment_terms: Option, pub total_amount: Decimal, - /// TTC canonique (#246) — colonne SQL calculée (`INVOICE_TTC_SUBQUERY_SQL`), - /// la projection ne charge pas les lignes. + /// TTC canonique (#246) — colonne SQL calculée (table dérivée `lt` depuis + /// la Story 25-4-b1 ; `invoice_ttc_parity.rs` la tient égale à la forme + /// corrélée), la projection ne charge pas les lignes. pub total_ttc: Decimal, + /// Story 25-4-b1 (#416) — total déjà réglé, forme jointe. + pub amount_settled: Decimal, + /// Story 25-4-b1 (#416) — **reste dû** : TTC − avoir émis − Σ règlements, + /// forme jointe (`INVOICE_AMOUNT_DUE_DERIVED_SQL`). Peut être négatif + /// (trop-perçu hérité) : jamais écrêté. + pub amount_due: Decimal, pub paid_at: Option, /// Story 21-6a (D10) — suspension des rappels, exposée en liste (badge). /// @@ -816,10 +826,14 @@ pub async fn list_by_company_paginated( let mut items_qb: QueryBuilder = QueryBuilder::new(format!( "SELECT i.id, i.company_id, i.contact_id, c.name AS contact_name, \ i.invoice_number, i.status, i.date, i.due_date, i.payment_terms, \ - i.total_amount, {INVOICE_TTC_SUBQUERY_SQL} AS total_ttc, \ + i.total_amount, COALESCE(lt.ttc, 0) AS total_ttc, \ + {settled} AS amount_settled, {due} AS amount_due, \ i.paid_at, i.dunning_paused_at, i.dunning_paused_note, \ i.version, i.created_at, i.updated_at \ - FROM invoices i INNER JOIN contacts c ON c.id = i.contact_id", + FROM invoices i INNER JOIN contacts c ON c.id = i.contact_id {joins}", + settled = INVOICE_AMOUNT_SETTLED_DERIVED_SQL, + due = INVOICE_AMOUNT_DUE_DERIVED_SQL, + joins = amount_due_derived_joins(), )); push_where_clauses(&mut items_qb, company_id, &query); items_qb.push(" ORDER BY "); @@ -887,16 +901,19 @@ pub async fn due_dates_summary( let mut qb: QueryBuilder = QueryBuilder::new(format!( // CAST AS SIGNED pour forcer BIGINT : MariaDB SUM(CASE…) retourne // DECIMAL par défaut, incompatible avec Rust i64. - // #246 (Story 21-2a) : totaux en TTC via la table dérivée `lt` - // (INVOICE_TTC_DERIVED_JOIN_SQL) — les KPI de l'échéancier sont des - // montants dus, pas des HT comptables. + // Story 25-4-b1 (#416) : les KPI de l'échéancier sont des RESTES DUS — + // TTC − avoir émis − Σ règlements, forme jointe — et non plus le TTC + // émis (#246, 21-2a), qui comptait une facture de 1 000.— réglée à + // 900.— pour 1 000.—. "SELECT \ COUNT(*) AS unpaid_count, \ - COALESCE(SUM(COALESCE(lt.ttc, 0)), CAST(0 AS DECIMAL(19,4))) AS unpaid_total, \ + COALESCE(SUM({due}), CAST(0 AS DECIMAL(19,4))) AS unpaid_total, \ CAST(COALESCE(SUM(CASE WHEN i.due_date < UTC_DATE() THEN 1 ELSE 0 END), 0) AS SIGNED) AS overdue_count, \ - COALESCE(SUM(CASE WHEN i.due_date < UTC_DATE() THEN COALESCE(lt.ttc, 0) ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS overdue_total \ + COALESCE(SUM(CASE WHEN i.due_date < UTC_DATE() THEN {due} ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS overdue_total \ FROM invoices i INNER JOIN contacts c ON c.id = i.contact_id \ - {INVOICE_TTC_DERIVED_JOIN_SQL}", + {joins}", + due = INVOICE_AMOUNT_DUE_DERIVED_SQL, + joins = amount_due_derived_joins(), )); qb.push(" WHERE i.company_id = "); qb.push_bind(company_id); @@ -2456,10 +2473,14 @@ pub async fn list_for_export( let mut items_qb: QueryBuilder = QueryBuilder::new(format!( "SELECT i.id, i.company_id, i.contact_id, c.name AS contact_name, \ i.invoice_number, i.status, i.date, i.due_date, i.payment_terms, \ - i.total_amount, {INVOICE_TTC_SUBQUERY_SQL} AS total_ttc, \ + i.total_amount, COALESCE(lt.ttc, 0) AS total_ttc, \ + {settled} AS amount_settled, {due} AS amount_due, \ i.paid_at, i.dunning_paused_at, i.dunning_paused_note, \ i.version, i.created_at, i.updated_at \ - FROM invoices i INNER JOIN contacts c ON c.id = i.contact_id", + FROM invoices i INNER JOIN contacts c ON c.id = i.contact_id {joins}", + settled = INVOICE_AMOUNT_SETTLED_DERIVED_SQL, + due = INVOICE_AMOUNT_DUE_DERIVED_SQL, + joins = amount_due_derived_joins(), )); push_where_clauses(&mut items_qb, company_id, query); // P13 (review pass 3 A) : défense en profondeur — l'export ne doit diff --git a/crates/kesh-db/tests/invoice_amount_due_parity.rs b/crates/kesh-db/tests/invoice_amount_due_parity.rs index 79523b62b..835fb88c7 100644 --- a/crates/kesh-db/tests/invoice_amount_due_parity.rs +++ b/crates/kesh-db/tests/invoice_amount_due_parity.rs @@ -19,8 +19,9 @@ 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, + INVOICE_AMOUNT_DUE_DERIVED_SQL, INVOICE_CREDITED_DERIVED_JOIN_SQL, + INVOICE_CREDITED_SUBQUERY_SQL, INVOICE_SETTLED_DERIVED_JOIN_SQL, INVOICE_SETTLED_SUBQUERY_SQL, + amount_due, amount_due_derived_joins, }; use kesh_db::repositories::{contacts, credit_notes, invoice_settlements_write, invoices}; use kesh_db::test_fixtures::{SeededCompany, seed_accounting_company}; @@ -136,7 +137,8 @@ async fn credit( } /// 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. +/// l'application, mais que des données antérieures à la 0.12.1 peuvent porter — +/// la 0.12.0 publiée acceptait cet avoir —, restaurées ou mises à jour sur place. /// /// Gabarit « détacher, créditer, rattacher » (AC 13) : les deux écritures /// viennent des **vrais chemins** ; seul le rattachement de la ligne de @@ -308,4 +310,139 @@ async fn settled_and_credited_forms_are_at_parity(pool: MySqlPool) { Decimal::ZERO, "un brouillon n'éteint rien" ); + + // Story 25-4-b1 (AC 2) — le reste dû sous forme JOINTE, celle des listes et + // agrégats, égale la forme scalaire facture par facture. + let joined: Vec<(i64, Decimal)> = sqlx::query_as(&format!( + "SELECT i.id, {INVOICE_AMOUNT_DUE_DERIVED_SQL} FROM invoices i {} \ + WHERE i.company_id = ? ORDER BY i.id", + amount_due_derived_joins() + )) + .bind(seeded.company_id) + .fetch_all(&pool) + .await + .expect("reste dû joint"); + assert_eq!(joined.len(), rows.len()); + for (id, due) in &joined { + assert_eq!( + *due, + amount_due(&pool, *id).await.unwrap(), + "reste dû joint ≠ scalaire, facture {id}" + ); + } + // Anti-vacuité : le jeu porte des restes dus distincts, dont un négatif. + assert!( + joined.iter().any(|(_, d)| *d < Decimal::ZERO), + "état hérité : reste dû négatif" + ); + assert!( + joined.iter().any(|(_, d)| *d > Decimal::ZERO), + "anti-vacuité : au moins un reste dû positif" + ); +} + +// ─── Story 25-4-b1 (#416) — l'échéancier porte le reste dû ──────────────────── + +fn unpaid_query() -> invoices::InvoiceListQuery { + invoices::InvoiceListQuery { + status: Some("validated".into()), + payment_status: Some(invoices::PaymentStatusFilter::Unpaid), + limit: 100, + ..Default::default() + } +} + +/// AC 7 — les totaux du résumé somment le reste dû : 108.10 réglé 40 pèse 68.10. +#[sqlx::test(migrations = "./test-schema")] +async fn due_dates_summary_totals_are_amount_due(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, &seeded).await; + let partial = validated(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00))]).await; + settle(&pool, &seeded, partial, dec!(40.00)).await; + let _open = validated(&pool, &seeded, contact, &[(dec!(2.60), dec!(50.00))]).await; + + let summary = invoices::due_dates_summary(&pool, seeded.company_id, &unpaid_query()) + .await + .unwrap(); + assert_eq!(summary.unpaid_count, 2); + // 68.10 + 51.30 + assert_eq!( + summary.unpaid_total, + dec!(119.40), + "reste dû, pas 159.40 de TTC" + ); +} + +/// AC 8 — les DEUX SELECT qui désérialisent `InvoiceListItem` portent le réglé +/// et le reste dû : la liste paginée ET l'export. +#[sqlx::test(migrations = "./test-schema")] +async fn list_items_carry_amount_due(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, &seeded).await; + let partial = validated(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00))]).await; + settle(&pool, &seeded, partial, dec!(40.00)).await; + let open = validated(&pool, &seeded, contact, &[(dec!(8.10), dec!(10.00))]).await; + + let page = invoices::list_by_company_paginated(&pool, seeded.company_id, unpaid_query()) + .await + .unwrap(); + let (export, _) = invoices::list_for_export(&pool, seeded.company_id, &unpaid_query(), 100) + .await + .unwrap(); + for (surface, items) in [("liste", &page.items), ("export", &export)] { + let p = items.iter().find(|i| i.id == partial).expect(surface); + assert_eq!(p.total_ttc, dec!(108.10), "{surface} : TTC"); + assert_eq!(p.amount_settled, dec!(40.00), "{surface} : réglé"); + assert_eq!(p.amount_due, dec!(68.10), "{surface} : reste dû"); + let o = items.iter().find(|i| i.id == open).expect(surface); + assert_eq!(o.amount_settled, Decimal::ZERO, "{surface}"); + assert_eq!(o.amount_due, dec!(10.81), "{surface}"); + } +} + +/// Revue de code 25-4-b1, passe 1 — un règlement ANNULÉ ne compte plus sur +/// aucune surface agrégée : liste, export et résumé repèsent le TTC entier. +#[sqlx::test(migrations = "./test-schema")] +async fn cancelled_settlement_leaves_the_aggregates(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, &seeded).await; + let inv = validated(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00))]).await; + let entry = settle(&pool, &seeded, inv, dec!(40.00)).await; + // Témoin : avant l'annulation, le résumé porte le reste dû. + let avant = invoices::due_dates_summary(&pool, seeded.company_id, &unpaid_query()) + .await + .unwrap(); + assert_eq!(avant.unpaid_total, dec!(68.10)); + + let settlement_id: i64 = + sqlx::query_scalar("SELECT id FROM invoice_settlements WHERE journal_entry_id = ?") + .bind(entry) + .fetch_one(&pool) + .await + .unwrap(); + invoice_settlements_write::cancel_settlement( + &pool, + seeded.admin_user_id, + seeded.company_id, + inv, + settlement_id, + ) + .await + .expect("annulation du règlement"); + + let summary = invoices::due_dates_summary(&pool, seeded.company_id, &unpaid_query()) + .await + .unwrap(); + assert_eq!(summary.unpaid_total, dec!(108.10), "résumé"); + let page = invoices::list_by_company_paginated(&pool, seeded.company_id, unpaid_query()) + .await + .unwrap(); + let (export, _) = invoices::list_for_export(&pool, seeded.company_id, &unpaid_query(), 100) + .await + .unwrap(); + for (surface, items) in [("liste", &page.items), ("export", &export)] { + let i = items.iter().find(|i| i.id == inv).expect(surface); + assert_eq!(i.amount_settled, Decimal::ZERO, "{surface} : réglé"); + assert_eq!(i.amount_due, dec!(108.10), "{surface} : reste dû"); + } } diff --git a/crates/kesh-db/tests/invoice_settlement.rs b/crates/kesh-db/tests/invoice_settlement.rs index f14d1f21c..ba67d009e 100644 --- a/crates/kesh-db/tests/invoice_settlement.rs +++ b/crates/kesh-db/tests/invoice_settlement.rs @@ -976,8 +976,9 @@ async fn monter(pool: &MySqlPool, motifs: &[SettlementCancelBlocker]) -> (Seeded 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 + // réglée, même en partie. Des **données antérieures à la 0.12.1** peuvent + // encore le porter — la 0.12.0 publiée acceptait cet avoir —, restaurées + // ou mises à jour sur place : c'est ce qui justifie de garder le motif // `InvoiceCredited` et ce montage. // // Gabarit « détacher, créditer, rattacher » : les deux écritures diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 6c151ed9c..883ca0377 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -649,6 +649,7 @@ due-dates-column-date = Datum due-dates-column-due-date = Fälligkeit due-dates-column-contact = Kunde due-dates-column-total = Total +due-dates-column-amount-due = Offener Betrag due-dates-column-payment-status = Status due-dates-column-paid-at = Bezahlt am due-dates-export-button = CSV exportieren @@ -728,6 +729,7 @@ echeancier-csv-header-date = Datum echeancier-csv-header-due-date = Fälligkeitsdatum echeancier-csv-header-contact = Kunde echeancier-csv-header-total = Total +echeancier-csv-header-amount-due = Offener Betrag echeancier-csv-header-payment-status = Zahlungsstatus echeancier-csv-header-paid-at = Zahlungsdatum echeancier-export-error-too-large = Zu viele Ergebnisse (> { $limit }). Bitte die Filter verfeinern (z. B. Datumsbereich oder Zahlungsstatus), bevor der Export erneut gestartet wird. diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 92ffd928c..7842a8f6e 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -649,6 +649,7 @@ due-dates-column-date = Date due-dates-column-due-date = Due date due-dates-column-contact = Customer due-dates-column-total = Total +due-dates-column-amount-due = Amount due due-dates-column-payment-status = Status due-dates-column-paid-at = Paid on due-dates-export-button = Export CSV @@ -728,6 +729,7 @@ echeancier-csv-header-date = Date echeancier-csv-header-due-date = Due date echeancier-csv-header-contact = Customer echeancier-csv-header-total = Total +echeancier-csv-header-amount-due = Amount due echeancier-csv-header-payment-status = Payment status echeancier-csv-header-paid-at = Paid on echeancier-export-error-too-large = Too many results (> { $limit }). Please refine your filters (e.g. date range or payment status) before exporting. diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 9e47a49eb..efec23fed 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -682,6 +682,7 @@ due-dates-column-date = Date due-dates-column-due-date = Échéance due-dates-column-contact = Client due-dates-column-total = Total +due-dates-column-amount-due = Reste dû due-dates-column-payment-status = Statut due-dates-column-paid-at = Payée le due-dates-export-button = Exporter CSV @@ -764,6 +765,7 @@ echeancier-csv-header-date = Date echeancier-csv-header-due-date = Date d'échéance echeancier-csv-header-contact = Client echeancier-csv-header-total = Total +echeancier-csv-header-amount-due = Reste dû echeancier-csv-header-payment-status = Statut paiement echeancier-csv-header-paid-at = Date paiement echeancier-export-error-too-large = Trop de résultats (> { $limit }). Veuillez affiner vos filtres (par ex. plage de dates ou statut de paiement) avant de relancer l'export. diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 06f352747..627734b42 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -649,6 +649,7 @@ due-dates-column-date = Data due-dates-column-due-date = Scadenza due-dates-column-contact = Cliente due-dates-column-total = Totale +due-dates-column-amount-due = Saldo dovuto due-dates-column-payment-status = Stato due-dates-column-paid-at = Pagata il due-dates-export-button = Esporta CSV @@ -728,6 +729,7 @@ echeancier-csv-header-date = Data echeancier-csv-header-due-date = Scadenza echeancier-csv-header-contact = Cliente echeancier-csv-header-total = Totale +echeancier-csv-header-amount-due = Saldo dovuto echeancier-csv-header-payment-status = Stato pagamento echeancier-csv-header-paid-at = Data pagamento echeancier-export-error-too-large = Troppi risultati (> { $limit }). Affinare i filtri (intervallo date o stato di pagamento) prima di esportare. diff --git a/crates/kesh-report/src/aged_receivables.rs b/crates/kesh-report/src/aged_receivables.rs index 96f700933..6498d7227 100644 --- a/crates/kesh-report/src/aged_receivables.rs +++ b/crates/kesh-report/src/aged_receivables.rs @@ -1,16 +1,26 @@ //! Balance âgée des créances clients (Story 21-7, #231, §E items 23/25). //! -//! Répartit l'encours débiteur **TTC** (postes ouverts = factures validées non -//! payées) par contact et par tranche d'ancienneté relative à une date de +//! Répartit l'encours débiteur — le **reste dû** de chaque facture validée non +//! soldée, après règlements partiels et avoirs (Story 25-4-b1, #416) — par contact et par tranche d'ancienneté relative à une date de //! référence `as_of` : //! //! `Non échu | 1-30 | 31-60 | 61-90 | 90+` jours de retard (`days = as_of − due_date`). //! //! Invariants : -//! - **TTC dérivé** via `INVOICE_TTC_DERIVED_JOIN_SQL` (#246, 21-2) — jamais -//! `total_amount` (qui est le HT). L'arrondi TVA est fait PAR LIGNE dans la -//! table dérivée (DC7), asservi au helper Rust `invoice_total_ttc` par un test -//! de parité (`tests/aged_receivables.rs`). +//! - **Reste dû dérivé** (`INVOICE_AMOUNT_DUE_DERIVED_SQL` sur +//! `amount_due_derived_joins`, Story 25-4-b1) : TTC − avoir émis − Σ +//! règlements, jamais le TTC seul ni `total_amount` (le HT). Le TTC y est +//! arrondi PAR LIGNE (DC7), asservi au helper Rust `invoice_total_ttc` +//! (`tests/aged_receivables.rs`) ; le reste dû joint, à `amount_due` +//! (`kesh-db/tests/invoice_amount_due_parity.rs`). +//! - ⛔ **Concordance avec le grand livre** — le total égale le solde du compte +//! débiteurs **si et seulement si** ce compte n'est mouvementé que par les +//! écritures de vente, de règlement client et d'avoir de factures validées +//! (contre-passations comprises), datées au plus tard à `as_of`, sans que le +//! compte débiteurs ait changé dans les réglages. Tout autre mouvement — solde +//! d'ouverture, écriture au journal, rapprochement hors facture, compensation +//! fournisseur, données antérieures à la 0.12.1 — l'en écarte, et la balance +//! âgée ne le montre pas (règle, non liste : `tests/aged_receivables.rs`). //! - **Factures suspendues INCLUSES** (D10) : le prédicat postes ouverts //! n'exclut PAS `dunning_paused_at` — une facture suspendue reste dans la //! balance âgée (elle ne sort que de la liste « à rappeler »). @@ -18,7 +28,9 @@ //! - Totaux généraux **sommés en Rust** (patron `balance_sheet`), pas en SQL. use chrono::NaiveDate; -use kesh_db::repositories::invoices::INVOICE_TTC_DERIVED_JOIN_SQL; +use kesh_db::repositories::invoice_settlements::{ + INVOICE_AMOUNT_DUE_DERIVED_SQL, amount_due_derived_joins, +}; use rust_decimal::Decimal; use serde::Serialize; use sqlx::MySqlPool; @@ -95,8 +107,9 @@ struct AgedRowSql { /// /// Une seule requête agrégée groupée par contact. Buckets calculés via /// `DATEDIFF(as_of, due_date)` ; `due_date IS NULL` → « Non échu ». Montants = -/// TTC dérivé (`INVOICE_TTC_DERIVED_JOIN_SQL`, alias `lt`). Scoping multi-tenant -/// obligatoire (`i.company_id = ?`). +/// **reste dû** dérivé (Story 25-4-b1) ; ⚠️ il ne filtre pas les règlements +/// par date : `as_of` borne les tranches, pas les pièces — la route le fixe à +/// aujourd'hui. Scoping multi-tenant obligatoire (`i.company_id = ?`). pub async fn generate( pool: &MySqlPool, company_id: i64, @@ -106,28 +119,40 @@ pub async fn generate( // Non échu : due_date NULL OU DATEDIFF <= 0 (échéance >= as_of) // 1-30 / 31-60 / 61-90 : DATEDIFF dans [lo, hi] // 90+ : DATEDIFF >= 91 (strictement plus de 90 jours — pas de recouvrement) - // Le TTC par facture vient de la table dérivée `lt`. HAVING total <> 0 - // écarte une facture legacy sans lignes (TTC 0) qui polluerait la liste. + // Le HAVING garde un contact dès qu'UNE de ses factures a un reste dû non + // nul ; il écarte ainsi une facture legacy sans lignes (reste dû 0) qui + // polluerait la liste. ⚠️ Un reste dû NÉGATIF (trop-perçu hérité) n'est + // pas écrêté : il apparaît. ⛔ Ne PAS revenir à `HAVING total <> 0` : la + // grandeur admet le négatif, et un trop-perçu hérité compensant une facture + // ouverte du même contact ferait disparaître la ligne entière — la relance + // due avec (revue de code 25-4-b1, passe 1). + // ⛔ Story 25-4-b1 (#416) : chaque tranche somme le RESTE DÛ, jamais le + // TTC — une facture de 1 000.— réglée à 900.— pèse 100.—. Les tranches + // restent assises sur `due_date` : un règlement partiel ne rajeunit pas la + // créance restante. Forme JOINTE (`amount_due_derived_joins`) : la forme + // scalaire serait réévaluée par ligne et par CASE. + let due = INVOICE_AMOUNT_DUE_DERIVED_SQL; let sql = format!( "SELECT c.id AS contact_id, c.name AS contact_name, \ COALESCE(SUM(CASE WHEN i.due_date IS NULL OR DATEDIFF(?, i.due_date) <= 0 \ - THEN COALESCE(lt.ttc, 0) ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS not_due, \ + THEN {due} ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS not_due, \ COALESCE(SUM(CASE WHEN DATEDIFF(?, i.due_date) BETWEEN 1 AND 30 \ - THEN COALESCE(lt.ttc, 0) ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS d1_30, \ + THEN {due} ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS d1_30, \ COALESCE(SUM(CASE WHEN DATEDIFF(?, i.due_date) BETWEEN 31 AND 60 \ - THEN COALESCE(lt.ttc, 0) ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS d31_60, \ + THEN {due} ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS d31_60, \ COALESCE(SUM(CASE WHEN DATEDIFF(?, i.due_date) BETWEEN 61 AND 90 \ - THEN COALESCE(lt.ttc, 0) ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS d61_90, \ + THEN {due} ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS d61_90, \ COALESCE(SUM(CASE WHEN DATEDIFF(?, i.due_date) >= 91 \ - THEN COALESCE(lt.ttc, 0) ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS d90p, \ - COALESCE(SUM(COALESCE(lt.ttc, 0)), CAST(0 AS DECIMAL(19,4))) AS total \ + THEN {due} ELSE 0 END), CAST(0 AS DECIMAL(19,4))) AS d90p, \ + COALESCE(SUM({due}), CAST(0 AS DECIMAL(19,4))) AS total \ FROM invoices i \ INNER JOIN contacts c ON c.id = i.contact_id \ - {INVOICE_TTC_DERIVED_JOIN_SQL} \ + {joins} \ WHERE i.company_id = ? AND i.status = 'validated' AND i.paid_at IS NULL \ GROUP BY c.id, c.name \ - HAVING total <> 0 \ - ORDER BY c.name, c.id" + HAVING SUM(CASE WHEN {due} <> 0 THEN 1 ELSE 0 END) > 0 \ + ORDER BY c.name, c.id", + joins = amount_due_derived_joins() ); let sql_rows = sqlx::query_as::<_, AgedRowSql>(&sql) diff --git a/crates/kesh-report/tests/aged_receivables.rs b/crates/kesh-report/tests/aged_receivables.rs index 23e180166..013ff4fef 100644 --- a/crates/kesh-report/tests/aged_receivables.rs +++ b/crates/kesh-report/tests/aged_receivables.rs @@ -11,9 +11,9 @@ use chrono::{Duration, NaiveDate}; use kesh_core::accounting::vat::invoice_total_ttc; use kesh_db::entities::contact::{ContactType, NewContact, Salutation}; -use kesh_db::entities::{NewInvoice, NewInvoiceLine}; +use kesh_db::entities::{NewCreditNote, NewInvoice, NewInvoiceLine, SettlementChoice}; use kesh_db::repositories::invoices::ValidatedInvoice; -use kesh_db::repositories::{contacts, invoices}; +use kesh_db::repositories::{contacts, credit_notes, invoice_settlements_write, invoices}; use kesh_db::test_fixtures::{SeededCompany, seed_accounting_company}; use kesh_report::aged_receivables::generate; use rust_decimal::Decimal; @@ -314,3 +314,302 @@ async fn aged_empty_when_no_open_items(pool: MySqlPool) { assert_eq!(report.totals.not_due, Decimal::ZERO); assert_eq!(report.as_of, as_of()); } + +// ─── Story 25-4-b1 (#416) — la balance âgée somme le RESTE DÛ ───────────────── + +async fn settle( + pool: &MySqlPool, + seeded: &SeededCompany, + invoice_id: i64, + amount: Decimal, + on: NaiveDate, +) { + invoice_settlements_write::settle_invoice( + pool, + seeded.admin_user_id, + seeded.company_id, + invoice_id, + SettlementChoice::InternalAccount { + account_id: seeded.accounts["1000"], + }, + amount, + on, + ) + .await + .expect("règlement"); +} + +/// Solde du compte débiteurs au grand livre, pour la société. +async fn receivable_balance(pool: &MySqlPool, seeded: &SeededCompany) -> Decimal { + sqlx::query_scalar( + "SELECT COALESCE(SUM(l.debit) - SUM(l.credit), 0) FROM journal_entry_lines l \ + INNER JOIN journal_entries e ON e.id = l.entry_id \ + WHERE e.company_id = ? AND l.account_id = ?", + ) + .bind(seeded.company_id) + .bind(seeded.accounts["1100"]) + .fetch_one(pool) + .await + .expect("solde 1100") +} + +/// AC 3 — une facture de 1 081.— (1 000.— à 8,1 %) réglée de 900.— pèse **181.—** +/// dans sa tranche, pas 1 081.—. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn aged_uses_amount_due_not_ttc(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let alpha = mk_contact(&pool, &seeded, "Alpha SA").await; + let v = mk_validated( + &pool, + &seeded, + alpha, + Some(days_before(45)), + dec!(8.10), + dec!(1000.00), + ) + .await; + settle(&pool, &seeded, v.invoice.id, dec!(900.00), days_before(40)).await; + + let r = generate(&pool, seeded.company_id, as_of()).await.unwrap(); + assert_eq!(r.rows.len(), 1); + assert_eq!( + r.rows[0].buckets.days_31_to_60, + dec!(181.00), + "reste dû, pas le TTC" + ); + assert_eq!(r.rows[0].buckets.total, dec!(181.00)); + assert_eq!(r.totals.total, dec!(181.00)); +} + +/// AC 3 — ⛔ un règlement partiel ne rajeunit pas la créance : la tranche suit +/// l'échéance de la facture, pas la date du règlement. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn aged_partial_payment_keeps_original_bucket(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let alpha = mk_contact(&pool, &seeded, "Alpha SA").await; + let v = mk_validated( + &pool, + &seeded, + alpha, + Some(days_before(100)), + dec!(8.10), + dec!(100.00), + ) + .await; + // Réglé en partie HIER : la créance restante reste à plus de 90 jours. + settle(&pool, &seeded, v.invoice.id, dec!(8.10), days_before(1)).await; + + let r = generate(&pool, seeded.company_id, as_of()).await.unwrap(); + let b = &r.rows[0].buckets; + assert_eq!(b.days_over_90, dec!(100.00)); + assert_eq!( + b.not_due + b.days_1_to_30 + b.days_31_to_60 + b.days_61_to_90, + Decimal::ZERO + ); +} + +/// AC 5 — ⛔ le total de la balance âgée égale le solde du compte débiteurs au +/// grand livre, **dans la règle** : compte mû seulement par les ventes, +/// règlements clients et avoirs de factures validées, datés au plus tard à +/// `as_of`. Quatre états vivants : sans règlement, réglée en partie, soldée, +/// créditée. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn aged_total_matches_receivable_ledger(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let alpha = mk_contact(&pool, &seeded, "Alpha SA").await; + let beta = mk_contact(&pool, &seeded, "Beta SA").await; + + let _open = mk_validated( + &pool, + &seeded, + alpha, + Some(days_before(20)), + dec!(8.10), + dec!(500.00), + ) + .await; + let partial = mk_validated( + &pool, + &seeded, + alpha, + Some(days_before(70)), + dec!(2.60), + dec!(333.33), + ) + .await; + settle( + &pool, + &seeded, + partial.invoice.id, + dec!(100.00), + days_before(10), + ) + .await; + let paid = mk_validated( + &pool, + &seeded, + beta, + Some(days_before(30)), + dec!(8.10), + dec!(200.00), + ) + .await; + settle( + &pool, + &seeded, + paid.invoice.id, + dec!(216.20), + days_before(5), + ) + .await; + let credited = mk_validated( + &pool, + &seeded, + beta, + Some(days_before(15)), + dec!(8.10), + dec!(50.00), + ) + .await; + credit_notes::create_credit_note( + &pool, + NewCreditNote { + company_id: seeded.company_id, + invoice_id: credited.invoice.id, + date: days_before(2), + }, + seeded.admin_user_id, + ) + .await + .expect("avoir"); + + let r = generate(&pool, seeded.company_id, as_of()).await.unwrap(); + let ledger = receivable_balance(&pool, &seeded).await; + // 540.50 + (341.996… arrondi par ligne : 333.33 + 8.67 = 342.00) − 100 = 782.50 + assert_eq!( + ledger, + dec!(782.50), + "anti-vacuité : le grand livre porte les quatre états" + ); + assert_eq!(r.totals.total, ledger, "balance âgée = compte débiteurs"); +} + +/// AC 4 — ⛔ un trop-perçu hérité qui COMPENSE une facture ouverte du même +/// contact ne fait pas disparaître le contact : la relance de la facture +/// ouverte reste visible, le négatif apparaît dans sa tranche. +/// +/// L'état est inatteignable par l'application (le trop-perçu est refusé à +/// l'écriture) ; il est fabriqué comme le porteraient des données héritées : +/// un règlement réel, passé sur une facture « parking » d'un autre contact +/// qui peut l'absorber, puis rattaché en SQL à la facture cible. +/// +/// Revue de code 25-4-b1, passe 1 : avec `HAVING total <> 0`, Alpha +/// (108.10 − 108.10 = 0) sortait du résultat. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn aged_compensating_legacy_overpayment_keeps_the_contact(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let alpha = mk_contact(&pool, &seeded, "Alpha SA").await; + let beta = mk_contact(&pool, &seeded, "Beta SA").await; + + // Alpha : 108.10 ouverte, en retard de 20 jours… + let _open = mk_validated( + &pool, + &seeded, + alpha, + Some(days_before(20)), + dec!(8.10), + dec!(100.00), + ) + .await; + // … et 54.05 qui portera un règlement hérité de 162.15 → reste −108.10. + let target = mk_validated( + &pool, + &seeded, + alpha, + Some(days_before(70)), + dec!(8.10), + dec!(50.00), + ) + .await; + // Parking : 216.20 chez Beta, réglée en partie (162.15) — paid_at reste NULL. + let parking = mk_validated( + &pool, + &seeded, + beta, + Some(days_before(10)), + dec!(8.10), + dec!(200.00), + ) + .await; + settle( + &pool, + &seeded, + parking.invoice.id, + dec!(162.15), + days_before(5), + ) + .await; + sqlx::query("UPDATE invoice_settlements SET invoice_id = ? WHERE invoice_id = ?") + .bind(target.invoice.id) + .bind(parking.invoice.id) + .execute(&pool) + .await + .expect("rattacher le règlement hérité"); + + let r = generate(&pool, seeded.company_id, as_of()).await.unwrap(); + let a = r + .rows + .iter() + .find(|row| row.contact_id == alpha) + .expect("Alpha ne doit pas disparaître : sa facture ouverte est due"); + assert_eq!(a.buckets.days_1_to_30, dec!(108.10), "relance due visible"); + assert_eq!(a.buckets.days_61_to_90, dec!(-108.10), "négatif non écrêté"); + assert_eq!(a.buckets.total, Decimal::ZERO); + let b = r + .rows + .iter() + .find(|row| row.contact_id == beta) + .expect("Beta"); + assert_eq!(b.buckets.total, dec!(216.20), "parking : tout redevient dû"); +} + +/// Revue de code 25-4-b1, passe 1 — un règlement ANNULÉ ne compte plus : la +/// facture repèse son TTC entier dans sa tranche. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn aged_cancelled_settlement_no_longer_counts(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let alpha = mk_contact(&pool, &seeded, "Alpha SA").await; + let v = mk_validated( + &pool, + &seeded, + alpha, + Some(days_before(45)), + dec!(8.10), + dec!(1000.00), + ) + .await; + settle(&pool, &seeded, v.invoice.id, dec!(900.00), days_before(40)).await; + // Témoin : avant l'annulation, la tranche porte le reste dû. + let avant = generate(&pool, seeded.company_id, as_of()).await.unwrap(); + assert_eq!(avant.rows[0].buckets.days_31_to_60, dec!(181.00)); + + let settlement_id: i64 = + sqlx::query_scalar("SELECT id FROM invoice_settlements WHERE invoice_id = ?") + .bind(v.invoice.id) + .fetch_one(&pool) + .await + .unwrap(); + invoice_settlements_write::cancel_settlement( + &pool, + seeded.admin_user_id, + seeded.company_id, + v.invoice.id, + settlement_id, + ) + .await + .expect("annulation du règlement"); + + let r = generate(&pool, seeded.company_id, as_of()).await.unwrap(); + assert_eq!(r.rows[0].buckets.days_31_to_60, dec!(1081.00)); + assert_eq!(r.totals.total, dec!(1081.00)); +} diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index 139a7469d..44a073f25 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 25dde525a..a10c56b34 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -936,6 +936,12 @@ \subsection{Échéancier des factures} \item Factures payées (historique). \end{itemize} +Chaque ligne montre le \textbf{total} de la facture (TTC) et son \textbf{reste dû}, qui tient compte des +règlements partiels ; une facture réglée en partie porte le statut «~Partiellement payée~». Les +totaux du haut de page additionnent les restes dus. Le bouton \emph{Régler} ouvre le dialogue de +règlement \textbf{pré-rempli au reste dû} --- modifiable pour un nouvel acompte. L'export CSV porte +la même colonne «~Reste dû~». + Pour relancer les factures en retard, Kesh dispose d'un \textbf{écran de rappels} dédié — voir \S\ref{sec:relances-user}. Une facture suspendue de rappels reste visible dans l'échéancier (rien ne se cache). \subsection{Enregistrer et annuler un règlement}\label{sec:reglement-client} @@ -1573,18 +1579,18 @@ \subsection{Grand livre}\label{sec:grand-livre} \subsection{Balance âgée des créances}\label{sec:balance-agee} -Depuis la version \textbf{0.7}, l'onglet \emph{Rapports → Balance âgée} répartit l'\textbf{encours débiteur} (le total dû, TVA comprise) par \textbf{client} et par \textbf{tranche d'ancienneté}, arrêté à ce jour. C'est l'outil de pilotage du recouvrement : il montre d'un coup d'œil quelles créances vieillissent et lesquelles prioriser. +Depuis la version \textbf{0.7}, l'onglet \emph{Rapports → Balance âgée} répartit l'\textbf{encours débiteur} --- le \textbf{reste dû} de chaque facture, TVA comprise, après ses règlements partiels et son avoir éventuel --- par \textbf{client} et par \textbf{tranche d'ancienneté}, arrêté à ce jour. C'est l'outil de pilotage du recouvrement : il montre d'un coup d'œil quelles créances vieillissent et lesquelles prioriser. -\keshscreenshot{balance-agee.png}{Balance âgée : encours débiteur TTC par client et par tranche de retard, avec total général.} % TODO capture: onglet Rapports/Balance âgée +\keshscreenshot{balance-agee.png}{Balance âgée : reste dû par client et par tranche de retard, avec total général.} % TODO capture: onglet Rapports/Balance âgée \begin{itemize} - \item \textbf{Colonnes} : \emph{Non échu} \textbar{} \emph{1-30} \textbar{} \emph{31-60} \textbar{} \emph{61-90} \textbar{} \emph{90+} jours de retard. La colonne « Non échu » garantit que le \textbf{total général réconcilie} avec le solde du compte clients (débiteurs) du bilan. + \item \textbf{Colonnes} : \emph{Non échu} \textbar{} \emph{1-30} \textbar{} \emph{31-60} \textbar{} \emph{61-90} \textbar{} \emph{90+} jours de retard. Avec la colonne « Non échu », le \textbf{total général réconcilie} avec le solde du compte clients (débiteurs) du bilan \emph{tant que ce compte n'est mouvementé que par les factures, leurs règlements et leurs avoirs} (y compris leurs annulations). Toute autre écriture qui le touche l'en écarte : solde de départ, écriture saisie au journal, virement bancaire affecté à ce compte sans passer par une facture, paiement d'une facture fournisseur compensé sur ce compte, ou règlement dont la contrepartie est ce compte lui-même. De même un changement du compte clients dans les réglages de facturation, ou des données antérieures à la version 0.12.1, restaurées ou mises à jour. \item \textbf{Détail} : une ligne par client, un total par tranche, et un total général. Chaque client renvoie d'un clic vers \textbf{ses factures}. \item \textbf{Export CSV} : réservé aux rôles \emph{Comptable} et \emph{Administrateur} (le rapport lui-même est consultable par tous les rôles). \end{itemize} \begin{keshnote} -Les factures dont les rappels sont \textbf{suspendus restent comptées} dans la balance âgée (elles ne sortent que de la liste « à rappeler »). Le montant réparti est le \textbf{total dû TTC} de chaque facture, jamais le montant hors taxe. +Les factures dont les rappels sont \textbf{suspendus restent comptées} dans la balance âgée (elles ne sortent que de la liste « à rappeler »). Le montant réparti est le \textbf{reste dû} de chaque facture, jamais le montant hors taxe ni le total facturé : une facture de 1\,081.--- réglée de 900.--- y pèse 181.---, dans la tranche de \emph{son échéance} --- un règlement partiel ne rajeunit pas la créance. \end{keshnote} \subsection{Journal des écritures} diff --git a/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte b/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte index bbf3949a2..218ed501b 100644 --- a/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte +++ b/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte @@ -13,6 +13,7 @@ le parent gère l'appel API et ses erreurs. --> @@ -431,6 +427,9 @@ toggleSort('TotalAmount')}> {i18nMsg('due-dates-column-total', 'Total')} + + {i18nMsg('due-dates-column-amount-due', 'Reste dû')} + {i18nMsg('due-dates-column-payment-status', 'Statut')} @@ -449,8 +448,13 @@ {inv.invoiceNumber ?? '—'} {inv.contactName} - + {formatInvoiceTotal(inv.totalTtc)} + + {formatInvoiceTotal(inv.amountDue)} + @@ -490,10 +494,8 @@ {/if} {#if markTarget} - + { @@ -504,7 +506,7 @@ } }} invoiceDate={markTarget.date} - amountDue={null} + amountDue={markTarget.amountDue} accounts={settleAccounts} bankAccounts={settleBankAccounts} submitting={markSubmitting} diff --git a/frontend/src/routes/(app)/invoices/due-dates/due-dates-page.test.ts b/frontend/src/routes/(app)/invoices/due-dates/due-dates-page.test.ts new file mode 100644 index 000000000..5e923260a --- /dev/null +++ b/frontend/src/routes/(app)/invoices/due-dates/due-dates-page.test.ts @@ -0,0 +1,144 @@ +/** + * L'échéancier porte le reste dû — Story 25-4-b1 (#416). + * + * Ce que ni le test Rust ni l'E2E ne prouvent seuls : que la page **affiche** + * le reste dû de la ligne, rend le statut « partiellement payée », et **passe** + * ce reste dû au dialogue de règlement, qui le pré-remplit. + * + * ⚠️ Chaque test nomme la MUTATION qu'il attrape. Patron : + * `invoices/[id]/invoice-settlements-page.test.ts` — mocks hoistés AVANT + * l'import du composant. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, fireEvent, cleanup, waitFor } from "@testing-library/svelte"; +import type { + DueDateItem, + DueDatesResponse, +} from "$lib/features/invoices/invoices.types"; + +vi.mock("$app/environment", () => ({ browser: true })); +vi.mock("$app/navigation", () => ({ goto: vi.fn() })); +vi.mock("$app/state", () => ({ + page: { + params: {}, + url: new URL("http://localhost/invoices/due-dates"), + }, +})); +vi.mock("$lib/shared/utils/i18n.svelte", () => ({ + i18nMsg: ( + _k: string, + fallback: string, + args?: Record, + ) => + args + ? fallback.replace(/\{\s*\$(\w+)\s*\}/g, (_, n) => String(args[n] ?? "")) + : fallback, +})); +vi.mock("$lib/shared/utils/notify", () => ({ + notifyError: vi.fn(), + notifySuccess: vi.fn(), + notifyWarning: vi.fn(), +})); +vi.mock("$lib/app/stores/auth.svelte", () => ({ + authState: { currentUser: { role: "Comptable", username: "c", userId: "1" } }, +})); + +const listDueDatesMock = vi.fn(); +vi.mock("$lib/features/invoices/invoices.api", () => ({ + listDueDates: (q: unknown) => listDueDatesMock(q), + settleInvoice: vi.fn(), + exportDueDatesCsv: vi.fn(), +})); +vi.mock("$lib/features/accounts/accounts.api", () => ({ + fetchAccounts: vi.fn(async () => []), +})); +vi.mock("$lib/features/bank-accounts/bank-accounts.api", () => ({ + listBankAccounts: vi.fn(async () => []), +})); +vi.mock("$lib/features/contacts/contacts.api", () => ({ + getContact: vi.fn(), + listContacts: vi.fn(async () => ({ items: [], total: 0, offset: 0, limit: 20 })), +})); + +import Page from "./+page.svelte"; + +function item(partial: Partial = {}): DueDateItem { + return { + id: 5, + companyId: 1, + contactId: 1, + contactName: "Client SA", + invoiceNumber: "F-2026-005", + status: "validated", + date: "2026-03-01", + dueDate: "2026-03-31", + paymentTerms: null, + totalAmount: "100.00", + totalTtc: "108.10", + amountSettled: "40.00", + amountDue: "68.10", + paidAt: null, + dunningPausedAt: null, + dunningPausedNote: null, + isOverdue: false, + version: 3, + createdAt: "2026-03-01T00:00:00", + updatedAt: "2026-03-05T00:00:00", + ...partial, + }; +} + +function response(items: DueDateItem[]): DueDatesResponse { + return { + items, + total: items.length, + offset: 0, + limit: 20, + summary: { + unpaidCount: items.length, + unpaidTotal: "68.10", + overdueCount: 0, + overdueTotal: "0.00", + }, + }; +} + +beforeEach(() => { + listDueDatesMock.mockResolvedValue(response([item()])); +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("échéancier — le reste dû (Story 25-4-b1)", () => { + it("la ligne affiche le reste dû, pas le seul TTC (mutation : colonne retirée)", async () => { + const { findByTestId } = render(Page); + const cell = await findByTestId("due-dates-amount-due"); + expect(cell.textContent).toContain("68.10"); + }); + + it("une facture réglée en partie est « partiellement payée » (mutation : `partial` retiré du statut)", async () => { + const { findByText } = render(Page); + expect(await findByText("Partiellement payée")).toBeTruthy(); + }); + + it("une facture sans règlement reste « impayée »", async () => { + listDueDatesMock.mockResolvedValue( + response([item({ amountSettled: "0.00", amountDue: "108.10" })]), + ); + const { findByText, queryByText } = render(Page); + expect(await findByText("Impayée")).toBeTruthy(); + expect(queryByText("Partiellement payée")).toBeNull(); + }); + + it("le dialogue de règlement est pré-rempli au reste dû (mutation : `amountDue={null}` remis)", async () => { + const { findByText, container } = render(Page); + await fireEvent.click(await findByText("Régler")); + await waitFor(() => { + const input = document.getElementById("settle-amount") as HTMLInputElement | null; + expect(input?.value).toBe("68.10"); + }); + expect(container).toBeTruthy(); + }); +}); diff --git a/frontend/tests/e2e/invoices_echeancier.spec.ts b/frontend/tests/e2e/invoices_echeancier.spec.ts index 2ae0e5d75..3c8f4e546 100644 --- a/frontend/tests/e2e/invoices_echeancier.spec.ts +++ b/frontend/tests/e2e/invoices_echeancier.spec.ts @@ -144,18 +144,12 @@ test.describe('Échéancier factures — Story 5.4', () => { // Le premier compte proposé suffit : ce cas porte sur le PARCOURS, pas sur // le choix du compte — celui-ci est couvert par les tests Rust. await page.getByTestId('settle-account').selectOption({ index: 1 }); - // ⚠️ **Le montant doit être SAISI ici, et c'est délibéré.** L'échéancier - // ne connaît pas encore le résiduel (colonnes reportées à une issue - // séparée), donc le dialogue reçoit `amountDue = null` — « non calculé » - // — et laisse le champ vide plutôt que de pré-remplir. - // - // ⛔ Y pré-remplir le TTC de la ligne serait FAUX : sur une facture - // déjà partiellement réglée, TTC ≠ résiduel, et l'utilisateur serait - // conduit vers un trop-perçu que le serveur refuse. Un champ vide dit la - // vérité ; un champ pré-rempli d'un mauvais chiffre ment. - // - // 100.00 HT à 8.10 % ⇒ 108.10 TTC (cf. `createAndValidateInvoice`). - await page.getByLabel(/Montant|Amount|Importo|Betrag/i).fill('108.10'); + // Story 25-4-b1 (#416) — le montant n'est plus SAISI : l'échéancier porte + // le reste dû, et le dialogue le pré-remplit. Sans règlement, le reste dû + // est le TTC : 100.00 HT à 8.10 % ⇒ 108.10 (cf. `createAndValidateInvoice`). + // Le cas d'une facture PARTIELLEMENT réglée, où reste dû ≠ TTC, est le + // test suivant. + await expect(page.getByLabel(/Montant|Amount|Importo|Betrag/i)).toHaveValue("108.10"); await page.getByTestId('settle-confirm').click(); // Après reload, la facture en retard disparaît du filtre "Impayées". @@ -197,8 +191,67 @@ test.describe('Échéancier factures — Story 5.4', () => { expect(futureId).toBeGreaterThan(0); }); - // Story 21-2a (#246) — la colonne Total de l'échéancier affiche le TTC. - test('la colonne Total affiche le TTC (montant dû), pas le HT', async ({ page }) => { + // Story 25-4-b1 (#416) — une facture réglée en partie montre son reste dû, + // son statut « partiellement payée », et le dialogue se pré-remplit au reste + // dû — pas au TTC, qui conduirait à un trop-perçu refusé. + test('facture réglée en partie : reste dû, statut, dialogue pré-rempli', async ({ page }) => { + await login(page); + const contactName = uniq('EchPartiel'); + const contactId = await createContactViaApi(page, contactName); + const id = await createAndValidateInvoice( + page, + contactId, + daysFromToday(-10), + daysFromToday(20), + '100.00', + ); + + const ctx = await authedApiContext(page); + try { + // Un compte de liquidités (classe 10) actif et imputable — le numéro + // exact dépend du plan comptable : ne pas le figer. + const accounts = await ctx.get('/api/v1/accounts?includeArchived=false'); + expect(accounts.ok(), `comptes: ${accounts.status()}`).toBeTruthy(); + const cash = ( + (await accounts.json()) as { + id: number; + number: string; + accountType: string; + active: boolean; + postable: boolean; + }[] + ).find((a) => a.active && a.postable && a.accountType === 'Asset' && a.number.startsWith('10')); + expect(cash, 'un compte de liquidités imputable').toBeTruthy(); + const settled = await ctx.post(`/api/v1/invoices/${id}/settlements`, { + data: { + settlementType: 'internal_account', + accountId: cash!.id, + amount: '40.00', + settledOn: daysFromToday(0), + }, + }); + expect(settled.ok(), `règlement: ${settled.status()}`).toBeTruthy(); + } finally { + await disposeContextSafe(ctx); + } + + await page.goto('/invoices/due-dates'); + const row = page.locator('tbody tr', { hasText: contactName }); + await expect(row).toBeVisible({ timeout: 5000 }); + // 108.10 − 40.00 = 68.10 + await expect(row.getByTestId('due-dates-amount-due')).toHaveText(/68\.10/); + await expect( + row.getByText(/Partiellement payée|Partially paid|Parzialmente pagata|Teilweise bezahlt/i), + ).toBeVisible(); + + await row.getByRole('button', { name: /Régler|Settle|Pagare|Erfassen/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByLabel(/Montant|Amount|Importo|Betrag/i)).toHaveValue("68.10"); + }); + + // Story 21-2a (#246) — la colonne Total de l'échéancier affiche le TTC émis ; + // le reste dû a sa propre colonne depuis la Story 25-4-b1. + test('la colonne Total affiche le TTC, pas le HT', async ({ page }) => { await login(page); const contactName = uniq('EchTtc'); const contactId = await createContactViaApi(page, contactName); @@ -209,7 +262,11 @@ test.describe('Échéancier factures — Story 5.4', () => { const row = page.locator('tbody tr', { hasText: contactName }).first(); await expect(row).toBeVisible({ timeout: 5000 }); // Le TTC formaté suisse est affiché ; le HT nu ne l'est pas. - await expect(row.getByText('108.10')).toBeVisible(); + // ⚠️ Story 25-4-b1 : sans règlement, la colonne « Reste dû » affiche le + // même 108.10 — viser la cellule Total (5ᵉ colonne) et non le texte seul, + // qui désignerait désormais deux cellules. + await expect(row.locator('td').nth(4)).toHaveText(/108\.10/); + await expect(row.getByTestId('due-dates-amount-due')).toHaveText(/108\.10/); await expect(row.getByText(/^100\.00$/)).toHaveCount(0); });