From 11cf3c9cdd4a5c6f53de6c67c9283c6b93ac8357 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Mon, 28 Sep 2026 00:11:33 +0200 Subject: [PATCH 01/12] =?UTF-8?q?docs(25-4-c):=20story=20cr=C3=A9=C3=A9e?= =?UTF-8?q?=20=E2=80=94=20le=20r=C3=A9siduel=20au=20rapprochement=20(refs?= =?UTF-8?q?=20#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 202 ++++++++++++++++++ .../sprint-status.yaml | 2 +- 2 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md new file mode 100644 index 00000000..d1736807 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -0,0 +1,202 @@ +# Story 25.4-c : Le résiduel au rapprochement bancaire + +Status: ready-for-dev + +**Issue : [#420]** — ⛔ la PR porte `closes #420`, titre ET corps (§ *Issue Tracking Rule*). Voisine : +**[#476]** (arrondi de la QR contre garde de trop-perçu), dont l'inclusion est une question à Guy (Q1). + +**Mère : `25-4-propager-le-residuel.md`** (`split`) — source des faits. **Sœurs** : 25-4-a (mergée, #472 : +le reste dû juste), 25-4-b1 (mergée, #475 : formes jointes aux agrégats), 25-4-b2 (PR #479 : les +rappels). Indépendante de b2 : branche `story/25-4-c-residuel-au-rapprochement` tirée de `main` +(`b10109be`). + +## Story + +En tant que comptable, +je veux que le rapprochement bancaire reconnaisse le virement du **solde** d'une facture déjà +partiellement réglée, +afin de ne pas avoir à rapprocher à la main un paiement qui correspond exactement à ce que le client +devait encore. + +## Le défaut, vérifié dans le code — pire que ce que dit l'issue + +#420 annonce un **score nul**. C'est en amont que ça casse : la facture **n'est même pas candidate**. + +| Site | Grandeur | Effet sur 1 000.— réglée de 400.—, virement de 600.— | +|---|---|---| +| Filtre des candidats — `find_unpaid_invoices_for_window`, `crates/kesh-db/src/repositories/reconciliation.rs:93-130` | `HAVING total_ttc BETWEEN ? - ? AND ? + ?` (`:115`), TTC par **sous-requête corrélée** (`:107`, `INVOICE_TTC_SUBQUERY_SQL`) | 1 000 ∉ 600 ± 0.05 : **exclue**, jamais scorée | +| Score — `propose_matches`, `crates/kesh-reconciliation/src/matching.rs:68` | `amount_score(tx.amount, *total_ttc)`, binaire (`:113`) | 0 si elle passait le filtre | +| Re-score à l'acceptation — `accept_one_invoice`, `crates/kesh-api/src/routes/reconciliation.rs:1196` | `invoices::total_ttc(&mut **tx, invoice.id)` | score 0 → `RECONCILIATION_SCORE_TOO_LOW` (`:1226-1235`), **avant** la garde de trop-perçu | +| Montant affiché dans la proposition — `reconciliation.rs:594` | `cand.total_ttc` (commentaire `:591-593` « = TTC ») | l'écran montre 1 000, pas 600 (`ReconciliationProposals.svelte:285`) | + +Déjà justes : la garde de trop-perçu (`:1329-1344`, `amount_due`), le solde atteint (`:1444-1451`), +l'écriture (montant de la transaction, `:1387-1422`). + +⚠️ Le défaut est né de la 24-2 (règlements multiples) — *la correction déplace le défaut vers ce +qu'elle vient d'écrire*. Aucun test ne pose un candidat sur une facture partiellement réglée ; +`accept_settles_an_invoice_in_two_payments` (`crates/kesh-api/tests/reconciliation_e2e.rs:2792`) passe +par l'API directe, grâce au score de **référence**, et n'asserte ni candidat ni score. + +### Ce que l'inventaire a établi, et qui fixe les choix + +1. **Le moteur de score est pur** : le montant arrive dans le triplet `(Invoice, Option, + Decimal)` (`matching.rs:63`). Passer au reste dû, c'est changer **ce que l'appelant y met** — + `matching.rs` ne demande que nommage et doc (`:8-11`, `:58-60`). `propose_matches` a exactement deux + appelants : `reconciliation.rs:575` (propositions) et `:1208` (re-score). +2. **Le filtre doit passer à la forme JOINTE** (`amount_due_derived_joins()` + + `INVOICE_AMOUNT_DUE_DERIVED_SQL`, `invoice_settlements.rs:93-106`), celle que la 25-4-b1 a mise en + service pour les listes : la forme corrélée y est interdite (`invoice_settlements.rs:32-34`, « N+1 + déguisé »). Le **re-score** porte sur UNE facture : forme scalaire (`amount_due`), dans la + transaction. +3. **Les factures fournisseur ne sont pas concernées** : absentes du rapprochement ; elles se règlent + en une fois (`supplier_invoices.rs:554-593`). Les règles, éclatements et rapprochements manuels ne + portent aucune facture (`invoice_id: 0`, `reconciliation.rs:1965`, `:2374` ; `post_manual` + `:2709-2710`). +4. **L'avoir** : une facture créditée est `cancelled` (`credit_notes.rs:586`, dépôt) et sort du filtre + `status = 'validated'` ; le terme « avoir » du reste dû y vaut 0 en pratique — la forme jointe le + porte de toute façon. +5. **L'arrondi (#476)** : `line_total` est à 4 décimales (`invoices.rs:425-427`), la TVA seule + arrondie à 2 (`:165-176`) — le **reste dû peut avoir 4 décimales** (10.0050). La transaction bancaire + est à 2 (`bank_imports.sql:62`). Conséquences : + - le **score** ne peut jamais valoir 1 : 10.01 ≠ 10.005 après `normalize` (`matching.rs:113`) ; + - les **gardes de trop-perçu** comparent la valeur brute : `reconciliation.rs:1336`, + `invoice_settlements_write.rs:167` — le paiement exact de la QR (10.01) est refusé ; + - les **tests de solde** aussi : `reconciliation.rs:1451` et `invoice_settlements_write.rs:233` + (`due_after <= 0`) — un paiement de 10.00 laisse 0.0050, la facture reste « partiellement réglée » + pour un demi-centime, sans `paid_at`. + Le filtre, lui, tolère l'écart grâce aux ± 0.05. +6. **`LIMIT 50` sans `ORDER BY`** (`reconciliation.rs:116`, dépôt) : au-delà de 50 candidats, lesquels + sont gardés n'est pas déterminé. Préexistant, hors périmètre — à ne pas aggraver. + +## Acceptance Criteria + +### Volet 1 — le reste dû dans le rapprochement + +**AC 1** — `find_unpaid_invoices_for_window` filtre sur le **reste dû** par la forme **jointe** : les +tables dérivées d'`amount_due_derived_joins()` et `INVOICE_AMOUNT_DUE_DERIVED_SQL`, jamais réécrites à +la main, ni la forme corrélée. Le candidat porte le reste dû (le champ est renommé — `amount_due` — et +tous ses lecteurs suivent) ; son doc-comment le dit. + +**AC 2** — `propose_matches` reçoit le **reste dû** aux deux appels : propositions (`:575`) et re-score +à l'acceptation (`:1196-1208`, forme scalaire `invoice_settlements::amount_due` **dans la transaction**, +à la place d'`invoices::total_ttc`). `matching.rs` : noms et doc-comments disent « montant à régler », +plus « TTC ». + +**AC 3** — Le montant affiché dans la proposition (`invoice_amount`, `:594`) est le **reste dû** ; le +commentaire `:591-593` suit *(présentation : Q3)*. + +**AC 4** — Une facture **sans règlement** se comporte exactement comme avant : reste dû = TTC ; +candidats, scores et montant affiché inchangés. Les tests existants le tiennent +(`reconciliation_repository.rs:484-531`, `:807` ; `reconciliation_e2e.rs:819-822`), noms et messages +mis à jour là où ils disent « TTC ». + +### Volet 2 — l'arrondi au centime *(si Q1 l'inclut)* + +**AC 5** — Une seule grandeur, **le reste dû arrondi au centime** (`MidpointAwayFromZero`, la stratégie +de la QR — `generator.rs:38-39`), sert de montant à régler : filtre des candidats, score, garde de +trop-perçu du rapprochement (`:1336`) **et** du règlement manuel (`invoice_settlements_write.rs:167`), +tests de solde (`:1451` et `:233`). Un helper unique, à côté d'`amount_due`, la porte ; aucun site ne +réarrondit à sa façon. ⛔ Le reste dû **stocké/calculé** n'est pas modifié : seule la comparaison +arrondit. + +**AC 6** — Sur une facture dont le reste dû brut est 10.0050 : le virement de **10.01** est candidat, +score 1 sur le montant, s'accepte, et **solde** la facture (`paid_at` posé, audit `invoice.paid`) ; un +virement de **10.02** reste un trop-perçu refusé ; un règlement manuel de 10.01 solde aussi. + +### Volet 3 — tests, textes + +**AC 7** — Tests qui auraient échoué avant le patch, chacun sur une facture **partiellement réglée** à +TVA **non nulle** : +- dépôt : la facture 1 000 réglée 400 est **candidate** pour 600, et ne l'est plus pour 1 000 ; +- propositions (e2e API) : `amountScore == 1.0` et `invoiceAmount` = le reste ; +- acceptation (e2e API) : le virement du solde s'accepte et solde la facture — sans passer par le + score de référence (numéro de facture absent de la transaction) ; +- *(si Q1)* le cas 10.0050 de l'AC 6, au rapprochement et au règlement manuel ; +- Playwright : une facture réglée en partie apparaît dans les propositions avec son reste. + +**AC 8** — Manuel : `user-manual.tex` § rapprochement dit que la proposition porte sur **ce qui reste +à payer** et qu'un solde de facture partiellement réglée est reconnu. ⚠️ **Le paragraphe du score +(`:1362-1371`) est faux sur le code, indépendamment de cette story** : il annonce un score gradué +(« écart < 1 CHF = score moyen »), un critère « Date », une « référence QR Bill », un seuil de 80 % et +un auto-accept à 95 % — rien de tel n'existe *(portée : Q2)*. PDF régénéré, contrôlé aplati. +CHANGELOG `[0.12.1]` *Fixed*. + +## Tasks / Subtasks + +- [ ] **T1 — candidats** (AC 1, 4) : forme jointe, champ renommé, lecteurs. +- [ ] **T2 — score et re-score** (AC 2, 3) : les deux appelants, affichage, `matching.rs`. +- [ ] **T3 — arrondi** (AC 5, 6) *(selon Q1)* : helper, cinq sites. +- [ ] **T4 — tests et mutations** (AC 7). +- [ ] **T5 — textes** (AC 8). +- [ ] **T6 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- ⛔ **La forme corrélée dans le filtre des candidats** : c'est une liste (`LIMIT 50`). +- ⛔ **Réécrire la formule du reste dû** : `INVOICE_AMOUNT_DUE_DERIVED_SQL` et `amount_due` existent. +- ⛔ **Rendre le score gradué** : le binaire est une décision v0.1 assumée (`matching.rs:108-117`) ; cette + story change la grandeur comparée, pas la forme du score. +- ⛔ **Toucher l'écriture** : elle porte le montant de la transaction, c'est juste. +- ⚠️ **L'ordre des gardes à l'acceptation** : le score (`:1226`) précède le trop-perçu (`:1336`). Un + virement supérieur au reste doit continuer de sortir en `RECONCILIATION_OVERPAYMENT` s'il passe le + score, en `SCORE_TOO_LOW` sinon — ne pas l'inverser sans le dire. +- ⚠️ `get_proposals_invoice_candidate_overrides_rule` (`reconciliation_rules_e2e.rs:962`) dépend du seuil + 0,5 (`INVOICE_OVERRIDE_THRESHOLD`, `:556`) : un score de montant qui change change la préséance. + +### Où regarder + +| Fichier | Pourquoi | +|---|---| +| `crates/kesh-db/src/repositories/reconciliation.rs:55-130` | candidats, fenêtre, tolérance | +| `crates/kesh-reconciliation/src/matching.rs:1-145` | score, triplet | +| `crates/kesh-api/src/routes/reconciliation.rs:480-640, 1056-1600` | propositions, acceptation, gardes | +| `crates/kesh-db/src/repositories/invoice_settlements.rs:27-220` | formes jointe et scalaire | +| `crates/kesh-db/src/repositories/invoice_settlements_write.rs:160-260` | règlement manuel (Q1) | +| `frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte:285`, `reconciliation.types.ts:30` | affichage | +| `docs/manual/fr/user-manual.tex:1355-1420` | rapprochement | + +### Gardes-fous du dépôt + +- Aucune migration. Repositories `kesh-db` touchés ⇒ **gate complet même en cours de boucle de revue**. +- Modules : `kesh-db`, `kesh-reconciliation`, `kesh-api`, `frontend` — **quatre** (cinq avec + `kesh-i18n` si un libellé change). + +## Questions pour Guy + +**Q1 — inclure #476 (l'arrondi) ?** Le reste dû peut porter des demi-centimes ; comparé brut, il +empêche le score de valoir 1, fait refuser le paiement exact de la QR comme trop-perçu, et laisse une +facture « partiellement réglée » pour 0.005. C'est la même comparaison que cette story touche, aux +mêmes sites, plus le règlement manuel. **Recommandé : l'inclure** (volet 2) — sans lui, la 25-4-c +reconnaît le solde d'une facture à 2 décimales mais pas celui d'une facture dont le TTC en a 4. + +**Q2 — le manuel du rapprochement.** Le paragraphe du score est faux sur le code (score gradué, date, +référence QR, seuils 80/95 % — inexistants), et deux autres passages aussi : l'acceptation par lot +dite « atomique » alors qu'elle est en succès partiel, et le rapprochement manuel / l'éclatement dits +« par facture » alors qu'ils n'en portent aucune (`:1392`, `:1396-1420`). **Recommandé** : corriger +dans cette story le paragraphe du score (il décrit la grandeur que la story change) ; ouvrir une issue +pour les deux autres. + +**Q3 — le montant affiché.** Proposé : le **reste dû** seul (« 600.00 ») ; sur une facture réglée en +partie, ajouter « reste dû sur 1 000.00 » pour que le comptable reconnaisse la facture ? + +## Dev Agent Record + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List + +## Change Log + +- **2026-09-28** — Créée. Inventaire par exploration, citations clés vérifiées ; le défaut est + l'**exclusion** par le filtre SQL, pas seulement un score nul ; un cinquième site d'arrondi trouvé + (`invoice_settlements_write.rs:233`) ; trois questions à Guy. + +[#416]: https://github.com/guycorbaz/kesh/issues/416 +[#420]: https://github.com/guycorbaz/kesh/issues/420 +[#476]: https://github.com/guycorbaz/kesh/issues/476 diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 3d6be1dd..8f53e10d 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -359,7 +359,7 @@ development_status: 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: done # 2026-09-27 revue CLOSE en 2 passes (1H/1M/1L -> 0 >LOW), gates verts (2510, 835, E2E conforme). PR a ouvrir : closes #416. # 2026-09-27 CREEE : frais non comptabilises => QR = reste du SANS frais (regle existante, admin-manual:1200) ; PDF de rappel = variante titree ; Q1 ligne "Frais de rappel", Q2 deja regle masque a zero, M5 papier hors perimetre (#477) ; validation CLOSE en 5 passes (1H/3M -> 0+1M -> 5M -> 1M -> 0 >LOW), pas de decoupage (Guy). # [#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-c-residuel-au-rapprochement: ready-for-dev # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. 25-4-d-solder-le-reste: backlog # [#384] ramenee de la 25-6 le 2026-09-27 (Guy) — perte sur debiteur, escompte, frais : clore une facture partiellement reglee ; la part TVA reduit la TVA due. 25-4-propager-le-residuel: split # 2026-09-26 DECOUPEE (six modules > 5) en 25-4-a / 25-4-b / 25-4-c, puis 25-4-d (#384, 2026-09-27) ; #455 et #456 rattachees. # [#416] [#420] — la 24-2 a introduit le reglement PARTIEL ; le residuel n'est propage ni a la balance agee ni a l'echeancier, et **le score de rapprochement compare au TTC et non au residuel** : le virement du solde d'une facture partiellement reglee **score 0**. 25-5-a-export-souverainete: done # 2026-09-26 **MERGEE (PR #452, #386 fermee) SANS REVUE DE CODE** ; revue a posteriori CLOSE en 3 passes sur `story/25-5-a-revue-code` (4 HIGH P1, 1 HIGH orchestrateur P2 — colonnes manquantes, garde de colonnes —, P3 ciblee test seul) ; gates 2466/2466, E2E 221/8/19 sans regression ; reports #465, #466. # 2026-09-23 **SPECIFIEE et VALIDEE en 1 passe** (0 CRITICAL, 4 HIGH, 3 MEDIUM, 3 LOW, tous corriges). [#386] — issue du DECOUPAGE de la 25-5 (arbitrage de Guy) : #385 (Balance vs Bilan) et #386 n'ont en commun que le mot « etat », et touchent des modules disjoints. ⛔ **Trois faits qui corrigent l'issue** : 21 tables absentes et non 10 ; le trou s'est CREUSE depuis son ecriture (`invoice_settlements`, Epic 24) ; et SIX repositories sur huit n'ont aucune lecture exhaustive — le vrai cout, absent de l'issue. Arbitrage de Guy : l'export CSV ne porte que les donnees COMPTABLES (11 dedans), les backups portent tout ; 10 exclusions AVEC motif ecrit, dont 3 tables de SECRETS. Les justificatifs (fichiers) partent en story separee sur la SAUVEGARDE. ⛔ La vraie livraison est la GARDE D'EXHAUSTIVITE (patron D4-ter + P7), non les 11 exports : trois epics ont rouvert le trou en silence. ⚠️ Pieges releves par la validation : le nombre 19 code en dur a SEPT endroits dont deux `debug_assert_eq!` qui PANIQUENT ; `audit_log` n'a AUCUN dump exhaustif (ses fonctions sont bornees, la route rejette au-dela de 10 000 lignes) ; `csv_sanitize` existe et n'est pas employee par cet export ; deux sites de manuel DEJA faux (un dossier `reports/` invente, un « 38 » pour 39). From 1db610a5636be519c4b96fcc1c82dcae2905f746 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Tue, 29 Sep 2026 08:36:04 +0200 Subject: [PATCH 02/12] =?UTF-8?q?docs(25-4-c):=20arbitrages=20Q1=E2=80=93Q?= =?UTF-8?q?3=20retenus=20par=20d=C3=A9faut,=20rebase=20sur=20la=20b2=20(re?= =?UTF-8?q?fs=20#420,=20refs=20#476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index d1736807..da3ec047 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -3,12 +3,12 @@ Status: ready-for-dev **Issue : [#420]** — ⛔ la PR porte `closes #420`, titre ET corps (§ *Issue Tracking Rule*). Voisine : -**[#476]** (arrondi de la QR contre garde de trop-perçu), dont l'inclusion est une question à Guy (Q1). +**[#476]** (arrondi de la QR contre garde de trop-perçu), **incluse** (Q1) — la PR porte aussi `closes #476`. **Mère : `25-4-propager-le-residuel.md`** (`split`) — source des faits. **Sœurs** : 25-4-a (mergée, #472 : -le reste dû juste), 25-4-b1 (mergée, #475 : formes jointes aux agrégats), 25-4-b2 (PR #479 : les -rappels). Indépendante de b2 : branche `story/25-4-c-residuel-au-rapprochement` tirée de `main` -(`b10109be`). +le reste dû juste), 25-4-b1 (mergée, #475 : formes jointes aux agrégats), 25-4-b2 (mergée, #479 : les +rappels). Branche `story/25-4-c-residuel-au-rapprochement`, rebasée sur `main` (`9098b2be`) ; +la b2 ne touche aucun fichier du rapprochement. ## Story @@ -83,14 +83,16 @@ tous ses lecteurs suivent) ; son doc-comment le dit. plus « TTC ». **AC 3** — Le montant affiché dans la proposition (`invoice_amount`, `:594`) est le **reste dû** ; le -commentaire `:591-593` suit *(présentation : Q3)*. +commentaire `:591-593` suit. Présentation *(Q3)* : le reste dû ; sur une facture déjà réglée en +partie, suivi de « reste dû sur » (libellé dans les 4 locales), pour que le comptable +reconnaisse la facture. Sans règlement, rien n'est ajouté. **AC 4** — Une facture **sans règlement** se comporte exactement comme avant : reste dû = TTC ; candidats, scores et montant affiché inchangés. Les tests existants le tiennent (`reconciliation_repository.rs:484-531`, `:807` ; `reconciliation_e2e.rs:819-822`), noms et messages mis à jour là où ils disent « TTC ». -### Volet 2 — l'arrondi au centime *(si Q1 l'inclut)* +### Volet 2 — l'arrondi au centime *(Q1 : inclus)* **AC 5** — Une seule grandeur, **le reste dû arrondi au centime** (`MidpointAwayFromZero`, la stratégie de la QR — `generator.rs:38-39`), sert de montant à régler : filtre des candidats, score, garde de @@ -111,21 +113,23 @@ TVA **non nulle** : - propositions (e2e API) : `amountScore == 1.0` et `invoiceAmount` = le reste ; - acceptation (e2e API) : le virement du solde s'accepte et solde la facture — sans passer par le score de référence (numéro de facture absent de la transaction) ; -- *(si Q1)* le cas 10.0050 de l'AC 6, au rapprochement et au règlement manuel ; +- le cas 10.0050 de l'AC 6, au rapprochement et au règlement manuel ; - Playwright : une facture réglée en partie apparaît dans les propositions avec son reste. **AC 8** — Manuel : `user-manual.tex` § rapprochement dit que la proposition porte sur **ce qui reste à payer** et qu'un solde de facture partiellement réglée est reconnu. ⚠️ **Le paragraphe du score (`:1362-1371`) est faux sur le code, indépendamment de cette story** : il annonce un score gradué (« écart < 1 CHF = score moyen »), un critère « Date », une « référence QR Bill », un seuil de 80 % et -un auto-accept à 95 % — rien de tel n'existe *(portée : Q2)*. PDF régénéré, contrôlé aplati. +un auto-accept à 95 % — rien de tel n'existe. Il est **réécrit dans cette story** sur le code réel *(Q2)* ; les deux autres +passages faux (lot dit « atomique », rapprochement manuel / éclatement dits « par facture », +`:1392`, `:1396-1420`) font l'objet d'une **issue séparée**, ouverte à l'implémentation. PDF régénéré, contrôlé aplati. CHANGELOG `[0.12.1]` *Fixed*. ## Tasks / Subtasks - [ ] **T1 — candidats** (AC 1, 4) : forme jointe, champ renommé, lecteurs. - [ ] **T2 — score et re-score** (AC 2, 3) : les deux appelants, affichage, `matching.rs`. -- [ ] **T3 — arrondi** (AC 5, 6) *(selon Q1)* : helper, cinq sites. +- [ ] **T3 — arrondi** (AC 5, 6) : helper, cinq sites. - [ ] **T4 — tests et mutations** (AC 7). - [ ] **T5 — textes** (AC 8). - [ ] **T6 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. @@ -160,26 +164,30 @@ CHANGELOG `[0.12.1]` *Fixed*. ### Gardes-fous du dépôt - Aucune migration. Repositories `kesh-db` touchés ⇒ **gate complet même en cours de boucle de revue**. -- Modules : `kesh-db`, `kesh-reconciliation`, `kesh-api`, `frontend` — **quatre** (cinq avec - `kesh-i18n` si un libellé change). +- Modules : `kesh-db`, `kesh-reconciliation`, `kesh-api`, `frontend`, `kesh-i18n` (le libellé « reste + dû sur » de Q3) — **cinq**, sous le seuil de découpage (« plus de 5 »). -## Questions pour Guy +## Arbitrages + +*Retenus le 2026-09-29 : Guy a demandé de continuer sans trancher ; ce sont les recommandations de +la création, appliquées par défaut et **révisables par lui**. La validation n'a pas à les contester, +seulement leur mise en œuvre.* **Q1 — inclure #476 (l'arrondi) ?** Le reste dû peut porter des demi-centimes ; comparé brut, il empêche le score de valoir 1, fait refuser le paiement exact de la QR comme trop-perçu, et laisse une facture « partiellement réglée » pour 0.005. C'est la même comparaison que cette story touche, aux -mêmes sites, plus le règlement manuel. **Recommandé : l'inclure** (volet 2) — sans lui, la 25-4-c +mêmes sites, plus le règlement manuel. **Retenu : inclus** (volet 2) — sans lui, la 25-4-c reconnaît le solde d'une facture à 2 décimales mais pas celui d'une facture dont le TTC en a 4. **Q2 — le manuel du rapprochement.** Le paragraphe du score est faux sur le code (score gradué, date, référence QR, seuils 80/95 % — inexistants), et deux autres passages aussi : l'acceptation par lot dite « atomique » alors qu'elle est en succès partiel, et le rapprochement manuel / l'éclatement dits -« par facture » alors qu'ils n'en portent aucune (`:1392`, `:1396-1420`). **Recommandé** : corriger +« par facture » alors qu'ils n'en portent aucune (`:1392`, `:1396-1420`). **Retenu** : corriger dans cette story le paragraphe du score (il décrit la grandeur que la story change) ; ouvrir une issue pour les deux autres. -**Q3 — le montant affiché.** Proposé : le **reste dû** seul (« 600.00 ») ; sur une facture réglée en -partie, ajouter « reste dû sur 1 000.00 » pour que le comptable reconnaisse la facture ? +**Q3 — le montant affiché.** **Retenu** : le **reste dû** (« 600.00 ») ; sur une facture réglée en +partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse la facture. ## Dev Agent Record @@ -196,6 +204,8 @@ partie, ajouter « reste dû sur 1 000.00 » pour que le comptable reconnaisse l - **2026-09-28** — Créée. Inventaire par exploration, citations clés vérifiées ; le défaut est l'**exclusion** par le filtre SQL, pas seulement un score nul ; un cinquième site d'arrondi trouvé (`invoice_settlements_write.rs:233`) ; trois questions à Guy. +- **2026-09-29** — Rebasée sur `main` après le merge de la b2 (#479). Q1–Q3 retenues selon les + recommandations (Guy : « continue ») ; #476 incluse. [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 From 36f9ce31c318411374f0aed5303e7365b9e18fef Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Tue, 29 Sep 2026 08:47:19 +0200 Subject: [PATCH 03/12] =?UTF-8?q?docs(25-4-c):=20validation=20P1=20?= =?UTF-8?q?=E2=80=94=203=20HIGH,=202=20MED,=201=20LOW=20rem=C3=A9di=C3=A9s?= =?UTF-8?q?=20(refs=20#420,=20refs=20#476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 70 ++++++++++++++++--- .../25-4-c-validate-prompt-p1.md | 63 +++++++++++++++++ 2 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c-validate-prompt-p1.md diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index da3ec047..d29546bc 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -64,8 +64,25 @@ par l'API directe, grâce au score de **référence**, et n'asserte ni candidat - les **tests de solde** aussi : `reconciliation.rs:1451` et `invoice_settlements_write.rs:233` (`due_after <= 0`) — un paiement de 10.00 laisse 0.0050, la facture reste « partiellement réglée » pour un demi-centime, sans `paid_at`. + - l'**annulation d'un règlement** aussi : `invoice_settlements_write.rs:464-465` + (`due_after > Decimal::ZERO` rouvre la facture) — un résidu de 0.0040 la rouvrirait ; + - le **dialogue de règlement** du frontend : `SettleInvoiceDialog.svelte:111` compare la saisie à + `Number(amountDue)` **brut**, alors que le champ est pré-rempli arrondi (`:75`, `toFixed(2)`) — + le 10.01 proposé par le dialogue lui-même y est refusé comme dépassant le reste (10.005). Le filtre, lui, tolère l'écart grâce aux ± 0.05. -6. **`LIMIT 50` sans `ORDER BY`** (`reconciliation.rs:116`, dépôt) : au-delà de 50 candidats, lesquels + - **L'arrondi existe déjà, deux fois** : `Money::round_to_centimes()` + (`crates/kesh-core/src/types/money.rs:61-69`, `MidpointAwayFromZero`) et `reminder_amount_due` + (`crates/kesh-api/src/routes/invoice_pdf_service.rs:112-118`, la 25-4-b2 : arrondi du reste dû + pour la QR du rappel, refus si ≤ 0 ; son test `:894` porte déjà le cas 10.0050 → 10.01). +6. **Aucun verrou sur la facture à l'acceptation** : `accept_one_invoice` lit la facture par + `find_invoice_by_id_for_company` (`crates/kesh-db/src/repositories/reconciliation.rs:217-230`, + `SELECT` simple) puis le reste dû (`:1329`) sans `FOR UPDATE`, alors que le règlement manuel + (`invoice_settlements_write.rs:63-68`), son annulation (`:380`) et l'annulation d'un rapprochement + (`reconciliation_cancel.rs:296`) verrouillent `invoices`. Deux acceptations concurrentes, ou une + acceptation contre un règlement manuel, peuvent lire le même reste et passer toutes deux la garde + de trop-perçu. Préexistant — mais cette story fait d'une facture partiellement réglée un candidat + **ordinaire**, là où elle était exclue : la course devient atteignable. D'où l'AC 5-bis. +7. **`LIMIT 50` sans `ORDER BY`** (`reconciliation.rs:116`, dépôt) : au-delà de 50 candidats, lesquels sont gardés n'est pas déterminé. Préexistant, hors périmètre — à ne pas aggraver. ## Acceptance Criteria @@ -97,13 +114,29 @@ mis à jour là où ils disent « TTC ». **AC 5** — Une seule grandeur, **le reste dû arrondi au centime** (`MidpointAwayFromZero`, la stratégie de la QR — `generator.rs:38-39`), sert de montant à régler : filtre des candidats, score, garde de trop-perçu du rapprochement (`:1336`) **et** du règlement manuel (`invoice_settlements_write.rs:167`), -tests de solde (`:1451` et `:233`). Un helper unique, à côté d'`amount_due`, la porte ; aucun site ne +tests de solde (`:1451` et `:233`), réouverture à l'annulation d'un règlement +(`invoice_settlements_write.rs:464-465`), et contrôle de saisie du dialogue de règlement +(`SettleInvoiceDialog.svelte:111`, comparé au reste arrondi et non à `Number(amountDue)`). Côté Rust, +un helper unique, à côté d'`amount_due`, la porte ; il **s'appuie sur `Money::round_to_centimes()`** +(`kesh-core`) sans recopier la stratégie, et **`reminder_amount_due` (b2) l'appelle** en gardant son +refus du reste nul — il ne reste qu'une définition du « reste dû au centime ». Aucun site ne réarrondit à sa façon. ⛔ Le reste dû **stocké/calculé** n'est pas modifié : seule la comparaison arrondit. **AC 6** — Sur une facture dont le reste dû brut est 10.0050 : le virement de **10.01** est candidat, score 1 sur le montant, s'accepte, et **solde** la facture (`paid_at` posé, audit `invoice.paid`) ; un -virement de **10.02** reste un trop-perçu refusé ; un règlement manuel de 10.01 solde aussi. +virement de **10.02** reste un trop-perçu refusé ; un règlement manuel de 10.01 solde aussi, **y +compris depuis le dialogue** (le 10.01 pré-rempli est accepté). L'annulation d'un règlement qui laisse +un reste brut de 0.0040 ne rouvre pas la facture. + +**AC 5-bis** — `accept_one_invoice` **verrouille la facture** (`SELECT … FROM invoices WHERE id = ? AND +company_id = ? FOR UPDATE`, le patron de `invoice_settlements_write.rs:63-68`) avant de lire le reste dû +pour le re-score et la garde de trop-perçu. ⚠️ **Ordre des verrous** : relever l'ordre que prennent +déjà le règlement manuel et l'annulation d'un rapprochement (facture, transaction bancaire, exercice) +et s'y conformer, faute de quoi le verrou ajouté ouvre un interblocage ; le dire dans le Dev Agent +Record. Test : deux acceptations concurrentes du même solde sur une facture partiellement réglée → une +acceptée, l'autre en `RECONCILIATION_OVERPAYMENT` (ou refusée par la garde de statut), jamais deux +règlements. ### Volet 3 — tests, textes @@ -113,25 +146,30 @@ TVA **non nulle** : - propositions (e2e API) : `amountScore == 1.0` et `invoiceAmount` = le reste ; - acceptation (e2e API) : le virement du solde s'accepte et solde la facture — sans passer par le score de référence (numéro de facture absent de la transaction) ; -- le cas 10.0050 de l'AC 6, au rapprochement et au règlement manuel ; +- le cas 10.0050 de l'AC 6, au rapprochement, au règlement manuel (API et Vitest du dialogue) et à + l'annulation ; +- la concurrence de l'AC 5-bis ; - Playwright : une facture réglée en partie apparaît dans les propositions avec son reste. **AC 8** — Manuel : `user-manual.tex` § rapprochement dit que la proposition porte sur **ce qui reste à payer** et qu'un solde de facture partiellement réglée est reconnu. ⚠️ **Le paragraphe du score -(`:1362-1371`) est faux sur le code, indépendamment de cette story** : il annonce un score gradué +(`:1384-1390`) est faux sur le code, indépendamment de cette story** : il annonce un score gradué (« écart < 1 CHF = score moyen »), un critère « Date », une « référence QR Bill », un seuil de 80 % et un auto-accept à 95 % — rien de tel n'existe. Il est **réécrit dans cette story** sur le code réel *(Q2)* ; les deux autres passages faux (lot dit « atomique », rapprochement manuel / éclatement dits « par facture », -`:1392`, `:1396-1420`) font l'objet d'une **issue séparée**, ouverte à l'implémentation. PDF régénéré, contrôlé aplati. +`:1411`, sous-section à partir de `:1421`) font l'objet d'une **issue séparée**, ouverte à l'implémentation. PDF régénéré, contrôlé aplati. CHANGELOG `[0.12.1]` *Fixed*. ## Tasks / Subtasks - [ ] **T1 — candidats** (AC 1, 4) : forme jointe, champ renommé, lecteurs. - [ ] **T2 — score et re-score** (AC 2, 3) : les deux appelants, affichage, `matching.rs`. -- [ ] **T3 — arrondi** (AC 5, 6) : helper, cinq sites. +- [ ] **T3 — arrondi** (AC 5, 6) : helper sur `Money::round_to_centimes()`, `reminder_amount_due` + rebranché, sept sites (dont le dialogue frontend). +- [ ] **T3-bis — verrou** (AC 5-bis) : `FOR UPDATE` dans `accept_one_invoice`, ordre des verrous. - [ ] **T4 — tests et mutations** (AC 7). -- [ ] **T5 — textes** (AC 8). +- [ ] **T5 — textes** (AC 8) ; si un champ TTC s'ajoute à la réponse des propositions (Q3), vérifier + `docs/api-external.md` (aujourd'hui muet sur ses champs). - [ ] **T6 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. ## Dev Notes @@ -157,9 +195,11 @@ CHANGELOG `[0.12.1]` *Fixed*. | `crates/kesh-reconciliation/src/matching.rs:1-145` | score, triplet | | `crates/kesh-api/src/routes/reconciliation.rs:480-640, 1056-1600` | propositions, acceptation, gardes | | `crates/kesh-db/src/repositories/invoice_settlements.rs:27-220` | formes jointe et scalaire | -| `crates/kesh-db/src/repositories/invoice_settlements_write.rs:160-260` | règlement manuel (Q1) | +| `crates/kesh-db/src/repositories/invoice_settlements_write.rs:60-70, 160-260, 460-470` | verrou, règlement manuel, annulation | +| `crates/kesh-core/src/types/money.rs:61-69`, `crates/kesh-api/src/routes/invoice_pdf_service.rs:112-118` | arrondis existants | +| `frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte:70-115` | contrôle de saisie | | `frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte:285`, `reconciliation.types.ts:30` | affichage | -| `docs/manual/fr/user-manual.tex:1355-1420` | rapprochement | +| `docs/manual/fr/user-manual.tex:1375-1440` | rapprochement | ### Gardes-fous du dépôt @@ -182,7 +222,7 @@ reconnaît le solde d'une facture à 2 décimales mais pas celui d'une facture d **Q2 — le manuel du rapprochement.** Le paragraphe du score est faux sur le code (score gradué, date, référence QR, seuils 80/95 % — inexistants), et deux autres passages aussi : l'acceptation par lot dite « atomique » alors qu'elle est en succès partiel, et le rapprochement manuel / l'éclatement dits -« par facture » alors qu'ils n'en portent aucune (`:1392`, `:1396-1420`). **Retenu** : corriger +« par facture » alors qu'ils n'en portent aucune (`:1411`, `:1421` et suivantes). **Retenu** : corriger dans cette story le paragraphe du score (il décrit la grandeur que la story change) ; ouvrir une issue pour les deux autres. @@ -206,6 +246,14 @@ partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse (`invoice_settlements_write.rs:233`) ; trois questions à Guy. - **2026-09-29** — Rebasée sur `main` après le merge de la b2 (#479). Q1–Q3 retenues selon les recommandations (Guy : « continue ») ; #476 incluse. +- **2026-09-29** — Validation P1 (Sonnet, prompt `25-4-c-validate-prompt-p1.md`) : **3 HIGH, 2 MEDIUM, + 1 LOW**, tous vérifiés sur le code avant patch. F1 HIGH : l'arrondi existait deux fois + (`Money::round_to_centimes`, `reminder_amount_due`) → le helper s'appuie sur le premier, le second + l'appelle. F2 HIGH : `SettleInvoiceDialog.svelte:111` refusait le 10.01 qu'il pré-remplit → ajouté à + l'AC 5. F3 MEDIUM : réouverture à l'annulation (`invoice_settlements_write.rs:464`) → ajoutée. + F4 MEDIUM : lignes du manuel décalées de 20 à 30 → corrigées. F5 HIGH : aucun verrou facture dans + `accept_one_invoice`, course rendue atteignable par la story → AC 5-bis. F6 LOW : `api-external.md` + → T5. Axes non exercés par la lentille : faisabilité du Playwright et tuabilité des mutations. [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 diff --git a/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p1.md new file mode 100644 index 00000000..883a4bc7 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p1.md @@ -0,0 +1,63 @@ +# Prompt — validation P1, Story 25-4-c (le résiduel au rapprochement) + +*Versionné le 2026-09-29. **Une lentille** (Sonnet), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c-residuel-au-rapprochement` (sur `main`, b2 +mergée). Fiche à valider : `_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md`. +Mère : `25-4-propager-le-residuel.md`. Sœurs : `25-4-a-residuel-juste.md`, +`25-4-b1-residuel-aux-agregats.md`, `25-4-b2-residuel-aux-rappels.md`. Issues : `gh issue view 420`, +`gh issue view 476`. Contexte : `24-2-encaissement-client.md`, les stories de l'Epic 8 sur le +rapprochement (`ls _bmad-output/implementation-artifacts/8-*`). Règles : `CLAUDE.md` (notamment le +§ *Pattern batch*). Checklist : `.claude/skills/bmad-create-story/checklist.md`. + +Les arbitrages Q1–Q3 de la fiche sont **retenus** : ne pas les contester, en contester la mise en œuvre. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme. +2. **Inventaire des sites qui comparent un montant bancaire à une facture client** — pars du + symptôme, pas de la liste de la fiche : + `grep -rn "total_ttc\|amount_due\|invoice_amount\|invoiceAmount\|amount_score\|OVERPAYMENT" crates/ frontend/src`. + Pour chaque site : couvert par la fiche, légitimement au TTC, ou oublié (au moins MEDIUM). + Existe-t-il d'autres chemins qui rapprochent ou règlent une facture client (import CAMT avec + référence QR, accept_batch, règlement manuel, page de la facture, tableau de bord) ? +3. **La forme jointe (AC 1)** : `amount_due_derived_joins()` et `INVOICE_AMOUNT_DUE_DERIVED_SQL` + s'insèrent-ils réellement dans la requête de `find_unpaid_invoices_for_window` (GROUP BY, HAVING, + paramètres liés, `company_id` des tables dérivées) ? Le scoping multi-tenant reste-t-il prouvé ? +4. **Le re-score (AC 2)** : `amount_due` scalaire dans la transaction — verrou, isolation, ordre des + gardes (score avant trop-perçu). Une course entre deux acceptations concurrentes sur la même + facture change-t-elle de comportement ? +5. **L'arrondi (AC 5, 6)** : les cinq sites sont-ils les seuls ? Cherche toute comparaison + `<= 0`, `> amount_due`, `== Decimal::ZERO` sur un reste dû (`grep -rn "due_after\|amount_due" crates/`). + La stratégie `MidpointAwayFromZero` est-elle cohérente avec la QR émise par la b2 (le rappel) et + par la facture ? Un reste de 0.004 : arrondi à 0.00, la facture est-elle soldée — et qui pose + `paid_at`, l'audit `invoice.paid` ? Le reste **affiché** (AC 3) est-il arrondi aussi ? +6. **L'affichage (AC 3, Q3)** : le libellé « reste dû sur » — clé i18n, 4 locales, + `lint-i18n-ownership`, formatage suisse des montants ; le type frontend doit-il porter le TTC en + plus du reste (champ API nouveau → documentation `api-external` s'il y en a) ? +7. **Les tests (AC 7)** : chacun est-il faisable et prouve-t-il ce qu'il annonce ? Le cas 10.0050 : + peut-on réellement fabriquer une facture dont le reste dû brut a 4 décimales (lignes, TVA) ? + L'E2E Playwright : peut-on monter un règlement partiel puis un import bancaire ? Mutations tuables ? +8. **Le manuel (AC 8)** : 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/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`). + Le paragraphe du score est-il bien faux sur le code, point par point ? D'autres textes (manuels + de/en/it, admin-manual, README, website, CHANGELOG) décrivent-ils le rapprochement sur le TTC ? +9. **Le périmètre** : cinq modules annoncés — recompte-les depuis les tâches. Quelque chose de la + 25-4-d (solder le reste) appartient-il ici, ou l'inverse ? + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact, **preuve** (commande et + résultat, code lu), correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un rapport sans elle + ne compte pas. + +## 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`. From 4ba1d2937d54665c3990cdf1a0ac94e0d7eedd5f Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Tue, 29 Sep 2026 08:53:03 +0200 Subject: [PATCH 04/12] =?UTF-8?q?docs(25-4-c):=20validation=20P2=20?= =?UTF-8?q?=E2=80=94=200=20finding=20sans=20preuve,=202=20MED=20repris=20p?= =?UTF-8?q?ar=20l'orchestrateur=20(refs=20#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 38 +++++++++++-- .../25-4-c-validate-prompt-p2.md | 56 +++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c-validate-prompt-p2.md diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index d29546bc..4f2f6316 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -65,7 +65,11 @@ par l'API directe, grâce au score de **référence**, et n'asserte ni candidat (`due_after <= 0`) — un paiement de 10.00 laisse 0.0050, la facture reste « partiellement réglée » pour un demi-centime, sans `paid_at`. - l'**annulation d'un règlement** aussi : `invoice_settlements_write.rs:464-465` - (`due_after > Decimal::ZERO` rouvre la facture) — un résidu de 0.0040 la rouvrirait ; + (`due_after > Decimal::ZERO` rouvre la facture). ⚠️ Branche **défensive** : l'application ne peut + pas y produire un résidu non nul inférieur au centime — il faudrait qu'avant l'annulation la + facture ait été **trop** payée, ce que la garde de trop-perçu refuse (le commentaire `:458-461` + le dit). Elle suit le helper par cohérence, et se teste par une ligne de règlement **insérée + directement en base** ; - le **dialogue de règlement** du frontend : `SettleInvoiceDialog.svelte:111` compare la saisie à `Number(amountDue)` **brut**, alors que le champ est pré-rempli arrondi (`:75`, `toFixed(2)`) — le 10.01 proposé par le dialogue lui-même y est refusé comme dépassant le reste (10.005). @@ -127,14 +131,28 @@ arrondit. score 1 sur le montant, s'accepte, et **solde** la facture (`paid_at` posé, audit `invoice.paid`) ; un virement de **10.02** reste un trop-perçu refusé ; un règlement manuel de 10.01 solde aussi, **y compris depuis le dialogue** (le 10.01 pré-rempli est accepté). L'annulation d'un règlement qui laisse -un reste brut de 0.0040 ne rouvre pas la facture. +un reste brut de 0.0040 ne rouvre pas la facture — test de **dépôt**, règlements insérés directement +en base (état inatteignable par l'application, cf. inventaire § 5), et le test le dit. **AC 5-bis** — `accept_one_invoice` **verrouille la facture** (`SELECT … FROM invoices WHERE id = ? AND company_id = ? FOR UPDATE`, le patron de `invoice_settlements_write.rs:63-68`) avant de lire le reste dû -pour le re-score et la garde de trop-perçu. ⚠️ **Ordre des verrous** : relever l'ordre que prennent -déjà le règlement manuel et l'annulation d'un rapprochement (facture, transaction bancaire, exercice) -et s'y conformer, faute de quoi le verrou ajouté ouvre un interblocage ; le dire dans le Dev Agent -Record. Test : deux acceptations concurrentes du même solde sur une facture partiellement réglée → une +pour le re-score et la garde de trop-perçu : au **chargement** de la facture (étape 5, +`reconciliation.rs:1104-1106`), donc avant la garde de statut, le re-score, la garde de trop-perçu et +le verrou d'exercice (`fiscal_years::find_open_covering_date`, `:1350`, `FOR UPDATE`). +**Ordre des verrous, relevé dans le code** : + +| Chemin | Ordre | +|---|---| +| `accept_one_invoice` (après patch) | `GET_LOCK` du compte bancaire (`with_account_lock`, tout le lot) → transaction bancaire lue **sans** verrou → **facture** → exercice → écritures, puis `UPDATE bank_transactions` | +| `settle_invoice` (`invoice_settlements_write.rs`) | **facture** (`:68`) → compte bancaire (`:116`) → compte (`:151`) → exercice (`:174`) | +| `cancel_reconciliation` (`reconciliation_cancel.rs`) | transaction bancaire (`:284`) → ligne de règlement (`:293`) → **facture** (`:296`) → écriture/exercice (`:309`) | + +Facture avant exercice partout : conforme. L'annulation, elle, verrouille la transaction bancaire et +la ligne de règlement **avant** la facture ; une acceptation et une annulation concurrentes **sur la même +facture** (deux transactions bancaires distinctes) peuvent donc s'interbloquer. InnoDB le détecte et +annule **toute** la transaction perdante (erreur 1213) — pas seulement le point de sauvegarde de la +proposition : le lot entier échoue en `DATABASE_ERROR` et se rejoue. Risque accepté, préexistant en +nature (le verrou d'exercice l'ouvre déjà) ; le Dev Agent Record le consigne, sans l'aggraver. Test : deux acceptations concurrentes du même solde sur une facture partiellement réglée → une acceptée, l'autre en `RECONCILIATION_OVERPAYMENT` (ou refusée par la garde de statut), jamais deux règlements. @@ -254,6 +272,14 @@ partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse F4 MEDIUM : lignes du manuel décalées de 20 à 30 → corrigées. F5 HIGH : aucun verrou facture dans `accept_one_invoice`, course rendue atteignable par la story → AC 5-bis. F6 LOW : `api-external.md` → T5. Axes non exercés par la lentille : faisabilité du Playwright et tuabilité des mutations. +- **2026-09-29** — Validation P2 (Haiku, prompt `25-4-c-validate-prompt-p2.md`) : **0 finding, rendu + sans aucune preuve** ; déclaré « constructible » le cas d'annulation à 0.0040 et « cohérent » l'ordre + des verrous sans l'avoir établi. Repris par l'orchestrateur : **2 MEDIUM**. (a) Le cas d'annulation + de l'AC 6 est **inatteignable** par l'application (il suppose un trop-perçu préalable) → test de + dépôt par insertion directe, dit comme tel. (b) L'AC 5-bis laissait l'ordre des verrous au dev → relevé + dans le code (`GET_LOCK` / facture / exercice ; annulation : transaction bancaire / règlement / + facture), placement du verrou fixé à l'étape 5, interblocage acceptation-annulation écrit comme + risque accepté. [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 diff --git a/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p2.md new file mode 100644 index 00000000..b63a47a8 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p2.md @@ -0,0 +1,56 @@ +# Prompt — validation P2, Story 25-4-c (le résiduel au rapprochement) + +*Versionné le 2026-09-29. **Une lentille** (Haiku), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c-residuel-au-rapprochement`. Fiche à valider : +`_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` — **lis le fichier dans +son état actuel**, ne raisonne pas sur un diff. Ce que la passe 1 a changé : `git show 36f9ce31 -- +_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` (à lire comme contexte ; +les numéros de ligne font foi dans le fichier courant, pas dans le diff). Règles : `CLAUDE.md`. + +Les arbitrages Q1–Q3 sont **retenus** : ne pas les contester, en contester la mise en œuvre. + +## Axes — tous obligatoires + +1. **La remédiation de la passe 1** — c'est là que la sévérité se déplace : + - AC 5 : le helper « sur `Money::round_to_centimes()` » est-il réalisable depuis `kesh-db` + (`crates/kesh-db/Cargo.toml`, API de `Money` dans `crates/kesh-core/src/types/money.rs`) ? + Rebrancher `reminder_amount_due` (`crates/kesh-api/src/routes/invoice_pdf_service.rs:112`) + change-t-il son comportement (refus ≤ 0, tests `:892-900`) ? + - AC 5, dialogue : comment le frontend obtient-il le reste arrondi (`amountDue` est une chaîne à + 4 décimales) ? `Big` arrondit-il dans le même sens que `MidpointAwayFromZero` (mode par défaut + de big.js) ? Le message d'erreur et ses clés i18n restent-ils justes ? + - AC 6 : l'annulation laissant 0.0040 — ce scénario est-il **constructible** (règlements de + quels montants, sur quelle facture) ? Sinon l'AC promet un test impossible. + - AC 5-bis : lis `accept_one_invoice` (`crates/kesh-api/src/routes/reconciliation.rs`, env. + `:1056-1600`), `settle_invoice` (`invoice_settlements_write.rs:40-260`) et + `reconciliation_cancel.rs:250-330`. **Quel ordre de verrous prennent-ils réellement** + (bank_transactions, invoices, fiscal_years, journal) ? L'AC le laisse au dev : faut-il le + trancher dans la fiche ? Un test de concurrence est-il faisable avec `#[sqlx::test]` (deux + transactions, pool) — existe-t-il un précédent dans le dépôt (`grep -rn "tokio::join\|join!" crates/*/tests`) ? +2. **Sites encore oubliés** : `grep -rn "amountDue\|amount_due\|due_after\|due_before" crates/ frontend/src` + — toute comparaison du reste dû à zéro ou à un montant, hors des sept sites de l'AC 5, est-elle + légitime ou oubliée ? +3. **Faisabilité des tests (AC 7)** — axe non exercé en P1 : l'E2E Playwright peut-il monter une + facture partiellement réglée puis un import bancaire ? Cherche les helpers existants + (`frontend/tests/e2e/`, fixtures CAMT, `authedApiContext`). Les mutations annoncées sont-elles + tuables par les tests prévus ? +4. **Cohérence interne** : numéros d'AC référencés par les tâches, section « Arbitrages », Change + Log, modules (recompte), aucune phrase contradictoire entre l'inventaire et les AC. + +## Ce que tu rends + +- **Findings** : sévérité, endroit exact, **preuve** (commande exécutée et sa sortie, ou code lu cité), + correction proposée. Pour toute affirmation qu'un élément est **absent**, cite le `grep -nF` qui le + prouve. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un rapport sans elle + ne compte pas. + +## 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`. From 73ea2b09a55229588c7e6bb402bd372cbc641514 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Tue, 29 Sep 2026 08:53:19 +0200 Subject: [PATCH 05/12] =?UTF-8?q?docs(25-4-c):=20r=C3=A9f=C3=A9rences=20de?= =?UTF-8?q?=20lignes=20du=20verrou=20recontr=C3=B4l=C3=A9es=20(refs=20#420?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index 4f2f6316..193d87f6 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -145,7 +145,7 @@ le verrou d'exercice (`fiscal_years::find_open_covering_date`, `:1350`, `FOR UPD |---|---| | `accept_one_invoice` (après patch) | `GET_LOCK` du compte bancaire (`with_account_lock`, tout le lot) → transaction bancaire lue **sans** verrou → **facture** → exercice → écritures, puis `UPDATE bank_transactions` | | `settle_invoice` (`invoice_settlements_write.rs`) | **facture** (`:68`) → compte bancaire (`:116`) → compte (`:151`) → exercice (`:174`) | -| `cancel_reconciliation` (`reconciliation_cancel.rs`) | transaction bancaire (`:284`) → ligne de règlement (`:293`) → **facture** (`:296`) → écriture/exercice (`:309`) | +| `cancel_in_tx` (`reconciliation_cancel.rs:276`) | transaction bancaire (`:284`) → ligne de règlement (`:294`) → **facture** (`:296`) → écriture/exercice (`:309`) | Facture avant exercice partout : conforme. L'annulation, elle, verrouille la transaction bancaire et la ligne de règlement **avant** la facture ; une acceptation et une annulation concurrentes **sur la même From de2057a08f1bb5af5e70fb159bd2682d73a7dccc Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Tue, 29 Sep 2026 09:03:44 +0200 Subject: [PATCH 06/12] =?UTF-8?q?docs(25-4-c):=20validation=20P3=20consign?= =?UTF-8?q?=C3=A9e=20=E2=80=94=201=20CRIT=20/=203=20HIGH,=20d=C3=A9coupage?= =?UTF-8?q?=20et=20arbitrages=20en=20attente=20(refs=20#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 10 +++ .../25-4-c-validate-prompt-p3.md | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 _bmad-output/implementation-artifacts/25-4-c-validate-prompt-p3.md diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index 193d87f6..683e96cb 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -280,6 +280,16 @@ partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse dans le code (`GET_LOCK` / facture / exercice ; annulation : transaction bancaire / règlement / facture), placement du verrou fixé à l'étape 5, interblocage acceptation-annulation écrit comme risque accepté. +- **2026-09-29** — Validation P3 (Opus, prompt `25-4-c-validate-prompt-p3.md`) : **1 CRITICAL, 3 HIGH, + 3 MEDIUM, 5 LOW** — **non appliqués, en attente d'arbitrage**. Le CRITICAL et deux HIGH portent sur + l'AC 5-bis écrit par la remédiation P2 : sous REPEATABLE READ, un `FOR UPDATE` posé après la première + lecture laisse `amount_due` lire l'instantané périmé tout en rafraîchissant `version` — il **désarme** + le verrou optimiste qui refuse aujourd'hui la seconde acceptation (F1, vérifié : `settle_invoice` ne + bumpe `version` qu'au solde, `invoice_settlements_write.rs:233-236` ; l'acceptation toujours, + `reconciliation.rs:1498-1501`) ; le test de concurrence ne discriminait rien (F2) ; l'interblocage + décrit était le mauvais et son issue est un 500 sans rejeu (F3). F4 HIGH : payer 10.01 une facture de + 10.0050 laisse la créance **créditrice** de 0.0050 au grand livre (lignes en `DECIMAL(19,4)`) et + affiche « Reste dû −0.01 ». **Règle de découpage déclenchée** : sévérité P3 > P2 (F7). [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 diff --git a/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p3.md new file mode 100644 index 00000000..e9595b45 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p3.md @@ -0,0 +1,64 @@ +# Prompt — validation P3, Story 25-4-c (le résiduel au rapprochement) + +*Versionné le 2026-09-29. **Une lentille** (Opus), contexte frais. La P2 (Haiku) a rendu 0 finding +sans preuve ; l'orchestrateur en a repris deux MEDIUM. Cette passe porte donc sur la fiche entière, +avec un poids particulier sur ce qui a été écrit depuis la P1.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c-residuel-au-rapprochement`. Fiche : +`_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` (état courant). Ce que +P1 et P2 ont changé : `git diff 1db610a5 HEAD -- _bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md`. +Mère : `25-4-propager-le-residuel.md` ; sœurs `25-4-a`, `25-4-b1`, `25-4-b2` (même répertoire). +Issues : `gh issue view 420`, `gh issue view 476`. Règles : `CLAUDE.md` (§ *Pattern batch*, +§ *Review Iteration Rule*). Checklist : `.claude/skills/bmad-create-story/checklist.md`. + +Les arbitrages Q1–Q3 sont **retenus** : ne pas les contester, en contester la mise en œuvre. + +## Axes — tous obligatoires + +1. **L'AC 5-bis et son tableau d'ordre des verrous** — vérifie chaque ligne contre le code + (`crates/kesh-api/src/routes/reconciliation.rs` autour de `accept_batch` et `accept_one_invoice`, + `crates/kesh-reconciliation/src/mutex.rs`, `crates/kesh-db/src/repositories/invoice_settlements_write.rs`, + `crates/kesh-db/src/repositories/reconciliation_cancel.rs`). L'interblocage décrit est-il le bon, + en manque-t-il (insertion dans `invoice_settlements` sous REPEATABLE READ, verrous de trou, route + de règlement manuel contre acceptation) ? L'affirmation « InnoDB annule toute la transaction, pas + le point de sauvegarde » est-elle exacte pour MariaDB, et le code d'`accept_batch` la gère-t-il + comme la fiche le dit (lis la boucle des savepoints) ? Le verrou au chargement change-t-il le + comportement de la garde de statut ou du verrou optimiste (`version`, `:1455-1470`) ? +2. **L'arrondi (AC 5, 6)** : sept sites — relis-les tous. Le helper « sur `Money::round_to_centimes()` » + et `reminder_amount_due` rebranché : même résultat qu'aujourd'hui pour le rappel ? Le filtre SQL + des candidats compare-t-il au reste **arrondi** (en SQL ou en Rust) et la tolérance ± 0.05 + l'exige-t-elle encore ? Le dialogue : quelle valeur de comparaison, quel mode d'arrondi de big.js ? + Le test de dépôt par insertion directe de l'AC 6 est-il cohérent avec les gardes-fous du dépôt + (base partagée vs `#[sqlx::test]`, squash `test-schema`) ? +3. **Inventaire des sites** — pars du symptôme, pas de la liste : + `grep -rn "total_ttc\|amount_due\|amountDue\|invoice_amount\|invoiceAmount\|due_after\|due_before\|OVERPAYMENT" crates/ frontend/src`. + Tout site qui compare un montant à régler, ou l'affiche au comptable dans le flux du rapprochement, + hors de la fiche : légitime ou oublié ? +4. **Tests (AC 7)** : chacun faisable, et **aurait-il échoué avant le patch** ? Le test de concurrence + de l'AC 5-bis est-il déterministe (sans `sleep`) — propose un montage. L'E2E Playwright : quels + helpers réels permettent règlement partiel + import bancaire (`frontend/tests/e2e/`) ? +5. **Le manuel (AC 8)** : 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/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`). + Le manuel parle-t-il ailleurs du règlement manuel ou de l'annulation d'une façon que l'arrondi + rendrait fausse ? +6. **Cohérence interne et périmètre** : AC ↔ tâches, modules recomptés, Change Log recompté + (sévérités de P1 et P2), aucune phrase contradictoire. Le verrou (AC 5-bis) et l'arrondi du règlement + manuel font-ils déborder la story au-delà de ce qu'une passe adversariale peut tenir (règle de + splitting du `CLAUDE.md`) ? + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact, **preuve** (commande exécutée et + sa sortie, ou code lu cité), correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un rapport sans elle + ne compte pas. + +## 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`. From 5d74361391e7411348c4b1d0cd8ca28e7b568722 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Tue, 29 Sep 2026 17:07:59 +0200 Subject: [PATCH 07/12] =?UTF-8?q?docs(25-4-c):=20d=C3=A9coup=C3=A9e=20?= =?UTF-8?q?=E2=80=94=20verrou=20en=2025-4-c2=20(#480),=20arrondi=20en=2025?= =?UTF-8?q?-4-c3=20(#476)=20;=20p=C3=A9rim=C3=A8tre=20r=C3=A9duit=20au=20r?= =?UTF-8?q?approchement=20(refs=20#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 185 +++++++----------- .../sprint-status.yaml | 4 +- 2 files changed, 78 insertions(+), 111 deletions(-) diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index 683e96cb..a1b04681 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -3,11 +3,13 @@ Status: ready-for-dev **Issue : [#420]** — ⛔ la PR porte `closes #420`, titre ET corps (§ *Issue Tracking Rule*). Voisine : -**[#476]** (arrondi de la QR contre garde de trop-perçu), **incluse** (Q1) — la PR porte aussi `closes #476`. +**[#476]** (arrondi au centime) et **[#480]** (verrou à l'acceptation) — **hors périmètre**, sorties +au découpage du 2026-09-29 vers les sœurs **25-4-c3** et **25-4-c2**. La PR ne ferme que #420. **Mère : `25-4-propager-le-residuel.md`** (`split`) — source des faits. **Sœurs** : 25-4-a (mergée, #472 : le reste dû juste), 25-4-b1 (mergée, #475 : formes jointes aux agrégats), 25-4-b2 (mergée, #479 : les -rappels). Branche `story/25-4-c-residuel-au-rapprochement`, rebasée sur `main` (`9098b2be`) ; +rappels) ; **25-4-c2** (#480, verrou et instantané à l'acceptation) et **25-4-c3** (#476, l'arrondi +au centime), nées du découpage de cette story. Branche `story/25-4-c-residuel-au-rapprochement`, rebasée sur `main` (`9098b2be`) ; la b2 ne touche aucun fichier du rapprochement. ## Story @@ -55,37 +57,25 @@ par l'API directe, grâce au score de **référence**, et n'asserte ni candidat 4. **L'avoir** : une facture créditée est `cancelled` (`credit_notes.rs:586`, dépôt) et sort du filtre `status = 'validated'` ; le terme « avoir » du reste dû y vaut 0 en pratique — la forme jointe le porte de toute façon. -5. **L'arrondi (#476)** : `line_total` est à 4 décimales (`invoices.rs:425-427`), la TVA seule - arrondie à 2 (`:165-176`) — le **reste dû peut avoir 4 décimales** (10.0050). La transaction bancaire - est à 2 (`bank_imports.sql:62`). Conséquences : - - le **score** ne peut jamais valoir 1 : 10.01 ≠ 10.005 après `normalize` (`matching.rs:113`) ; - - les **gardes de trop-perçu** comparent la valeur brute : `reconciliation.rs:1336`, - `invoice_settlements_write.rs:167` — le paiement exact de la QR (10.01) est refusé ; - - les **tests de solde** aussi : `reconciliation.rs:1451` et `invoice_settlements_write.rs:233` - (`due_after <= 0`) — un paiement de 10.00 laisse 0.0050, la facture reste « partiellement réglée » - pour un demi-centime, sans `paid_at`. - - l'**annulation d'un règlement** aussi : `invoice_settlements_write.rs:464-465` - (`due_after > Decimal::ZERO` rouvre la facture). ⚠️ Branche **défensive** : l'application ne peut - pas y produire un résidu non nul inférieur au centime — il faudrait qu'avant l'annulation la - facture ait été **trop** payée, ce que la garde de trop-perçu refuse (le commentaire `:458-461` - le dit). Elle suit le helper par cohérence, et se teste par une ligne de règlement **insérée - directement en base** ; - - le **dialogue de règlement** du frontend : `SettleInvoiceDialog.svelte:111` compare la saisie à - `Number(amountDue)` **brut**, alors que le champ est pré-rempli arrondi (`:75`, `toFixed(2)`) — - le 10.01 proposé par le dialogue lui-même y est refusé comme dépassant le reste (10.005). - Le filtre, lui, tolère l'écart grâce aux ± 0.05. - - **L'arrondi existe déjà, deux fois** : `Money::round_to_centimes()` - (`crates/kesh-core/src/types/money.rs:61-69`, `MidpointAwayFromZero`) et `reminder_amount_due` - (`crates/kesh-api/src/routes/invoice_pdf_service.rs:112-118`, la 25-4-b2 : arrondi du reste dû - pour la QR du rappel, refus si ≤ 0 ; son test `:894` porte déjà le cas 10.0050 → 10.01). -6. **Aucun verrou sur la facture à l'acceptation** : `accept_one_invoice` lit la facture par - `find_invoice_by_id_for_company` (`crates/kesh-db/src/repositories/reconciliation.rs:217-230`, - `SELECT` simple) puis le reste dû (`:1329`) sans `FOR UPDATE`, alors que le règlement manuel - (`invoice_settlements_write.rs:63-68`), son annulation (`:380`) et l'annulation d'un rapprochement - (`reconciliation_cancel.rs:296`) verrouillent `invoices`. Deux acceptations concurrentes, ou une - acceptation contre un règlement manuel, peuvent lire le même reste et passer toutes deux la garde - de trop-perçu. Préexistant — mais cette story fait d'une facture partiellement réglée un candidat - **ordinaire**, là où elle était exclue : la course devient atteignable. D'où l'AC 5-bis. +5. **L'arrondi — hors périmètre (25-4-c3, #476).** `line_total` est à 4 décimales + (`invoices.rs:425-427`) : le reste dû peut en porter 4 (10.0050), la transaction bancaire 2 + (`bank_imports.sql:62`). Ce que cette story en laisse, **sans l'aggraver** : + - le **filtre** reste juste sur le reste **brut** : la tolérance ± 0.05 couvre l'écart + |brut − arrondi| ≤ 0.005 ; aucun `ROUND` n'y est ajouté (ce serait une seconde définition de + l'arrondi, que la c3 doit poser une seule fois) ; + - le **score** d'une facture à reste de 4 décimales ne vaut pas 1 (10.01 ≠ 10.005 après `normalize`, + `matching.rs:113`) — **comme aujourd'hui avec le TTC** ; + - les gardes de trop-perçu, les tests de solde, le dialogue de règlement comparent toujours le brut. + Tout cela est la c3 — y compris l'écart d'un demi-centime qu'un paiement arrondi laisserait au grand + livre (lignes en `DECIMAL(19,4)`), question comptable ouverte à Guy. +6. **Le verrou à l'acceptation — hors périmètre (25-4-c2, #480).** `accept_one_invoice` ne verrouille + pas la facture. ⛔ **Cette story n'y ajoute aucun `FOR UPDATE`** : sous REPEATABLE READ, un verrou + posé après la première lecture de la transaction lirait `version` à jour et `amount_due` périmé, et + désarmerait le contrôle optimiste (`reconciliation.rs:1498-1506`) qui refuse aujourd'hui une seconde + acceptation concurrente. Ce qui reste exposé après cette story : une acceptation contre un règlement + manuel **partiel** simultané (`settle_invoice` n'incrémente `version` qu'au solde, + `invoice_settlements_write.rs:233-236`). Préexistant — atteignable aujourd'hui par le score de + référence — et rendu plus fréquent par cette story ; la c2 le ferme, et **doit suivre**. 7. **`LIMIT 50` sans `ORDER BY`** (`reconciliation.rs:116`, dépôt) : au-delà de 50 candidats, lesquels sont gardés n'est pas déterminé. Préexistant, hors périmètre — à ne pas aggraver. @@ -95,82 +85,44 @@ par l'API directe, grâce au score de **référence**, et n'asserte ni candidat **AC 1** — `find_unpaid_invoices_for_window` filtre sur le **reste dû** par la forme **jointe** : les tables dérivées d'`amount_due_derived_joins()` et `INVOICE_AMOUNT_DUE_DERIVED_SQL`, jamais réécrites à -la main, ni la forme corrélée. Le candidat porte le reste dû (le champ est renommé — `amount_due` — et -tous ses lecteurs suivent) ; son doc-comment le dit. +la main, ni la forme corrélée — sur le reste **brut**, tolérance inchangée (inventaire § 5). Le +candidat porte **le reste dû (`amount_due`) et le TTC (`total_ttc`)** : le premier pour le filtre, le +score et l'affichage, le second pour la mention de l'AC 3 ; tous les lecteurs de l'actuel `total_ttc` +passent au reste dû, sauf cette mention. Les doc-comments le disent. **AC 2** — `propose_matches` reçoit le **reste dû** aux deux appels : propositions (`:575`) et re-score à l'acceptation (`:1196-1208`, forme scalaire `invoice_settlements::amount_due` **dans la transaction**, à la place d'`invoices::total_ttc`). `matching.rs` : noms et doc-comments disent « montant à régler », plus « TTC ». -**AC 3** — Le montant affiché dans la proposition (`invoice_amount`, `:594`) est le **reste dû** ; le -commentaire `:591-593` suit. Présentation *(Q3)* : le reste dû ; sur une facture déjà réglée en -partie, suivi de « reste dû sur » (libellé dans les 4 locales), pour que le comptable -reconnaisse la facture. Sans règlement, rien n'est ajouté. +**AC 3** — Le montant affiché dans la proposition (`invoice_amount`, `:594`) est le **reste dû**, au +même format qu'aujourd'hui (`normalize().to_string()`) ; le commentaire `:591-593` suit. Présentation +*(Q3)* : sur une facture déjà réglée en partie (reste ≠ TTC), suivi de « reste dû sur » — +champ TTC ajouté à la réponse, libellé dans les 4 locales. Sans règlement, rien n'est ajouté. **AC 4** — Une facture **sans règlement** se comporte exactement comme avant : reste dû = TTC ; candidats, scores et montant affiché inchangés. Les tests existants le tiennent (`reconciliation_repository.rs:484-531`, `:807` ; `reconciliation_e2e.rs:819-822`), noms et messages mis à jour là où ils disent « TTC ». -### Volet 2 — l'arrondi au centime *(Q1 : inclus)* - -**AC 5** — Une seule grandeur, **le reste dû arrondi au centime** (`MidpointAwayFromZero`, la stratégie -de la QR — `generator.rs:38-39`), sert de montant à régler : filtre des candidats, score, garde de -trop-perçu du rapprochement (`:1336`) **et** du règlement manuel (`invoice_settlements_write.rs:167`), -tests de solde (`:1451` et `:233`), réouverture à l'annulation d'un règlement -(`invoice_settlements_write.rs:464-465`), et contrôle de saisie du dialogue de règlement -(`SettleInvoiceDialog.svelte:111`, comparé au reste arrondi et non à `Number(amountDue)`). Côté Rust, -un helper unique, à côté d'`amount_due`, la porte ; il **s'appuie sur `Money::round_to_centimes()`** -(`kesh-core`) sans recopier la stratégie, et **`reminder_amount_due` (b2) l'appelle** en gardant son -refus du reste nul — il ne reste qu'une définition du « reste dû au centime ». Aucun site ne -réarrondit à sa façon. ⛔ Le reste dû **stocké/calculé** n'est pas modifié : seule la comparaison -arrondit. - -**AC 6** — Sur une facture dont le reste dû brut est 10.0050 : le virement de **10.01** est candidat, -score 1 sur le montant, s'accepte, et **solde** la facture (`paid_at` posé, audit `invoice.paid`) ; un -virement de **10.02** reste un trop-perçu refusé ; un règlement manuel de 10.01 solde aussi, **y -compris depuis le dialogue** (le 10.01 pré-rempli est accepté). L'annulation d'un règlement qui laisse -un reste brut de 0.0040 ne rouvre pas la facture — test de **dépôt**, règlements insérés directement -en base (état inatteignable par l'application, cf. inventaire § 5), et le test le dit. - -**AC 5-bis** — `accept_one_invoice` **verrouille la facture** (`SELECT … FROM invoices WHERE id = ? AND -company_id = ? FOR UPDATE`, le patron de `invoice_settlements_write.rs:63-68`) avant de lire le reste dû -pour le re-score et la garde de trop-perçu : au **chargement** de la facture (étape 5, -`reconciliation.rs:1104-1106`), donc avant la garde de statut, le re-score, la garde de trop-perçu et -le verrou d'exercice (`fiscal_years::find_open_covering_date`, `:1350`, `FOR UPDATE`). -**Ordre des verrous, relevé dans le code** : - -| Chemin | Ordre | -|---|---| -| `accept_one_invoice` (après patch) | `GET_LOCK` du compte bancaire (`with_account_lock`, tout le lot) → transaction bancaire lue **sans** verrou → **facture** → exercice → écritures, puis `UPDATE bank_transactions` | -| `settle_invoice` (`invoice_settlements_write.rs`) | **facture** (`:68`) → compte bancaire (`:116`) → compte (`:151`) → exercice (`:174`) | -| `cancel_in_tx` (`reconciliation_cancel.rs:276`) | transaction bancaire (`:284`) → ligne de règlement (`:294`) → **facture** (`:296`) → écriture/exercice (`:309`) | - -Facture avant exercice partout : conforme. L'annulation, elle, verrouille la transaction bancaire et -la ligne de règlement **avant** la facture ; une acceptation et une annulation concurrentes **sur la même -facture** (deux transactions bancaires distinctes) peuvent donc s'interbloquer. InnoDB le détecte et -annule **toute** la transaction perdante (erreur 1213) — pas seulement le point de sauvegarde de la -proposition : le lot entier échoue en `DATABASE_ERROR` et se rejoue. Risque accepté, préexistant en -nature (le verrou d'exercice l'ouvre déjà) ; le Dev Agent Record le consigne, sans l'aggraver. Test : deux acceptations concurrentes du même solde sur une facture partiellement réglée → une -acceptée, l'autre en `RECONCILIATION_OVERPAYMENT` (ou refusée par la garde de statut), jamais deux -règlements. - -### Volet 3 — tests, textes - -**AC 7** — Tests qui auraient échoué avant le patch, chacun sur une facture **partiellement réglée** à +### Volet 2 — tests, textes + +**AC 5** — Tests qui auraient échoué avant le patch, chacun sur une facture **partiellement réglée** à TVA **non nulle** : - dépôt : la facture 1 000 réglée 400 est **candidate** pour 600, et ne l'est plus pour 1 000 ; - propositions (e2e API) : `amountScore == 1.0` et `invoiceAmount` = le reste ; - acceptation (e2e API) : le virement du solde s'accepte et solde la facture — sans passer par le - score de référence (numéro de facture absent de la transaction) ; -- le cas 10.0050 de l'AC 6, au rapprochement, au règlement manuel (API et Vitest du dialogue) et à - l'annulation ; -- la concurrence de l'AC 5-bis ; + score de référence (numéro de facture absent de la transaction) **ni par le score de contact** + (contrepartie absente ou différente du contact : sinon le total vaut 0.10 et l'acceptation passe + déjà sur le code actuel, `:1224`) ; la réponse asserte `amountScore == 1.0` ; - Playwright : une facture réglée en partie apparaît dans les propositions avec son reste. -**AC 8** — Manuel : `user-manual.tex` § rapprochement dit que la proposition porte sur **ce qui reste -à payer** et qu'un solde de facture partiellement réglée est reconnu. ⚠️ **Le paragraphe du score +**AC 6** — Manuel : `user-manual.tex` § rapprochement dit que la proposition porte sur **ce qui reste +à payer** et qu'un solde de facture partiellement réglée est reconnu. Deux passages voisins suivent : +`:1011` promet déjà que la QR du rappel « permet au rapprochement bancaire de reconnaître le +paiement » — vrai **par** cette story, à garder cohérent ; `:1014` dit le versement « reste dû + frais » +« refusé comme trop-perçu au rapprochement » — en réalité il n'est **pas proposé** (hors tolérance), et +n'est refusé en trop-perçu que s'il porte la référence de la facture : à préciser. ⚠️ **Le paragraphe du score (`:1384-1390`) est faux sur le code, indépendamment de cette story** : il annonce un score gradué (« écart < 1 CHF = score moyen »), un critère « Date », une « référence QR Bill », un seuil de 80 % et un auto-accept à 95 % — rien de tel n'existe. Il est **réécrit dans cette story** sur le code réel *(Q2)* ; les deux autres @@ -180,15 +132,12 @@ CHANGELOG `[0.12.1]` *Fixed*. ## Tasks / Subtasks -- [ ] **T1 — candidats** (AC 1, 4) : forme jointe, champ renommé, lecteurs. -- [ ] **T2 — score et re-score** (AC 2, 3) : les deux appelants, affichage, `matching.rs`. -- [ ] **T3 — arrondi** (AC 5, 6) : helper sur `Money::round_to_centimes()`, `reminder_amount_due` - rebranché, sept sites (dont le dialogue frontend). -- [ ] **T3-bis — verrou** (AC 5-bis) : `FOR UPDATE` dans `accept_one_invoice`, ordre des verrous. -- [ ] **T4 — tests et mutations** (AC 7). -- [ ] **T5 — textes** (AC 8) ; si un champ TTC s'ajoute à la réponse des propositions (Q3), vérifier - `docs/api-external.md` (aujourd'hui muet sur ses champs). -- [ ] **T6 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. +- [ ] **T1 — candidats** (AC 1, 4) : forme jointe, `amount_due` + `total_ttc` au candidat, lecteurs. +- [ ] **T2 — score et re-score** (AC 2, 3) : les deux appelants, affichage et mention, `matching.rs`. +- [ ] **T3 — tests et mutations** (AC 5). +- [ ] **T4 — textes** (AC 6) ; le champ TTC ajouté à la réponse : vérifier `docs/api-external.md` + (aujourd'hui muet sur ses champs) ; ouvrir l'issue des deux passages faux du manuel (Q2). +- [ ] **T5 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. ## Dev Notes @@ -199,6 +148,9 @@ CHANGELOG `[0.12.1]` *Fixed*. - ⛔ **Rendre le score gradué** : le binaire est une décision v0.1 assumée (`matching.rs:108-117`) ; cette story change la grandeur comparée, pas la forme du score. - ⛔ **Toucher l'écriture** : elle porte le montant de la transaction, c'est juste. +- ⛔ **Ajouter un `FOR UPDATE` dans `accept_one_invoice`** (inventaire § 6) : c'est la c2, et fait à + moitié il désarme le contrôle optimiste. +- ⛔ **Arrondir le reste dû** où que ce soit, filtre compris (inventaire § 5) : c'est la c3. - ⚠️ **L'ordre des gardes à l'acceptation** : le score (`:1226`) précède le trop-perçu (`:1336`). Un virement supérieur au reste doit continuer de sortir en `RECONCILIATION_OVERPAYMENT` s'il passe le score, en `SCORE_TOO_LOW` sinon — ne pas l'inverser sans le dire. @@ -213,29 +165,36 @@ CHANGELOG `[0.12.1]` *Fixed*. | `crates/kesh-reconciliation/src/matching.rs:1-145` | score, triplet | | `crates/kesh-api/src/routes/reconciliation.rs:480-640, 1056-1600` | propositions, acceptation, gardes | | `crates/kesh-db/src/repositories/invoice_settlements.rs:27-220` | formes jointe et scalaire | -| `crates/kesh-db/src/repositories/invoice_settlements_write.rs:60-70, 160-260, 460-470` | verrou, règlement manuel, annulation | -| `crates/kesh-core/src/types/money.rs:61-69`, `crates/kesh-api/src/routes/invoice_pdf_service.rs:112-118` | arrondis existants | -| `frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte:70-115` | contrôle de saisie | | `frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte:285`, `reconciliation.types.ts:30` | affichage | -| `docs/manual/fr/user-manual.tex:1375-1440` | rapprochement | +| `docs/manual/fr/user-manual.tex:1005-1016, 1375-1440` | rappel (QR), rapprochement | + +### Montage du Playwright (AC 5) + +Aucune spec ne montre aujourd'hui une facture candidate (`reconciliation.spec.ts:9-13` le déclare hors +périmètre). Recette : `createAndValidateInvoiceViaApi(page, contactId, '2026-05-10')` +(`frontend/tests/e2e/helpers/api-fixtures.ts:100`), règlement partiel par +`POST /api/v1/invoices/{id}/settlements`, puis import d'un CAMT réécrit comme dans +`reconciliation-cancel.spec.ts:87-107` (date de comptabilisation 2026-05-15, montant unique égal au +reste, sans référence de facture). ### Gardes-fous du dépôt - Aucune migration. Repositories `kesh-db` touchés ⇒ **gate complet même en cours de boucle de revue**. - Modules : `kesh-db`, `kesh-reconciliation`, `kesh-api`, `frontend`, `kesh-i18n` (le libellé « reste dû sur » de Q3) — **cinq**, sous le seuil de découpage (« plus de 5 »). +- Née d'un **découpage** (règle de splitting, non-convergence P2 → P3) : la validation reprend sur ce + périmètre réduit. ## Arbitrages -*Retenus le 2026-09-29 : Guy a demandé de continuer sans trancher ; ce sont les recommandations de +*Retenus le 2026-09-29 : Guy a demandé de continuer sans trancher (le découpage, lui, a son accord +explicite) ; ce sont les recommandations de la création, appliquées par défaut et **révisables par lui**. La validation n'a pas à les contester, seulement leur mise en œuvre.* -**Q1 — inclure #476 (l'arrondi) ?** Le reste dû peut porter des demi-centimes ; comparé brut, il -empêche le score de valoir 1, fait refuser le paiement exact de la QR comme trop-perçu, et laisse une -facture « partiellement réglée » pour 0.005. C'est la même comparaison que cette story touche, aux -mêmes sites, plus le règlement manuel. **Retenu : inclus** (volet 2) — sans lui, la 25-4-c -reconnaît le solde d'une facture à 2 décimales mais pas celui d'une facture dont le TTC en a 4. +**Q1 — inclure #476 (l'arrondi) ?** Retenu d'abord par défaut, puis **retiré au découpage du +2026-09-29** (accord de Guy) : la validation P3 a montré qu'arrondir la comparaison laisse un +demi-centime au grand livre — une question comptable, pas une comparaison. → **25-4-c3**. **Q2 — le manuel du rapprochement.** Le paragraphe du score est faux sur le code (score gradué, date, référence QR, seuils 80/95 % — inexistants), et deux autres passages aussi : l'acceptation par lot @@ -290,7 +249,13 @@ partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse décrit était le mauvais et son issue est un 500 sans rejeu (F3). F4 HIGH : payer 10.01 une facture de 10.0050 laisse la créance **créditrice** de 0.0050 au grand livre (lignes en `DECIMAL(19,4)`) et affiche « Reste dû −0.01 ». **Règle de découpage déclenchée** : sévérité P3 > P2 (F7). +- **2026-09-29** — **Découpée** (accord de Guy) : l'arrondi (ex-volet 2, #476) part en **25-4-c3**, le + verrou (ex-AC 5-bis, #480 ouverte) en **25-4-c2** ; F1–F4, F7, F8, F10, F12 de P3 les suivent. Restent + appliqués ici : F5 (filtre sur le brut, candidat portant reste dû **et** TTC), F6 (test d'acceptation + hors score de contact), F9 (manuel `:1011`, `:1014`), F11 (recette Playwright). AC renumérotées : + ex-AC 7 → AC 5, ex-AC 8 → AC 6. [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 [#476]: https://github.com/guycorbaz/kesh/issues/476 +[#480]: https://github.com/guycorbaz/kesh/issues/480 diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 8f53e10d..15826a79 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -359,7 +359,9 @@ development_status: 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: done # 2026-09-27 revue CLOSE en 2 passes (1H/1M/1L -> 0 >LOW), gates verts (2510, 835, E2E conforme). PR a ouvrir : closes #416. # 2026-09-27 CREEE : frais non comptabilises => QR = reste du SANS frais (regle existante, admin-manual:1200) ; PDF de rappel = variante titree ; Q1 ligne "Frais de rappel", Q2 deja regle masque a zero, M5 papier hors perimetre (#477) ; validation CLOSE en 5 passes (1H/3M -> 0+1M -> 5M -> 1M -> 0 >LOW), pas de decoupage (Guy). # [#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: ready-for-dev # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. + 25-4-c-residuel-au-rapprochement: ready-for-dev # 2026-09-29 PERIMETRE REDUIT au reste du dans le rapprochement (verrou -> c2 #480, arrondi -> c3 #476) ; validation P1 3H, P2 2M (orchestrateur), P3 1C/3H -> decoupage ; validation reprend en P4. # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. + 25-4-c2-verrou-acceptation: backlog # [#480] 2026-09-29 NEE du decoupage de la 25-4-c (validation P3, severite P2 -> P3 non decroissante ; accord de Guy) — accept_one_invoice ne verrouille pas la facture : double reglement possible contre un reglement manuel partiel (settle_invoice ne bumpe version qu'au solde). ⛔ un simple FOR UPDATE au chargement desarme le controle optimiste (REPEATABLE READ : version lue a jour, amount_due perime) — lecture verrouillante du reste du, ou bump de version au reglement partiel. Interblocage lot/exercice -> 500 sans rejeu (retry_with ?). Findings P3 F1-F3 dans le Change Log de la 25-4-c. DOIT suivre la 25-4-c. + 25-4-c3-arrondi-centime: backlog # [#476] 2026-09-29 NEE du decoupage de la 25-4-c — le reste du peut porter 4 decimales ; comparer au centime : 8 sites (filtre, score, gardes de trop-percu :1336 et settlements_write:167, soldes :1451 et :233, reouverture :464, dialogue SettleInvoiceDialog:111) ; helper sur Money::round_to_centimes, reminder_amount_due rebranche. ⚠️ QUESTION COMPTABLE A GUY : payer 10.01 une facture de 10.0050 laisse la creance crediteur de 0.0050 au grand livre (DECIMAL(19,4)) et affiche « Reste du -0.01 » — assumer, afficher arrondi, ecriture d'ecart, ou arrondir le TTC a la validation. Findings P1 F1-F3, P3 F4/F8/F10/F12 dans le Change Log de la 25-4-c. 25-4-d-solder-le-reste: backlog # [#384] ramenee de la 25-6 le 2026-09-27 (Guy) — perte sur debiteur, escompte, frais : clore une facture partiellement reglee ; la part TVA reduit la TVA due. 25-4-propager-le-residuel: split # 2026-09-26 DECOUPEE (six modules > 5) en 25-4-a / 25-4-b / 25-4-c, puis 25-4-d (#384, 2026-09-27) ; #455 et #456 rattachees. # [#416] [#420] — la 24-2 a introduit le reglement PARTIEL ; le residuel n'est propage ni a la balance agee ni a l'echeancier, et **le score de rapprochement compare au TTC et non au residuel** : le virement du solde d'une facture partiellement reglee **score 0**. 25-5-a-export-souverainete: done # 2026-09-26 **MERGEE (PR #452, #386 fermee) SANS REVUE DE CODE** ; revue a posteriori CLOSE en 3 passes sur `story/25-5-a-revue-code` (4 HIGH P1, 1 HIGH orchestrateur P2 — colonnes manquantes, garde de colonnes —, P3 ciblee test seul) ; gates 2466/2466, E2E 221/8/19 sans regression ; reports #465, #466. # 2026-09-23 **SPECIFIEE et VALIDEE en 1 passe** (0 CRITICAL, 4 HIGH, 3 MEDIUM, 3 LOW, tous corriges). [#386] — issue du DECOUPAGE de la 25-5 (arbitrage de Guy) : #385 (Balance vs Bilan) et #386 n'ont en commun que le mot « etat », et touchent des modules disjoints. ⛔ **Trois faits qui corrigent l'issue** : 21 tables absentes et non 10 ; le trou s'est CREUSE depuis son ecriture (`invoice_settlements`, Epic 24) ; et SIX repositories sur huit n'ont aucune lecture exhaustive — le vrai cout, absent de l'issue. Arbitrage de Guy : l'export CSV ne porte que les donnees COMPTABLES (11 dedans), les backups portent tout ; 10 exclusions AVEC motif ecrit, dont 3 tables de SECRETS. Les justificatifs (fichiers) partent en story separee sur la SAUVEGARDE. ⛔ La vraie livraison est la GARDE D'EXHAUSTIVITE (patron D4-ter + P7), non les 11 exports : trois epics ont rouvert le trou en silence. ⚠️ Pieges releves par la validation : le nombre 19 code en dur a SEPT endroits dont deux `debug_assert_eq!` qui PANIQUENT ; `audit_log` n'a AUCUN dump exhaustif (ses fonctions sont bornees, la route rejette au-dela de 10 000 lignes) ; `csv_sanitize` existe et n'est pas employee par cet export ; deux sites de manuel DEJA faux (un dossier `reports/` invente, un « 38 » pour 39). From 08646df748ee9ac473e476b11dc985f675528303 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Tue, 29 Sep 2026 17:16:32 +0200 Subject: [PATCH 08/12] =?UTF-8?q?docs(25-4-c):=20validation=20P4=20?= =?UTF-8?q?=E2=80=94=201=20MED,=201=20LOW=20rem=C3=A9di=C3=A9s=20(refs=20#?= =?UTF-8?q?420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 21 ++++--- .../25-4-c-validate-prompt-p4.md | 61 +++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c-validate-prompt-p4.md diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index a1b04681..77f856f1 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -114,7 +114,7 @@ TVA **non nulle** : - acceptation (e2e API) : le virement du solde s'accepte et solde la facture — sans passer par le score de référence (numéro de facture absent de la transaction) **ni par le score de contact** (contrepartie absente ou différente du contact : sinon le total vaut 0.10 et l'acceptation passe - déjà sur le code actuel, `:1224`) ; la réponse asserte `amountScore == 1.0` ; + déjà sur le code actuel, `:1226`) ; la réponse asserte `amountScore == 1.0` ; - Playwright : une facture réglée en partie apparaît dans les propositions avec son reste. **AC 6** — Manuel : `user-manual.tex` § rapprochement dit que la proposition porte sur **ce qui reste @@ -125,9 +125,10 @@ paiement » — vrai **par** cette story, à garder cohérent ; `:1014` dit le v n'est refusé en trop-perçu que s'il porte la référence de la facture : à préciser. ⚠️ **Le paragraphe du score (`:1384-1390`) est faux sur le code, indépendamment de cette story** : il annonce un score gradué (« écart < 1 CHF = score moyen »), un critère « Date », une « référence QR Bill », un seuil de 80 % et -un auto-accept à 95 % — rien de tel n'existe. Il est **réécrit dans cette story** sur le code réel *(Q2)* ; les deux autres -passages faux (lot dit « atomique », rapprochement manuel / éclatement dits « par facture », -`:1411`, sous-section à partir de `:1421`) font l'objet d'une **issue séparée**, ouverte à l'implémentation. PDF régénéré, contrôlé aplati. +un auto-accept à 95 % — rien de tel n'existe. Il est **réécrit dans cette story** sur le code réel *(Q2)* ; les trois autres +passages faux (action « Modifier » — choisir une autre facture — qui n'existe pas, `:1406` ; lot dit +« atomique », `:1411` ; rapprochement manuel / éclatement dits « par facture », sous-section à partir +de `:1421`) font l'objet d'une **issue séparée**, ouverte à l'implémentation. PDF régénéré, contrôlé aplati. CHANGELOG `[0.12.1]` *Fixed*. ## Tasks / Subtasks @@ -136,7 +137,7 @@ CHANGELOG `[0.12.1]` *Fixed*. - [ ] **T2 — score et re-score** (AC 2, 3) : les deux appelants, affichage et mention, `matching.rs`. - [ ] **T3 — tests et mutations** (AC 5). - [ ] **T4 — textes** (AC 6) ; le champ TTC ajouté à la réponse : vérifier `docs/api-external.md` - (aujourd'hui muet sur ses champs) ; ouvrir l'issue des deux passages faux du manuel (Q2). + (aujourd'hui muet sur ses champs) ; ouvrir l'issue des trois passages faux du manuel (Q2). - [ ] **T5 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. ## Dev Notes @@ -199,9 +200,10 @@ demi-centime au grand livre — une question comptable, pas une comparaison. → **Q2 — le manuel du rapprochement.** Le paragraphe du score est faux sur le code (score gradué, date, référence QR, seuils 80/95 % — inexistants), et deux autres passages aussi : l'acceptation par lot dite « atomique » alors qu'elle est en succès partiel, et le rapprochement manuel / l'éclatement dits -« par facture » alors qu'ils n'en portent aucune (`:1411`, `:1421` et suivantes). **Retenu** : corriger +« par facture » alors qu'ils n'en portent aucune (`:1411`, `:1421` et suivantes) ; s'y ajoute, trouvée en +P4, l'action « Modifier » (`:1406`), que l'écran n'offre pas. **Retenu** : corriger dans cette story le paragraphe du score (il décrit la grandeur que la story change) ; ouvrir une issue -pour les deux autres. +pour les trois autres. **Q3 — le montant affiché.** **Retenu** : le **reste dû** (« 600.00 ») ; sur une facture réglée en partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse la facture. @@ -254,6 +256,11 @@ partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse appliqués ici : F5 (filtre sur le brut, candidat portant reste dû **et** TTC), F6 (test d'acceptation hors score de contact), F9 (manuel `:1011`, `:1014`), F11 (recette Playwright). AC renumérotées : ex-AC 7 → AC 5, ex-AC 8 → AC 6. +- **2026-09-29** — Validation P4 (Sonnet, prompt `25-4-c-validate-prompt-p4.md`, périmètre réduit) : + **1 MEDIUM, 1 LOW**, vérifiés. F2 MEDIUM : un troisième passage faux du manuel, l'action « Modifier » + (`user-manual.tex:1406`), absent de l'inventaire → ajouté au périmètre de l'issue séparée (AC 6, Q2). + F1 LOW : `:1224` → `:1226`. Frontière du découpage confirmée : réalisable sans c2 ni c3, aucun état + pire. Axe non exercé : tuabilité des mutations (pas d'outillage, raisonnée seulement). [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 diff --git a/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p4.md new file mode 100644 index 00000000..f309e1ca --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p4.md @@ -0,0 +1,61 @@ +# Prompt — validation P4, Story 25-4-c (le résiduel au rapprochement) — périmètre réduit + +*Versionné le 2026-09-29. **Une lentille** (Sonnet), contexte frais. La story vient d'être **découpée** +(règle de splitting, sévérité P2 → P3 non décroissante) : le verrou part en 25-4-c2 (#480), l'arrondi +en 25-4-c3 (#476). Cette passe valide le périmètre réduit, en entier.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c-residuel-au-rapprochement`. Fiche : +`_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` (état courant). Le +découpage : `git show 5d743613`. Issues : `gh issue view 420`, `gh issue view 476`, `gh issue view 480`. +Mère : `25-4-propager-le-residuel.md` ; sœurs `25-4-a`, `25-4-b1`, `25-4-b2`. Règles : `CLAUDE.md`. +Checklist : `.claude/skills/bmad-create-story/checklist.md`. + +Les arbitrages (Q1 retiré, Q2, Q3) et le découpage sont **retenus** : ne pas les contester, en contester +la mise en œuvre. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme — y compris celles + ajoutées au découpage (Dev Notes « Montage du Playwright », manuel `:1011`, `:1014`, `:1224`). +2. **La frontière du découpage** : la fiche réduite est-elle réalisable **sans** rien de la c2 ni de + la c3 ? Relis l'inventaire § 5 et § 6. Le filtre sur le reste **brut** avec la tolérance ± 0.05 : + l'argument |brut − arrondi| ≤ 0.005 tient-il ? La story laisse-t-elle un état **pire** + qu'aujourd'hui sur un point quelconque (course acceptation / règlement manuel, reste à 4 décimales + affiché, score) ? Reste-t-il dans la fiche une phrase, une tâche ou un test qui appartient à la c2 ou + à la c3 ? +3. **Inventaire des sites** — pars du symptôme : + `grep -rn "total_ttc\|invoice_amount\|invoiceAmount\|find_unpaid_invoices_for_window\|propose_matches" crates/ frontend/src`. + Tout lecteur du champ candidat ou du montant affiché hors de la fiche : couvert, légitime, oublié ? +4. **AC 1 et AC 3** : le candidat porte `amount_due` et `total_ttc` — la requête jointe peut-elle + rendre les deux (GROUP BY / HAVING, `INVOICE_TTC_SUBQUERY_SQL` corrélé encore utilisé ?) sans + retomber dans la forme corrélée interdite ? Le champ TTC ajouté à la réponse : type frontend + (`reconciliation.types.ts`), sérialisation camelCase, `docs/api-external.md`, clé i18n et + `lint-i18n-ownership`. +5. **Les tests (AC 5)** : chacun **aurait-il échoué avant le patch** ? Le test d'acceptation (hors + score de référence et de contact) : lis `matching.rs` et vérifie qu'aucun autre sous-score ne le fait + passer sur le code actuel. Le Playwright : la recette des Dev Notes est-elle exécutable (lis les + helpers cités) ? Mutations tuables ? +6. **Le manuel (AC 6)** : lignes citées, **PDF aplati** + (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers + `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`). + L'affirmation sur `:1014` (versement « reste + frais » non proposé, refusé en trop-perçu seulement + avec la référence) est-elle exacte sur le code ? +7. **Cohérence interne** : numérotation des AC après renumérotation (ex-7 → 5, ex-8 → 6) partout — + tâches, Dev Notes, Change Log ; modules recomptés ; `sprint-status.yaml` (lignes 25-4-c, c2, c3) + cohérent avec la fiche. + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact, **preuve** (commande exécutée et + sa sortie, ou code lu cité), correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un rapport sans elle + ne compte pas. + +## 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`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers le scratchpad, `cargo check`. From 7aa56d2f3471729af6f57dac3fa13e1d7a486c88 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Tue, 29 Sep 2026 17:20:28 +0200 Subject: [PATCH 09/12] =?UTF-8?q?docs(25-4-c):=20validation=20P5=20cibl?= =?UTF-8?q?=C3=A9e=20=E2=80=94=20close,=200=20>=20LOW=20(refs=20#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 18 ++++++-- .../25-4-c-validate-prompt-p5.md | 46 +++++++++++++++++++ .../sprint-status.yaml | 2 +- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c-validate-prompt-p5.md diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index 77f856f1..ecf02f2d 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -198,10 +198,10 @@ seulement leur mise en œuvre.* demi-centime au grand livre — une question comptable, pas une comparaison. → **25-4-c3**. **Q2 — le manuel du rapprochement.** Le paragraphe du score est faux sur le code (score gradué, date, -référence QR, seuils 80/95 % — inexistants), et deux autres passages aussi : l'acceptation par lot -dite « atomique » alors qu'elle est en succès partiel, et le rapprochement manuel / l'éclatement dits -« par facture » alors qu'ils n'en portent aucune (`:1411`, `:1421` et suivantes) ; s'y ajoute, trouvée en -P4, l'action « Modifier » (`:1406`), que l'écran n'offre pas. **Retenu** : corriger +référence QR, seuils 80/95 % — inexistants), et trois autres passages aussi : l'action « Modifier » +(`:1406`, trouvée en P4), que l'écran n'offre pas ; l'acceptation par lot dite « atomique » alors +qu'elle est en succès partiel (`:1411`) ; le rapprochement manuel / l'éclatement dits « par facture » +alors qu'ils n'en portent aucune (`:1421` et suivantes). **Retenu** : corriger dans cette story le paragraphe du score (il décrit la grandeur que la story change) ; ouvrir une issue pour les trois autres. @@ -261,6 +261,16 @@ partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse (`user-manual.tex:1406`), absent de l'inventaire → ajouté au périmètre de l'issue séparée (AC 6, Q2). F1 LOW : `:1224` → `:1226`. Frontière du découpage confirmée : réalisable sans c2 ni c3, aucun état pire. Axe non exercé : tuabilité des mutations (pas d'outillage, raisonnée seulement). +- **2026-09-29** — Validation P5 **ciblée** (Haiku, prompt `25-4-c-validate-prompt-p5.md`, sur + `08646df7`), preuves jointes : **1 finding annoncé MEDIUM, reclassé LOW** — « deux autres passages » + dans Q2, suivi de l'ajout de « Modifier » : décompte maladroit, non faux ; la lentille qualifie + elle-même la sévérité globale de LOW. Reformulé. Section rapprochement du manuel relue en entier + contre le code : **aucun passage faux hors des quatre inventoriés**. La remédiation ne touche que la + prose de la fiche : **validation close**. + + **Bilan de la validation** — P1 Sonnet 3H/2M/1L → P2 Haiku 0 sans preuve, 2M repris par + l'orchestrateur → P3 Opus 1C/3H/3M/5L → **découpage** (c2 #480, c3 #476) → P4 Sonnet 1M/1L → P5 Haiku + ciblée 0 > LOW. Modèles : Sonnet, Haiku, Opus, Sonnet, Haiku. Reclassements : P5-F1 MEDIUM → LOW. [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 diff --git a/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p5.md b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p5.md new file mode 100644 index 00000000..9cfa4250 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p5.md @@ -0,0 +1,46 @@ +# Prompt — validation P5 ciblée, Story 25-4-c (le résiduel au rapprochement) + +*Versionné le 2026-09-29. **Passe ciblée** (CLAUDE.md § « La passe ciblée ») : une lentille (Haiku), +contexte frais, braquée sur la remédiation de la P4. La P4 a trouvé un seul MEDIUM : un passage faux +du manuel du rapprochement, absent de l'inventaire de la fiche.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c-residuel-au-rapprochement`. Fiche : +`_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` — **lis le fichier dans son +état actuel**. La remédiation à relire : `git show 08646df7 -- _bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` +(les numéros de ligne font foi dans le fichier courant, pas dans le diff). + +## Axes — tous obligatoires + +1. **La remédiation elle-même** : `:1226` (dans `crates/kesh-api/src/routes/reconciliation.rs`) est-il + bien le test `score.total <= 0.0` ? `:1406` de `docs/manual/fr/user-manual.tex` est-il bien l'action + « Modifier » ? Le décompte « trois autres passages » est-il cohérent **partout** dans la fiche + (AC 6, T4, section Arbitrages) — `grep -n "deux autres\|trois autres\|deux passages\|trois passages" ` ? +2. **Le symptôme, pas le site** : la P4 a trouvé un passage faux que la liste omettait ; il peut y en + avoir d'autres. Lis **toute** la section rapprochement du manuel + (`grep -n "section{Réconciliation\|subsection" docs/manual/fr/user-manual.tex`, puis la section + entière) et, pour chaque affirmation vérifiable (bouton, action, code d'erreur, compte, seuil, + comportement), confronte-la au code (`crates/kesh-api/src/routes/reconciliation.rs`, + `frontend/src/lib/features/reconciliation/`, `frontend/src/routes/(app)/reconciliation/` s'il existe). + Toute affirmation fausse **non** listée par la fiche (ni corrigée par l'AC 6, ni renvoyée à l'issue + séparée) est un finding. +3. **Le PDF** : `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers + `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/p5-manual.txt` ; + les passages que tu signales y figurent-ils tels quels ? + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact, **preuve** : pour chaque finding, + la commande exécutée **et sa sortie copiée**, ou l'extrait de code lu avec son numéro de ligne. Pour + toute affirmation qu'un élément est **absent** du code, le `grep -rnF` qui le prouve et sa sortie + (vide). Un finding sans preuve ne sera pas retenu ; un « 0 finding » sans preuve non plus. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été**, et pour l'axe 2 la liste + des affirmations du manuel contrôlées (ligne, verdict). + +## 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`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers le scratchpad. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 15826a79..0789bb98 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -359,7 +359,7 @@ development_status: 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: done # 2026-09-27 revue CLOSE en 2 passes (1H/1M/1L -> 0 >LOW), gates verts (2510, 835, E2E conforme). PR a ouvrir : closes #416. # 2026-09-27 CREEE : frais non comptabilises => QR = reste du SANS frais (regle existante, admin-manual:1200) ; PDF de rappel = variante titree ; Q1 ligne "Frais de rappel", Q2 deja regle masque a zero, M5 papier hors perimetre (#477) ; validation CLOSE en 5 passes (1H/3M -> 0+1M -> 5M -> 1M -> 0 >LOW), pas de decoupage (Guy). # [#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: ready-for-dev # 2026-09-29 PERIMETRE REDUIT au reste du dans le rapprochement (verrou -> c2 #480, arrondi -> c3 #476) ; validation P1 3H, P2 2M (orchestrateur), P3 1C/3H -> decoupage ; validation reprend en P4. # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. + 25-4-c-residuel-au-rapprochement: ready-for-dev # 2026-09-29 VALIDATION CLOSE en 5 passes (P4 1M/1L, P5 ciblee 0 >LOW) — prete pour dev. # 2026-09-29 PERIMETRE REDUIT au reste du dans le rapprochement (verrou -> c2 #480, arrondi -> c3 #476) ; validation P1 3H, P2 2M (orchestrateur), P3 1C/3H -> decoupage ; validation reprend en P4. # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. 25-4-c2-verrou-acceptation: backlog # [#480] 2026-09-29 NEE du decoupage de la 25-4-c (validation P3, severite P2 -> P3 non decroissante ; accord de Guy) — accept_one_invoice ne verrouille pas la facture : double reglement possible contre un reglement manuel partiel (settle_invoice ne bumpe version qu'au solde). ⛔ un simple FOR UPDATE au chargement desarme le controle optimiste (REPEATABLE READ : version lue a jour, amount_due perime) — lecture verrouillante du reste du, ou bump de version au reglement partiel. Interblocage lot/exercice -> 500 sans rejeu (retry_with ?). Findings P3 F1-F3 dans le Change Log de la 25-4-c. DOIT suivre la 25-4-c. 25-4-c3-arrondi-centime: backlog # [#476] 2026-09-29 NEE du decoupage de la 25-4-c — le reste du peut porter 4 decimales ; comparer au centime : 8 sites (filtre, score, gardes de trop-percu :1336 et settlements_write:167, soldes :1451 et :233, reouverture :464, dialogue SettleInvoiceDialog:111) ; helper sur Money::round_to_centimes, reminder_amount_due rebranche. ⚠️ QUESTION COMPTABLE A GUY : payer 10.01 une facture de 10.0050 laisse la creance crediteur de 0.0050 au grand livre (DECIMAL(19,4)) et affiche « Reste du -0.01 » — assumer, afficher arrondi, ecriture d'ecart, ou arrondir le TTC a la validation. Findings P1 F1-F3, P3 F4/F8/F10/F12 dans le Change Log de la 25-4-c. 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. From 63a6d6a7c1a02f6b3fa1a0c097f9f62355196019 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 11:23:42 +0200 Subject: [PATCH 10/12] =?UTF-8?q?fix(25-4-c):=20le=20rapprochement=20recon?= =?UTF-8?q?na=C3=AEt=20le=20solde=20d'une=20facture=20r=C3=A9gl=C3=A9e=20e?= =?UTF-8?q?n=20partie=20(refs=20#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le filtre des candidates, le score, le re-score à l'acceptation et le montant affiché portent sur le reste dû (TTC − avoir − règlements), par la forme jointe ; le TTC ne suit qu'en mention quand les deux diffèrent. Manuel et brochure réécrits sur le score réel ; #481 ouverte pour le reste. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../25-4-c-residuel-au-rapprochement.md | 80 ++++++++- .../sprint-status.yaml | 2 +- crates/kesh-api/src/routes/reconciliation.rs | 38 ++-- crates/kesh-api/tests/reconciliation_e2e.rs | 168 +++++++++++++++++- .../src/repositories/reconciliation.rs | 62 ++++--- .../tests/reconciliation_repository.rs | 112 +++++++++++- crates/kesh-i18n/locales/de-CH/messages.ftl | 1 + crates/kesh-i18n/locales/en-CH/messages.ftl | 1 + crates/kesh-i18n/locales/fr-CH/messages.ftl | 1 + crates/kesh-i18n/locales/it-CH/messages.ftl | 1 + crates/kesh-reconciliation/src/matching.rs | 35 ++-- docs/manual/fr/marketing-brochure.pdf | Bin 50524 -> 50503 bytes docs/manual/fr/marketing-brochure.tex | 2 +- docs/manual/fr/user-manual.pdf | Bin 298325 -> 299414 bytes docs/manual/fr/user-manual.tex | 17 +- .../ReconciliationProposals.svelte | 7 +- .../ReconciliationProposals.test.ts | 34 +++- .../reconciliation/reconciliation.types.ts | 3 + frontend/src/lib/shared/i18n-keys.test.ts | 7 +- .../e2e/reconciliation-amount-due.spec.ts | 157 ++++++++++++++++ 21 files changed, 652 insertions(+), 78 deletions(-) create mode 100644 frontend/tests/e2e/reconciliation-amount-due.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6eb4302..e658a44b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **Un rappel réclamait le montant entier d'une facture déjà réglée en partie ([#416](https://github.com/guycorbaz/kesh/issues/416)).** Une facture de 1 081.— réglée de 900.— était relancée pour **1 081.— plus les frais**, et le PDF joint — la facture elle-même — portait une QR-facture à 1 081.— : le client qui la scannait **payait une seconde fois** ce qu'il avait déjà réglé. Le rappel réclame désormais le **reste dû** : le courrier l'annonce (plus les frais s'il y en a), et le PDF joint est un **rappel** — il en porte le titre, dit ce qui est déjà réglé et ce qui reste à payer, et sa QR demande le reste, avec la **même référence** que la facture, pour que le rapprochement reconnaisse le paiement. +- **Le rapprochement bancaire ne reconnaissait pas le virement qui soldait une facture déjà réglée en partie ([#420](https://github.com/guycorbaz/kesh/issues/420)).** Une facture de 1 000.— réglée de 400.— n'était même pas proposée pour un virement de 600.— — exactement ce que le client devait encore —, parce que la recherche comparait le montant d'origine : il fallait rapprocher à la main un paiement parfaitement identifiable. Le rapprochement cherche et note désormais les factures sur leur **reste dû** ; la proposition affiche ce reste, suivi du total d'origine (« 600, reste dû sur 1000 ») quand la facture est déjà réglée en partie. Le manuel décrit enfin le score tel qu'il est calculé — il annonçait un score gradué, un critère de date et des seuils de confiance qui n'existent pas. + **Un règlement arrivé pendant que l'aperçu est ouvert fait refuser l'envoi** : le courrier validé annoncerait un autre montant que la QR jointe. Il suffit de rouvrir l'aperçu. **Les frais de rappel ne s'affichent plus quand ils sont nuls** — les modèles fournis écrivaient « frais de rappel de 0.00 ». Ils ne sont **jamais** dans la QR : ils ne sont pas comptabilisés ([#401](https://github.com/guycorbaz/kesh/issues/401)), et un versement qui les inclurait serait refusé comme trop-perçu ; le PDF les mentionne sur une ligne à part. diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index ecf02f2d..ff5f1e41 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -1,6 +1,6 @@ # Story 25.4-c : Le résiduel au rapprochement bancaire -Status: ready-for-dev +Status: review **Issue : [#420]** — ⛔ la PR porte `closes #420`, titre ET corps (§ *Issue Tracking Rule*). Voisine : **[#476]** (arrondi au centime) et **[#480]** (verrou à l'acceptation) — **hors périmètre**, sorties @@ -133,12 +133,12 @@ CHANGELOG `[0.12.1]` *Fixed*. ## Tasks / Subtasks -- [ ] **T1 — candidats** (AC 1, 4) : forme jointe, `amount_due` + `total_ttc` au candidat, lecteurs. -- [ ] **T2 — score et re-score** (AC 2, 3) : les deux appelants, affichage et mention, `matching.rs`. -- [ ] **T3 — tests et mutations** (AC 5). -- [ ] **T4 — textes** (AC 6) ; le champ TTC ajouté à la réponse : vérifier `docs/api-external.md` +- [x] **T1 — candidats** (AC 1, 4) : forme jointe, `amount_due` + `total_ttc` au candidat, lecteurs. +- [x] **T2 — score et re-score** (AC 2, 3) : les deux appelants, affichage et mention, `matching.rs`. +- [x] **T3 — tests et mutations** (AC 5). +- [x] **T4 — textes** (AC 6) ; le champ TTC ajouté à la réponse : vérifier `docs/api-external.md` (aujourd'hui muet sur ses champs) ; ouvrir l'issue des trois passages faux du manuel (Q2). -- [ ] **T5 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. +- [x] **T5 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. ## Dev Notes @@ -212,12 +212,75 @@ partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse ### Agent Model Used +Claude Opus 5.5 (`claude-opus-5-5`). + ### Debug Log References +- Mutations (chacune restaurée, fichier touché ensuite) : filtre remis sur `total_ttc` → le test de + dépôt **et** le Playwright échouent ; re-score remis sur `invoices::total_ttc` → le test + d'acceptation échoue ; triplet des propositions remis sur `total_ttc` → le test des propositions + échoue ; montant affiché remis sur le TTC → idem. Les quatre mutations sont tuées. +- Gate E2E complet : 9 échecs, tous à la liste de `docs/testing.md` § « Les échecs attendus » — + 7 KF-029 (#97) et 2 KF-045 (#421, run à 09:22 UTC, avant midi) ; aucune pollution. + ### Completion Notes List +- **Dépôt** : `find_unpaid_invoices_for_window` filtre sur le reste dû par la forme jointe + (`amount_due_derived_joins()` + `INVOICE_AMOUNT_DUE_DERIVED_SQL`), sur le brut, tolérance + inchangée ; `UnpaidInvoiceCandidate` porte `amount_due` **et** `total_ttc` (`COALESCE(lt.ttc, 0)`, + la graphie de `invoices.rs` pour les listes). Conditions `WHERE` qualifiées `i.` ; les colonnes de + `INVOICE_COLUMNS` restent non qualifiées — les tables dérivées n'exposent que `invoice_id` et leur + agrégat. +- **API** : les propositions passent le reste dû au score et l'affichent (`invoiceAmount`) ; nouveau + champ `invoiceTotalTtc`, présent seulement si reste ≠ TTC. Le re-score à l'acceptation lit + `invoice_settlements::amount_due` dans la transaction ; `invoices::total_ttc` n'a plus d'appelant + dans la réconciliation (import `invoices` retiré). **Aucun verrou ajouté** (c2), **aucun arrondi** + (c3). +- **Moteur** (`matching.rs`) : doc et noms disent « montant à régler », plus « TTC ». +- **Frontend** : mention `reconciliation-labels-amount-due-of` (« reste dû sur { $total } »), clé + neuve dans les 4 catalogues ; la garde i18n passe de 1751 à 1752 sites (recompté). +- **Tests** (périmètre : `main` → cette branche) : +1 test de dépôt, +2 e2e API (propositions, + acceptation hors score de référence **et** de contact, `amountScore == 1` asserté), +1 Vitest, + +1 spec Playwright (`reconciliation-amount-due.spec.ts`) ; l'e2e existant sans règlement asserte + en plus `invoiceTotalTtc` nul (AC 4). +- **Textes** : le paragraphe du score du manuel réécrit sur le code (candidates, trois critères et + leurs poids, pas de seuil ni d'acceptation automatique) ; `:1011` (QR du rappel) et `:1014` + (versement reste + frais : **non proposé**, refusé en trop-perçu au règlement manuel) ajustés. + **Trouvé par grep du symptôme** : la brochure marketing annonçait le même scoring faux (« date, + montant, référence, libellé ») → corrigée. PDF régénérés et contrôlés aplatis ; + `admin-manual.pdf`, régénéré sans changement de source, remis à l'identique. CHANGELOG + `[0.12.1]` *Fixed*. `docs/api-external.md` ne décrit pas les champs des propositions : rien à y + changer. Issue **#481** ouverte pour les trois passages faux restants (Q2). +- ⚠️ **Limite connue, à la c3** : une facture dont le reste dû a plus de deux décimales reste + candidate (tolérance ± 0.05) mais son score de montant vaut 0 ; sans référence ni contact + reconnus, elle n'est pas proposée. C'était déjà le cas sur le TTC. +- ⚠️ **À la c2** : la course acceptation / règlement manuel partiel (#480) devient plus atteignable ; + la c2 doit suivre. + ### File List +- `CHANGELOG.md` +- `crates/kesh-api/src/routes/reconciliation.rs` +- `crates/kesh-api/tests/reconciliation_e2e.rs` +- `crates/kesh-db/src/repositories/reconciliation.rs` +- `crates/kesh-db/tests/reconciliation_repository.rs` +- `crates/kesh-i18n/locales/de-CH/messages.ftl` +- `crates/kesh-i18n/locales/en-CH/messages.ftl` +- `crates/kesh-i18n/locales/fr-CH/messages.ftl` +- `crates/kesh-i18n/locales/it-CH/messages.ftl` +- `crates/kesh-reconciliation/src/matching.rs` +- `docs/manual/fr/marketing-brochure.pdf` +- `docs/manual/fr/marketing-brochure.tex` +- `docs/manual/fr/user-manual.pdf` +- `docs/manual/fr/user-manual.tex` +- `frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte` +- `frontend/src/lib/features/reconciliation/ReconciliationProposals.test.ts` +- `frontend/src/lib/features/reconciliation/reconciliation.types.ts` +- `frontend/src/lib/shared/i18n-keys.test.ts` +- `frontend/tests/e2e/reconciliation-amount-due.spec.ts` +- `_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` +- `_bmad-output/implementation-artifacts/sprint-status.yaml` + ## Change Log - **2026-09-28** — Créée. Inventaire par exploration, citations clés vérifiées ; le défaut est @@ -271,6 +334,11 @@ partie, suivi de « reste dû sur 1 000.00 » pour que le comptable reconnaisse **Bilan de la validation** — P1 Sonnet 3H/2M/1L → P2 Haiku 0 sans preuve, 2M repris par l'orchestrateur → P3 Opus 1C/3H/3M/5L → **découpage** (c2 #480, c3 #476) → P4 Sonnet 1M/1L → P5 Haiku ciblée 0 > LOW. Modèles : Sonnet, Haiku, Opus, Sonnet, Haiku. Reclassements : P5-F1 MEDIUM → LOW. +- **2026-09-30** — Implémentée (`bmad-dev-story`) : reste dû au filtre, au score, au re-score et à + l'affichage, mention du TTC ; manuel et brochure réécrits sur le code ; issue #481. Gates **réellement + exécutés** : backend complet sur base remise à zéro **2513/2513** (4 ignorés) ; frontend `check`, + `lint-i18n-ownership`, **836/836**, build ; **E2E complet 226 passed / 19 skipped / 9 failed**, + les 9 à la liste des échecs attendus. Statut → `review`. [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 0789bb98..a731c477 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -359,7 +359,7 @@ development_status: 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: done # 2026-09-27 revue CLOSE en 2 passes (1H/1M/1L -> 0 >LOW), gates verts (2510, 835, E2E conforme). PR a ouvrir : closes #416. # 2026-09-27 CREEE : frais non comptabilises => QR = reste du SANS frais (regle existante, admin-manual:1200) ; PDF de rappel = variante titree ; Q1 ligne "Frais de rappel", Q2 deja regle masque a zero, M5 papier hors perimetre (#477) ; validation CLOSE en 5 passes (1H/3M -> 0+1M -> 5M -> 1M -> 0 >LOW), pas de decoupage (Guy). # [#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: ready-for-dev # 2026-09-29 VALIDATION CLOSE en 5 passes (P4 1M/1L, P5 ciblee 0 >LOW) — prete pour dev. # 2026-09-29 PERIMETRE REDUIT au reste du dans le rapprochement (verrou -> c2 #480, arrondi -> c3 #476) ; validation P1 3H, P2 2M (orchestrateur), P3 1C/3H -> decoupage ; validation reprend en P4. # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. + 25-4-c-residuel-au-rapprochement: review # 2026-09-30 IMPLEMENTEE — gates backend 2513/2513, frontend 836/836, E2E 226/19/9 (9 attendus) ; #481 ouverte (manuel). # 2026-09-29 VALIDATION CLOSE en 5 passes (P4 1M/1L, P5 ciblee 0 >LOW) — prete pour dev. # 2026-09-29 PERIMETRE REDUIT au reste du dans le rapprochement (verrou -> c2 #480, arrondi -> c3 #476) ; validation P1 3H, P2 2M (orchestrateur), P3 1C/3H -> decoupage ; validation reprend en P4. # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. 25-4-c2-verrou-acceptation: backlog # [#480] 2026-09-29 NEE du decoupage de la 25-4-c (validation P3, severite P2 -> P3 non decroissante ; accord de Guy) — accept_one_invoice ne verrouille pas la facture : double reglement possible contre un reglement manuel partiel (settle_invoice ne bumpe version qu'au solde). ⛔ un simple FOR UPDATE au chargement desarme le controle optimiste (REPEATABLE READ : version lue a jour, amount_due perime) — lecture verrouillante du reste du, ou bump de version au reglement partiel. Interblocage lot/exercice -> 500 sans rejeu (retry_with ?). Findings P3 F1-F3 dans le Change Log de la 25-4-c. DOIT suivre la 25-4-c. 25-4-c3-arrondi-centime: backlog # [#476] 2026-09-29 NEE du decoupage de la 25-4-c — le reste du peut porter 4 decimales ; comparer au centime : 8 sites (filtre, score, gardes de trop-percu :1336 et settlements_write:167, soldes :1451 et :233, reouverture :464, dialogue SettleInvoiceDialog:111) ; helper sur Money::round_to_centimes, reminder_amount_due rebranche. ⚠️ QUESTION COMPTABLE A GUY : payer 10.01 une facture de 10.0050 laisse la creance crediteur de 0.0050 au grand livre (DECIMAL(19,4)) et affiche « Reste du -0.01 » — assumer, afficher arrondi, ecriture d'ecart, ou arrondir le TTC a la validation. Findings P1 F1-F3, P3 F4/F8/F10/F12 dans le Change Log de la 25-4-c. 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. diff --git a/crates/kesh-api/src/routes/reconciliation.rs b/crates/kesh-api/src/routes/reconciliation.rs index 595f3b5c..e0403484 100644 --- a/crates/kesh-api/src/routes/reconciliation.rs +++ b/crates/kesh-api/src/routes/reconciliation.rs @@ -27,8 +27,8 @@ use kesh_db::errors::DbError; use kesh_db::repositories::reconciliation::UnpaidInvoiceCandidate; use kesh_db::repositories::{ accounts as accounts_repo, audit_log, bank_accounts, contacts as contacts_repo, fiscal_years, - invoice_settlements, invoices, journal_entries, projects, - reconciliation as reconciliation_repo, reconciliation_rules, + invoice_settlements, journal_entries, projects, reconciliation as reconciliation_repo, + reconciliation_rules, }; use kesh_reconciliation::{ MatchScore, ReconciliationError, SplitDetail, build_journal_entry_for_counterparty, @@ -118,7 +118,13 @@ pub struct ReconciliationCandidate { // ----- Invoice candidate fields ----- pub invoice_id: Option, pub invoice_number: Option, + /// Montant à régler de la facture : son **reste dû** (#420, Story 25-4-c), + /// le montant comparé à la transaction — le TTC tant que rien n'est réglé. pub invoice_amount: Option, + /// TTC de la facture, **seulement** si elle est déjà réglée en partie + /// (reste dû ≠ TTC) — pour la mention « reste dû sur » qui la fait + /// reconnaître (Story 25-4-c, Q3). `None` sinon : rien à ajouter. + pub invoice_total_ttc: Option, pub invoice_date: Option, // ----- Rule candidate fields (Story 8-5b) ----- pub rule_id: Option, @@ -557,7 +563,8 @@ pub async fn get_proposals( let proposals: Vec = tx_candidates .into_iter() .map(|(tx, candidate_invoices)| { - // #246 (21-2b) : le triplet candidat porte le TTC (matching sur TTC). + // #420 (25-4-c) : le triplet candidat porte le RESTE DÛ — le TTC + // excluait le virement qui soldait une facture réglée en partie. let candidates_with_contacts: Vec<( Invoice, Option, @@ -568,7 +575,7 @@ pub async fn get_proposals( ( c.invoice.clone(), contacts_map.get(&c.invoice.contact_id).cloned(), - c.total_ttc, + c.amount_due, ) }) .collect(); @@ -588,10 +595,13 @@ pub async fn get_proposals( candidate_type: CandidateType::Invoice, invoice_id: Some(inv.id), invoice_number: inv.invoice_number.clone(), - // #246 (21-2b, V4-1) : montant affiché dans l'UI candidats - // = TTC (à côté du montant de la tx, TTC lui aussi) — - // `total_amount` (HT) affichait un montant ≠ tx. - invoice_amount: Some(cand.total_ttc.normalize().to_string()), + // #420 (25-4-c) : montant affiché dans l'UI candidats + // = reste dû, la grandeur comparée à la tx (après le + // HT, #246, puis le TTC, qui affichait 1 000 pour un + // solde de 600). Le TTC ne suit que si les deux diffèrent. + invoice_amount: Some(cand.amount_due.normalize().to_string()), + invoice_total_ttc: (cand.amount_due != cand.total_ttc) + .then(|| cand.total_ttc.normalize().to_string()), invoice_date: Some(inv.date), rule_id: None, rule_label: None, @@ -624,6 +634,7 @@ pub async fn get_proposals( invoice_id: None, invoice_number: None, invoice_amount: None, + invoice_total_ttc: None, invoice_date: None, rule_id: Some(rule.id), rule_label: Some(rule.label.clone()), @@ -1190,10 +1201,11 @@ async fn accept_one_invoice( let sale_entry_id = invoice.journal_entry_id.unwrap(); // Step 7 — re-calculer score serveur-side (M7 Pass 1). - // #246 (21-2b) : re-score sur le TTC — `invoice` est chargé sans lignes, - // on récupère le TTC via l'expression SQL canonique (identique au filtre - // de `find_unpaid_invoices_for_window`, parité garantie 21-2a). - let invoice_ttc = invoices::total_ttc(&mut **tx, invoice.id) + // #420 (25-4-c) : re-score sur le RESTE DÛ, la grandeur du filtre de + // `find_unpaid_invoices_for_window` — forme scalaire (UNE facture), dans + // la transaction ; les deux formes sont tenues d'accord par le test de + // parité (`invoice_amount_due_parity.rs`). + let invoice_amount_due = invoice_settlements::amount_due(&mut **tx, invoice.id) .await .map_err(|e| FailedProposal { bank_transaction_id, @@ -1204,7 +1216,7 @@ async fn accept_one_invoice( Invoice, Option, rust_decimal::Decimal, - )> = vec![(invoice.clone(), contact.clone(), invoice_ttc)]; + )> = vec![(invoice.clone(), contact.clone(), invoice_amount_due)]; let proposals_score = propose_matches(&bank_transaction, &candidates_for_score); let score = proposals_score .first() diff --git a/crates/kesh-api/tests/reconciliation_e2e.rs b/crates/kesh-api/tests/reconciliation_e2e.rs index 28f9ec5b..3a0e05f9 100644 --- a/crates/kesh-api/tests/reconciliation_e2e.rs +++ b/crates/kesh-api/tests/reconciliation_e2e.rs @@ -358,9 +358,10 @@ async fn insert_invoice( .await .expect("invoice insert"); let invoice_id = result.last_insert_id() as i64; - // #246 (21-2b) : le matching filtre sur le TTC (dérivé des lignes). Sans - // ligne, TTC = 0 → jamais candidate. Ligne unique vat_rate 0 → TTC = - // total_amount, les assertions de montant existantes restent valides. + // #246 (21-2b), #420 (25-4-c) : le matching filtre sur le reste dû (TTC + // dérivé des lignes, moins les règlements). Sans ligne, reste dû = 0 → + // jamais candidate. Ligne unique vat_rate 0, sans règlement → reste dû = + // TTC = total_amount, les assertions de montant existantes restent valides. sqlx::query( "INSERT INTO invoice_lines (invoice_id, position, description, quantity, unit_price, vat_rate, line_total) \ VALUES (?, 1, 'Ligne test', 1, ?, 0, ?)", @@ -817,9 +818,14 @@ async fn get_proposals_matches_and_shows_ttc(pool: MySqlPool) { .expect("facture candidate pour la tx TTC"); assert_eq!( inv_cand["invoiceAmount"], "108.1", - "invoiceAmount doit être le TTC (normalisé), pas le HT 100" + "invoiceAmount doit être le TTC (normalisé), pas le HT 100 — sans \ + règlement, le reste dû vaut le TTC (#420, Story 25-4-c)" ); assert_eq!(inv_cand["score"]["amountScore"], 1.0); + assert!( + inv_cand["invoiceTotalTtc"].is_null(), + "sans règlement, pas de mention « reste dû sur » : reste dû = TTC" + ); // tx 100.00 (HT) → pas de candidate facture (le HT ne matche plus). let ht_prop = proposals @@ -2893,6 +2899,160 @@ async fn accept_settles_an_invoice_in_two_payments(pool: MySqlPool) { assert!(settlements.iter().all(|(entry_id, _)| *entry_id != je_id)); } +/// Story 25-4-c (#420) — une facture de 1 000.— **à TVA non nulle** (HT 925.07 +/// @ 8.1 % → TVA 74.93), déjà réglée de 400.— par un premier virement accepté, +/// plus une transaction de 600.— — le solde — qui ne porte **ni** la référence +/// de la facture **ni** le nom du contact : seul le score de MONTANT peut la +/// relier à la facture. Retourne `(invoice_id, tx_du_solde)`. +async fn seed_partially_settled_vat_invoice( + pool: &MySqlPool, + app: &TestApp, + ctx: &CompanyCtx, +) -> (i64, i64) { + let day = NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(); + let inv_date = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(); + // L'écriture de vente débite la créance du TTC, 1 000.— ; la ligne de + // facture est ensuite remplacée par sa forme à TVA, de même TTC. + let (inv_id, _je_id) = seed_validated_invoice( + pool, + ctx.company_id, + ctx.contact_id, + "INV-VAT-PART", + inv_date, + dec!(1000.00), + ) + .await; + sqlx::query( + "UPDATE invoice_lines SET unit_price = 925.07, line_total = 925.07, vat_rate = 8.10 \ + WHERE invoice_id = ?", + ) + .bind(inv_id) + .execute(pool) + .await + .expect("ligne à TVA"); + sqlx::query("UPDATE invoices SET total_amount = 925.07 WHERE id = ?") + .bind(inv_id) + .execute(pool) + .await + .expect("HT de la facture"); + + let tx_ids = seed_bank_transactions( + pool, + ctx.company_id, + ctx.bank_account_id, + ctx.user_id, + &unique_hash("partially_settled_vat"), + day, + day, + vec![ + // Premier virement : 400.—, avec la référence (score de référence). + make_new_tx( + ctx.company_id, + ctx.bank_account_id, + day, + Some(day), + dec!(400.00), + "CHF", + "INV-VAT-PART", + Some("Solde Client"), + ), + // Le solde : 600.—, sans référence ni contact reconnaissables. + make_new_tx( + ctx.company_id, + ctx.bank_account_id, + day, + Some(day), + dec!(600.00), + "CHF", + "VIREMENT DIVERS", + Some("Tiers Inconnu SA"), + ), + ], + ) + .await; + let body = post_accept_one(app, ctx, tx_ids[0], inv_id).await; + assert_eq!( + body["accepted"].as_array().unwrap().len(), + 1, + "le premier virement doit régler 400.— ; failed = {:?}", + body["failed"] + ); + (inv_id, tx_ids[1]) +} + +/// #420 (Story 25-4-c) — le virement du **solde** d'une facture réglée en +/// partie est proposé, score de montant 1, montant affiché = le reste, et le +/// TTC en mention. Avant la correction, la facture n'était même pas candidate. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn get_proposals_offers_the_amount_due_of_a_partially_settled_invoice(pool: MySqlPool) { + let ctx = setup_company(&pool, "Solde", "CH4431999123000889012", Role::Comptable).await; + let app = spawn_app(pool.clone()).await; + let (inv_id, balance_tx_id) = seed_partially_settled_vat_invoice(&pool, &app, &ctx).await; + + let resp = app + .client + .get(app.url(&format!( + "/api/v1/reconciliation/proposals?bankAccountId={}", + ctx.bank_account_id + ))) + .bearer_auth(&ctx.jwt) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + let prop = body["proposals"] + .as_array() + .expect("proposals array") + .iter() + .find(|p| p["bankTransactionId"] == balance_tx_id) + .expect("proposition pour le virement du solde"); + let cand = prop["candidates"] + .as_array() + .unwrap() + .iter() + .find(|c| c["invoiceId"] == inv_id) + .expect("la facture réglée en partie doit être candidate pour son solde"); + assert_eq!(cand["score"]["amountScore"], 1.0); + assert_eq!(cand["score"]["referenceScore"], 0.0); + assert_eq!(cand["score"]["contactScore"], 0.0); + assert_eq!( + cand["invoiceAmount"], "600", + "le montant affiché est le reste dû" + ); + assert_eq!(cand["invoiceTotalTtc"], "1000", "le TTC suit, en mention"); +} + +/// #420 (Story 25-4-c) — le virement du solde **s'accepte** et solde la +/// facture, sur le seul score de montant : sans référence ni contact, le total +/// valait 0 avant la correction (`RECONCILIATION_SCORE_TOO_LOW`). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn accept_settles_the_balance_of_a_partially_settled_invoice_on_amount(pool: MySqlPool) { + let ctx = setup_company(&pool, "Solde2", "CH4431999123000889012", Role::Comptable).await; + let app = spawn_app(pool.clone()).await; + let (inv_id, balance_tx_id) = seed_partially_settled_vat_invoice(&pool, &app, &ctx).await; + + let body = post_accept_one(&app, &ctx, balance_tx_id, inv_id).await; + let accepted = body["accepted"].as_array().unwrap(); + assert_eq!(accepted.len(), 1, "failed = {:?}", body["failed"]); + assert_eq!(accepted[0]["score"]["amountScore"], 1.0); + assert_eq!(accepted[0]["score"]["referenceScore"], 0.0); + assert_eq!(accepted[0]["score"]["contactScore"], 0.0); + + let paid_at: Option = + sqlx::query_scalar("SELECT paid_at FROM invoices WHERE id = ?") + .bind(inv_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(paid_at.is_some(), "le solde versé, la facture est payée"); + assert_eq!( + receivable_balance(&pool, ctx.receivable_account_id).await, + Decimal::ZERO, + "⛔ le compte de créance se solde exactement" + ); +} + /// ⛔ **Le trop-perçu est refusé, il ne s'écrit pas.** /// /// Sans ce garde, le compte de créance passerait créditeur — un solde contre diff --git a/crates/kesh-db/src/repositories/reconciliation.rs b/crates/kesh-db/src/repositories/reconciliation.rs index ad0b3656..bcf9b03d 100644 --- a/crates/kesh-db/src/repositories/reconciliation.rs +++ b/crates/kesh-db/src/repositories/reconciliation.rs @@ -41,16 +41,28 @@ use crate::entities::bank_transaction::BankTransaction; use crate::entities::contact::Contact; use crate::entities::invoice::Invoice; use crate::errors::{DbError, map_db_error}; +use crate::repositories::invoice_settlements::{ + INVOICE_AMOUNT_DUE_DERIVED_SQL, amount_due_derived_joins, +}; /// Colonnes Invoice pour SELECT (cohérent FIND_INVOICE_SCOPED_SQL). const INVOICE_COLUMNS: &str = "id, company_id, contact_id, invoice_number, status, date, \ due_date, payment_terms, total_amount, journal_entry_id, paid_at, emailed_at, emailed_to, \ project_id, dunning_paused_at, dunning_paused_note, version, created_at, updated_at"; -/// Facture candidate à la réconciliation, accompagnée de son **TTC** (#246, -/// Story 21-2b) — le montant réellement comparé à l'encaissement bancaire -/// (`invoice.total_amount` est le HT et ne matcherait jamais une facture -/// avec TVA). +/// Facture candidate à la réconciliation, accompagnée de son **reste dû** et +/// de son **TTC**. +/// +/// - `amount_due` (#420, Story 25-4-c) — le montant réellement comparé à +/// l'encaissement bancaire : `TTC − avoir émis − Σ règlements`. Une facture +/// déjà réglée en partie attend son **solde**, pas son TTC ; comparer le TTC +/// l'excluait des candidates du virement qui la soldait. +/// - `total_ttc` (#246, Story 21-2b) — conservé pour l'**affichage** seulement +/// (« reste dû sur 1 000.00 »), jamais pour comparer. `invoice.total_amount` +/// est le HT et ne matcherait jamais une facture avec TVA. +/// +/// Le reste dû est **brut** (jusqu'à 4 décimales) : l'arrondi au centime est +/// la Story 25-4-c3 (#476), pas celle-ci. /// /// `#[sqlx(flatten)]` : **premier usage du workspace** — `Invoice` dérive /// `FromRow` et le SELECT liste ses colonnes via `INVOICE_COLUMNS`, donc le @@ -60,6 +72,7 @@ const INVOICE_COLUMNS: &str = "id, company_id, contact_id, invoice_number, statu pub struct UnpaidInvoiceCandidate { #[sqlx(flatten)] pub invoice: Invoice, + pub amount_due: Decimal, pub total_ttc: Decimal, } @@ -77,10 +90,15 @@ const BANK_TX_COLUMNS: &str = "id, company_id, import_id, bank_account_id, booki /// 2. `status = 'validated' AND paid_at IS NULL AND journal_entry_id IS NOT NULL` /// (factures éligibles à la réconciliation v0.1). /// 3. `date BETWEEN tx_date - window_days AND tx_date + window_days`. -/// 4. **TTC** `BETWEEN tx_amount - amount_tolerance AND tx_amount + amount_tolerance` -/// (#246, Story 21-2b) — le filtre porte sur le TTC (dérivé des lignes via -/// l'expression SQL canonique `INVOICE_TTC_SUBQUERY_SQL`), plus sur le HT -/// `total_amount` : une facture avec TVA ne matchait jamais un encaissement. +/// 4. **Reste dû** `BETWEEN tx_amount - amount_tolerance AND tx_amount + amount_tolerance` +/// (#420, Story 25-4-c) — après le HT (#246 : une facture avec TVA ne +/// matchait jamais) puis le TTC (une facture réglée en partie ne matchait +/// jamais son solde). Le reste dû est pris **brut** : la tolérance couvre +/// l'écart à son arrondi au centime (au plus 0.005). +/// +/// ⛔ **Forme jointe** ([`amount_due_derived_joins`] + +/// [`INVOICE_AMOUNT_DUE_DERIVED_SQL`]) : c'est une liste, la forme corrélée y +/// serait réévaluée par ligne — un N+1 déguisé. /// /// **Pas de filtre currency v0.1** — colonne inexistante (cf. L38 + S4-1 Pass 4). /// @@ -89,7 +107,7 @@ const BANK_TX_COLUMNS: &str = "id, company_id, import_id, bank_account_id, booki /// /// **Index** : `idx_invoices_company_validated_unpaid_date` créé en /// migration `20260507100001_reconciliation_8_4.sql` (couvre le `WHERE` ; -/// le filtre TTC est appliqué en `HAVING` après retrait des non-éligibles). +/// le filtre du reste dû est appliqué en `HAVING` après retrait des non-éligibles). pub async fn find_unpaid_invoices_for_window<'e, E>( executor: E, company_id: i64, @@ -101,19 +119,23 @@ pub async fn find_unpaid_invoices_for_window<'e, E>( where E: sqlx::Executor<'e, Database = MySql>, { - // Alias `i` requis par la sous-requête corrélée TTC (`WHERE l.invoice_id = i.id`). - // Filtre TTC en HAVING pour référencer l'alias `total_ttc` sans dupliquer - // l'expression (SQL n'autorise pas un alias de SELECT dans le WHERE). - let ttc_sql = crate::repositories::invoices::INVOICE_TTC_SUBQUERY_SQL; + // Alias `i` requis par les tables dérivées du reste dû (`… ON … = i.id`). + // Elles n'exposent que `invoice_id` et leur agrégat : les colonnes de + // `INVOICE_COLUMNS` restent sans ambiguïté. Filtre en HAVING pour + // référencer l'alias `amount_due` sans dupliquer l'expression (SQL + // n'autorise pas un alias de SELECT dans le WHERE). sqlx::query_as::<_, UnpaidInvoiceCandidate>(&format!( - "SELECT {INVOICE_COLUMNS}, {ttc_sql} AS total_ttc FROM invoices i \ - WHERE company_id = ? \ - AND status = 'validated' \ - AND paid_at IS NULL \ - AND journal_entry_id IS NOT NULL \ - AND date BETWEEN DATE_SUB(?, INTERVAL ? DAY) AND DATE_ADD(?, INTERVAL ? DAY) \ - HAVING total_ttc BETWEEN ? - ? AND ? + ? \ + "SELECT {INVOICE_COLUMNS}, {due} AS amount_due, COALESCE(lt.ttc, 0) AS total_ttc \ + FROM invoices i {joins} \ + WHERE i.company_id = ? \ + AND i.status = 'validated' \ + AND i.paid_at IS NULL \ + AND i.journal_entry_id IS NOT NULL \ + AND i.date BETWEEN DATE_SUB(?, INTERVAL ? DAY) AND DATE_ADD(?, INTERVAL ? DAY) \ + HAVING amount_due BETWEEN ? - ? AND ? + ? \ LIMIT 50", + due = INVOICE_AMOUNT_DUE_DERIVED_SQL, + joins = amount_due_derived_joins(), )) .bind(company_id) .bind(tx_date) diff --git a/crates/kesh-db/tests/reconciliation_repository.rs b/crates/kesh-db/tests/reconciliation_repository.rs index 4f8f7637..280c326d 100644 --- a/crates/kesh-db/tests/reconciliation_repository.rs +++ b/crates/kesh-db/tests/reconciliation_repository.rs @@ -182,9 +182,10 @@ async fn insert_test_invoice( .await .expect("invoice insert"); let invoice_id = result.last_insert_id() as i64; - // #246 (21-2b) : le matching filtre désormais sur le TTC (dérivé des - // lignes). Une facture sans ligne aurait un TTC = 0 et ne matcherait - // jamais. On pose une ligne unique à vat_rate 0 → TTC = total_amount, + // #246 (21-2b), #420 (25-4-c) : le matching filtre sur le reste dû, + // dérivé des lignes (TTC) et des règlements. Une facture sans ligne + // aurait un reste dû = 0 et ne matcherait jamais. On pose une ligne + // unique à vat_rate 0, sans règlement → reste dû = TTC = total_amount, // les assertions de montant existantes restent inchangées. sqlx::query( "INSERT INTO invoice_lines (invoice_id, position, description, quantity, unit_price, vat_rate, line_total) \ @@ -805,6 +806,9 @@ async fn find_unpaid_matches_on_ttc_not_ht(pool: MySqlPool) { "la facture doit matcher l'encaissement TTC 108.10" ); assert_eq!(cand.unwrap().total_ttc, dec!(108.10)); + // #420 (Story 25-4-c) : sans règlement, le reste dû — la grandeur filtrée + // et comparée — vaut exactement le TTC. + assert_eq!(cand.unwrap().amount_due, dec!(108.10)); // tx = 100.00 (HT) → PAS de match (le bug d'avant matchait le HT). let found_ht = reconciliation_repo::find_unpaid_invoices_for_window( @@ -822,3 +826,105 @@ async fn find_unpaid_matches_on_ttc_not_ht(pool: MySqlPool) { "le HT 100.00 ne doit plus matcher (régression #246 corrigée)" ); } + +/// #420 (Story 25-4-c) — le filtre porte sur le **reste dû**, plus sur le TTC. +/// Régression corrigée : une facture de 1 000.— (TVA 8.1 %) déjà réglée de +/// 400.— était **exclue** des candidates d'un virement de 600.— — le solde +/// exact qu'attendait le client —, parce que le filtre comparait le TTC. +#[sqlx::test(migrations = "./test-schema")] +async fn find_unpaid_matches_on_amount_due_of_partially_settled_invoice(pool: MySqlPool) { + let company_id = create_test_company(&pool, "Reste Co").await; + let user_id = create_test_user(&pool, "carla", company_id).await; + let contact_id = create_test_contact(&pool, company_id, user_id, "Client reste").await; + let bank_id = create_test_bank_account(&pool, company_id, "CH9300762011623852957").await; + let fy_id = insert_fake_fiscal_year(&pool, company_id).await; + let sale_je_id = insert_fake_journal_entry(&pool, company_id, fy_id).await; + + let tx_date = NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(); + let inv_date = NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(); + + // HT 925.07 @ 8.1 % → TVA 74.93 → TTC 1 000.00. + let inv_id = sqlx::query( + "INSERT INTO invoices (company_id, contact_id, invoice_number, status, date, \ + total_amount, journal_entry_id, version, created_at, updated_at) \ + VALUES (?, ?, 'INV-PART-1', 'validated', ?, ?, ?, 1, NOW(3), NOW(3))", + ) + .bind(company_id) + .bind(contact_id) + .bind(inv_date) + .bind(dec!(925.07)) + .bind(sale_je_id) + .execute(&pool) + .await + .expect("invoice insert") + .last_insert_id() as i64; + sqlx::query( + "INSERT INTO invoice_lines (invoice_id, position, description, quantity, unit_price, vat_rate, line_total) \ + VALUES (?, 1, 'Prestation', 1, 925.07, 8.10, 925.07)", + ) + .bind(inv_id) + .execute(&pool) + .await + .expect("line insert"); + + // Règlement partiel de 400.— : il lui faut sa propre écriture + // (`uq_invoice_settlements_entry`). + let settle_je_id = sqlx::query( + "INSERT INTO journal_entries (company_id, fiscal_year_id, entry_number, entry_date, \ + journal, description, version, created_at, updated_at) \ + VALUES (?, ?, 2, '2026-05-05', 'Banque', 'règlement partiel', 1, NOW(3), NOW(3))", + ) + .bind(company_id) + .bind(fy_id) + .execute(&pool) + .await + .expect("settlement journal_entry insert") + .last_insert_id() as i64; + sqlx::query( + "INSERT INTO invoice_settlements (company_id, invoice_id, journal_entry_id, amount, \ + settled_on, settlement_type, settlement_bank_account_id) \ + VALUES (?, ?, ?, 400.00, '2026-05-05', 'bank_transfer', ?)", + ) + .bind(company_id) + .bind(inv_id) + .bind(settle_je_id) + .bind(bank_id) + .execute(&pool) + .await + .expect("settlement insert"); + + // Virement du solde, 600.— → la facture est candidate, avec son reste + // ET son TTC (la mention « reste dû sur 1 000.00 » de l'écran). + let found_due = reconciliation_repo::find_unpaid_invoices_for_window( + &pool, + company_id, + tx_date, + dec!(600.00), + 30, + dec!(0.05), + ) + .await + .expect("find_unpaid reste dû"); + let cand = found_due + .iter() + .find(|c| c.invoice.id == inv_id) + .expect("la facture réglée en partie doit être candidate pour son reste de 600.00"); + assert_eq!(cand.amount_due, dec!(600.00)); + assert_eq!(cand.total_ttc, dec!(1000.00)); + + // Virement du TTC, 1 000.— → plus candidate : il dépasserait le reste. + let found_ttc = reconciliation_repo::find_unpaid_invoices_for_window( + &pool, + company_id, + tx_date, + dec!(1000.00), + 30, + dec!(0.05), + ) + .await + .expect("find_unpaid TTC"); + assert!( + !found_ttc.iter().any(|c| c.invoice.id == inv_id), + "le TTC 1 000.00 ne doit plus matcher une facture dont il reste 600.00" + ); +} diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 0bb1a195..29e13d7b 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -934,6 +934,7 @@ reconciliation-labels-empty = Keine Transaktionen zur Abstimmung vorhanden. reconciliation-labels-no-account = Kein Bankkonto konfiguriert. reconciliation-labels-account-select = Bankkonto reconciliation-labels-no-candidate = Keine Übereinstimmung +reconciliation-labels-amount-due-of = Restbetrag von { $total } reconciliation-labels-success-suffix = Vorgang/Vorgänge erfolgreich. reconciliation-labels-failed = Teilfehler reconciliation-cols-tx-date = Datum diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index eb833fb7..81a9c74f 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -934,6 +934,7 @@ reconciliation-labels-empty = No transactions awaiting reconciliation. reconciliation-labels-no-account = No bank account configured. reconciliation-labels-account-select = Bank account reconciliation-labels-no-candidate = No match +reconciliation-labels-amount-due-of = balance due of { $total } reconciliation-labels-success-suffix = operation(s) succeeded. reconciliation-labels-failed = Partial failures reconciliation-cols-tx-date = Date diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 425c0dd8..59fdad51 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -983,6 +983,7 @@ reconciliation-labels-empty = Aucune transaction en attente de réconciliation. reconciliation-labels-no-account = Aucun compte bancaire configuré. reconciliation-labels-account-select = Compte bancaire reconciliation-labels-no-candidate = Aucune correspondance +reconciliation-labels-amount-due-of = reste dû sur { $total } reconciliation-labels-success-suffix = opération(s) réussie(s). reconciliation-labels-failed = Échecs partiels reconciliation-cols-tx-date = Date diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 25711aad..5bcd832b 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -934,6 +934,7 @@ reconciliation-labels-empty = Nessuna transazione in attesa di riconciliazione. reconciliation-labels-no-account = Nessun conto bancario configurato. reconciliation-labels-account-select = Conto bancario reconciliation-labels-no-candidate = Nessuna corrispondenza +reconciliation-labels-amount-due-of = residuo su { $total } reconciliation-labels-success-suffix = operazione/i riuscita/e. reconciliation-labels-failed = Errori parziali reconciliation-cols-tx-date = Data diff --git a/crates/kesh-reconciliation/src/matching.rs b/crates/kesh-reconciliation/src/matching.rs index 6ae09c1b..aed947d0 100644 --- a/crates/kesh-reconciliation/src/matching.rs +++ b/crates/kesh-reconciliation/src/matching.rs @@ -5,10 +5,11 @@ //! //! - **Montant** (0.50) : `1.0` si exact ([`Decimal::normalize`]), //! sinon `0.0` (pas de gradient — décision conservatrice v0.1). Le montant -//! comparé est le **TTC** de la facture (#246, Story 21-2b) — un encaissement -//! bancaire est TTC ; le HT `invoice.total_amount` ne matcherait jamais une -//! facture avec TVA. Le crate restant pur (pas d'accès DB), le caller fournit -//! le TTC dans le tuple candidat. +//! comparé est le **montant à régler** de la facture, son **reste dû** +//! (`TTC − avoir émis − Σ règlements`, #420, Story 25-4-c) : un virement qui +//! solde une facture réglée en partie porte ce reste, pas le TTC — et +//! encore moins le HT `invoice.total_amount` (#246). Le crate restant pur +//! (pas d'accès DB), le caller fournit ce montant dans le tuple candidat. //! - **Référence** (0.40) : `1.0` si containment bidirectionnel post //! normalisation `coalesce(reference, end_to_end_id, transaction_id)` //! vs `invoice.invoice_number`, `0.5` si common prefix ≥ 4 chars @@ -55,17 +56,17 @@ pub struct MatchProposal { /// candidates. Retourne uniquement les paires `score.total > 0.0`, /// triées par `score.total DESC`. Pure (zéro I/O). /// -/// Chaque candidat est un triplet `(facture, contact, total_ttc)` — le -/// **TTC** (#246, Story 21-2b) fourni par le caller (le crate reste pur) et -/// comparé au montant de la transaction bancaire. +/// Chaque candidat est un triplet `(facture, contact, montant à régler)` — le +/// **reste dû** de la facture (#420, Story 25-4-c), fourni par le caller (le +/// crate reste pur) et comparé au montant de la transaction bancaire. pub fn propose_matches( tx: &BankTransaction, candidates: &[(Invoice, Option, Decimal)], ) -> Vec { let mut out: Vec = candidates .iter() - .filter_map(|(invoice, contact, total_ttc)| { - let amount_score = amount_score(tx.amount, *total_ttc); + .filter_map(|(invoice, contact, amount_to_settle)| { + let amount_score = amount_score(tx.amount, *amount_to_settle); let reference_score = reference_score( tx.reference.as_deref(), tx.end_to_end_id.as_deref(), @@ -105,12 +106,12 @@ pub fn propose_matches( out } -fn amount_score(tx_amount: rust_decimal::Decimal, invoice_amount: rust_decimal::Decimal) -> f64 { +fn amount_score(tx_amount: rust_decimal::Decimal, amount_to_settle: rust_decimal::Decimal) -> f64 { // M4 (équivalent Story 8-3) : `Decimal::normalize()` strip trailing // zeros pour matcher 1.50 == 1.5 (DB peut renvoyer scale différent // du parser). Décision v0.1 : binaire 0/1 (pas de gradient sur // écart de centimes) — cf. L17. - if tx_amount.normalize() == invoice_amount.normalize() { + if tx_amount.normalize() == amount_to_settle.normalize() { 1.0 } else { 0.0 @@ -271,13 +272,13 @@ mod tests { } } - /// Construit un triplet candidat `(facture, contact, ttc)` pour les tests - /// (#246, 21-2b). Ces fixtures n'ont pas de lignes TVA → le TTC vaut le - /// `total_amount` posé directement, ce qui préserve exactement les - /// assertions de montant existantes. + /// Construit un triplet candidat `(facture, contact, montant à régler)` + /// pour les tests (#246, #420). Ces fixtures n'ont ni lignes TVA ni + /// règlement → le reste dû vaut le `total_amount` posé directement, ce qui + /// préserve exactement les assertions de montant existantes. fn cand(invoice: Invoice, contact: Option) -> (Invoice, Option, Decimal) { - let ttc = invoice.total_amount; - (invoice, contact, ttc) + let amount_to_settle = invoice.total_amount; + (invoice, contact, amount_to_settle) } /// AC #30 — full match → score 1.0 (0.50 + 0.40 + 0.10). diff --git a/docs/manual/fr/marketing-brochure.pdf b/docs/manual/fr/marketing-brochure.pdf index 02f4f5d0fdf51dde9a5d2029ba001d35197bd5e7..6f6048dc59b2bc6350cce2217dbc003d0bc27bb6 100644 GIT binary patch delta 8350 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 F{{rKoPW1o) delta 8324 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 D^@AXT diff --git a/docs/manual/fr/marketing-brochure.tex b/docs/manual/fr/marketing-brochure.tex index 8aa98ed2..a9d3a6d9 100644 --- a/docs/manual/fr/marketing-brochure.tex +++ b/docs/manual/fr/marketing-brochure.tex @@ -164,7 +164,7 @@ \section*{\color{keshPrimary}Ce qui distingue Kesh} \sffamily Import natif des extraits de compte \textbf{CAMT.053 ISO 20022} (standard suisse officiel) et CSV multi-encodage avec \emph{profils banque réutilisables}. Détection automatique des doublons, gestion du rejet partiel. -Réconciliation \emph{automatique avec scoring} (date, montant, référence, libellé) et \emph{manuelle} pour les cas complexes. Éclatement de transactions agrégées (split). Règles d'affectation automatique pour les écritures récurrentes (loyers, salaires, abonnements). +Réconciliation \emph{automatique avec scoring} (montant restant dû, référence, contact, sur une fenêtre de 30 jours — le solde d'une facture réglée en partie est reconnu) et \emph{manuelle} pour les cas complexes. Éclatement de transactions agrégées (split). Règles d'affectation automatique pour les écritures récurrentes (loyers, salaires, abonnements). \end{tcolorbox} \vspace{0.3cm} diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index bb5b4dd8fbcc32fb8846c9d50fdcf965a447039d..f68c17a33eef99c152198f4fadefa0357eb9ab58 100644 GIT binary patch delta 27403 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 {p.candidates[0].invoiceNumber ?? `#${p.candidates[0].invoiceId}`} - ({p.candidates[0].invoiceAmount}) + ({p.candidates[0].invoiceAmount}{#if p.candidates[0].invoiceTotalTtc} + , {i18nMsg('reconciliation-labels-amount-due-of', 'reste dû sur { $total }', { + total: p.candidates[0].invoiceTotalTtc, + })}{/if}) {/if} diff --git a/frontend/src/lib/features/reconciliation/ReconciliationProposals.test.ts b/frontend/src/lib/features/reconciliation/ReconciliationProposals.test.ts index 0b396220..155e0f2f 100644 --- a/frontend/src/lib/features/reconciliation/ReconciliationProposals.test.ts +++ b/frontend/src/lib/features/reconciliation/ReconciliationProposals.test.ts @@ -31,9 +31,10 @@ vi.mock('$lib/features/accounts/accounts.api', () => ({ })); // Mock i18n util pour rendre le composant déterministe (renvoie le -// fallback fourni en deuxième argument). +// fallback fourni en deuxième argument, variables `{ $x }` interpolées). vi.mock('$lib/shared/utils/i18n.svelte', () => ({ - i18nMsg: (_key: string, fallback: string) => fallback, + i18nMsg: (_key: string, fallback: string, args?: Record) => + args ? fallback.replace(/\{\s*\$(\w+)\s*\}/g, (_, k) => String(args[k] ?? '')) : fallback, })); import * as api from './reconciliation.api'; @@ -45,6 +46,10 @@ function makeProposalWithCandidate( bankTransactionId: number, invoiceId: number, scoreTotal: number, + amounts: { invoiceAmount: string; invoiceTotalTtc: string | null } = { + invoiceAmount: '100.00', + invoiceTotalTtc: null, + }, ): GetProposalsResponse['proposals'][number] { return { bankTransactionId, @@ -61,7 +66,8 @@ function makeProposalWithCandidate( candidateType: 'invoice' as const, invoiceId, invoiceNumber: `INV-2026-${invoiceId}`, - invoiceAmount: '100.00', + invoiceAmount: amounts.invoiceAmount, + invoiceTotalTtc: amounts.invoiceTotalTtc, invoiceDate: '2026-05-10', ruleId: null, ruleLabel: null, @@ -128,6 +134,28 @@ describe('ReconciliationProposals', () => { expect(badges[0].getAttribute('data-score-tier')).toBe('high'); }); + // Story 25-4-c (#420) — la candidate affiche le RESTE DÛ ; le TTC ne + // suit, en mention, que pour une facture déjà réglée en partie. + it('shows the amount due, and the TTC only for a partially settled invoice', async () => { + mockApi.getProposals.mockResolvedValue({ + proposals: [ + makeProposalWithCandidate(1, 101, 1.0, { invoiceAmount: '600', invoiceTotalTtc: '1000' }), + makeProposalWithCandidate(2, 102, 1.0), + ], + hasMore: false, + } satisfies GetProposalsResponse); + + const { findAllByTestId, queryAllByTestId } = render(ReconciliationProposals, { + bankAccountId: 17, + }); + + const tops = await findAllByTestId('candidate-top-1'); + expect(tops[0].textContent).toContain('(600, reste dû sur 1000)'); + expect(tops[1].textContent).toContain('(100.00)'); + expect(tops[1].textContent).not.toContain('reste dû sur'); + expect(queryAllByTestId('candidate-top-1-amount-due-of')).toHaveLength(1); + }); + it('shows neutral state for tx without candidates', async () => { mockApi.getProposals.mockResolvedValue({ proposals: [makeProposalWithoutCandidate(2)], diff --git a/frontend/src/lib/features/reconciliation/reconciliation.types.ts b/frontend/src/lib/features/reconciliation/reconciliation.types.ts index 5dde4073..9274c2df 100644 --- a/frontend/src/lib/features/reconciliation/reconciliation.types.ts +++ b/frontend/src/lib/features/reconciliation/reconciliation.types.ts @@ -27,7 +27,10 @@ export interface ReconciliationCandidate { // Invoice candidate fields invoiceId: number | null; invoiceNumber: string | null; + /** Montant à régler : le reste dû de la facture (Story 25-4-c, #420). */ invoiceAmount: string | null; + /** TTC, présent seulement si la facture est déjà réglée en partie (Story 25-4-c). */ + invoiceTotalTtc: string | null; invoiceDate: string | null; // Story 8-5b — Rule candidate fields ruleId: number | null; diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts index 70eb906f..d68b723a 100644 --- a/frontend/src/lib/shared/i18n-keys.test.ts +++ b/frontend/src/lib/shared/i18n-keys.test.ts @@ -338,7 +338,12 @@ const ATTENDU = { // trouve rien là où l'appel n'existe pas encore : chercher les clés mal // traduites ne révèle jamais celles qui ne sont pas appelées du tout — et // partir d'une liste de clés ne révèle jamais celles que la liste omet.* - sitesTotal: 1751, + // + // Story 25-4-c (#420) : **1751 → 1752**, recompté par ce test et par + // `grep -c "i18nMsg("` aux deux bornes de `ReconciliationProposals.svelte` + // (17 → 18) — la mention `reconciliation-labels-amount-due-of` (« reste dû + // sur … »), clé neuve dans les quatre catalogues. + sitesTotal: 1752, sitesNonResolus: 34, relais: 7, sitesGabarit: 10, diff --git a/frontend/tests/e2e/reconciliation-amount-due.spec.ts b/frontend/tests/e2e/reconciliation-amount-due.spec.ts new file mode 100644 index 00000000..ccecce4e --- /dev/null +++ b/frontend/tests/e2e/reconciliation-amount-due.spec.ts @@ -0,0 +1,157 @@ +/** + * Story 25-4-c (#420) — le rapprochement propose le SOLDE d'une facture déjà + * réglée en partie, de bout en bout. + * + * Une facture validée (900.— HT @ 8.1 % → 972.90 TTC), un règlement partiel, + * puis un relevé dont la transaction porte exactement le reste dû. La page de + * réconciliation doit proposer la facture, afficher le reste — et le TTC en + * mention. Avant la correction, la facture n'était même pas candidate : le + * filtre comparait le TTC. + * + * ⚠️ **Des montants UNIQUES à chaque exécution** : la base est partagée entre + * specs. Le règlement partiel varie, donc le reste aussi ; le relevé est + * réécrit (références, montant) comme dans `reconciliation-cancel.spec.ts`. + * + * Pré-requis : MariaDB up + KESH_TEST_MODE=true (cf. docs/testing.md). + */ + +import { expect, test, type Page } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { + seedTestState, + clearAuthStorage, + authedApiContext, + disposeContextSafe, +} from './helpers/test-state'; +import { + createContactWithAddressViaApi, + createAndValidateInvoiceViaApi, +} from './helpers/api-fixtures'; + +const FIXTURE = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'camt053_v04_minimal.xml', +); +const TEST_IBAN = 'CH4431999123000889012'; +/** TTC de la facture de `createAndValidateInvoiceViaApi` : 4.5 × 200.— @ 8.1 %. */ +const TTC_CENTS = 97290; + +test.beforeAll(async () => { + await seedTestState('with-company'); +}); + +test.afterEach(async ({ page }) => { + await clearAuthStorage(page); +}); + +async function login(page: Page): Promise { + await page.goto('/login'); + await page.fill('#username', 'admin'); + await page.fill('#password', 'admin123'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/'); +} + +/** Un montant en centimes, au format du backend (`Decimal::normalize`). */ +function normalized(cents: number): string { + return String(cents / 100); +} + +/** + * Le décor : une facture réglée en partie, et un relevé importé dont la + * transaction porte son reste. Rend `(bankAccountId, invoiceId, resteCents)`. + */ +async function partiallySettledInvoiceAndItsBalance( + page: Page, +): Promise<{ bankAccountId: number; invoiceId: number; dueCents: number }> { + const unique = `${Date.now()}`; + const contactId = await createContactWithAddressViaApi(page, `Solde ${unique}`); + // Date de facture dans la fenêtre de 30 jours autour du relevé (2026-05-15). + const invoiceId = await createAndValidateInvoiceViaApi(page, contactId, '2026-05-10'); + + const ctx = await authedApiContext(page); + try { + // Règlement partiel, de 100.00 à 499.99 — le reste est donc unique. + const partialCents = 10000 + (Number(unique.slice(-6)) % 40000); + const accounts = (await (await ctx.get('/api/v1/accounts?includeArchived=false')).json()) as { + id: number; + number: string; + accountType: string; + active: boolean; + postable: boolean; + }[]; + const cash = accounts.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/${invoiceId}/settlements`, { + data: { + settlementType: 'internal_account', + accountId: cash!.id, + amount: (partialCents / 100).toFixed(2), + settledOn: '2026-05-12', + }, + }); + expect(settled.ok(), `règlement partiel: ${settled.status()} ${await settled.text()}`).toBeTruthy(); + const dueCents = TTC_CENTS - partialCents; + + type Ba = { id: number; iban: string }; + const findAccount = async (): Promise => { + const list = await ctx.get('/api/v1/bank-accounts'); + expect(list.ok()).toBeTruthy(); + return ((await list.json()) as Ba[]).find((b) => b.iban === TEST_IBAN); + }; + let ba = await findAccount(); + if (!ba) { + const res = await ctx.post('/api/v1/bank-accounts', { + data: { bankName: 'UBS Test', iban: TEST_IBAN, qrIban: null, isPrimary: true }, + }); + expect(res.ok(), `bank-account create: ${res.status()}`).toBeTruthy(); + ba = await findAccount(); + } + const bankAccountId = ba!.id; + + const xml = fs + .readFileSync(FIXTURE, 'utf8') + .replace('STMT-2026-05-001', `STMT-SOLDE-${unique}`) + .replace('BANK-TX-42', `BANK-TX-SOLDE-${unique}`) + .replace('E2E-2026-05-001', `E2E-SOLDE-${unique}`) + .replace('1234.56', `${(dueCents / 100).toFixed(2)}`); + const imported = await ctx.post('/api/v1/bank-imports', { + multipart: { + bankAccountId: bankAccountId.toString(), + file: { + name: `solde-${unique}.xml`, + mimeType: 'application/xml', + buffer: Buffer.from(xml, 'utf8'), + }, + confirmBalanceMismatch: 'true', + confirmDuplicateFile: 'true', + }, + }); + expect(imported.status(), await imported.text()).toBe(201); + return { bankAccountId, invoiceId, dueCents }; + } finally { + await disposeContextSafe(ctx); + } +} + +test('le solde d’une facture réglée en partie est proposé, avec son reste', async ({ page }) => { + await login(page); + const { bankAccountId, invoiceId, dueCents } = await partiallySettledInvoiceAndItsBalance(page); + + await page.goto('/reconciliation'); + await page.getByTestId('bank-account-select').selectOption(bankAccountId.toString()); + + const candidate = page.locator( + `[data-testid="candidate-top-1"][data-invoice-id="${invoiceId}"]`, + ); + await expect(candidate).toBeVisible({ timeout: 10000 }); + await expect(candidate).toContainText(`(${normalized(dueCents)},`); + await expect(candidate.getByTestId('candidate-top-1-amount-due-of')).toContainText( + normalized(TTC_CENTS), + ); +}); From 8d33460c5635d5699de7aaf64101c3c1e390e35c Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 11:58:39 +0200 Subject: [PATCH 11/12] =?UTF-8?q?fix(25-4-c):=20revue=20de=20code=20P1=20?= =?UTF-8?q?=E2=80=94=20CHANGELOG=20replac=C3=A9,=20traductions=20univoques?= =?UTF-8?q?,=20Playwright=20d=C3=A9partag=C3=A9=20(refs=20#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- .../25-4-c-residuel-au-rapprochement.md | 18 +++++++ .../25-4-c-review-prompt-p1.md | 54 +++++++++++++++++++ crates/kesh-i18n/locales/de-CH/messages.ftl | 2 +- crates/kesh-i18n/locales/en-CH/messages.ftl | 2 +- .../e2e/reconciliation-amount-due.spec.ts | 27 +++++++--- 6 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c-review-prompt-p1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e658a44b..588bdb7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,14 +36,14 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **Un rappel réclamait le montant entier d'une facture déjà réglée en partie ([#416](https://github.com/guycorbaz/kesh/issues/416)).** Une facture de 1 081.— réglée de 900.— était relancée pour **1 081.— plus les frais**, et le PDF joint — la facture elle-même — portait une QR-facture à 1 081.— : le client qui la scannait **payait une seconde fois** ce qu'il avait déjà réglé. Le rappel réclame désormais le **reste dû** : le courrier l'annonce (plus les frais s'il y en a), et le PDF joint est un **rappel** — il en porte le titre, dit ce qui est déjà réglé et ce qui reste à payer, et sa QR demande le reste, avec la **même référence** que la facture, pour que le rapprochement reconnaisse le paiement. -- **Le rapprochement bancaire ne reconnaissait pas le virement qui soldait une facture déjà réglée en partie ([#420](https://github.com/guycorbaz/kesh/issues/420)).** Une facture de 1 000.— réglée de 400.— n'était même pas proposée pour un virement de 600.— — exactement ce que le client devait encore —, parce que la recherche comparait le montant d'origine : il fallait rapprocher à la main un paiement parfaitement identifiable. Le rapprochement cherche et note désormais les factures sur leur **reste dû** ; la proposition affiche ce reste, suivi du total d'origine (« 600, reste dû sur 1000 ») quand la facture est déjà réglée en partie. Le manuel décrit enfin le score tel qu'il est calculé — il annonçait un score gradué, un critère de date et des seuils de confiance qui n'existent pas. - **Un règlement arrivé pendant que l'aperçu est ouvert fait refuser l'envoi** : le courrier validé annoncerait un autre montant que la QR jointe. Il suffit de rouvrir l'aperçu. **Les frais de rappel ne s'affichent plus quand ils sont nuls** — les modèles fournis écrivaient « frais de rappel de 0.00 ». Ils ne sont **jamais** dans la QR : ils ne sont pas comptabilisés ([#401](https://github.com/guycorbaz/kesh/issues/401)), et un versement qui les inclurait serait refusé comme trop-perçu ; le PDF les mentionne sur une ligne à part. ⚠️ **Le rappel PDF ne part qu'avec un rappel envoyé par e-mail.** Pour une mise en demeure envoyée hors de Kesh ou un contact sans e-mail, le PDF imprimable reste celui de la facture, au montant total ([#477](https://github.com/guycorbaz/kesh/issues/477)). ⚠️ **Un modèle de rappel personnalisé** qui écrit `{reminderFee}` en toutes lettres affiche toujours « 0.00 » sans frais : la nouvelle variable `{feeNotice}` est vide dans ce cas. +- **Le rapprochement bancaire ne reconnaissait pas le virement qui soldait une facture déjà réglée en partie ([#420](https://github.com/guycorbaz/kesh/issues/420)).** Une facture de 1 000.— TTC réglée de 400.— n'était même pas proposée pour un virement de 600.— — exactement ce que le client devait encore —, parce que la recherche comparait le montant d'origine : il fallait rapprocher à la main un paiement parfaitement identifiable. Le rapprochement cherche et note désormais les factures sur leur **reste dû** ; la proposition affiche ce reste, suivi du total d'origine (« 600, reste dû sur 1000 ») quand la facture est déjà réglée en partie. *(API : le champ `invoiceAmount` des propositions porte désormais le reste dû — identique au TTC tant que rien n'est réglé — et le nouveau champ `invoiceTotalTtc` le total d'origine, seulement quand les deux diffèrent.)* Le manuel décrit enfin le score tel qu'il est calculé — il annonçait un score gradué, un critère de date et des seuils de confiance qui n'existent pas. + ### 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-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index ff5f1e41..c6e0a15e 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -280,6 +280,7 @@ Claude Opus 5.5 (`claude-opus-5-5`). - `frontend/tests/e2e/reconciliation-amount-due.spec.ts` - `_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` - `_bmad-output/implementation-artifacts/sprint-status.yaml` +- `_bmad-output/implementation-artifacts/25-4-c-review-prompt-p1.md` ## Change Log @@ -339,6 +340,23 @@ Claude Opus 5.5 (`claude-opus-5-5`). exécutés** : backend complet sur base remise à zéro **2513/2513** (4 ignorés) ; frontend `check`, `lint-i18n-ownership`, **836/836**, build ; **E2E complet 226 passed / 19 skipped / 9 failed**, les 9 à la liste des échecs attendus. Statut → `review`. +- **2026-09-30** — Revue de code P1 (3 lentilles Sonnet, prompt `25-4-c-review-prompt-p1.md`, diff + `7aa56d2f..63a6d6a7`) : Blind Hunter 1 HIGH / 4 MEDIUM / 4 LOW, Edge Case Hunter 3 MEDIUM / 1 LOW, + Acceptance Auditor 0 (AC 1–6, interdits, manuel et PDF vérifiés). Tous vérifiés sur le code. + **Corrigés** : HIGH — l'entrée #420 du CHANGELOG coupait le bloc #416 de ses paragraphes de suite + (vérifié : ils s'y rattachaient au rendu) → placée en fin de `### Fixed` ; MEDIUM — traductions EN + (« balance due of ») et DE (« Restbetrag von ») lisibles à contresens → « remaining out of », + « Rest von insgesamt » ; MEDIUM → LOW — Playwright exposé à une collision de reste sur la base + partagée → la contrepartie du relevé porte le nom du contact (le score de contact départage), et + chaque remplacement dans la fixture est vérifié ; LOW — CHANGELOG précise « TTC » et documente le + changement de sens de `invoiceAmount` (Blind Hunter F2, gardé sous ce nom : identique au TTC tant + que rien n'est réglé). **Écartés** : l'avoir (Edge F1, Blind F5) — émettre un avoir passe la + facture en `cancelled` dans la même transaction (`credit_notes.rs:586`), une candidate n'en porte + donc jamais ; fan-out des jointures (Blind F4) — tables dérivées agrégées par facture, confirmé par + Edge. **Reporté** : aucune garde de signe dans `accept_one_invoice` (Edge F2), préexistant par le + score de référence → **issue #482**. LOW laissés : conversion centimes du Playwright (Blind F8), + TOCTOU du compte bancaire hérité de `reconciliation-cancel.spec.ts` (Edge F4). Gates ciblés : + `kesh-i18n` 30/30, Playwright de la story vert, `npm run check` ; aucune ligne Rust modifiée. [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 diff --git a/_bmad-output/implementation-artifacts/25-4-c-review-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c-review-prompt-p1.md new file mode 100644 index 00000000..0f12bab6 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c-review-prompt-p1.md @@ -0,0 +1,54 @@ +# Prompts — revue de code P1, Story 25-4-c (le résiduel au rapprochement) + +*Versionné le 2026-09-30. Trois lentilles en parallèle, **Sonnet**, contexte frais. Diff : le commit +d'implémentation seul, `git diff 7aa56d2f 63a6d6a7 -- . ':!*.pdf'` (1142 lignes), écrit dans +`/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/25-4-c-p1.diff`.* + +## Commun aux trois lentilles + +**Rendu** : findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact (`fichier:ligne`), +**preuve** (code lu cité, commande et sortie), correction proposée. ⛔ **La liste des axes réellement +exercés ET de ceux qui ne l'ont pas été** — un rapport sans elle ne compte pas. + +**Interdits** : n'écrire 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`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers le scratchpad, `cargo check`. + +## Lentille 1 — Blind Hunter (`bmad-review-adversarial-general`) + +Reçoit **le diff seul**, aucun contexte projet. Revue adversariale générale : défauts logiques, SQL, +cas limites, tests qui ne prouvent pas ce qu'ils annoncent, incohérences entre commentaires et code. + +## Lentille 2 — Edge Case Hunter (`bmad-review-edge-case-hunter`) + +Reçoit le diff **et** l'accès en lecture au dépôt. Parcourir chaque branche et chaque frontière : + +- la requête de `find_unpaid_invoices_for_window` (`crates/kesh-db/src/repositories/reconciliation.rs`) : + ambiguïté de colonnes avec les trois tables dérivées, `HAVING` sans `GROUP BY` sur des `LEFT JOIN`, + facture sans ligne, avoir émis (facture `cancelled`), reste négatif, `LIMIT 50` ; +- **tous les lecteurs** de `UnpaidInvoiceCandidate` et de `invoice_amount` / `invoiceAmount` — + `grep -rn "total_ttc\|amount_due\|invoiceAmount\|invoiceTotalTtc\|UnpaidInvoiceCandidate" crates/ frontend/src` ; +- le re-score à l'acceptation (`accept_one_invoice`, `crates/kesh-api/src/routes/reconciliation.rs`) : + ordre des gardes (score avant trop-perçu), reste nul ou négatif, facture sans ligne ; +- `invoiceTotalTtc` : égalité `Decimal` à échelles différentes (`600.0000` contre `600`), sérialisation ; +- le frontend (`ReconciliationProposals.svelte`) : rendu, espaces, i18n (4 catalogues, clé + `reconciliation-labels-amount-due-of`), garde `i18n-keys.test.ts` ; +- le Playwright (`frontend/tests/e2e/reconciliation-amount-due.spec.ts`) : dépendance à l'état partagé + de la base, collisions de montants, exercice couvrant le 2026-05-10, sélection du compte bancaire. + +## Lentille 3 — Acceptance Auditor + +Reçoit le diff, la fiche `_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` +et l'accès en lecture au dépôt. Vérifier chaque AC (1 à 6) contre le code, les « Ce qu'il ne faut pas +faire » (aucun `FOR UPDATE`, aucun arrondi, forme jointe, score binaire), le Dev Agent Record +(affirme-t-il seulement ce qui a tourné ? décomptes recomptés depuis la source ?). + +⛔ **Le manuel** : `docs/manual/fr/user-manual.tex` (section *Réconciliation bancaire* et le +paragraphe *Ce que réclame un rappel*) et `docs/manual/fr/marketing-brochure.tex:167` — chaque +affirmation contre le code (critères, poids, fenêtre, tolérance, « créditrice, en CHF », « pas de +seuil ni d'acceptation automatique », QR du rappel, versement reste + frais). **Contrôler le PDF**, +aplati : `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` (et la brochure), +vers le scratchpad. Le CHANGELOG `[0.12.1]` dit-il vrai ? diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 29e13d7b..96d451e3 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -934,7 +934,7 @@ reconciliation-labels-empty = Keine Transaktionen zur Abstimmung vorhanden. reconciliation-labels-no-account = Kein Bankkonto konfiguriert. reconciliation-labels-account-select = Bankkonto reconciliation-labels-no-candidate = Keine Übereinstimmung -reconciliation-labels-amount-due-of = Restbetrag von { $total } +reconciliation-labels-amount-due-of = Rest von insgesamt { $total } reconciliation-labels-success-suffix = Vorgang/Vorgänge erfolgreich. reconciliation-labels-failed = Teilfehler reconciliation-cols-tx-date = Datum diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 81a9c74f..7bbf7ca8 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -934,7 +934,7 @@ reconciliation-labels-empty = No transactions awaiting reconciliation. reconciliation-labels-no-account = No bank account configured. reconciliation-labels-account-select = Bank account reconciliation-labels-no-candidate = No match -reconciliation-labels-amount-due-of = balance due of { $total } +reconciliation-labels-amount-due-of = remaining out of { $total } reconciliation-labels-success-suffix = operation(s) succeeded. reconciliation-labels-failed = Partial failures reconciliation-cols-tx-date = Date diff --git a/frontend/tests/e2e/reconciliation-amount-due.spec.ts b/frontend/tests/e2e/reconciliation-amount-due.spec.ts index ccecce4e..2dbd458b 100644 --- a/frontend/tests/e2e/reconciliation-amount-due.spec.ts +++ b/frontend/tests/e2e/reconciliation-amount-due.spec.ts @@ -11,6 +11,11 @@ * ⚠️ **Des montants UNIQUES à chaque exécution** : la base est partagée entre * specs. Le règlement partiel varie, donc le reste aussi ; le relevé est * réécrit (références, montant) comme dans `reconciliation-cancel.spec.ts`. + * La contrepartie du relevé porte en outre le nom du contact de la facture : + * si une autre facture de la base partagée tombait sur le même reste, le score + * de contact départagerait — c'est la NÔTRE qui serait la candidate affichée. + * (Que le score de montant suffise seul est l'objet des e2e API, + * `reconciliation_e2e.rs`.) * * Pré-requis : MariaDB up + KESH_TEST_MODE=true (cf. docs/testing.md). */ @@ -68,7 +73,8 @@ async function partiallySettledInvoiceAndItsBalance( page: Page, ): Promise<{ bankAccountId: number; invoiceId: number; dueCents: number }> { const unique = `${Date.now()}`; - const contactId = await createContactWithAddressViaApi(page, `Solde ${unique}`); + const contactName = `Solde ${unique}`; + const contactId = await createContactWithAddressViaApi(page, contactName); // Date de facture dans la fenêtre de 30 jours autour du relevé (2026-05-15). const invoiceId = await createAndValidateInvoiceViaApi(page, contactId, '2026-05-10'); @@ -114,12 +120,19 @@ async function partiallySettledInvoiceAndItsBalance( } const bankAccountId = ba!.id; - const xml = fs - .readFileSync(FIXTURE, 'utf8') - .replace('STMT-2026-05-001', `STMT-SOLDE-${unique}`) - .replace('BANK-TX-42', `BANK-TX-SOLDE-${unique}`) - .replace('E2E-2026-05-001', `E2E-SOLDE-${unique}`) - .replace('1234.56', `${(dueCents / 100).toFixed(2)}`); + // Chaque remplacement est vérifié : une fixture modifiée ailleurs ne doit + // pas laisser passer un relevé à moitié réécrit. + let xml = fs.readFileSync(FIXTURE, 'utf8'); + for (const [from, to] of [ + ['STMT-2026-05-001', `STMT-SOLDE-${unique}`], + ['BANK-TX-42', `BANK-TX-SOLDE-${unique}`], + ['E2E-2026-05-001', `E2E-SOLDE-${unique}`], + ['1234.56', `${(dueCents / 100).toFixed(2)}`], + ['Acme SA', `${contactName}`], + ]) { + expect(xml.includes(from), `fixture : « ${from} » introuvable`).toBeTruthy(); + xml = xml.replace(from, to); + } const imported = await ctx.post('/api/v1/bank-imports', { multipart: { bankAccountId: bankAccountId.toString(), From 4c36ac9969960d7e1804bc129865dc1f0fcb05fd Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 12:15:56 +0200 Subject: [PATCH 12/12] =?UTF-8?q?docs(25-4-c):=20revue=20de=20code=20P2=20?= =?UTF-8?q?=E2=80=94=20close,=200=20>=20LOW=20;=20story=20done=20(refs=20#?= =?UTF-8?q?420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c-residuel-au-rapprochement.md | 22 ++++++- .../25-4-c-review-prompt-p2.md | 66 +++++++++++++++++++ .../sprint-status.yaml | 2 +- 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c-review-prompt-p2.md diff --git a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md index c6e0a15e..bf6e3f88 100644 --- a/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -1,6 +1,6 @@ # Story 25.4-c : Le résiduel au rapprochement bancaire -Status: review +Status: done **Issue : [#420]** — ⛔ la PR porte `closes #420`, titre ET corps (§ *Issue Tracking Rule*). Voisine : **[#476]** (arrondi au centime) et **[#480]** (verrou à l'acceptation) — **hors périmètre**, sorties @@ -281,6 +281,7 @@ Claude Opus 5.5 (`claude-opus-5-5`). - `_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` - `_bmad-output/implementation-artifacts/sprint-status.yaml` - `_bmad-output/implementation-artifacts/25-4-c-review-prompt-p1.md` +- `_bmad-output/implementation-artifacts/25-4-c-review-prompt-p2.md` ## Change Log @@ -357,6 +358,25 @@ Claude Opus 5.5 (`claude-opus-5-5`). score de référence → **issue #482**. LOW laissés : conversion centimes du Playwright (Blind F8), TOCTOU du compte bancaire hérité de `reconciliation-cancel.spec.ts` (Edge F4). Gates ciblés : `kesh-i18n` 30/30, Playwright de la story vert, `npm run check` ; aucune ligne Rust modifiée. +- **2026-09-30** — Revue de code P2 (2 lentilles **Haiku 4.5**, prompt `25-4-c-review-prompt-p2.md`, diff + unique aplati `7aa56d2f..8d33460c`) : Blind Hunter 3 CRITICAL / 5 HIGH / 5 MEDIUM / 2 LOW, Edge Case + Hunter 3 findings — **aucun ne survit à la vérification**. Réfutés : C1–C3 (« non visible dans le + diff » — code hors diff, compilé, testé, alias `lt` vérifié en P1) ; H1 et Edge-F1 (`Decimal` à + échelles différentes) — **réfuté par exécution** : `600.0000 == 600.00` rend `true` (rust_decimal de + `Cargo.lock`, programme jetable) ; H3 et Edge-F3 (collision `Date.now()`) — `workers: 1`, + `playwright.config.ts:53` ; M2 (doc de `matching.rs`) — `grep -nF` : la doc dit déjà « montant à + régler » (`:59`) ; H2 — la formule borne le règlement à 499.99 < 972.90. Reclassés **LOW** et + laissés : `amount_due > 0` au `HAVING` (Edge-F2 — reste négatif inatteignable, et dans la fenêtre + seulement pour une transaction < 0.05) ; formulation du CHANGELOG (H5) et du libellé anglais (M1) ; + nom de `get_proposals_matches_and_shows_ttc` (H4 — sans règlement, il montre bien le TTC). + **Revue close : 0 > LOW.** Gate **complet** au dernier commit de la boucle, base remise à zéro : + backend **2513/2513** ; frontend `check`, `lint-i18n-ownership`, **836/836**, build ; E2E **226 passed + / 19 skipped / 9 failed**, les 9 à la liste des échecs attendus (run à 10:15 UTC). + + **Bilan de la revue** — P1 Sonnet ×3 : 1 HIGH / 7 MEDIUM / 5 LOW (1 HIGH et 2 MEDIUM corrigés, + 2 MEDIUM → LOW corrigés, 3 écartés, 1 reporté en #482) → P2 Haiku ×2 : 0 > LOW après vérification. + Reclassements : P1 Edge-F3 MEDIUM → LOW ; P2 tous les CRITICAL/HIGH/MEDIUM réfutés ou LOW. Statut + → `done`. [#416]: https://github.com/guycorbaz/kesh/issues/416 [#420]: https://github.com/guycorbaz/kesh/issues/420 diff --git a/_bmad-output/implementation-artifacts/25-4-c-review-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-c-review-prompt-p2.md new file mode 100644 index 00000000..41a8445d --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c-review-prompt-p2.md @@ -0,0 +1,66 @@ +# Prompts — revue de code P2, Story 25-4-c (le résiduel au rapprochement) + +*Versionné le 2026-09-30. Deux lentilles en parallèle (Blind Hunter, Edge Case Hunter), **Haiku 4.5**, contexte frais. Diff : l'implémentation et la remédiation P1 +`git diff 7aa56d2f 8d33460c -- . ':(exclude)*.pdf' ':(exclude)_bmad-output'` (diff UNIQUE aplati, implémentation + remédiation P1 — règle Haiku du CLAUDE.md), écrit dans +`/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/25-4-c-p2.diff`.* + +## Commun aux trois lentilles + +**Rendu** : findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact (`fichier:ligne`), +**preuve** (code lu cité, commande et sortie), correction proposée. ⛔ **La liste des axes réellement +exercés ET de ceux qui ne l'ont pas été** — un rapport sans elle ne compte pas. + +**Interdits** : n'écrire 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`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers le scratchpad, `cargo check`. + +## Lentille 1 — Blind Hunter (`bmad-review-adversarial-general`) + +Reçoit **le diff seul**, aucun contexte projet. Revue adversariale générale : défauts logiques, SQL, +cas limites, tests qui ne prouvent pas ce qu'ils annoncent, incohérences entre commentaires et code. + +## Lentille 2 — Edge Case Hunter (`bmad-review-edge-case-hunter`) + +Reçoit le diff **et** l'accès en lecture au dépôt. Parcourir chaque branche et chaque frontière : + +- la requête de `find_unpaid_invoices_for_window` (`crates/kesh-db/src/repositories/reconciliation.rs`) : + ambiguïté de colonnes avec les trois tables dérivées, `HAVING` sans `GROUP BY` sur des `LEFT JOIN`, + facture sans ligne, avoir émis (facture `cancelled`), reste négatif, `LIMIT 50` ; +- **tous les lecteurs** de `UnpaidInvoiceCandidate` et de `invoice_amount` / `invoiceAmount` — + `grep -rn "total_ttc\|amount_due\|invoiceAmount\|invoiceTotalTtc\|UnpaidInvoiceCandidate" crates/ frontend/src` ; +- le re-score à l'acceptation (`accept_one_invoice`, `crates/kesh-api/src/routes/reconciliation.rs`) : + ordre des gardes (score avant trop-perçu), reste nul ou négatif, facture sans ligne ; +- `invoiceTotalTtc` : égalité `Decimal` à échelles différentes (`600.0000` contre `600`), sérialisation ; +- le frontend (`ReconciliationProposals.svelte`) : rendu, espaces, i18n (4 catalogues, clé + `reconciliation-labels-amount-due-of`), garde `i18n-keys.test.ts` ; +- le Playwright (`frontend/tests/e2e/reconciliation-amount-due.spec.ts`) : dépendance à l'état partagé + de la base, collisions de montants, exercice couvrant le 2026-05-10, sélection du compte bancaire. + +## Lentille 3 — Acceptance Auditor + +Reçoit le diff, la fiche `_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md` +et l'accès en lecture au dépôt. Vérifier chaque AC (1 à 6) contre le code, les « Ce qu'il ne faut pas +faire » (aucun `FOR UPDATE`, aucun arrondi, forme jointe, score binaire), le Dev Agent Record +(affirme-t-il seulement ce qui a tourné ? décomptes recomptés depuis la source ?). + +⛔ **Le manuel** : `docs/manual/fr/user-manual.tex` (section *Réconciliation bancaire* et le +paragraphe *Ce que réclame un rappel*) et `docs/manual/fr/marketing-brochure.tex:167` — chaque +affirmation contre le code (critères, poids, fenêtre, tolérance, « créditrice, en CHF », « pas de +seuil ni d'acceptation automatique », QR du rappel, versement reste + frais). **Contrôler le PDF**, +aplati : `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` (et la brochure), +vers le scratchpad. Le CHANGELOG `[0.12.1]` dit-il vrai ? + +## Spécifique à la P2 + +- La lentille 3 (Acceptance Auditor) n'est pas rejouée : elle a rendu 0 finding en P1, preuves à l'appui. +- ⚠️ **Toute affirmation qu'un code est ABSENT, ou qu'un anti-pattern est PRÉSENT, doit citer le + `grep -nF` qui le prouve, avec sa sortie** — lu sur le fichier courant du dépôt (lentille 2) ou sur + le diff (lentille 1), jamais sur un numéro de ligne de hunk. +- Poids particulier sur ce qu'a écrit la remédiation P1 : l'emplacement de l'entrée #420 dans + `CHANGELOG.md` (section `### Fixed` de `[0.12.1]`, rendu Markdown), les libellés EN/DE de + `reconciliation-labels-amount-due-of`, et la boucle de remplacements du Playwright (la contrepartie + `` = le nom du contact : le score de contact rend-il le test moins probant ? la fixture + contient-elle chaque motif exactement là où on l'attend ?). diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index a731c477..a1b1a193 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -359,7 +359,7 @@ development_status: 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: done # 2026-09-27 revue CLOSE en 2 passes (1H/1M/1L -> 0 >LOW), gates verts (2510, 835, E2E conforme). PR a ouvrir : closes #416. # 2026-09-27 CREEE : frais non comptabilises => QR = reste du SANS frais (regle existante, admin-manual:1200) ; PDF de rappel = variante titree ; Q1 ligne "Frais de rappel", Q2 deja regle masque a zero, M5 papier hors perimetre (#477) ; validation CLOSE en 5 passes (1H/3M -> 0+1M -> 5M -> 1M -> 0 >LOW), pas de decoupage (Guy). # [#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: review # 2026-09-30 IMPLEMENTEE — gates backend 2513/2513, frontend 836/836, E2E 226/19/9 (9 attendus) ; #481 ouverte (manuel). # 2026-09-29 VALIDATION CLOSE en 5 passes (P4 1M/1L, P5 ciblee 0 >LOW) — prete pour dev. # 2026-09-29 PERIMETRE REDUIT au reste du dans le rapprochement (verrou -> c2 #480, arrondi -> c3 #476) ; validation P1 3H, P2 2M (orchestrateur), P3 1C/3H -> decoupage ; validation reprend en P4. # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. + 25-4-c-residuel-au-rapprochement: done # 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/7M -> P2 Haiku x2 0 >LOW apres verification) ; gate complet vert (2513, 836, E2E 226/19/9 attendus) ; #482 ouverte (signe a l acceptation) ; non poussee — PR closes #420 sur accord de Guy. # 2026-09-30 IMPLEMENTEE — gates backend 2513/2513, frontend 836/836, E2E 226/19/9 (9 attendus) ; #481 ouverte (manuel). # 2026-09-29 VALIDATION CLOSE en 5 passes (P4 1M/1L, P5 ciblee 0 >LOW) — prete pour dev. # 2026-09-29 PERIMETRE REDUIT au reste du dans le rapprochement (verrou -> c2 #480, arrondi -> c3 #476) ; validation P1 3H, P2 2M (orchestrateur), P3 1C/3H -> decoupage ; validation reprend en P4. # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. 25-4-c2-verrou-acceptation: backlog # [#480] 2026-09-29 NEE du decoupage de la 25-4-c (validation P3, severite P2 -> P3 non decroissante ; accord de Guy) — accept_one_invoice ne verrouille pas la facture : double reglement possible contre un reglement manuel partiel (settle_invoice ne bumpe version qu'au solde). ⛔ un simple FOR UPDATE au chargement desarme le controle optimiste (REPEATABLE READ : version lue a jour, amount_due perime) — lecture verrouillante du reste du, ou bump de version au reglement partiel. Interblocage lot/exercice -> 500 sans rejeu (retry_with ?). Findings P3 F1-F3 dans le Change Log de la 25-4-c. DOIT suivre la 25-4-c. 25-4-c3-arrondi-centime: backlog # [#476] 2026-09-29 NEE du decoupage de la 25-4-c — le reste du peut porter 4 decimales ; comparer au centime : 8 sites (filtre, score, gardes de trop-percu :1336 et settlements_write:167, soldes :1451 et :233, reouverture :464, dialogue SettleInvoiceDialog:111) ; helper sur Money::round_to_centimes, reminder_amount_due rebranche. ⚠️ QUESTION COMPTABLE A GUY : payer 10.01 une facture de 10.0050 laisse la creance crediteur de 0.0050 au grand livre (DECIMAL(19,4)) et affiche « Reste du -0.01 » — assumer, afficher arrondi, ecriture d'ecart, ou arrondir le TTC a la validation. Findings P1 F1-F3, P3 F4/F8/F10/F12 dans le Change Log de la 25-4-c. 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.