From 11cf3c9cdd4a5c6f53de6c67c9283c6b93ac8357 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Mon, 28 Sep 2026 00:11:33 +0200 Subject: [PATCH 01/43] =?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/43] =?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/43] =?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/43] =?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/43] =?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/43] =?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/43] =?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/43] =?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/43] =?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/43] =?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 zcmai)MNpjqmu+#k;O-XOU4lah5HwhDcZZ98xVyUscXtVpOK>N+U)o#`Y*)D-eA}N79Z@y$3&TDZaCB+QAG^ zA|((mW#eq(`y|NH9MqExjZgvNnk@J_W=-Zg#6IXmT}{RNm#XaeRbpBf*VZ%9LBK8c z1h$lJTXPN&9_D%Z)c#@6va!_%9<1lJGk$vMQrHE0e;pW#v53DZ0qo!!=Vo&U+)G98ajSc*eETf<&?%rk=(vCSF_oBKN*@%N{L^jZ4wX+ z9P{hx`RlzmbbnwI15qwnY$wFB+3s@)n$OlFWUeZNwsZS|#CD~?LglFI(}~{`B+P=< z5LEgwrGGP0X!8Vmg8X>&4w$*v;@%w+z};?zYoh0fGszl~&OY{fjj4Qqm?qubW-_>-s-G3=fL~`4>F$kX=AQ$RqTv$hc{r z!TvVO_p)AdtVypWev*|qCj+*!H`BGi464qgI;nir>t3 zd9vm9r=Q^hdXZC0e$5gmHChw^8?Pu@+nh}5pionKS{yn@5mlAr{;U2)iayXSf(4A) zv;#b78+WZO?rGLlCzxFGc*?v~Fn=5@c3BZ)*~||aUspa#BM%NMbrsCIP#EdhO- zexeP>7b^Q%ghPb5$97qLYoRv(-GA}JoXkF5EMyYIKCx42!vf2OU1Qp|2A!|LLOf2UBk z+5sLDiu`eapTRn8KQl+F6~k@0te2jFWg^}j@;fJ%lQ{tm#`+U|7IR#^Juh!oTP=GNr(OS+pKZHVp)ArqdF`v?$%64 z!3q1(#w8!OUJFT{%ky29IJ4B}jWL)l%313GHL*;aXoMRu#S5jBT|%v^)eFECiFB)s zjmfUO4e-VNn()!S=ZegX2^Q64S`q-@=>nybm(X?M$hdT}z8T+C1wHoh!@IKOj+XM# z%h#+mY<$+OAnPxfq1v95!!Svq33iGAQ&^P}oFgl5f9o0KY&iC`DTdx@t;xjtCfR*$ zor{dzzCj~o%B80yMwQQOY?FZ%7tzEy=XriMnCNGw0S`=ttsrA~)pAm1XD$Powie&X zkMkagY`7aHR$Ulp$1!!v2VEu%DRY%IfjviabwTQypLij1!EL{4?lPU4QDs)O%n)UY z6?!H#8V5%YJo@L5OE!IaV7_Z|e(n2vAl)+Um!YXud7xitSt^9F;$c%Yf2*>rA5Fmp z$$qf9szJ&|SKUu-yaQ6j^BOzr}x8}#<;;{qZQF0P%l~*8I0C$$kl%h>q#*z-psMCPmikd1eb*! zI!R&rF6@&(RZd{PZp)aFs9$^3*O~pm8C+BsxA!G!|EI~X0xg1V@aqr|JT1A-Mbq|c z1xfaVwZcx11z_mYv`a^MRcdXGZJN?+C1W#R?|Fj{NYQp{Tt#&dIpEZ`Y+8AQ2w&x02^};nje5P;u*IY#`Ad)b9hP*&m&F_D&dOMxY?ldaffmZB64ALSfNZ zuz`gYSWJPb#j32@lV@7Z*s0l0^1eNgUprWXU5JavFBgTJ)hOI?R ziG9fPA>mnq$@VCoo0mozU!6w&;k2fkkr2;*hMdl!wAZo4`sjVr#wO=AL3}{ZEY~5< zC{kV>TSG%P+G~1rc@7OUcKllvr>&Dn zRXWr9MImEcEMb!alt(nZSACiC_F3|KP*}UWt9rBF5F0$O#EgB9f>|Fvi@j||JBTa3 zPc8Aa)fn0&9*-0nxPr|x!J|=C9mQWCH(*M&fi8iB?bhO*2niY2A`p;T3bF^F>Id~m zE${OOqYh>L3r3|Tef-=*4Z_v%tEqk(jZt4Dr9=p!d>b~^*F!vt>=8Ri1zm86qBt3; zsb<3qiem!o0j$T(c;;M2B@$iJTQEj29@=*1TrGv+s5Y*c1oIY&NH@eq6|+}iOy=LT zTbQ+Ds*$t!`p}2ADqq66m8&>wb7>HCq>LVyPk#oYg@MaZ%`?nUkW!Ku%D;U*wwD9M z#-{Ue!a#3h+yi3?7u2;;9Hhb%7h0R_5`$qh4(xpM z?r1uz{0^^S5`)Mku!jyl(MrG-!0mus`0+v`)ED9*A#*v`qP7~TZCUM+DAz8gXvjQ| zlLCP36LtKK-p^jGF?T93*)B@52nK1r3VO2yDk^VH;n>bM{k#gt+8w3%QXx-?=or3Ei9i|>bTJlUiO8#uVBWZz`<^3R!n2l+VyfoZVc3u|Zb@u;AXHjQ8s`5* zni5O4OZnzng=J4B|1NTYlU>RtIzJa-UfGwp;6?-+X)oyUJ$b@}Gj!;0T8lG2eJi^{ zR_bKnfmX%Mni`eb>%#91fsFoD9WrbH@2X#b!r@?nBs2wE=r**I2yQr$cvsog;_y2@ zMER@3jqU=LuT|+`nB>=rcd~%9;Bw^Si2}qP7daF-Z(k94j!n7iSb^By3shIDtcL5j zLaS0I-JrwWXpExF-Hp$};C0P@dQ#!8uMMF4k~m2xG{*4AhZRGqJsL(VH2E^XI8tn9 zWInHxW5M{aT8A$!$)46&2i&x&s0Tw_6sN=#EQdOZL7^^zWTl@ z2g+LfmHx&y!FxFrk3XlV_xyb1CP2_ulJE8v*-cGmd-Vo1y0x$QPf1_TmZXRchtD#Y z|7wVY?n5`L@C`!_pP(w7QLtx7U_Ybn(1!}QE`mhhOa2meg@sU|Utk*n?(OF%M% zznOYEHW9*S!gi-D+l!~C7=QY1UM7aRi$eG$6qX}skcDGp<&*`~EJ)wJuFaMD7%D4m zrkpV8=KWZ-GGTl+b9QHcU$m);`7xxPMP<}(kXo{u+R*?*B1_eDc@H+ZW9gFOdPEEG*e>Up5RN7XI-N_MpAp#WVd8W@J;Du~7m29mO zl-g%dVI+?$XH^{?+lLO|l<~n`Wqolr_i{fGS<;HA;QfL6@Or4$)3Z&58sdrB5fHU1xk^F|>DXdDSB$>}09) zDQLw7o-~H!{IgLSu_2@lh0)d4%OS@2Be^)N3nurLzx`6^*KYq5OpfyhOkV!*9d7fB zyk3-Jy*~anb7sZH>NoA1x5jRaw#8GEAxq9c@;`rQ>RRsx8+I7g|DlD2=DrUP_WjoI zngF}&&1^52+h-#IS(@uQ-P|V+QxtkyB)?Jls+VU#RIV??{r*zEFCXF?WN4Dpz5cqr z&%$7{n$i`>(Vf9TS-K;?sXi~l^{_Nze%*n3Ktsaz4aC=_qhHVE)xMJ``D}Ry?P7cV z+U^C0c}sN7=`}{`$R#ej0bCRlbEBlUE5j65Fz+Ose<}-98T;qot?{?*Tue+4fpMIBfVa~}s{vm$(BU+G zy49iAx`-{c+pTfAnoasKVR5T?=oxH1-|a_+ocout$NZ8;2rj5F;a}FP(7|?|iH(i$ z^ubs^tZpgzkHY(@$T;p}1la ztokWA$BdQwEW=Ful1H#to0OSlhnyyA>?hd8QiOEv`?)?+6>HDhpHaDbM|Y^*;J|_a z{U>2Jr5L#uZaT(KU$wGv$^@%Ci@OoN=)eJ+32$4UPl5RrZw#J=!IYiJu8p@i{fz|xSmZ9Z1AQ-_DTuahv{N6$)mKS6v6$s(Oe9pImes^ac#fFk5w`A)XyNPIQiP~y7XY%$^VBXv*Jr}y)Sdea*^Yv1g# zt23FS-6JI^>H~*3j#edd`;3)fHy3Cl&wjSDkr1K|J@pqHQ8!g1#U!oc4ChD`(fkDW z_7=BOI3pZZ#-+%)M7nHb;Uym_PWU@nO>E2aT=s+Tb~4){uE^6y(83eyCc;b6EB@R0 zDOhasEMg#dRiW+Z@)uw3Ia=-}^3<86CLV*)5fu6{#)8kF{|@*GgMrM{@fVP}BxqHt z;{3uQJ+-U*{3mD|pz^k3t75L$+QfkvpsGrn{zaP<*_NP5{=LqNfqf{`JS%Ezx4zL{YCF!f1NJm2Nj3}~ZntFU zzFJ*_0ySAnN|pYaK_YkakO!8Auo&nSbxm>E6l8wv{#(4w9&E*63OxSwG`gu#K_gbS zHF&&M>p44N|Km}D|3s}jS_G|?+(f^EL?wf_DK)56OO2E@*bVKk6`?zDF;qW~g&in@ zLY)D}jA3@E$mMF1$Huh7CBe_h7AqU|&M5Pky%h5KXLg$`Vj{)e963PjwY%;tM;4d$ z{j?nR25w#_TCJCPZbbSFya6I}DrcB6iR_4+M z?R`b>V?bo)D3hecO69;U=T;Y8ALNqYj&QRWocg>MEJUNk`rI3;W?`yeEF&DM>t_7T zzpdnqyNFTzs`ob6BmppUwAD~t8yw$9{!w!$P&9(u_b1@6Ix~A@DNjJwx4=kvA>|GK z2qHqoE_C&wfsXL8#PIE3z~O$kumJzppEUYJbcSLQz4-&JIDDC(ZjNuT@K@tMNn&@D zCrT8{M)sYKj+Ly2c3W4|qHMI;S%o;A4%@5#scl#uDCEl2 zcWb$X>{|b8%CY7Wp4o}@R|9=%%BW!T6jzbg!oWCPgk38XeH1u2G2Lh+$4>SY8QZ`y z=f5%#U;rvNrtewarcLEOjr|QzUl2!Z-gFj#m|ET;p7fdSZAReutc?IsI9;pv>>u#m ze}{;hDCl>SFu4B*E+k`h@WQ+pH_3H0Km&~myT+IHF&{uRmtc#3xPAK-I^`W+4A4aO ze9-iK?vcn4egjfv2!@!S`5eqWU5u!z#@%q0Oe-|)$+)DE76;>SDPmCZ$#3+D-sJb3 zbQ)-g(~p?x8nUy$6z|YyvN+1gfCOJvu6+!CK5Wgd`f8L3I6h;`1;N~5Ivz>q1G`vS zNq6u#h7*NmA~mvOm09>VHV{(vqKh2yBlAs)iP^hM@u1IVyUicoy-HO!nRV-uGBXE% z?RL|!x-T$a(d&AT)VBfrq|K-!-;TqBV*g&2=s#GrM0ia^MFG-uPcEFOhA+G#E10a{ zAI?c))C2i1{n#i-pW$Iby}!WN0=oWPZ!H=6tfM1z8R71^={I|{9|B|$$9#$!qIW*x zMll&59@^6_%D8+L$5Qvy^IY6wzeoMWo*wwyd!jS*k*UFKUsH^w09SN5l{w}3d$&uG z)p-&Ionp=E)K(nEDXp(wDr4>{x6R)2#df&I!YJk{*)0eQa~|t+qG#l@fF-ftd7RiB zw_VNedo;rb{i%q!-t;-=)MJi|DmN9hSm{=~3N&(Bg1%SO5@vE0Ml2l)%(5pJzOi1# z)D=P2#io@h;@xLPnu{riM01w|o{t#wP9N_!`$A9UvGq}i1H`4wg?68|?s(KNVW9V# z#_^_7JFU2TAeIFwe0Yompv5yZ7pr>{r9WQ-#X6)mW+*I%ddWrm|H5zixhAT%<}vhR z4UaHw3!k|`8xCG1>aaCt$c}dk0=vIn3Sw)G?vjgIt=S0@SJtm*V9y)=C1aJ0wEc3w z;PH~S(ZJsJ|MrfCJ$vyu=_-bPYss|dqSS4524ceeV3yQ#t{1x7{8eVddHm&j7 za;X*_MVY3|7jS36^##%nhDz#2>0M$kQ&WXzbw0@h40{{Am{?te9n8g!?G*=5Na_%ODKsL&*hL22q-ESgaJBL62(BOKuts~$J`uYF| zIocSB&zPM@9wjUq_b+($`Pz3OBhX{8cQg1MpTFeKQS&5>ogvoRVuL&f)fI#XcJQ|w}h=|?xP`?_QuP?BAI_sfVa&cORuIkArqars4z=VdWOY=@2 zahf>O-^!;K+LPtO5OXNs=Ug87_|uwMqDtSDEWL&+-Ac_8`oC@2g&A)mII!xjGs`@h zt(4R#Po0&$)vH#(r?=Gd64+^{@jX+w0W&L%xBz|$? zcO&f1@A$Uj#_c?fi|e@0HpkPG-})`vY91)*N>0nP0$@tyZ(^A5tw{PAr>W&tbEvC&oWcKeH{Q{a!G_Rr_>WkFwQ56&uzVZ{7NG>)`4F{H^ z(=QMyHa-H9cAo4r`~HpHLwqNGw& z&O*76pmr~uONj|v5y4g#ztnO$3#&f#*Z1hXvZr!F8X^Tt76f~d5Y*5*z&Cr~;pdgV z7`$iPu}eC^>bA@K_S2s&t2rV{Q$dn6LnzK;43lv~i5MJA7}>vUMoy6R10)P6n%DR) z4`HL*U~N_5`bOpevi3vt_9cdX^YFkjYVf*L%zx1N{mT3De&-_leaOily1!ki|Isku z1IDD!rrDlRqZ! z^HDjGeomkNP0`(fmyVXq6NH@Sz3o-FUo{>k&YWzEV--fO5yMGjidX<_FJglmu>0X2 zvLo*lWe?0|kQF#%23c4lb=4Xf> zyQ{l;+8nxrL3ba&?p_iR!2N3Qs_Hj;%myOZGA_qGs|Amh=@13ycGC6BeT5yTB~F21zBkdANCud3d?`IQV!?P5I3@cujeXx%kZmxcE&3Ed&DhQR)c$^yeBoAd{dhmmPyunJ5z%Zt0y9%w#Aq>+I?sjfAKru(wZoaM z7A1inOL#5zrty}Nh~K5w68>cRtTBG6RKy70d)@Snc}V;46s7jp{~UJecp0Tenq85LQS 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/43] =?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/43] =?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. From b90d0d25917eef5cae929b22f5becdf53587d8c1 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 15:26:59 +0200 Subject: [PATCH 13/43] =?UTF-8?q?docs(25-4-c2):=20story=20cr=C3=A9=C3=A9e?= =?UTF-8?q?=20=E2=80=94=20l'invariant=20de=20version=20au=20r=C3=A8glement?= =?UTF-8?q?,=20et=20le=20rejeu=20sur=20interblocage=20(refs=20#480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c2-verrou-acceptation.md | 220 ++++++++++++++++++ .../sprint-status.yaml | 2 +- 2 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md diff --git a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md new file mode 100644 index 00000000..dc85e626 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md @@ -0,0 +1,220 @@ +# Story 25.4-c2 : Le verrou à l'acceptation d'un rapprochement + +Status: ready-for-dev + +**Issue : [#480]** — ⛔ la PR porte `closes #480`, titre ET corps (§ *Issue Tracking Rule*). + +**Née du découpage de la 25-4-c** (validation P3, sévérité P2 → P3 non décroissante ; accord de +Guy le 2026-09-29). Les findings P3 F1–F3 de la 25-4-c sont la source des faits, recontrôlés ici +dans le code. **Empilée sur la 25-4-c** (PR #483, non mergée) : branche +`story/25-4-c2-verrou-acceptation` tirée de `4c36ac99`. La 25-4-c fait d'une facture partiellement +réglée un candidat **ordinaire** du rapprochement : la course décrite ici devient atteignable d'un +clic, d'où l'ordre. + +## Story + +En tant que comptable, +je veux qu'un rapprochement et un règlement manuel enregistrés en même temps sur la même facture ne +puissent pas la régler deux fois, +afin que le compte clients ne devienne jamais créditeur sans que rien ne le signale. + +## Le défaut, vérifié dans le code + +**Isolation réelle** : MariaDB **10.11** en dev, en CI et en production (`docker-compose*.yml`, +`.github/workflows/ci.yml`), `REPEATABLE-READ`, `innodb_snapshot_isolation = OFF`, attente de verrou +50 s (relevé le 2026-09-30 sur `kesh-mariadb-dev`). Conséquence : une lecture **simple** lit +l'**instantané** de la transaction (fixé à sa première lecture InnoDB) ; une lecture **verrouillante** +(`FOR UPDATE`) et un `UPDATE` lisent la **dernière version validée**. + +### Comment l'acceptation se protège aujourd'hui : le verrou optimiste + +`accept_one_invoice` (`crates/kesh-api/src/routes/reconciliation.rs:1067`) lit la facture sans +verrou (`find_invoice_by_id_for_company`, étape 5, `:1115`), retient `invoice_version_pre` +(`:1255`), lit le reste dû sans verrou (`invoice_settlements::amount_due`, garde de trop-perçu +`:1341`), écrit l'encaissement, puis termine par +`UPDATE invoices … WHERE … AND version = ?` (`:1510-1518`). Cet `UPDATE` lit la version **courante** : +si une autre transaction a validé une écriture qui a **incrémenté `version`** depuis l'instantané, il +ne touche aucune ligne et la proposition sort en `RECONCILIATION_INVOICE_NOT_ELIGIBLE` +(`race_during_update`, `:1527-1533`) — son point de sauvegarde est annulé, rien n'est écrit. + +**Le verrou optimiste ne vaut que si TOUT écrit qui change le reste dû incrémente `version`.** + +### L'inventaire des écrivains de `invoice_settlements` (ensemble clos) + +`grep -rn "INSERT INTO invoice_settlements\|DELETE FROM invoice_settlements\|UPDATE invoice_settlements\|invoice_settlements::create_in_tx" crates/*/src` +(le 2026-09-30) : + +| Écrivain | Site | Incrémente `version` ? | +|---|---|---| +| Acceptation d'un rapprochement | `reconciliation.rs:1428` (création) + `:1510` | **toujours** | +| Règlement manuel — `settle_invoice` | `invoice_settlements_write.rs:218` + `:233-246` | ⛔ **seulement au solde** (`if fully_settled`) | +| Annulation d'un règlement — `cancel_settlement_in_tx` (règlement manuel **et** annulation d'un rapprochement, `reconciliation_cancel.rs:352`) | `invoice_settlements_write.rs:451` + `:464-470` | **toujours** (les deux branches) | +| Rejeu post-restauration | `post_restore/20260828000001_invoice_settlements_type.sql:23` | hors ligne (import d'installation) | +| Tests | `invoices.rs:4220`, `:4257` | `mod tests` | + +**Le trou est unique** : un règlement manuel **partiel** ne touche pas `version`. D'où les deux +courses : + +1. **Acceptation contre règlement manuel partiel** — l'acceptation fige son instantané, le règlement + manuel partiel (400 sur 1 000) valide **sans** incrémenter `version`, l'acceptation lit le reste + périmé (1 000), passe la garde de trop-perçu avec 1 000, et son `UPDATE … version = ?` **réussit**. + Facture réglée 1 400 pour 1 000 : **compte clients créditeur**, sans erreur. +2. **Acceptation contre acceptation** (deux comptes bancaires, donc deux `GET_LOCK` distincts) — la + seconde est **déjà** refusée par le verrou optimiste, puisque l'acceptation incrémente toujours. + À garder tel quel. + +Symétriquement, un règlement manuel qui suit une acceptation en cours attend sur la ligne `invoices` +(son `FOR UPDATE`, `:63-68`, est sa **première** instruction ; l'acceptation tient un verrou exclusif +depuis son `UPDATE`) puis lit un état à jour : ce sens-là est sûr. + +### ⛔ Pourquoi PAS un `FOR UPDATE` à l'acceptation (P3 F1 de la 25-4-c) + +Posé au chargement de la facture, il lirait `version` **à jour** — donc le contrôle `version = ?` +passerait toujours — alors que `amount_due`, lecture simple, lirait encore l'**instantané** +(`reconciliation_cancel.rs:291` et `users.rs:100-107` documentent la même règle). Il **désarmerait** +le verrou optimiste qui refuse aujourd'hui la course n° 2, sans fermer la n° 1. Le patron de +`settle_invoice` ne se transpose pas : son `FOR UPDATE` est la première instruction de SA transaction, +alors que dans un lot l'instantané est fixé dès la première proposition. + +### Les interblocages, et leur issue actuelle (P3 F3) + +Le lot garde, d'une proposition à l'autre, le verrou d'exercice (`find_open_covering_date … FOR +UPDATE`, `:1362`) et celui de la numérotation des écritures ; le règlement manuel et l'annulation +prennent la **facture puis** l'exercice (`invoice_settlements_write.rs:68` → `:174` ; +`reconciliation_cancel.rs:296` → `:309`). Sur deux comptes bancaires distincts, InnoDB peut donc +choisir l'acceptation comme victime d'un interblocage (1213) — **préexistant** (le commentaire +`reconciliation.rs:3616-3619` le décrit déjà pour l'annulation). Il annule **toute** la transaction ; +l'instruction fautive ressort en `FailedProposal` `DATABASE_ERROR`, puis `ROLLBACK TO SAVEPOINT` +(`:979`) échoue faute de point de sauvegarde, le `?` produit `ReconciliationError::Database`, et la +réponse est un **HTTP 500** — sans rejeu : `retry_with` n'existe que sur l'annulation (`:3624-3646`). +Une attente dépassée (1205) n'annule, elle, que l'instruction (`innodb_rollback_on_timeout` par +défaut) : le point de sauvegarde survit, la proposition sort en échec au bout de 50 s. + +## Acceptance Criteria + +**AC 1 — L'invariant.** Tout écrit qui change le reste dû d'une facture incrémente `invoices.version` +dans la **même** transaction. Concrètement : `settle_invoice` incrémente `version` (et `updated_at`) +**à chaque règlement**, partiel compris — `paid_at` n'est posé qu'au solde, comme aujourd'hui. Le +doc-comment de `settle_invoice` et celui d'`accept_one_invoice` (au `UPDATE … version = ?`) énoncent +l'invariant et nomment l'inventaire ci-dessus. + +**AC 2 — La course n° 1 est refusée.** Une acceptation dont l'instantané précède un règlement manuel +partiel validé depuis sort en `RECONCILIATION_INVOICE_NOT_ELIGIBLE` (`race_during_update`), sans rien +écrire : aucun règlement, aucune écriture d'encaissement, transaction bancaire toujours `pending`. +**Test déterministe** (sans `sleep`, AC 5). + +**AC 3 — La course n° 2 reste refusée.** Deux acceptations du même solde sur la même facture, depuis +deux comptes bancaires : une acceptée, l'autre refusée, jamais deux règlements. Test déterministe. + +**AC 4 — Un interblocage se rejoue.** Quand la transaction du lot est annulée par InnoDB (1213), la +route d'acceptation **rejoue toute l'opération** — transaction neuve, verrou de compte repris —, par +`retry_with` et le prédicat `is_deadlock_error`, patron de `post_cancel_reconciliation` +(`reconciliation.rs:3612-3646`). ⚠️ L'erreur qui remonte aujourd'hui n'est **pas** le 1213 mais l'échec +du `ROLLBACK TO SAVEPOINT` (1305) : `accept_batch` doit reconnaître qu'une transaction a été annulée +sous lui et le faire remonter **comme un interblocage** (prédicat `is_deadlock_error` vrai), jamais +comme un 500 opaque. ⛔ **Ne pas classer en lisant le texte d'un message d'erreur** : le code d'erreur +MySQL, pas la chaîne. Après `DEFAULT_MAX_DEADLOCK_ATTEMPTS` tentatives, l'erreur finale reste un 500. +Test : un interblocage provoqué de façon déterministe (deux connexions, +`attendre_une_requete_en_cours`) est rejoué et la proposition finit acceptée. Si un interblocage +déterministe s'avère impossible à monter, le Dev Agent Record le dit et le test porte sur la +reconnaissance de l'annulation (1305 après `ROLLBACK`) — **jamais** un test qui passe à vide. + +**AC 5 — Tests déterministes, et qui auraient échoué avant.** Chaque test de course : +- met l'acceptation en attente **à un point connu** (par exemple : une connexion de test tient + `FOR UPDATE` sur la ligne `fiscal_years` que l'acceptation va verrouiller en (d), **après** sa garde + de trop-perçu), attend qu'elle y soit (`kesh_db::test_fixtures::attendre_une_requete_en_cours`, + `test_fixtures.rs:514` ; patron `credit_notes_repository.rs:594-640`), fait valider l'écriture + concurrente, puis relâche ; +- ⚠️ le règlement manuel concurrent ne doit pas dépendre du verrou que tient le test : le dater dans + un **autre exercice ouvert** que celui que l'acceptation attend, ou tout autre montage qui évite de + bloquer le règlement derrière le verrou de test ; +- est **éprouvé par mutation** : l'incrément de `version` retiré du règlement partiel, le test AC 2 + échoue (double règlement) ; le rejeu retiré, le test AC 4 échoue. + +**AC 6 — Rien d'autre ne change.** Aucun `FOR UPDATE` ajouté dans `accept_one_invoice` ; aucune +modification de la formule du reste dû ; l'ordre des gardes (score avant trop-perçu) intact. Les +écrans qui gardent `version` après un règlement la relisent : `+page.svelte` de la facture reprend +`res.invoice` (`invoices/[id]/+page.svelte:435-436`) — vérifier que la réponse du règlement porte la +facture **relue après validation**, sans quoi un `pauseDunning` qui suit (`:512`, version en garde) +sortirait en 409. Les tests existants qui assertent la `version` d'une facture après un règlement +partiel sont mis à jour, et le Dev Agent Record les nomme. + +**AC 7 — Textes.** `docs/api-external.md` : la route d'acceptation rejoue un interblocage, comme +l'annulation (`:291`). CHANGELOG `[0.12.1]` *Fixed*. Le manuel : relire la section *Réconciliation +bancaire* et le règlement manuel — n'y rien écrire s'ils ne promettent rien sur la concurrence, et le +dire dans le Dev Agent Record. + +## Tasks / Subtasks + +- [ ] **T1 — l'invariant** (AC 1, 6) : `settle_invoice` incrémente `version` à chaque règlement ; + doc-comments ; réponse du règlement relue après validation ; tests existants de `version`. +- [ ] **T2 — le rejeu** (AC 4) : `accept_batch` reconnaît la transaction annulée ; route d'acceptation + sous `retry_with` (une fonction « une tentative », comme `cancel_reconciliation_once`). +- [ ] **T3 — tests de course et mutations** (AC 2, 3, 5). +- [ ] **T4 — textes** (AC 7). +- [ ] **T5 — gates** : backend complet (base remise à zéro — ⛔ `kesh-db` touché : gate complet même en + cours de boucle), frontend complet, **E2E complet**. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- ⛔ **`FOR UPDATE` sur la facture dans `accept_one_invoice`** — il désarme le verrou optimiste (voir + plus haut). Toute autre lecture verrouillante qui rafraîchirait `invoice_version_pre` aussi. +- ⛔ **Classer une erreur par son texte** (`contains("Deadlock")`) : le code MySQL, via + `kesh_db::retry::is_deadlock_error` ou un équivalent sur le code. +- ⛔ **Un test de course avec `sleep`** : il passe à vide un jour sur deux (`tests-qui-prouvent-moins`). +- ⛔ **Changer le niveau d'isolation** (`READ COMMITTED`, `SERIALIZABLE`) pour régler la course : + `pool.rs:15-19` assume REPEATABLE READ pour tout le crate. +- ⚠️ **Mise à niveau de MariaDB** : à partir de la 11.6, `innodb_snapshot_isolation` vaut `ON` par + défaut — une lecture verrouillante ou un `UPDATE` sur une ligne modifiée depuis l'instantané échoue + alors (1020, *Record has changed since last read*) au lieu de lire la dernière version. Le verrou + optimiste reste juste dans les deux régimes, mais l'erreur change de forme : le dire dans le + doc-comment d'`accept_one_invoice`, pour que la mise à niveau ne le découvre pas en production. + +### Où regarder + +| Fichier | Pourquoi | +|---|---| +| `crates/kesh-api/src/routes/reconciliation.rs:700-990` | route d'acceptation, `with_account_lock`, `accept_batch` et ses points de sauvegarde | +| `crates/kesh-api/src/routes/reconciliation.rs:1067-1575` | `accept_one_invoice` : lectures, gardes, `UPDATE … version = ?` | +| `crates/kesh-api/src/routes/reconciliation.rs:3612-3680` | patron du rejeu (`post_cancel_reconciliation`, `cancel_reconciliation_once`) | +| `crates/kesh-db/src/repositories/invoice_settlements_write.rs:40-260` | `settle_invoice` : verrou, garde, incrément conditionnel à changer | +| `crates/kesh-db/src/repositories/invoice_settlements_write.rs:371-480` | `cancel_settlement_in_tx` : incrément inconditionnel, le modèle | +| `crates/kesh-db/src/retry.rs:39-160` | `retry_with`, `is_deadlock_error`, `DEFAULT_MAX_DEADLOCK_ATTEMPTS` | +| `crates/kesh-reconciliation/src/mutex.rs:66-120` | `with_account_lock` (`GET_LOCK`), à reprendre à chaque tentative | +| `crates/kesh-db/src/test_fixtures.rs:505-540`, `crates/kesh-db/tests/credit_notes_repository.rs:594-640` | attente déterministe d'une requête bloquée | +| `crates/kesh-api/tests/reconciliation_e2e.rs` | `setup_company`, `seed_validated_invoice`, `post_accept_one`, `seed_partially_settled_vat_invoice` (25-4-c) | +| `frontend/src/routes/(app)/invoices/[id]/+page.svelte:430-440, 505-520` | version relue après règlement, `pauseDunning` | + +### Gardes-fous du dépôt + +- Aucune migration. `kesh-db` touché (repository) ⇒ **gate complet même en cours de boucle**. +- Modules : `kesh-db`, `kesh-api` (+ `docs`) — sous le seuil de découpage. +- ⚠️ La base partagée : les tests de course sont des `#[sqlx::test]` (base éphémère), jamais sur + `kesh` — deux connexions du même pool éphémère. + +## Questions pour Guy + +Aucune bloquante. Un choix est fait par défaut et révisable : **le rejeu (AC 4) est inclus**. L'issue +#480 le pose comme question (« `retry_with` ? ») ; sans lui, la story laisse un 500 sur un +interblocage que la 25-4-c rend plus probable, et le patron existe déjà sur l'annulation. + +## Dev Agent Record + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List + +## Change Log + +- **2026-09-30** — Créée depuis #480 et les findings P3 F1–F3 de la 25-4-c, recontrôlés dans le code : + isolation réelle relevée sur MariaDB 10.11 ; inventaire clos des écrivains de `invoice_settlements` + — **un seul** n'incrémente pas `version` (le règlement manuel partiel) ; la solution retenue est + l'invariant d'incrément, **pas** un `FOR UPDATE` ; rejeu sur interblocage inclus par défaut. + +[#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 a1b1a193..eba95865 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -360,7 +360,7 @@ development_status: 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: 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-c2-verrou-acceptation: ready-for-dev # 2026-09-30 CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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**. From cc792871d2fb2fb5d50c425afdce19ad888c9b5e Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 15:41:19 +0200 Subject: [PATCH 14/43] =?UTF-8?q?docs(25-4-c2):=20validation=20P1=20?= =?UTF-8?q?=E2=80=94=201=20HIGH,=202=20MED,=202=20LOW=20rem=C3=A9di=C3=A9s?= =?UTF-8?q?=20(refs=20#480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c2-validate-prompt-p1.md | 62 +++++++++++++++++ .../25-4-c2-verrou-acceptation.md | 66 ++++++++++++++----- 2 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p1.md diff --git a/_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p1.md new file mode 100644 index 00000000..9f0a6624 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p1.md @@ -0,0 +1,62 @@ +# Prompt — validation P1, Story 25-4-c2 (le verrou à l'acceptation) + +*Versionné le 2026-09-30. **Une lentille** (Sonnet), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c2-verrou-acceptation` (empilée sur la 25-4-c, +PR #483). Fiche à valider : `_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md`. +Source des faits : `25-4-c-residuel-au-rapprochement.md` (Change Log, validation P3, findings F1–F3). +Issue : `gh issue view 480`. Règles : `CLAUDE.md`. Checklist : `.claude/skills/bmad-create-story/checklist.md`. + +Le choix par défaut (rejeu inclus) est **retenu** : ne pas le 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. **L'inventaire des écrivains** — pars du symptôme, pas de la liste : tout ce qui change le reste dû + d'une facture client, c'est-à-dire `invoice_settlements`, les lignes de facture (`invoice_lines`), + les avoirs (`credit_notes`, `credit_note_lines`), le statut (`invoices.status`). + `grep -rn "invoice_settlements\|invoice_lines\|credit_note" crates/*/src --include=*.rs | grep -iE "insert|delete|update"`. + Chacun incrémente-t-il `invoices.version` dans la même transaction ? Un écrivain oublié qui + n'incrémente pas est au moins HIGH : il rouvre la course. +3. **Le raisonnement d'isolation** : sous REPEATABLE READ (MariaDB 10.11, `innodb_snapshot_isolation` + OFF), le verrou optimiste `UPDATE … WHERE version = ?` ferme-t-il réellement la course n° 1 une + fois l'invariant posé ? Cherche un contre-exemple : un ordre d'événements où l'acceptation lit un + reste périmé et où son `UPDATE` réussit quand même. Et `due_after` / `fully_settled`, calculés sur + l'instantané après l'insertion du règlement : peuvent-ils poser `paid_at` à tort quand le contrôle + de version a réussi ? +4. **Le rejeu (AC 4)** : l'affirmation « le 1213 remonte en 1305 au `ROLLBACK TO SAVEPOINT` » est-elle + exacte sur MariaDB 10.11 (lis la doc de MariaDB si tu peux ; sinon dis-le) ? Les autres chemins du + lot (`accept_one_split`, `accept_one_rule`) sont-ils touchés par la même reconnaissance ? Rejouer + un lot entier est-il sûr (effets hors transaction : audit, e-mails, fichiers) ? Le verrou + `GET_LOCK` est-il bien relâché puis repris entre deux tentatives ? +5. **Faisabilité des tests (AC 5)** : le montage proposé (verrou `fiscal_years` tenu par le test, + règlement concurrent daté dans un autre exercice) fonctionne-t-il vraiment ? L'acceptation + atteint-elle `find_open_covering_date` **après** la garde de trop-perçu ? Un règlement manuel + dans un autre exercice est-il permis par `settle_invoice` (date ≥ date de facture, exercice ouvert) ? + Un interblocage déterministe est-il montable ? Mutations tuables ? +6. **Effets de bord de l'invariant** : qui lit `invoices.version` d'une facture validée (frontend, + API, clés d'API, tests) et casserait si un règlement partiel l'incrémente ? La réponse de + `POST /invoices/{id}/settlements` porte-t-elle la facture relue après validation ? +7. **Le manuel et les textes** : `docs/manual/fr/user-manual.tex` (règlement manuel, réconciliation) + et `docs/api-external.md` — que promettent-ils sur la concurrence ou sur `version` ? 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/`). +8. **Périmètre et cohérence** : AC ↔ tâches, modules recomptés ; la story laisse-t-elle un état pire + qu'aujourd'hui sur un point quelconque ? + +## 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`, toute requête d'écriture sur MariaDB. +Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers le +scratchpad, `cargo check`, requêtes `SELECT`/`SHOW` en lecture seule. diff --git a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md index dc85e626..b0c2a0ee 100644 --- a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md +++ b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md @@ -52,6 +52,19 @@ ne touche aucune ligne et la proposition sort en `RECONCILIATION_INVOICE_NOT_ELI | Rejeu post-restauration | `post_restore/20260828000001_invoice_settlements_type.sql:23` | hors ligne (import d'installation) | | Tests | `invoices.rs:4220`, `:4257` | `mod tests` | +**Au-delà des règlements** — le reste dû soustrait aussi l'**avoir émis** +(`INVOICE_AMOUNT_DUE_DERIVED_SQL`, `invoice_settlements.rs:105-106`), et l'acceptation exige le statut +`validated`. Les autres écrivains qui touchent ces grandeurs, vérifiés sûrs : + +| Écrivain | Site | Incrémente `version` ? | +|---|---|---| +| Émission d'un avoir — `create_credit_note` | `credit_notes.rs:283` (`FOR UPDATE` en tête) + `:586` (`status = 'cancelled'`) | **toujours** | +| Dévalidation — `invoices::unvalidate` | `invoices.rs:1453` (`FOR UPDATE` en tête) + `:1577` | **toujours** | +| Lignes de facture (`invoice_lines`) | brouillon seulement : une facture validée est immuable | sans objet | + +L'`UPDATE … AND version = ? AND status = 'validated'` d'`accept_one_invoice` refuse donc déjà ces deux +courses. **C'est l'ensemble complet** qu'énonce l'invariant de l'AC 1. + **Le trou est unique** : un règlement manuel **partiel** ne touche pas `version`. D'où les deux courses : @@ -71,7 +84,7 @@ depuis son `UPDATE`) puis lit un état à jour : ce sens-là est sûr. Posé au chargement de la facture, il lirait `version` **à jour** — donc le contrôle `version = ?` passerait toujours — alors que `amount_due`, lecture simple, lirait encore l'**instantané** -(`reconciliation_cancel.rs:291` et `users.rs:100-107` documentent la même règle). Il **désarmerait** +(`reconciliation_cancel.rs:292-293` et `users.rs:100-107` documentent la même règle). Il **désarmerait** le verrou optimiste qui refuse aujourd'hui la course n° 2, sans fermer la n° 1. Le patron de `settle_invoice` ne se transpose pas : son `FOR UPDATE` est la première instruction de SA transaction, alors que dans un lot l'instantané est fixé dès la première proposition. @@ -96,7 +109,8 @@ défaut) : le point de sauvegarde survit, la proposition sort en échec au bout dans la **même** transaction. Concrètement : `settle_invoice` incrémente `version` (et `updated_at`) **à chaque règlement**, partiel compris — `paid_at` n'est posé qu'au solde, comme aujourd'hui. Le doc-comment de `settle_invoice` et celui d'`accept_one_invoice` (au `UPDATE … version = ?`) énoncent -l'invariant et nomment l'inventaire ci-dessus. +l'invariant et nomment les **deux** inventaires ci-dessus (règlements, et avoir / dévalidation) : le +prochain écrivain qui y manquerait doit trouver la règle écrite là où il écrit. **AC 2 — La course n° 1 est refusée.** Une acceptation dont l'instantané précède un règlement manuel partiel validé depuis sort en `RECONCILIATION_INVOICE_NOT_ELIGIBLE` (`race_during_update`), sans rien @@ -108,12 +122,16 @@ deux comptes bancaires : une acceptée, l'autre refusée, jamais deux règlement **AC 4 — Un interblocage se rejoue.** Quand la transaction du lot est annulée par InnoDB (1213), la route d'acceptation **rejoue toute l'opération** — transaction neuve, verrou de compte repris —, par -`retry_with` et le prédicat `is_deadlock_error`, patron de `post_cancel_reconciliation` -(`reconciliation.rs:3612-3646`). ⚠️ L'erreur qui remonte aujourd'hui n'est **pas** le 1213 mais l'échec -du `ROLLBACK TO SAVEPOINT` (1305) : `accept_batch` doit reconnaître qu'une transaction a été annulée -sous lui et le faire remonter **comme un interblocage** (prédicat `is_deadlock_error` vrai), jamais -comme un 500 opaque. ⛔ **Ne pas classer en lisant le texte d'un message d'erreur** : le code d'erreur -MySQL, pas la chaîne. Après `DEFAULT_MAX_DEADLOCK_ATTEMPTS` tentatives, l'erreur finale reste un 500. +`retry_with`, patron de `post_cancel_reconciliation` (`reconciliation.rs:3612-3646`). ⚠️ L'erreur qui +remonte aujourd'hui n'est **pas** le 1213 mais l'échec du `ROLLBACK TO SAVEPOINT` (1305, mode d'échec +connu du couple interblocage / point de sauvegarde) : `accept_batch` doit reconnaître qu'une +transaction a été annulée sous lui et le faire remonter par une **erreur typée dédiée** (par exemple +un variant `ReconciliationError::TransactionAborted`, posé explicitement quand `ROLLBACK TO SAVEPOINT` +échoue en 1305), que le prédicat de rejeu **de la route d'acceptation** reconnaît, au même titre que +`is_deadlock_error`. ⛔ **Ne pas élargir `kesh_db::retry::is_deadlock_sqlx` / `is_deadlock_error`** +(`retry.rs:71-85`, 1213 seulement) : ils sont partagés par tout le crate, et reclasser tout 1305 en +interblocage masquerait ailleurs un vrai défaut de point de sauvegarde. ⛔ **Ne pas classer en lisant +le texte d'un message d'erreur** : le code d'erreur MySQL, pas la chaîne. Après `DEFAULT_MAX_DEADLOCK_ATTEMPTS` tentatives, l'erreur finale reste un 500. Test : un interblocage provoqué de façon déterministe (deux connexions, `attendre_une_requete_en_cours`) est rejoué et la proposition finit acceptée. Si un interblocage déterministe s'avère impossible à monter, le Dev Agent Record le dit et le test porte sur la @@ -128,6 +146,10 @@ reconnaissance de l'annulation (1305 après `ROLLBACK`) — **jamais** un test q - ⚠️ le règlement manuel concurrent ne doit pas dépendre du verrou que tient le test : le dater dans un **autre exercice ouvert** que celui que l'acceptation attend, ou tout autre montage qui évite de bloquer le règlement derrière le verrou de test ; +- ⚠️ ne met dans le lot **que** la proposition sous test : `ROLLBACK TO SAVEPOINT` ne relâche pas les + verrous de ligne pris après le point de sauvegarde (seuls ceux des lignes insérées) — une + proposition antérieure sur la même facture garderait la ligne `invoices` verrouillée jusqu'à la fin + du lot et fausserait le montage ; - est **éprouvé par mutation** : l'incrément de `version` retiré du règlement partiel, le test AC 2 échoue (double règlement) ; le rejeu retiré, le test AC 4 échoue. @@ -139,8 +161,11 @@ facture **relue après validation**, sans quoi un `pauseDunning` qui suit (`:512 sortirait en 409. Les tests existants qui assertent la `version` d'une facture après un règlement partiel sont mis à jour, et le Dev Agent Record les nomme. -**AC 7 — Textes.** `docs/api-external.md` : la route d'acceptation rejoue un interblocage, comme -l'annulation (`:291`). CHANGELOG `[0.12.1]` *Fixed*. Le manuel : relire la section *Réconciliation +**AC 7 — Textes.** `docs/api-external.md` ne documente **pas** `POST /api/v1/reconciliation/accept` +(la section *Annuler un rapprochement bancaire*, `:287`, ne couvre que la consultation et +l'annulation) : y ajouter une entrée courte pour l'acceptation — accès, corps, succès partiel +`{ accepted, failed }`, et le rejeu d'un interblocage transitoire, dans les termes de l'annulation +(`:291`). CHANGELOG `[0.12.1]` *Fixed*. Le manuel : relire la section *Réconciliation bancaire* et le règlement manuel — n'y rien écrire s'ils ne promettent rien sur la concurrence, et le dire dans le Dev Agent Record. @@ -148,8 +173,9 @@ dire dans le Dev Agent Record. - [ ] **T1 — l'invariant** (AC 1, 6) : `settle_invoice` incrémente `version` à chaque règlement ; doc-comments ; réponse du règlement relue après validation ; tests existants de `version`. -- [ ] **T2 — le rejeu** (AC 4) : `accept_batch` reconnaît la transaction annulée ; route d'acceptation - sous `retry_with` (une fonction « une tentative », comme `cancel_reconciliation_once`). +- [ ] **T2 — le rejeu** (AC 4) : `accept_batch` reconnaît la transaction annulée (erreur typée + dédiée) ; route d'acceptation sous `retry_with` (une fonction « une tentative », comme + `cancel_reconciliation_once`), prédicat local à la route. - [ ] **T3 — tests de course et mutations** (AC 2, 3, 5). - [ ] **T4 — textes** (AC 7). - [ ] **T5 — gates** : backend complet (base remise à zéro — ⛔ `kesh-db` touché : gate complet même en @@ -161,11 +187,13 @@ dire dans le Dev Agent Record. - ⛔ **`FOR UPDATE` sur la facture dans `accept_one_invoice`** — il désarme le verrou optimiste (voir plus haut). Toute autre lecture verrouillante qui rafraîchirait `invoice_version_pre` aussi. -- ⛔ **Classer une erreur par son texte** (`contains("Deadlock")`) : le code MySQL, via - `kesh_db::retry::is_deadlock_error` ou un équivalent sur le code. +- ⛔ **Classer une erreur par son texte** (`contains("Deadlock")`) : le code MySQL. +- ⛔ **Élargir `is_deadlock_error` au 1305** : prédicat partagé ; la reconnaissance du point de + sauvegarde perdu reste locale à l'acceptation (`reconciliation.rs` est le seul site à utiliser des + `SAVEPOINT` nommés du dépôt). - ⛔ **Un test de course avec `sleep`** : il passe à vide un jour sur deux (`tests-qui-prouvent-moins`). - ⛔ **Changer le niveau d'isolation** (`READ COMMITTED`, `SERIALIZABLE`) pour régler la course : - `pool.rs:15-19` assume REPEATABLE READ pour tout le crate. + `pool.rs:17-21` assume REPEATABLE READ pour tout le crate. - ⚠️ **Mise à niveau de MariaDB** : à partir de la 11.6, `innodb_snapshot_isolation` vaut `ON` par défaut — une lecture verrouillante ou un `UPDATE` sur une ligne modifiée depuis l'instantané échoue alors (1020, *Record has changed since last read*) au lieu de lire la dernière version. Le verrou @@ -216,5 +244,13 @@ interblocage que la 25-4-c rend plus probable, et le patron existe déjà sur l' isolation réelle relevée sur MariaDB 10.11 ; inventaire clos des écrivains de `invoice_settlements` — **un seul** n'incrémente pas `version` (le règlement manuel partiel) ; la solution retenue est l'invariant d'incrément, **pas** un `FOR UPDATE` ; rejeu sur interblocage inclus par défaut. +- **2026-09-30** — Validation P1 (Sonnet, prompt `25-4-c2-validate-prompt-p1.md`) : **1 HIGH, + 2 MEDIUM, 2 LOW**, vérifiés. HIGH : `is_deadlock_error` ne teste que le 1213 (`retry.rs:71-85`), l'AC 4 + le nommait pour reconnaître un 1305 → erreur typée dédiée, prédicat local, interdiction d'élargir le + prédicat partagé. MEDIUM : `api-external.md` ne documente pas `/accept` → une entrée à écrire, pas une + phrase. MEDIUM : l'inventaire ne couvrait que `invoice_settlements` → avoir et dévalidation ajoutés + (vérifiés : `FOR UPDATE` en tête, incrément inconditionnel). LOW : `pool.rs:17-21`, + `reconciliation_cancel.rs:292-293`. Remarque intégrée à l'AC 5 : `ROLLBACK TO SAVEPOINT` ne relâche + pas les verrous de ligne. [#480]: https://github.com/guycorbaz/kesh/issues/480 From 6810cd97ee206f90d5fafa243ada15d466dfb114 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 15:45:33 +0200 Subject: [PATCH 15/43] =?UTF-8?q?docs(25-4-c2):=20validation=20P2=20?= =?UTF-8?q?=E2=80=94=20close,=200=20>=20LOW=20(refs=20#480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c2-validate-prompt-p2.md | 43 +++++++++++++++++++ .../25-4-c2-verrou-acceptation.md | 19 +++++++- .../sprint-status.yaml | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p2.md diff --git a/_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p2.md new file mode 100644 index 00000000..a1e9957d --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p2.md @@ -0,0 +1,43 @@ +# Prompt — validation P2, Story 25-4-c2 (le verrou à l'acceptation) + +*Versionné le 2026-09-30. **Une lentille** (Haiku 4.5), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c2-verrou-acceptation`. Fiche : +`_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md` — **lis le fichier dans son état +actuel**. Ce que la P1 a changé : `git show cc792871 -- _bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md` +(contexte seulement ; les numéros de ligne font foi dans les fichiers courants, jamais dans le diff). +Règles : `CLAUDE.md`. + +## Axes — tous obligatoires + +1. **La remédiation P1** : + - AC 4 : une erreur typée dédiée posée par `accept_batch` quand `ROLLBACK TO SAVEPOINT` échoue — + est-ce réalisable dans `crates/kesh-reconciliation/src/errors.rs` (`ReconciliationError`) et le + `match` de la route (`crates/kesh-api/src/routes/reconciliation.rs`, autour de `lock_result`) ? + Ce `match` est-il exhaustif, et la nouvelle variante y aurait-elle un bras ? Le prédicat de + `retry_with` voit-il un `AppError` ou un `ReconciliationError` ? `with_account_lock` + (`crates/kesh-reconciliation/src/mutex.rs:66`) relâche-t-il `GET_LOCK` quand la closure échoue ? + - Les deux inventaires : `credit_notes.rs:283` et `:586`, `invoices.rs:1453` et `:1577` — lis-les. + Existe-t-il un autre écrivain du statut d'une facture validée ou de son avoir + (`grep -rn "UPDATE invoices SET" crates/*/src`) qui n'incrémente pas `version` ? + - AC 7 : la section `docs/api-external.md:287` et suivantes. +2. **Le contre-exemple** : cherche un ordre d'événements où, l'invariant posé, l'acceptation écrit un + règlement fondé sur un reste périmé **et** réussit son `UPDATE invoices … version = ?`. +3. **Cohérence interne** : AC ↔ tâches, « Ce qu'il ne faut pas faire », Change Log. + +## Ce que tu rends + +- **Findings** : sévérité, endroit exact, **preuve** : 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**, le + `grep -rnF` qui le prouve et sa sortie. 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é.** + +## 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`, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md index b0c2a0ee..53e1245f 100644 --- a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md +++ b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md @@ -131,7 +131,14 @@ un variant `ReconciliationError::TransactionAborted`, posé explicitement quand `is_deadlock_error`. ⛔ **Ne pas élargir `kesh_db::retry::is_deadlock_sqlx` / `is_deadlock_error`** (`retry.rs:71-85`, 1213 seulement) : ils sont partagés par tout le crate, et reclasser tout 1305 en interblocage masquerait ailleurs un vrai défaut de point de sauvegarde. ⛔ **Ne pas classer en lisant -le texte d'un message d'erreur** : le code d'erreur MySQL, pas la chaîne. Après `DEFAULT_MAX_DEADLOCK_ATTEMPTS` tentatives, l'erreur finale reste un 500. +le texte d'un message d'erreur** : le code d'erreur MySQL, pas la chaîne. +Chemin à tenir, vérifié dans le code : le prédicat de `retry_with` voit un **`AppError`** (patron +`:3635`) ; la variante typée traverse donc le `match lock_result` de la route (exhaustif, un bras à +ajouter) vers une variante d'`AppError` que le prédicat reconnaît, et qui, faute de rejeu possible, +rend un 500. Un 1213 qui remonte **directement** par un `?` (`SAVEPOINT`, `RELEASE SAVEPOINT`) arrive +déjà en `AppError::Database(DbError::Sqlx(1213))` : `is_deadlock_error` le couvre. `GET_LOCK` est un +verrou de **session**, relâché par `with_account_lock` même quand la closure échoue +(`mutex.rs:66-160`) : une nouvelle tentative, transaction neuve, le reprend proprement. Après `DEFAULT_MAX_DEADLOCK_ATTEMPTS` tentatives, l'erreur finale reste un 500. Test : un interblocage provoqué de façon déterministe (deux connexions, `attendre_une_requete_en_cours`) est rejoué et la proposition finit acceptée. Si un interblocage déterministe s'avère impossible à monter, le Dev Agent Record le dit et le test porte sur la @@ -252,5 +259,15 @@ interblocage que la 25-4-c rend plus probable, et le patron existe déjà sur l' (vérifiés : `FOR UPDATE` en tête, incrément inconditionnel). LOW : `pool.rs:17-21`, `reconciliation_cancel.rs:292-293`. Remarque intégrée à l'AC 5 : `ROLLBACK TO SAVEPOINT` ne relâche pas les verrous de ligne. +- **2026-09-30** — Validation P2 (Haiku, prompt `25-4-c2-validate-prompt-p2.md`) : 1 CRITICAL, 1 HIGH, + 1 MEDIUM annoncés — **tous écartés, erreur de catégorie** : la lentille reproche au code de ne pas + encore porter ce que la fiche prévoit (variante typée, `retry_with`, entrée d'API), ce qui est + l'objet de l'implémentation. Axes utiles, preuves jointes : inventaire avoir / dévalidation vérifié ; + **aucun contre-exemple** à l'invariant (relève aussi la suspension des rappels, qui incrémente + `version` : une acceptation concurrente est refusée, sens prudent). Axe mal exercé (faisabilité du + rejeu) **repris par l'orchestrateur** : `GET_LOCK` de session relâché sur erreur, prédicat sur + `AppError`, 1213 direct déjà couvert — précisions ajoutées à l'AC 4. **Validation close : 0 > LOW.** + + **Bilan** — P1 Sonnet 1H/2M/2L → P2 Haiku 0 > LOW (3 annoncés, écartés). Modèles : Sonnet, Haiku. [#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 eba95865..892288f4 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -360,7 +360,7 @@ development_status: 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: 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: ready-for-dev # 2026-09-30 CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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-c2-verrou-acceptation: ready-for-dev # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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**. From 177135f2d65999b31d2ddc2f3df94e6b48c836aa Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 16:14:44 +0200 Subject: [PATCH 16/43] =?UTF-8?q?fix(25-4-c2):=20un=20r=C3=A8glement=20par?= =?UTF-8?q?tiel=20marque=20la=20facture,=20et=20l'acceptation=20rejoue=20u?= =?UTF-8?q?n=20interblocage=20(refs=20#480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le règlement manuel incrémente invoices.version à chaque règlement, partiel compris : le verrou optimiste de l'acceptation refuse désormais celle qui arrive après un règlement concurrent. Un interblocage absorbé dans une proposition (ROLLBACK TO SAVEPOINT en 1305) remonte en TransactionAborted et la route rejoue le lot par retry_with. Trois tests de course sans sleep. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../25-4-c2-verrou-acceptation.md | 83 +++- .../sprint-status.yaml | 2 +- crates/kesh-api/src/errors.rs | 15 + crates/kesh-api/src/routes/reconciliation.rs | 156 ++++++- crates/kesh-api/tests/reconciliation_e2e.rs | 437 ++++++++++++++++++ .../repositories/invoice_settlements_write.rs | 37 +- crates/kesh-reconciliation/src/errors.rs | 14 + docs/api-external.md | 6 + 9 files changed, 717 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 588bdb7e..997a3817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **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. +- **Un rapprochement et un règlement manuel enregistrés au même moment pouvaient régler deux fois la même facture ([#480](https://github.com/guycorbaz/kesh/issues/480)).** Un règlement manuel **partiel** ne marquait pas la facture comme modifiée : une acceptation de rapprochement en cours, qui avait lu le reste dû juste avant, l'encaissait une seconde fois — une facture de 1 000.— finissait réglée 1 400.—, et le compte clients devenait créditeur sans que rien ne le signale. Chaque règlement marque désormais la facture, et l'acceptation qui arrive trop tard est refusée sans rien écrire (« la facture a changé ») : il suffit de relire les propositions. Un interblocage entre deux opérations simultanées, qui rendait une erreur interne, est maintenant rejoué par le serveur. + ### 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-c2-verrou-acceptation.md b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md index 53e1245f..072906c4 100644 --- a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md +++ b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md @@ -1,6 +1,6 @@ # Story 25.4-c2 : Le verrou à l'acceptation d'un rapprochement -Status: ready-for-dev +Status: review **Issue : [#480]** — ⛔ la PR porte `closes #480`, titre ET corps (§ *Issue Tracking Rule*). @@ -178,14 +178,14 @@ dire dans le Dev Agent Record. ## Tasks / Subtasks -- [ ] **T1 — l'invariant** (AC 1, 6) : `settle_invoice` incrémente `version` à chaque règlement ; +- [x] **T1 — l'invariant** (AC 1, 6) : `settle_invoice` incrémente `version` à chaque règlement ; doc-comments ; réponse du règlement relue après validation ; tests existants de `version`. -- [ ] **T2 — le rejeu** (AC 4) : `accept_batch` reconnaît la transaction annulée (erreur typée +- [x] **T2 — le rejeu** (AC 4) : `accept_batch` reconnaît la transaction annulée (erreur typée dédiée) ; route d'acceptation sous `retry_with` (une fonction « une tentative », comme `cancel_reconciliation_once`), prédicat local à la route. -- [ ] **T3 — tests de course et mutations** (AC 2, 3, 5). -- [ ] **T4 — textes** (AC 7). -- [ ] **T5 — gates** : backend complet (base remise à zéro — ⛔ `kesh-db` touché : gate complet même en +- [x] **T3 — tests de course et mutations** (AC 2, 3, 5). +- [x] **T4 — textes** (AC 7). +- [x] **T5 — gates** : backend complet (base remise à zéro — ⛔ `kesh-db` touché : gate complet même en cours de boucle), frontend complet, **E2E complet**. ## Dev Notes @@ -239,12 +239,76 @@ interblocage que la 25-4-c rend plus probable, et le patron existe déjà sur l' ### Agent Model Used +Claude Opus 5.5 (`claude-opus-5-5`). + ### Debug Log References +- **Le montage proposé par l'AC 5 ne tient pas** : la recherche d'exercice (`find_open_covering_date … FOR + UPDATE`) parcourt les exercices de la société par ordre de date de début (index + `uq_fiscal_years_company_start_date`) et verrouille chaque ligne examinée. Le règlement manuel et + l'acceptation se croisent donc **toujours** sur les exercices, quel que soit celui que le test tient + — premier essai : le règlement attend 50 s (1205). La vraie fenêtre de la course va de la première + lecture de l'acceptation à sa recherche d'exercice, et l'acceptation n'y fait que des lectures + simples. **Montage retenu** : une lecture simple n'attend pas un verrou de ligne, mais elle attend un + verrou de **métadonnées** — `LOCK TABLES contacts WRITE` arrête l'acceptation à la lecture du contact + (étape 5bis), instantané figé, **avant** la garde de trop-perçu. Le règlement manuel ne lit pas + `contacts`. +- **Rouge avant correctif, pour la bonne raison** : `accept_refuses_when_a_partial_manual_settlement_lands_meanwhile` + a rendu `accepted: [1]` — la facture de 1 000.— réglée 1 400.—. +- L'acceptation bute sur la facture dès l'**insertion du règlement** (la clé étrangère vers `invoices` + y pose un verrou partagé), pas à l'`UPDATE invoices` : motif d'attente du test d'interblocage ajusté. +- Mutations (chacune restaurée, fichier touché ensuite) : incrément conditionnel rétabli au règlement + partiel → test de la course n° 1 rouge (double règlement) ; prédicat de rejeu à `false` → test + d'interblocage rouge (500 `INTERNAL_ERROR`) ; `is_savepoint_lost` à `false` → idem ; `version = ?` + relâché en `version >= ?` dans l'acceptation → les tests des courses n° 1 **et** n° 2 rouges. +- Gate E2E : 225 passed / 19 skipped / 10 failed. Les 10, un par un : 7 KF-029 (#97) ; + `sidebar-navigation.spec.ts:75` = KF-046 (#424), à la liste, vert rejoué seul deux fois ; + `accounts.spec.ts:145` et `product-revenue-account.spec.ts:133` rouges rejoués sur la base salie par + la suite, **verts sur `kesh_e2e` reconstruite** — pollution d'état. KF-045 non déclenchée (run à + 14:13 UTC). + ### Completion Notes List +- **L'invariant (AC 1)** : `settle_invoice` incrémente `version` et `updated_at` à **chaque** règlement ; + `paid_at` n'est posé qu'au solde (`NULL` sinon — il l'était déjà : une facture payée n'accepte plus de + règlement). Doc-comments sur `settle_invoice` et sur l'`UPDATE … version = ?` d'`accept_one_invoice`, + qui nomment les deux inventaires, l'interdit du `FOR UPDATE` et la bascule MariaDB 11.6. +- **Le rejeu (AC 4)** : variante `ReconciliationError::TransactionAborted`, posée par `accept_batch` + quand `ROLLBACK TO SAVEPOINT` échoue en **1305** (`is_savepoint_lost`, sur le code MySQL) ; + `AppError::ReconciliationTransactionAborted` → 500 `INTERNAL_ERROR` une fois les tentatives épuisées ; + `post_accept` rejoue `accept_once` (transaction neuve, `GET_LOCK` repris) par `retry_with`, prédicat + **local** : `is_deadlock_error` **ou** la variante. `is_deadlock_error` inchangé (1213 seul). Les + quatre autres `match` exhaustifs de `ReconciliationError` reçoivent un bras défensif. +- **AC 6** : aucun `FOR UPDATE` ajouté dans `accept_one_invoice`, formule du reste dû et ordre des gardes + intacts. La réponse de `POST /invoices/{id}/settlements` relit déjà la facture après validation + (`routes/invoices.rs`, validation P1) : aucun écran ne garde une version périmée. **Aucun test + existant** n'assertait la version d'une facture après un règlement partiel : le gate backend passe + de 2513 à 2516, les trois nouveaux seuls. +- **Tests** (périmètre : `4c36ac99` → cette branche) : +3 e2e dans `reconciliation_e2e.rs` — course + n° 1 (règlement manuel partiel pendant l'acceptation), course n° 2 (deux acceptations, deux comptes), + interblocage dont l'acceptation est la victime (transaction de test alourdie de 500 lignes, cycle + facture / exercice), chacun sans `sleep`. +- **Textes (AC 7)** : `docs/api-external.md` — entrée neuve *Accepter des propositions de + rapprochement* (accès, corps, succès partiel, `race_during_update`, rejeu, champs de la 25-4-c) ; + CHANGELOG `[0.12.1]` *Fixed*. **Le manuel ne promet rien** sur les opérations simultanées dans le + règlement ni le rapprochement : rien écrit. En le vérifiant, une affirmation fausse trouvée ailleurs + (numérotation des factures « dans une transaction `SERIALIZABLE` », qui n'existe nulle part) → + **issue #484**. + ### File List +- `CHANGELOG.md` +- `crates/kesh-api/src/errors.rs` +- `crates/kesh-api/src/routes/reconciliation.rs` +- `crates/kesh-api/tests/reconciliation_e2e.rs` +- `crates/kesh-db/src/repositories/invoice_settlements_write.rs` +- `crates/kesh-reconciliation/src/errors.rs` +- `docs/api-external.md` +- `_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md` +- `_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p1.md` +- `_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p2.md` +- `_bmad-output/implementation-artifacts/sprint-status.yaml` + ## Change Log - **2026-09-30** — Créée depuis #480 et les findings P3 F1–F3 de la 25-4-c, recontrôlés dans le code : @@ -269,5 +333,12 @@ interblocage que la 25-4-c rend plus probable, et le patron existe déjà sur l' `AppError`, 1213 direct déjà couvert — précisions ajoutées à l'AC 4. **Validation close : 0 > LOW.** **Bilan** — P1 Sonnet 1H/2M/2L → P2 Haiku 0 > LOW (3 annoncés, écartés). Modèles : Sonnet, Haiku. +- **2026-09-30** — Implémentée (`bmad-dev-story`) : invariant d'incrément au règlement manuel, rejeu sur + interblocage, trois tests de course éprouvés par mutation, entrée d'API, issue #484. ⚠️ Le montage + de test de l'AC 5 (verrou d'exercice) s'est révélé impossible — les deux flux se croisent toujours sur + les exercices — et a été remplacé par un verrou de métadonnées (Debug Log). Gates **réellement + exécutés** : backend complet sur base remise à zéro **2516/2516** (4 ignorés), fmt, clippy ; + frontend `check`, `lint-i18n-ownership`, **836/836**, build ; **E2E 225 / 19 / 10**, les 10 expliqués + un par un. Statut → `review`. [#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 892288f4..0467ae57 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -360,7 +360,7 @@ development_status: 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: 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: ready-for-dev # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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-c2-verrou-acceptation: review # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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**. diff --git a/crates/kesh-api/src/errors.rs b/crates/kesh-api/src/errors.rs index 3b2b5723..2ae3314c 100644 --- a/crates/kesh-api/src/errors.rs +++ b/crates/kesh-api/src/errors.rs @@ -719,6 +719,13 @@ pub enum AppError { #[error("Échec de libération du verrou de réconciliation")] ReconciliationLockReleaseFailed { bank_account_id: i64 }, + /// Story 25-4-c2 (#480) — la transaction d'un lot d'acceptation a été + /// annulée par InnoDB (interblocage). **Rejouable** : `post_accept` la + /// rejoue ; elle n'arrive au client, en `500`, qu'une fois les tentatives + /// épuisées. + #[error("Transaction de réconciliation annulée par un interblocage")] + ReconciliationTransactionAborted, + // ----- Story 8-5a-base — réconciliation manuelle FR45 ----- /// Le `bank_account` ciblé n'a pas de `journal_account_id` /// configuré (8-5a-zero foundation). Le user doit configurer le @@ -1944,6 +1951,14 @@ impl IntoResponse for AppError { }); (StatusCode::CONFLICT, Json(body)).into_response() } + AppError::ReconciliationTransactionAborted => { + tracing::error!("reconciliation accept: transaction aborted, retries exhausted"); + build_response( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + &t("error-internal", "Erreur interne"), + ) + } AppError::ReconciliationLockReleaseFailed { bank_account_id } => { let msg = t( "reconciliation-errors-lock-release-failed", diff --git a/crates/kesh-api/src/routes/reconciliation.rs b/crates/kesh-api/src/routes/reconciliation.rs index e0403484..37d1cdaa 100644 --- a/crates/kesh-api/src/routes/reconciliation.rs +++ b/crates/kesh-api/src/routes/reconciliation.rs @@ -807,25 +807,76 @@ pub async fn post_accept( ))); } - // Step 1 — Acquire UN seul lock pour tout le batch (H5). - let mut tx_outer = state - .pool - .begin() - .await - .map_err(|e| AppError::Database(DbError::Sqlx(e)))?; - let bank_account_id = body.bank_account_id; - let proposals = body.proposals.clone(); - let user_id = current_user.user_id; - // Story 17-2a (DC5 cat ii) — attribution PAT propagée dans les helpers/closures. - let actor_api_key_id = current_user.api_key_id; - let company_id = current_user.company_id; - // C2 Pass 1 — `tx_map` (snapshot pré-flight 0ter) n'est plus passé // dans le lock : `accept_one` recharge la BankTransaction inside // lock pour fermer la fenêtre TOCTOU entre le pré-flight (hors-lock) // et l'UPDATE step 8. Le pré-flight 0ter reste utile pour valider // le batch ownership avant d'acquérir le lock (fail-fast 400). drop(tx_map); + + // Step 1 — UN seul lock pour tout le batch (H5), dans une tentative que + // `retry_with` rejoue en entier sur interblocage (Story 25-4-c2, #480). + // + // ⛔ **Rejeu au plus dehors** — transaction neuve, verrou de compte repris —, + // patron de `post_cancel_reconciliation`. Deux causes, deux formes : + // - un 1213 qui remonte **directement** par un `?` (`SAVEPOINT`, `RELEASE`) + // arrive en `AppError::Database(DbError::Sqlx(_))`, que `is_deadlock_error` + // reconnaît ; + // - un 1213 levé **dans** une proposition y est absorbé en `FailedProposal` ; + // InnoDB a pourtant annulé toute la transaction, et c'est le + // `ROLLBACK TO SAVEPOINT` suivant qui échoue (1305) — `accept_batch` le + // remonte en `ReconciliationError::TransactionAborted`, rendu ici en + // `AppError::ReconciliationTransactionAborted`. + // ⚠️ Prédicat LOCAL à cette route : `is_deadlock_error` reste 1213 seul, un + // 1305 ailleurs dans le crate n'a pas ce sens. + use kesh_db::retry::{DEFAULT_MAX_DEADLOCK_ATTEMPTS, is_deadlock_error, retry_with}; + let company_id = current_user.company_id; + let user_id = current_user.user_id; + // Story 17-2a (DC5 cat ii) — attribution PAT propagée dans les helpers/closures. + let actor_api_key_id = current_user.api_key_id; + let bank_account_id = body.bank_account_id; + retry_with( + DEFAULT_MAX_DEADLOCK_ATTEMPTS, + |err: &AppError| { + matches!(err, AppError::Database(db) if is_deadlock_error(db)) + || matches!(err, AppError::ReconciliationTransactionAborted) + }, + || { + let pool = state.pool.clone(); + let proposals = body.proposals.clone(); + async move { + accept_once( + &pool, + company_id, + bank_account_id, + user_id, + actor_api_key_id, + proposals, + ) + .await + } + }, + ) + .await + .map(Json) +} + +/// Une tentative de `POST /accept` : transaction neuve, verrou du compte +/// bancaire, lot, commit (Story 25-4-c2 — extraite de `post_accept` pour que +/// `retry_with` puisse la rejouer en entier). +async fn accept_once( + pool: &sqlx::MySqlPool, + company_id: i64, + bank_account_id: i64, + user_id: i64, + actor_api_key_id: Option, + proposals: Vec, +) -> Result { + let mut tx_outer = pool + .begin() + .await + .map_err(|e| AppError::Database(DbError::Sqlx(e)))?; + let lock_result = with_account_lock( &mut tx_outer, company_id, @@ -851,7 +902,14 @@ pub async fn post_accept( .commit() .await .map_err(|e| AppError::Database(DbError::Sqlx(e)))?; - Ok(Json(response)) + Ok(response) + } + // Story 25-4-c2 — la transaction du lot a été annulée sous lui par + // InnoDB (interblocage) : rejouable, cf. `post_accept`. + Err(ReconciliationError::TransactionAborted { source }) => { + drop(tx_outer); + tracing::warn!(error = %source, "reconciliation accept: transaction aborted underneath (deadlock), retrying"); + Err(AppError::ReconciliationTransactionAborted) } Err(ReconciliationError::AccountLocked { bank_account_id, @@ -976,9 +1034,19 @@ async fn accept_batch( accepted.push(entry); } Err(failure) => { - sqlx::query(&format!("ROLLBACK TO SAVEPOINT {savepoint}")) + // Story 25-4-c2 (#480) — un 1213 absorbé dans la proposition a + // annulé TOUTE la transaction : le point de sauvegarde n'existe + // plus (1305). Le signaler comme tel, pour que la route rejoue + // le lot au lieu de rendre un 500 opaque. + if let Err(e) = sqlx::query(&format!("ROLLBACK TO SAVEPOINT {savepoint}")) .execute(&mut **tx_outer) - .await?; + .await + { + if is_savepoint_lost(&e) { + return Err(ReconciliationError::TransactionAborted { source: e }); + } + return Err(ReconciliationError::Database(e)); + } failed.push(failure); } } @@ -987,6 +1055,18 @@ async fn accept_batch( Ok(AcceptResponse { accepted, failed }) } +/// Code MariaDB `ER_SP_DOES_NOT_EXIST` — ici, un point de sauvegarde disparu +/// avec la transaction qu'InnoDB a annulée. +const MARIADB_SAVEPOINT_DOES_NOT_EXIST: u16 = 1305; + +/// Le point de sauvegarde du lot a-t-il disparu ? Lu sur le **code** MySQL, +/// jamais sur le texte du message (Story 25-4-c2). +fn is_savepoint_lost(err: &sqlx::Error) -> bool { + err.as_database_error() + .and_then(|db| db.try_downcast_ref::()) + .is_some_and(|my| my.number() == MARIADB_SAVEPOINT_DOES_NOT_EXIST) +} + /// Helper interne — dispatch sur le variant `AcceptProposalInput` Q2. /// /// Story 8-5a-bis F3''' Pass 3 Opus + H1 Pass 4 : pattern match top-level @@ -1502,6 +1582,28 @@ async fn accept_one_invoice( // ⛔ `paid_at` seulement si le solde est tombé à zéro (Story 24-2). Un // encaissement partiel bump la version et `updated_at` — la facture a bien // changé d'état — mais laisse `paid_at` à NULL. + // + // ⛔ **Ce `version = ?` est le verrou qui interdit de régler deux fois** + // (Story 25-4-c2, #480). Le reste dû (`due_before`, garde (c)) est lu sur + // l'**instantané** de la transaction, fixé dès sa première lecture : un + // écrit validé depuis est invisible. Cet `UPDATE`, lui, lit la version + // **courante** : il ne touche aucune ligne si la facture a changé depuis + // l'instantané, et la proposition est refusée sans rien écrire. Cela ne + // vaut que par l'invariant : **tout écrit qui change le reste dû incrémente + // `version`** — l'acceptation (ici), le règlement manuel + // (`invoice_settlements_write::settle_invoice`, partiel compris), l'annulation + // d'un règlement (`cancel_settlement_in_tx`), l'émission d'un avoir + // (`credit_notes::create_credit_note`), la dévalidation (`invoices::unvalidate`). + // + // ⛔ **Pas de `FOR UPDATE` sur la facture en amont** : il lirait `version` à + // jour — ce contrôle passerait toujours — alors que le reste dû resterait lu + // sur l'instantané. Il désarmerait le verrou au lieu de le renforcer. + // + // ⚠️ MariaDB 10.11, `innodb_snapshot_isolation = OFF`. À partir de la 11.6, + // la valeur par défaut passe à `ON` : un `UPDATE` sur une ligne modifiée + // depuis l'instantané échoue alors en 1020 (*Record has changed since last + // read*) au lieu de lire la version courante. Le refus reste juste, mais il + // sortirait en `DATABASE_ERROR` et non en `race_during_update`. let paid_at_to_set: Option = if fully_settled { Some(paid_at_dt) } else { @@ -2538,6 +2640,12 @@ pub async fn post_reject( } // Story 8-5a-bis — branche exhaustive uniquement (reject_batch // n'émet pas SplitImbalance). Unreachable en pratique. + // Story 25-4-c2 — posée par `accept_batch` seulement ; défensif ici. + Err(ReconciliationError::TransactionAborted { source }) => { + drop(tx_outer); + tracing::error!(error = %source, "unexpected TransactionAborted outside accept_batch"); + Err(AppError::ReconciliationTransactionAborted) + } Err(ReconciliationError::SplitImbalance { expected, actual, @@ -3065,6 +3173,12 @@ pub async fn post_manual( } // Story 8-5a-bis — branche exhaustive (post_manual ne fait pas // de split, unreachable en pratique). + // Story 25-4-c2 — posée par `accept_batch` seulement ; défensif ici. + Err(ReconciliationError::TransactionAborted { source }) => { + drop(tx_outer); + tracing::error!(error = %source, "unexpected TransactionAborted outside accept_batch"); + Err(AppError::ReconciliationTransactionAborted) + } Err(ReconciliationError::SplitImbalance { expected, actual, @@ -3503,6 +3617,12 @@ pub async fn post_split( let _ = tx_outer.rollback().await; Err(AppError::Database(DbError::Sqlx(e))) } + // Story 25-4-c2 — posée par `accept_batch` seulement ; défensif ici. + Err(ReconciliationError::TransactionAborted { source }) => { + drop(tx_outer); + tracing::error!(error = %source, "unexpected TransactionAborted outside accept_batch"); + Err(AppError::ReconciliationTransactionAborted) + } Err(ReconciliationError::SplitImbalance { expected, actual, @@ -3713,6 +3833,10 @@ async fn cancel_reconciliation_once( // c'est ce que lit le prédicat du rejeu. ReconciliationError::Db(db) => AppError::Database(db), ReconciliationError::Database(e) => AppError::Database(DbError::Sqlx(e)), + // Story 25-4-c2 — posée par `accept_batch` seulement ; défensif ici. + ReconciliationError::TransactionAborted { .. } => { + AppError::ReconciliationTransactionAborted + } other => { tracing::error!(error = %other, "variante inattendue au dé-rapprochement"); AppError::Internal("internal: unexpected reconciliation error".into()) diff --git a/crates/kesh-api/tests/reconciliation_e2e.rs b/crates/kesh-api/tests/reconciliation_e2e.rs index 3a0e05f9..3053c04a 100644 --- a/crates/kesh-api/tests/reconciliation_e2e.rs +++ b/crates/kesh-api/tests/reconciliation_e2e.rs @@ -4231,3 +4231,440 @@ async fn an_api_key_cancels_and_is_named_in_the_audit(pool: MySqlPool) { .unwrap(); assert_eq!((actor_type.as_str(), actor_key), ("api_key", Some(key_id))); } + +// ============================================================ +// Story 25-4-c2 (#480) — l'acceptation contre les écritures concurrentes +// ============================================================ + +/// Lance `POST /reconciliation/accept` pour une seule proposition, **en tâche** : +/// le test garde la main pour agir pendant que l'acceptation attend. Rend le +/// statut HTTP et le corps. +fn accept_in_background( + app: &TestApp, + ctx: &CompanyCtx, + tx_id: i64, + inv_id: i64, +) -> tokio::task::JoinHandle<(u16, Value)> { + let client = app.client.clone(); + let url = app.url("/api/v1/reconciliation/accept"); + let jwt = ctx.jwt.clone(); + let body = serde_json::json!({ + "bankAccountId": ctx.bank_account_id, + "proposals": [{ "type": "invoice", "bankTransactionId": tx_id, "invoiceId": inv_id }], + }); + tokio::spawn(async move { + let resp = client + .post(url) + .bearer_auth(jwt) + .json(&body) + .send() + .await + .unwrap(); + let status = resp.status().as_u16(); + (status, resp.json().await.unwrap_or(Value::Null)) + }) +} + +/// ⛔ **#480, la course n° 1 : un règlement manuel PARTIEL validé pendant +/// qu'une acceptation est en cours ne doit pas laisser régler la facture deux fois.** +/// +/// La fenêtre réelle de la course va de la **première lecture** de la +/// transaction d'acceptation (l'instantané se fige à l'étape 2) jusqu'à sa +/// recherche d'exercice (d) : dans cet intervalle elle ne pose aucun verrou de +/// ligne, et un règlement manuel peut s'y valider entièrement. Après (d), elle +/// tient les exercices de la société — la recherche d'exercice les parcourt en +/// les verrouillant —, et le règlement manuel l'attend : ce sens-là est sûr. +/// +/// Montage, sans `sleep` — une lecture simple n'attend jamais un verrou de +/// ligne, mais elle attend un verrou de **métadonnées** : +/// 1. une connexion de test tient `LOCK TABLES contacts WRITE` ; +/// 2. l'acceptation (1 000.— sur une facture de 1 000.—) part en tâche : elle +/// fige son instantané, lit la facture, puis s'arrête à la lecture du +/// contact (étape 5bis), **avant** sa garde de trop-perçu — on l'y attend ; +/// 3. un règlement manuel de 400.— est validé (il ne lit pas `contacts`) ; +/// 4. `UNLOCK TABLES` : l'acceptation reprend, sur un instantané où la +/// facture doit encore 1 000.—. +/// +/// Avant la 25-4-c2, le règlement partiel n'incrémentait pas `version` : +/// l'`UPDATE invoices … version = ?` de l'acceptation réussissait, et la +/// facture finissait réglée 1 400.— pour 1 000.—, compte clients créditeur. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn accept_refuses_when_a_partial_manual_settlement_lands_meanwhile(pool: MySqlPool) { + use kesh_db::entities::SettlementChoice; + use kesh_db::repositories::invoice_settlements_write; + + let ctx = setup_company(&pool, "Course1", "CH4431999123000889012", Role::Comptable).await; + let day = NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(); + let inv_date = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(); + let (inv_id, _) = seed_validated_invoice( + &pool, + ctx.company_id, + ctx.contact_id, + "INV-RACE-1", + inv_date, + dec!(1000.00), + ) + .await; + let tx_ids = seed_bank_transactions( + &pool, + ctx.company_id, + ctx.bank_account_id, + ctx.user_id, + &unique_hash("race_partial_manual"), + day, + day, + vec![make_new_tx( + ctx.company_id, + ctx.bank_account_id, + day, + Some(day), + dec!(1000.00), + "CHF", + "INV-RACE-1", + Some("Course1 Client"), + )], + ) + .await; + let app = spawn_app(pool.clone()).await; + + // (1) Le verrou de métadonnées, sur une connexion hors transaction. + let mut verrou = pool.acquire().await.unwrap(); + sqlx::query("LOCK TABLES contacts WRITE") + .execute(&mut *verrou) + .await + .unwrap(); + + // (2) L'acceptation, jusqu'à la lecture du contact. + let acceptation = accept_in_background(&app, &ctx, tx_ids[0], inv_id); + let bloquee = + kesh_db::test_fixtures::attendre_une_requete_en_cours(&pool, &["FROM contacts"], || { + acceptation.is_finished() + }) + .await; + assert!( + bloquee, + "l'acceptation devait attendre sur `contacts` — le montage ne prouve rien sinon" + ); + + // (3) Le règlement manuel partiel, validé pendant l'attente. + let partiel = invoice_settlements_write::settle_invoice( + &pool, + ctx.user_id, + ctx.company_id, + inv_id, + SettlementChoice::BankTransfer { + bank_account_id: ctx.bank_account_id, + }, + dec!(400.00), + day, + ) + .await + .expect("règlement manuel partiel"); + assert!(!partiel.fully_settled); + + // (4) Relâcher : l'acceptation reprend sur un reste périmé. + sqlx::query("UNLOCK TABLES") + .execute(&mut *verrou) + .await + .unwrap(); + drop(verrou); + let (status, body) = acceptation.await.unwrap(); + assert_eq!(status, 200, "succès partiel = succès HTTP ; corps = {body}"); + assert!( + body["accepted"].as_array().unwrap().is_empty(), + "⛔ l'acceptation a réglé une facture déjà réglée de 400.— entre-temps : {body}" + ); + let failed = body["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1); + assert_eq!( + failed[0]["errorCode"], + "RECONCILIATION_INVOICE_NOT_ELIGIBLE" + ); + assert_eq!(failed[0]["details"]["reason"], "race_during_update"); + + // Rien d'écrit par l'acceptation : un seul règlement, le manuel. + let settled: Vec = + sqlx::query_scalar("SELECT amount FROM invoice_settlements WHERE invoice_id = ?") + .bind(inv_id) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(settled, vec![dec!(400.0000)]); + let tx_status: String = sqlx::query_scalar("SELECT status FROM bank_transactions WHERE id = ?") + .bind(tx_ids[0]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(tx_status, "pending", "la transaction reste à rapprocher"); + assert_eq!( + receivable_balance(&pool, ctx.receivable_account_id).await, + dec!(600.00), + "le compte clients porte le reste, jamais un solde créditeur" + ); +} + +/// ⛔ **#480 — un interblocage dont l'acceptation est la victime se REJOUE.** +/// +/// Avant la 25-4-c2, InnoDB annulait toute la transaction du lot, l'instruction +/// fautive ressortait en `FailedProposal` `DATABASE_ERROR`, le `ROLLBACK TO +/// SAVEPOINT` suivant échouait (1305) et la route rendait un **500** — sans +/// rejeu, `retry_with` n'existant que sur l'annulation. +/// +/// Montage d'un interblocage **déterministe**, dont l'acceptation est la +/// victime : +/// 1. une transaction de test s'alourdit (500 lignes insérées : InnoDB choisit +/// pour victime la transaction la plus légère), puis verrouille la facture ; +/// 2. l'acceptation prend l'exercice (d), passe son écriture, puis attend la +/// facture dès l'insertion du règlement — la clé étrangère vers `invoices` +/// y pose un verrou partagé sur la ligne — : on l'y attend ; +/// 3. le test demande l'exercice : cycle, InnoDB annule l'acceptation ; +/// 4. le test annule sa transaction ; le rejeu repart à neuf et accepte. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn accept_replays_the_batch_when_it_is_the_deadlock_victim(pool: MySqlPool) { + let ctx = setup_company(&pool, "Victime", "CH4431999123000889012", Role::Comptable).await; + let day = NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(); + let inv_date = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(); + let (inv_id, _) = seed_validated_invoice( + &pool, + ctx.company_id, + ctx.contact_id, + "INV-DEADLOCK-1", + inv_date, + dec!(250.00), + ) + .await; + let tx_ids = seed_bank_transactions( + &pool, + ctx.company_id, + ctx.bank_account_id, + ctx.user_id, + &unique_hash("deadlock_victim"), + day, + day, + vec![make_new_tx( + ctx.company_id, + ctx.bank_account_id, + day, + Some(day), + dec!(250.00), + "CHF", + "INV-DEADLOCK-1", + Some("Victime Client"), + )], + ) + .await; + let fy_2026: i64 = sqlx::query_scalar( + "SELECT id FROM fiscal_years WHERE company_id = ? AND start_date <= ? AND end_date >= ? \ + AND status = 'Open' LIMIT 1", + ) + .bind(ctx.company_id) + .bind(day) + .bind(day) + .fetch_one(&pool) + .await + .unwrap(); + // Une table de lest, hors du flux d'acceptation (DDL hors transaction). + sqlx::query( + "CREATE TABLE lest_interblocage (id INT AUTO_INCREMENT PRIMARY KEY, x INT) ENGINE=InnoDB", + ) + .execute(&pool) + .await + .unwrap(); + let app = spawn_app(pool.clone()).await; + + // (1) La transaction de test : lourde, puis la facture. + let mut lourde = pool.begin().await.unwrap(); + for _ in 0..10 { + sqlx::query("INSERT INTO lest_interblocage (x) SELECT seq FROM seq_1_to_50") + .execute(&mut *lourde) + .await + .unwrap(); + } + sqlx::query("SELECT id FROM invoices WHERE id = ? FOR UPDATE") + .bind(inv_id) + .fetch_one(&mut *lourde) + .await + .unwrap(); + + // (2) L'acceptation, jusqu'à l'insertion de son règlement. + let acceptation = accept_in_background(&app, &ctx, tx_ids[0], inv_id); + let bloquee = kesh_db::test_fixtures::attendre_une_requete_en_cours( + &pool, + &["INSERT INTO invoice_settlements"], + || acceptation.is_finished(), + ) + .await; + assert!( + bloquee, + "l'acceptation devait attendre la facture — le montage ne prouve rien sinon" + ); + + // (3) Le cycle : l'exercice, que tient l'acceptation. La transaction de + // test DOIT l'obtenir — sinon c'est elle la victime, et le test ne dit rien. + sqlx::query("SELECT id FROM fiscal_years WHERE id = ? FOR UPDATE") + .bind(fy_2026) + .fetch_one(&mut *lourde) + .await + .expect("la transaction de test doit survivre à l'interblocage"); + + // (4) Relâcher : le rejeu repart à neuf. + lourde.rollback().await.unwrap(); + let (status, body) = acceptation.await.unwrap(); + assert_eq!( + status, 200, + "⛔ un interblocage doit être rejoué, pas rendu en erreur : {body}" + ); + assert_eq!( + body["accepted"].as_array().unwrap().len(), + 1, + "le rejeu accepte la proposition ; corps = {body}" + ); + let settled: Vec = + sqlx::query_scalar("SELECT amount FROM invoice_settlements WHERE invoice_id = ?") + .bind(inv_id) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + settled, + vec![dec!(250.0000)], + "un seul règlement, celui du rejeu" + ); +} + +/// #480, la course n° 2 — **deux acceptations du même solde, depuis deux +/// comptes bancaires** (donc deux `GET_LOCK` distincts) : une seule règle la +/// facture. Déjà refusée avant la 25-4-c2, puisque l'acceptation incrémente +/// toujours `version` ; ce test la garde, parce que c'est ce refus qu'un +/// `FOR UPDATE` mal placé désarmerait. +/// +/// Montage : une transaction de test tient la facture ; l'acceptation A prend +/// l'exercice et bute sur la facture (insertion du règlement) ; l'acceptation B, +/// son instantané déjà figé, bute sur l'exercice que tient A. Le test relâche : +/// A règle, B lit un reste périmé et doit être refusée. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn two_acceptances_of_the_same_balance_settle_it_once(pool: MySqlPool) { + let ctx = setup_company(&pool, "Course2", "CH4431999123000889012", Role::Comptable).await; + let day = NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(); + let inv_date = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(); + let (inv_id, _) = seed_validated_invoice( + &pool, + ctx.company_id, + ctx.contact_id, + "INV-RACE-2", + inv_date, + dec!(700.00), + ) + .await; + // Le second compte bancaire, câblé sur le même compte du grand livre. + let second_account = create_bank_account(&pool, ctx.company_id, "CH9300762011623852957").await; + sqlx::query("UPDATE bank_accounts SET journal_account_id = ? WHERE id = ?") + .bind(ctx.bank_ledger_account_id) + .bind(second_account) + .execute(&pool) + .await + .unwrap(); + let tx_a = seed_bank_transactions( + &pool, + ctx.company_id, + ctx.bank_account_id, + ctx.user_id, + &unique_hash("race2_a"), + day, + day, + vec![make_new_tx( + ctx.company_id, + ctx.bank_account_id, + day, + Some(day), + dec!(700.00), + "CHF", + "INV-RACE-2", + Some("Course2 Client"), + )], + ) + .await[0]; + let tx_b = seed_bank_transactions( + &pool, + ctx.company_id, + second_account, + ctx.user_id, + &unique_hash("race2_b"), + day, + day, + vec![make_new_tx( + ctx.company_id, + second_account, + day, + Some(day), + dec!(700.00), + "CHF", + "INV-RACE-2", + Some("Course2 Client"), + )], + ) + .await[0]; + let app = spawn_app(pool.clone()).await; + let ctx_b = CompanyCtx { + bank_account_id: second_account, + jwt: ctx.jwt.clone(), + ..ctx + }; + + let mut verrou = pool.begin().await.unwrap(); + sqlx::query("SELECT id FROM invoices WHERE id = ? FOR UPDATE") + .bind(inv_id) + .fetch_one(&mut *verrou) + .await + .unwrap(); + + let acceptation_a = accept_in_background(&app, &ctx, tx_a, inv_id); + assert!( + kesh_db::test_fixtures::attendre_une_requete_en_cours( + &pool, + &["INSERT INTO invoice_settlements"], + || acceptation_a.is_finished(), + ) + .await, + "A devait attendre la facture" + ); + let acceptation_b = accept_in_background(&app, &ctx_b, tx_b, inv_id); + assert!( + kesh_db::test_fixtures::attendre_une_requete_en_cours( + &pool, + &["FROM fiscal_years", "FOR UPDATE"], + || acceptation_b.is_finished(), + ) + .await, + "B devait attendre l'exercice que tient A" + ); + + verrou.rollback().await.unwrap(); + let (status_a, body_a) = acceptation_a.await.unwrap(); + let (status_b, body_b) = acceptation_b.await.unwrap(); + assert_eq!( + (status_a, status_b), + (200, 200), + "A = {body_a} ; B = {body_b}" + ); + assert_eq!( + body_a["accepted"].as_array().unwrap().len(), + 1, + "A règle : {body_a}" + ); + assert!( + body_b["accepted"].as_array().unwrap().is_empty(), + "⛔ B a réglé une seconde fois une facture déjà soldée : {body_b}" + ); + assert_eq!( + body_b["failed"][0]["details"]["reason"], + "race_during_update" + ); + let settled: Vec = + sqlx::query_scalar("SELECT amount FROM invoice_settlements WHERE invoice_id = ?") + .bind(inv_id) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(settled, vec![dec!(700.0000)], "un seul règlement"); +} diff --git a/crates/kesh-db/src/repositories/invoice_settlements_write.rs b/crates/kesh-db/src/repositories/invoice_settlements_write.rs index 02d4185d..3248c972 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements_write.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements_write.rs @@ -229,20 +229,33 @@ pub async fn settle_invoice( .await?; // (8) ⛔ `paid_at` est la PROJECTION du résiduel à zéro, pas un drapeau. + // + // ⛔ **`version` bouge À CHAQUE règlement, partiel compris** (Story 25-4-c2, + // #480). C'est l'invariant dont dépend le verrou optimiste de l'acceptation + // d'un rapprochement (`accept_one_invoice`, `UPDATE invoices … AND version = ?`) : + // **tout écrit qui change le reste dû d'une facture incrémente `version` dans + // la même transaction.** L'acceptation lit le reste dû sur son instantané ; + // un règlement partiel validé depuis, qui n'incrémentait pas `version`, + // passait sous son contrôle — la facture finissait réglée deux fois. Les + // autres écrivains le tiennent déjà : l'acceptation elle-même, l'annulation + // d'un règlement (`cancel_settlement_in_tx`, les deux branches), l'émission + // d'un avoir (`credit_notes::create_credit_note`) et la dévalidation + // (`invoices::unvalidate`). Un nouvel écrivain qui y manquerait rouvrirait + // la course. let due_after = invoice_settlements::amount_due(&mut *tx, invoice_id).await?; let fully_settled = due_after <= Decimal::ZERO; - if fully_settled { - sqlx::query( - "UPDATE invoices SET paid_at = ?, version = version + 1, updated_at = NOW(3) \ - WHERE id = ? AND company_id = ? AND status = 'validated'", - ) - .bind(settled_on.and_hms_opt(0, 0, 0).expect("minuit est valide")) - .bind(invoice_id) - .bind(company_id) - .execute(&mut *tx) - .await - .map_err(map_db_error)?; - } + let paid_at = + fully_settled.then(|| settled_on.and_hms_opt(0, 0, 0).expect("minuit est valide")); + sqlx::query( + "UPDATE invoices SET paid_at = ?, version = version + 1, updated_at = NOW(3) \ + WHERE id = ? AND company_id = ? AND status = 'validated'", + ) + .bind(paid_at) + .bind(invoice_id) + .bind(company_id) + .execute(&mut *tx) + .await + .map_err(map_db_error)?; // (9) ⛔ L'audit, dans la MÊME transaction. Un règlement est un fait // comptable : s'il s'enregistre sans laisser de trace, la piste d'audit diff --git a/crates/kesh-reconciliation/src/errors.rs b/crates/kesh-reconciliation/src/errors.rs index 2a077a69..c0c1769f 100644 --- a/crates/kesh-reconciliation/src/errors.rs +++ b/crates/kesh-reconciliation/src/errors.rs @@ -33,6 +33,20 @@ pub enum ReconciliationError { #[error("database error: {0}")] Database(#[from] sqlx::Error), + /// Story 25-4-c2 (#480) — la transaction d'un lot a été **annulée sous lui** + /// par InnoDB : un interblocage (1213) levé dans une proposition y a été + /// absorbé en `FailedProposal`, mais il a annulé toute la transaction, et le + /// `ROLLBACK TO SAVEPOINT` qui suit échoue faute de point de sauvegarde + /// (1305). Le lot entier est rejouable ; la route d'acceptation le rejoue. + /// + /// ⚠️ Posée par `accept_batch` **seulement** : c'est le seul site du dépôt à + /// utiliser des `SAVEPOINT` nommés. Ailleurs, un 1305 n'a pas ce sens. + #[error("transaction aborted underneath the batch (deadlock victim)")] + TransactionAborted { + #[source] + source: sqlx::Error, + }, + /// Story 8-5a-base — l'exercice fiscal couvrant `entry_date` est /// soit inexistant soit `Closed` (helper /// `fiscal_years::find_open_covering_date` retourne `None` pour diff --git a/docs/api-external.md b/docs/api-external.md index 22a95812..e94115bd 100644 --- a/docs/api-external.md +++ b/docs/api-external.md @@ -284,6 +284,12 @@ Refus, dans l'ordre de précédence : `SUPPLIER_INVOICE_CANCELLED` (`409`, déj ⚠️ **`FISCAL_YEAR_CLOSED` rend ici `409`**, alors que la dévalidation le rend en `400` : c'est un refus du **geste** d'annulation, qui porte sur l'exercice du **règlement** ; la contre-passation, elle, serait datée d'un exercice ouvert. +### Accepter des propositions de rapprochement + +**`POST /api/v1/reconciliation/accept`** — écriture (`read-write`), rôle Comptable, ouverte aux clés comme les autres routes de réconciliation ; la clé est nommée au journal d'audit (`reconciliation.accepted`). Corps : `{ bankAccountId, proposals: [...] }`, chaque proposition portant `type` (`invoice`, `split` ou `rule`) et `bankTransactionId` — plus `invoiceId` pour une facture. Les propositions viennent de `GET /api/v1/reconciliation/proposals`, où `invoiceAmount` est le **reste dû** de la facture (le TTC tant que rien n'est réglé) et `invoiceTotalTtc` son total d'origine, présent seulement quand les deux diffèrent. + +La réponse est un **succès partiel** en `200` : `{ accepted: [...], failed: [{ bankTransactionId, errorCode, details }] }` — une proposition refusée n'empêche pas les autres. Pour une facture, le score est recalculé par le serveur, et un montant supérieur au reste dû est refusé (`RECONCILIATION_OVERPAYMENT`, jamais écrit). Si la facture change **pendant** l'acceptation — un règlement, un avoir, une dévalidation enregistrés au même moment —, la proposition est refusée en `RECONCILIATION_INVOICE_NOT_ELIGIBLE` (`details.reason` = `race_during_update`) sans rien écrire : relire les propositions et recommencer. Un interblocage transitoire avec une autre opération est **rejoué** par le serveur, sans être montré. Refus de la requête entière : `400` (corps invalide), `404` (compte bancaire inconnu), `409 RECONCILIATION_ACCOUNT_LOCKED` (une autre acceptation tient le compte). + ### Annuler un rapprochement bancaire **`GET /api/v1/reconciliation/transactions/{id}`** — lecture, rôle Comptable (comme les propositions). La transaction bancaire (mêmes champs que dans le détail d'un import, dont `matchedEntryId`), `kind` (`invoice_settlement` : le rapprochement a réglé une facture client ; `entry` : une écriture que seule la transaction possède — éclatement, règle, rapprochement manuel ; `null` : la transaction n'est pas rapprochée), `invoiceId`, `invoiceNumber`, et **`cancellable`** — calculé par la fonction même qui refuserait l'annulation, lu **dans un seul instantané**. Quand il vaut `false`, `cancelBlockedBy` porte le code du motif, `cancelBlockedLabel` le numéro du compte archivé et `cancelBlockedDocumentId` l'**autre** transaction qui pointe la même écriture. ⚠️ Calculé pour **une** transaction : le détail d'un import ne le porte pas. From b3a740a8b4f6a6a56429d4739f5a05b3cead1212 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 16:30:42 +0200 Subject: [PATCH 17/43] =?UTF-8?q?fix(25-4-c2):=20revue=20de=20code=20P1=20?= =?UTF-8?q?=E2=80=94=20gardes=20rows=5Faffected,=20RELEASE=20SAVEPOINT,=20?= =?UTF-8?q?verrou=20de=20test=20d=C3=A9tach=C3=A9=20(refs=20#480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c2-review-prompt-p1.md | 52 +++++++++++++++++++ .../25-4-c2-verrou-acceptation.md | 16 ++++++ crates/kesh-api/src/routes/reconciliation.rs | 32 ++++++++---- crates/kesh-api/tests/reconciliation_e2e.rs | 11 ++-- .../repositories/invoice_settlements_write.rs | 20 ++++++- docs/api-external.md | 2 +- 6 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c2-review-prompt-p1.md diff --git a/_bmad-output/implementation-artifacts/25-4-c2-review-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c2-review-prompt-p1.md new file mode 100644 index 00000000..0e9db6af --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c2-review-prompt-p1.md @@ -0,0 +1,52 @@ +# Prompts — revue de code P1, Story 25-4-c2 (le verrou à l'acceptation) + +*Versionné le 2026-09-30. Trois lentilles en parallèle, **Sonnet**, contexte frais. Diff : le commit +d'implémentation, `git diff 6810cd97 177135f2 -- . ':(exclude)_bmad-output'`, écrit dans +`/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/25-4-c2-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. + +## Lentille 2 — Edge Case Hunter (`bmad-review-edge-case-hunter`) + +Diff **et** lecture du dépôt (MariaDB 10.11, REPEATABLE READ, `innodb_snapshot_isolation` OFF). +- `accept_batch` : chaque chemin où la transaction peut être annulée sous le lot — l'erreur remonte-t-elle + toujours en `TransactionAborted` ou en 1213 direct ? Et `RELEASE SAVEPOINT` après une proposition + réussie, `SAVEPOINT` suivant, `commit` ? Un 1205 (attente dépassée) est-il pris à tort pour un + interblocage ? +- `post_accept` / `accept_once` : le rejeu est-il sûr — effets hors transaction (audit, e-mails, + fichiers, métriques), `GET_LOCK` relâché puis repris, corps réutilisé ? Les validations et le + pré-vol faits une seule fois restent-ils valables à la tentative suivante ? +- `settle_invoice` : l'`UPDATE` inconditionnel (`paid_at` à `NULL` sur un partiel) peut-il effacer un + `paid_at` légitime ? `rows_affected` non vérifié — une facture non `validated` ? +- Les tests de course : montages réellement déterministes ? `LOCK TABLES` sur une connexion du pool + rendue au pool verrouillée si le test panique avant `UNLOCK TABLES` ? Le test d'interblocage : la + victime est-elle garantie ? Un test qui passerait à vide ? +- Inventaire : tout autre écrivain du reste dû ou du statut d'une facture validée qui n'incrémente pas + `version` (`grep -rn "UPDATE invoices\|invoice_settlements\|credit_note" crates/*/src`). + +## Lentille 3 — Acceptance Auditor + +Diff, fiche `_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md`, lecture du dépôt. +Chaque AC (1 à 7) contre le code ; « Ce qu'il ne faut pas faire » (aucun `FOR UPDATE`, pas d'élargissement +de `is_deadlock_error`, pas de classement par le texte, pas de `sleep`, pas de changement d'isolation) ; +le Dev Agent Record (affirme-t-il seulement ce qui a tourné ? décomptes recomptés depuis la source ? +la dérogation au montage de l'AC 5 est-elle justifiée ?). ⛔ **Le manuel** : +`docs/manual/fr/user-manual.tex` (règlement manuel, réconciliation) et `docs/api-external.md` — +l'entrée *Accepter des propositions de rapprochement* dit-elle vrai sur le code (codes, statuts, +champs) ? PDF aplati : `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '`. Le +CHANGELOG dit-il vrai ? diff --git a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md index 072906c4..26caf2f3 100644 --- a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md +++ b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md @@ -307,6 +307,7 @@ Claude Opus 5.5 (`claude-opus-5-5`). - `_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md` - `_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p1.md` - `_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p2.md` +- `_bmad-output/implementation-artifacts/25-4-c2-review-prompt-p1.md` - `_bmad-output/implementation-artifacts/sprint-status.yaml` ## Change Log @@ -340,5 +341,20 @@ Claude Opus 5.5 (`claude-opus-5-5`). exécutés** : backend complet sur base remise à zéro **2516/2516** (4 ignorés), fmt, clippy ; frontend `check`, `lint-i18n-ownership`, **836/836**, build ; **E2E 225 / 19 / 10**, les 10 expliqués un par un. Statut → `review`. +- **2026-09-30** — Revue de code P1 (3 lentilles Sonnet, prompt `25-4-c2-review-prompt-p1.md`, diff + `6810cd97..177135f2`) : Blind Hunter 1 HIGH / 3 MEDIUM / 7 LOW, Edge Case Hunter 5 findings non cotés, + Acceptance Auditor **0** (sept AC, interdits, manuel, API vérifiés). Vérifiés sur le code. **Corrigés** : + HIGH (Blind + Edge) — l'`UPDATE` de `settle_invoice`, ligne dont dépend l'invariant, ne vérifiait pas + `rows_affected` (sûr aujourd'hui par le `FOR UPDATE` initial, muet s'il disparaissait) → `DbError::Invariant` + si ≠ 1, **et** même garde dans `cancel_settlement_in_tx` ; MEDIUM — `RELEASE SAVEPOINT` sans la lecture du + 1305 de la branche d'échec → symétrique ; MEDIUM — `LOCK TABLES` sur une connexion rendue au pool + verrouillée si le test panique (la suppression de la base éphémère attendrait sans fin) → connexion + **détachée** ; MEDIUM — trois bras défensifs identiques → `transaction_aborted_outside_accept` ; + MEDIUM — `api-external.md` taisait le `500` quand les tentatives s'épuisent → écrit. **Laissés LOW** : + archivage du compte non revérifié entre deux tentatives (fenêtre ≤ 150 ms, préexistante hors verrou) ; + `RELEASE_LOCK` en échec après une erreur métier (préexistant, le rejeu ne l'aggrave pas) ; message brut + du 1305 (déjà journalisé) ; `drop` contre `rollback` (cohérent avec les bras voisins) ; heuristique de + la victime (le test asserte que sa transaction survit, il ne peut passer à vide) ; branche non-1305 non + testée ; doublon documentaire de l'invariant. Gate **complet** (`kesh-db` touché) : **2516/2516**. [#480]: https://github.com/guycorbaz/kesh/issues/480 diff --git a/crates/kesh-api/src/routes/reconciliation.rs b/crates/kesh-api/src/routes/reconciliation.rs index 37d1cdaa..9c798ded 100644 --- a/crates/kesh-api/src/routes/reconciliation.rs +++ b/crates/kesh-api/src/routes/reconciliation.rs @@ -1028,9 +1028,17 @@ async fn accept_batch( .await { Ok(entry) => { - sqlx::query(&format!("RELEASE SAVEPOINT {savepoint}")) + // Même lecture que la branche d'échec : un point de sauvegarde + // disparu signale une transaction annulée sous le lot. + if let Err(e) = sqlx::query(&format!("RELEASE SAVEPOINT {savepoint}")) .execute(&mut **tx_outer) - .await?; + .await + { + if is_savepoint_lost(&e) { + return Err(ReconciliationError::TransactionAborted { source: e }); + } + return Err(ReconciliationError::Database(e)); + } accepted.push(entry); } Err(failure) => { @@ -1055,6 +1063,14 @@ async fn accept_batch( Ok(AcceptResponse { accepted, failed }) } +/// `TransactionAborted` n'est posée que par `accept_batch` : ailleurs, elle +/// signale un défaut, rendu en `500` (Story 25-4-c2 — bras défensif partagé par +/// `post_reject`, `post_manual` et `post_split`). +fn transaction_aborted_outside_accept(source: &sqlx::Error) -> AppError { + tracing::error!(error = %source, "unexpected TransactionAborted outside accept_batch"); + AppError::ReconciliationTransactionAborted +} + /// Code MariaDB `ER_SP_DOES_NOT_EXIST` — ici, un point de sauvegarde disparu /// avec la transaction qu'InnoDB a annulée. const MARIADB_SAVEPOINT_DOES_NOT_EXIST: u16 = 1305; @@ -2640,11 +2656,9 @@ pub async fn post_reject( } // Story 8-5a-bis — branche exhaustive uniquement (reject_batch // n'émet pas SplitImbalance). Unreachable en pratique. - // Story 25-4-c2 — posée par `accept_batch` seulement ; défensif ici. Err(ReconciliationError::TransactionAborted { source }) => { drop(tx_outer); - tracing::error!(error = %source, "unexpected TransactionAborted outside accept_batch"); - Err(AppError::ReconciliationTransactionAborted) + Err(transaction_aborted_outside_accept(&source)) } Err(ReconciliationError::SplitImbalance { expected, @@ -3173,11 +3187,9 @@ pub async fn post_manual( } // Story 8-5a-bis — branche exhaustive (post_manual ne fait pas // de split, unreachable en pratique). - // Story 25-4-c2 — posée par `accept_batch` seulement ; défensif ici. Err(ReconciliationError::TransactionAborted { source }) => { drop(tx_outer); - tracing::error!(error = %source, "unexpected TransactionAborted outside accept_batch"); - Err(AppError::ReconciliationTransactionAborted) + Err(transaction_aborted_outside_accept(&source)) } Err(ReconciliationError::SplitImbalance { expected, @@ -3617,11 +3629,9 @@ pub async fn post_split( let _ = tx_outer.rollback().await; Err(AppError::Database(DbError::Sqlx(e))) } - // Story 25-4-c2 — posée par `accept_batch` seulement ; défensif ici. Err(ReconciliationError::TransactionAborted { source }) => { drop(tx_outer); - tracing::error!(error = %source, "unexpected TransactionAborted outside accept_batch"); - Err(AppError::ReconciliationTransactionAborted) + Err(transaction_aborted_outside_accept(&source)) } Err(ReconciliationError::SplitImbalance { expected, diff --git a/crates/kesh-api/tests/reconciliation_e2e.rs b/crates/kesh-api/tests/reconciliation_e2e.rs index 3053c04a..685913db 100644 --- a/crates/kesh-api/tests/reconciliation_e2e.rs +++ b/crates/kesh-api/tests/reconciliation_e2e.rs @@ -4327,10 +4327,13 @@ async fn accept_refuses_when_a_partial_manual_settlement_lands_meanwhile(pool: M .await; let app = spawn_app(pool.clone()).await; - // (1) Le verrou de métadonnées, sur une connexion hors transaction. - let mut verrou = pool.acquire().await.unwrap(); + // (1) Le verrou de métadonnées, sur une connexion hors transaction — + // DÉTACHÉE du pool : si le test panique avant `UNLOCK TABLES`, elle se + // ferme au lieu de retourner au pool verrouillée, et sa session emporte le + // verrou (sinon la suppression de la base éphémère attendrait sans fin). + let mut verrou = pool.acquire().await.unwrap().detach(); sqlx::query("LOCK TABLES contacts WRITE") - .execute(&mut *verrou) + .execute(&mut verrou) .await .unwrap(); @@ -4364,7 +4367,7 @@ async fn accept_refuses_when_a_partial_manual_settlement_lands_meanwhile(pool: M // (4) Relâcher : l'acceptation reprend sur un reste périmé. sqlx::query("UNLOCK TABLES") - .execute(&mut *verrou) + .execute(&mut verrou) .await .unwrap(); drop(verrou); diff --git a/crates/kesh-db/src/repositories/invoice_settlements_write.rs b/crates/kesh-db/src/repositories/invoice_settlements_write.rs index 3248c972..46fd6645 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements_write.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements_write.rs @@ -246,7 +246,7 @@ pub async fn settle_invoice( let fully_settled = due_after <= Decimal::ZERO; let paid_at = fully_settled.then(|| settled_on.and_hms_opt(0, 0, 0).expect("minuit est valide")); - sqlx::query( + let marked = sqlx::query( "UPDATE invoices SET paid_at = ?, version = version + 1, updated_at = NOW(3) \ WHERE id = ? AND company_id = ? AND status = 'validated'", ) @@ -256,6 +256,14 @@ pub async fn settle_invoice( .execute(&mut *tx) .await .map_err(map_db_error)?; + // Le verrou de l'étape (1) rend ce cas impossible aujourd'hui ; s'il se + // produisait, le règlement serait écrit SANS incrémenter `version` et + // l'invariant ci-dessus serait rompu en silence : on refuse tout. + if marked.rows_affected() != 1 { + return Err(DbError::Invariant( + "règlement : la facture n'a pas pu être marquée modifiée (version)".into(), + )); + } // (9) ⛔ L'audit, dans la MÊME transaction. Un règlement est un fait // comptable : s'il s'enregistre sans laisser de trace, la piste d'audit @@ -482,12 +490,20 @@ pub async fn cancel_settlement_in_tx( "UPDATE invoices SET version = version + 1, updated_at = NOW(3) \ WHERE id = ? AND company_id = ?" }; - sqlx::query(sql) + let marked = sqlx::query(sql) .bind(invoice_id) .bind(company_id) .execute(&mut **tx) .await .map_err(map_db_error)?; + // Même garde que `settle_invoice` (Story 25-4-c2) : une annulation qui ne + // marquerait pas la facture romprait l'invariant de version. + if marked.rows_affected() != 1 { + return Err(DbError::Invariant( + "annulation de règlement : la facture n'a pas pu être marquée modifiée (version)" + .into(), + )); + } // (7) L'audit du GESTE. ⚠️ La contre-passation a déjà écrit // `journal_entry.reversed` sur l'écriture : deux lignes, c'est voulu — diff --git a/docs/api-external.md b/docs/api-external.md index e94115bd..74b569c3 100644 --- a/docs/api-external.md +++ b/docs/api-external.md @@ -288,7 +288,7 @@ Refus, dans l'ordre de précédence : `SUPPLIER_INVOICE_CANCELLED` (`409`, déj **`POST /api/v1/reconciliation/accept`** — écriture (`read-write`), rôle Comptable, ouverte aux clés comme les autres routes de réconciliation ; la clé est nommée au journal d'audit (`reconciliation.accepted`). Corps : `{ bankAccountId, proposals: [...] }`, chaque proposition portant `type` (`invoice`, `split` ou `rule`) et `bankTransactionId` — plus `invoiceId` pour une facture. Les propositions viennent de `GET /api/v1/reconciliation/proposals`, où `invoiceAmount` est le **reste dû** de la facture (le TTC tant que rien n'est réglé) et `invoiceTotalTtc` son total d'origine, présent seulement quand les deux diffèrent. -La réponse est un **succès partiel** en `200` : `{ accepted: [...], failed: [{ bankTransactionId, errorCode, details }] }` — une proposition refusée n'empêche pas les autres. Pour une facture, le score est recalculé par le serveur, et un montant supérieur au reste dû est refusé (`RECONCILIATION_OVERPAYMENT`, jamais écrit). Si la facture change **pendant** l'acceptation — un règlement, un avoir, une dévalidation enregistrés au même moment —, la proposition est refusée en `RECONCILIATION_INVOICE_NOT_ELIGIBLE` (`details.reason` = `race_during_update`) sans rien écrire : relire les propositions et recommencer. Un interblocage transitoire avec une autre opération est **rejoué** par le serveur, sans être montré. Refus de la requête entière : `400` (corps invalide), `404` (compte bancaire inconnu), `409 RECONCILIATION_ACCOUNT_LOCKED` (une autre acceptation tient le compte). +La réponse est un **succès partiel** en `200` : `{ accepted: [...], failed: [{ bankTransactionId, errorCode, details }] }` — une proposition refusée n'empêche pas les autres. Pour une facture, le score est recalculé par le serveur, et un montant supérieur au reste dû est refusé (`RECONCILIATION_OVERPAYMENT`, jamais écrit). Si la facture change **pendant** l'acceptation — un règlement, un avoir, une dévalidation enregistrés au même moment —, la proposition est refusée en `RECONCILIATION_INVOICE_NOT_ELIGIBLE` (`details.reason` = `race_during_update`) sans rien écrire : relire les propositions et recommencer. Un interblocage transitoire avec une autre opération est **rejoué** par le serveur, sans être montré ; s'il persiste après plusieurs tentatives, la requête finit en `500 INTERNAL_ERROR` et peut être renvoyée telle quelle — rien n'a été écrit. Refus de la requête entière : `400` (corps invalide), `404` (compte bancaire inconnu), `409 RECONCILIATION_ACCOUNT_LOCKED` (une autre acceptation tient le compte). ### Annuler un rapprochement bancaire From 3de67e6294d0df95080d01c22a9fa4db950bcab3 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 16:43:55 +0200 Subject: [PATCH 18/43] =?UTF-8?q?docs(25-4-c2):=20revue=20de=20code=20P2?= =?UTF-8?q?=20cibl=C3=A9e=20=E2=80=94=20close,=200=20>=20LOW=20;=20story?= =?UTF-8?q?=20done=20(refs=20#480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../25-4-c2-review-prompt-p2.md | 42 +++++++++++++++++++ .../25-4-c2-verrou-acceptation.md | 13 +++++- .../sprint-status.yaml | 2 +- 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c2-review-prompt-p2.md diff --git a/_bmad-output/implementation-artifacts/25-4-c2-review-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-c2-review-prompt-p2.md new file mode 100644 index 00000000..ee58b682 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c2-review-prompt-p2.md @@ -0,0 +1,42 @@ +# Prompt — revue de code P2 ciblée, Story 25-4-c2 (le verrou à l'acceptation) + +*Versionné le 2026-09-30. **Passe ciblée** (CLAUDE.md § « La passe ciblée ») : une lentille (Haiku 4.5), +contexte frais, braquée sur la remédiation P1 seule — `git show b3a740a8`, écrit dans +`/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/25-4-c2-p2.diff`. +Dépôt `/home/gcorbaz/devel/kesh`, lecture seule. Fiche : `_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md`.* + +## Axes — tous obligatoires + +1. **Les gardes `rows_affected() != 1`** (`crates/kesh-db/src/repositories/invoice_settlements_write.rs`, + dans `settle_invoice` et `cancel_settlement_in_tx`) : lis les deux fonctions en entier dans le fichier + courant. Un chemin **légitime** peut-il rendre 0 ligne et désormais échouer à tort (facture `cancelled` + par un avoir, facture dont le règlement s'annule après avoir été créditée, `WHERE` sans garde de + statut dans l'annulation) ? Qui appelle `cancel_settlement_in_tx` (`grep -rn "cancel_settlement_in_tx" crates/`), + et chacun garantit-il la ligne ? Comment `DbError::Invariant` est-il rendu au client + (`crates/kesh-api/src/errors.rs`) ? +2. **`RELEASE SAVEPOINT`** dans `accept_batch` (`crates/kesh-api/src/routes/reconciliation.rs`) : la + nouvelle branche est-elle correcte ; l'erreur d'origine est-elle préservée ? +3. **`transaction_aborted_outside_accept`** : les trois sites l'appellent-ils, et `drop(tx_outer)` est-il + toujours fait **avant** ? +4. **Le test** : la connexion détachée (`.detach()`) — `&mut verrou` sur un `MySqlConnection`, les deux + requêtes passent-elles bien par elle ? Une connexion détachée non fermée explicitement bloque-t-elle + quoi que ce soit en fin de test ? +5. **`docs/api-external.md`** : la phrase ajoutée est-elle exacte sur le code (`500 INTERNAL_ERROR`, + « rien n'a été écrit ») ? + +## Ce que tu rends + +- **Findings** : sévérité, endroit exact, **preuve** : la commande exécutée **et sa sortie copiée**, ou + l'extrait de code lu avec son numéro de ligne dans le fichier courant. Pour toute affirmation qu'un + élément est absent, le `grep -rnF` qui le prouve. 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é.** + +## 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`, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md index 26caf2f3..339db6c2 100644 --- a/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md +++ b/_bmad-output/implementation-artifacts/25-4-c2-verrou-acceptation.md @@ -1,6 +1,6 @@ # Story 25.4-c2 : Le verrou à l'acceptation d'un rapprochement -Status: review +Status: done **Issue : [#480]** — ⛔ la PR porte `closes #480`, titre ET corps (§ *Issue Tracking Rule*). @@ -308,6 +308,7 @@ Claude Opus 5.5 (`claude-opus-5-5`). - `_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p1.md` - `_bmad-output/implementation-artifacts/25-4-c2-validate-prompt-p2.md` - `_bmad-output/implementation-artifacts/25-4-c2-review-prompt-p1.md` +- `_bmad-output/implementation-artifacts/25-4-c2-review-prompt-p2.md` - `_bmad-output/implementation-artifacts/sprint-status.yaml` ## Change Log @@ -356,5 +357,15 @@ Claude Opus 5.5 (`claude-opus-5-5`). du 1305 (déjà journalisé) ; `drop` contre `rollback` (cohérent avec les bras voisins) ; heuristique de la victime (le test asserte que sa transaction survit, il ne peut passer à vide) ; branche non-1305 non testée ; doublon documentaire de l'invariant. Gate **complet** (`kesh-db` touché) : **2516/2516**. +- **2026-09-30** — Revue de code P2 **ciblée** (Haiku, prompt `25-4-c2-review-prompt-p2.md`, sur + `b3a740a8`) : **0 > LOW**, cinq axes exercés. Recoupée par l'orchestrateur sur l'axe le plus sensible : + `cancel_settlement_in_tx` verrouille la facture en tête et son `UPDATE` n'a pas de garde de statut — la + nouvelle garde ne peut pas refuser à tort ; `DbError::Invariant` rend un `500` journalisé. **Revue + close.** Gate complet au dernier commit : backend **2516/2516** (base remise à zéro), frontend inchangé + depuis le gate précédent (836/836), **E2E 228 passed / 19 skipped / 7 failed** — les 7 KF-029, rien + d'autre (run à 14:43 UTC). + + **Bilan de la revue** — P1 Sonnet ×3 : 1 HIGH / 4 MEDIUM corrigés, 7 LOW laissés → P2 Haiku ciblée : + 0 > LOW. Statut → `done`. [#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 0467ae57..59fe6b28 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -360,7 +360,7 @@ development_status: 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: 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: review # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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-c2-verrou-acceptation: done # 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/4M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2516, 836, E2E 228/19/7 KF-029) ; non poussee, empilee sur #483 — PR closes #480 apres le merge de #483. # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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**. From 5d7b1b3194d53918c3adf10154b908a4596c287d Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 16:48:42 +0200 Subject: [PATCH 19/43] =?UTF-8?q?chore(25-4-c3):=20arbitrage=20de=20Guy=20?= =?UTF-8?q?=E2=80=94=20l'=C3=A9cart=20d'arrondi=20passe=20en=20=C3=A9critu?= =?UTF-8?q?re=20;=20PR=20#485=20ouverte=20(refs=20#476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- _bmad-output/implementation-artifacts/sprint-status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 59fe6b28..8bffa24f 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -360,8 +360,8 @@ development_status: 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: 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: done # 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/4M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2516, 836, E2E 228/19/7 KF-029) ; non poussee, empilee sur #483 — PR closes #480 apres le merge de #483. # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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-c2-verrou-acceptation: done # 2026-09-30 PR #485 OUVERTE (closes #480), apres merge de #483 (merge -s ours, arbre identique). REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/4M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2516, 836, E2E 228/19/7 KF-029) ; non poussee, empilee sur #483 — PR closes #480 apres le merge de #483. # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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 # 2026-09-30 ARBITRAGE DE GUY : l ecart d arrondi au centime se PASSE EN ECRITURE, sur un compte de differences d arrondi (ni simple affichage, ni arrondi du TTC a la validation). # [#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 7e98ca74f97a99113e93eb07d2231159e7b29a3f Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 17:03:26 +0200 Subject: [PATCH 20/43] =?UTF-8?q?docs(25-4-c3):=20d=C3=A9coup=C3=A9e=20en?= =?UTF-8?q?=20a1=20/=20a2=20/=20b=20;=20story=20c3-a1=20cr=C3=A9=C3=A9e=20?= =?UTF-8?q?=E2=80=94=20le=20r=C3=A9glage=20du=20compte=20d'arrondi=20(refs?= =?UTF-8?q?=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-c3-a1-reglage-compte-arrondi.md | 163 ++++++++++++++++++ .../sprint-status.yaml | 5 +- 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md new file mode 100644 index 00000000..bdd9a538 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md @@ -0,0 +1,163 @@ +# Story 25.4-c3-a1 : Le réglage du compte de différences d'arrondi + +Status: ready-for-dev + +**Issue : [#476]** — ⛔ la PR porte `refs #476` (la sœur **25-4-c3-b** la fermera). + +**Née du découpage de la 25-4-c3** (règle de splitting : sept modules). Arbitrages de Guy le +2026-09-30 : l'écart d'arrondi au centime se **passe en écriture** sur un **compte de différences +d'arrondi** ; ce compte est désigné par **un réglage dans les paramètres** (pas un rôle de compte) ; il +doit pouvoir être **créé dans le plan comptable et choisi dans les paramètres** ; les plans livrés en +proposeront un (« 6940 Différences d'arrondi », charge), désigné d'office à l'onboarding — c'est la +sœur **25-4-c3-a2**. La sœur **25-4-c3-b** fera l'arrondi et passera l'écart en écriture, en lisant ce +réglage. + +**Empilée sur la 25-4-c2** (PR #485, non mergée) : branche `story/25-4-c3-arrondi-centime`. + +## Story + +En tant qu'administrateur de la comptabilité, +je veux désigner, dans les paramètres de facturation, le compte qui reçoit les différences d'arrondi, +afin que Kesh sache où passer l'écart d'un demi-centime quand un paiement arrondi solde une facture. + +## Ce qui existe, vérifié dans le code + +- **Le précédent : les comptes de TVA** (Story 18-1a). Trois colonnes facultatives de + `company_invoice_settings` (`default_vat_payable_account_id`, `default_vat_recoverable_account_id`, + `default_vat_decompte_account_id`), ajoutées par `20260614000001_vat_accounts_config.sql:53-62` (FK + `ON DELETE RESTRICT`), que l'onboarding ne remplit pas et qui se règlent dans *Paramètres → + Facturation*. C'est le patron à suivre, à la différence près des types et de la postabilité (plus bas). +- **L'entité** : `crates/kesh-db/src/entities/company_invoice_settings.rs:20-40` (+ la structure de mise + à jour qui suit, remplacement intégral). +- **Le dépôt** : `crates/kesh-db/src/repositories/company_invoice_settings.rs` — liste des colonnes + (`:28`), JSON d'audit (`:41`), comparaison « rien n'a changé » (`:120`), `update` (`:131`, `:174-186`), + `insert_with_defaults` (`:277`, projection `:358`) et son miroir `insert_with_defaults_in_tx` (`:403`, + projection `:476`) — ⚠️ commentaire `MIRROR` : les deux se tiennent à la main. +- **La route** : `crates/kesh-api/src/routes/company_invoice_settings.rs` — `validate_account` + (`:94-124`) contrôle société, existence, `active` et **un** type attendu ; les appels (`:~175-222`). + ⚠️ Elle **ne contrôle pas `postable`**. +- **L'écran** : `frontend/src/routes/(app)/settings/invoicing/+page.svelte` — listes filtrées par type + (`:51-57`, `a.active && a.postable && a.accountType === …`), `withCurrentAccount` (`:70`) pour garder + visible un compte déjà choisi, chargement (`:91`), envoi (`:124`), relecture après conflit (`:146`), + sélecteur (`:325-328`). Type : `frontend/src/lib/features/invoices/invoices.types.ts`. +- **L'export de souveraineté** : `crates/kesh-api/src/exports/csv_tables.rs:905-946` + (`serialize_company_invoice_settings_csv`) — la garde d'exhaustivité de la 25-5-a le fera rougir si la + colonne n'y est pas. +- **La sauvegarde** : `crates/kesh-db/src/backup.rs:66` liste la table ; un `.keshbackup` **antérieur** + n'aura pas la colonne. +- **Le manuel** : `docs/manual/fr/admin-manual.tex:1994` (*Configuration des comptes TVA*, depuis + *Paramètres → Facturation*). + +## Acceptance Criteria + +**AC 1 — La colonne.** Migration **non breaking** : `company_invoice_settings.default_rounding_account_id +BIGINT NULL`, FK vers `accounts(id)` `ON DELETE RESTRICT` (patron `20260614000001`). DDL seul, aucune +donnée écrite (⇒ pas de triage P7). Garde-fous de la § *Migration breaking policy* : ligne à +`docs/migrations-idempotence-audit.md` et **ses compteurs recomptés** (P5) ; `grep -rn +"migrations.len()\|apply_migrations_up_to" crates/` inspecté (P6) ; squash `test-schema` régénéré et +`test_schema_guard` vert ; tout manifeste de sommes de contrôle des migrations mis à jour +(`migrations.sha384` si présent, précédent 24-5). ⛔ P8 : une fois appliquée, la migration ne se modifie +plus. + +**AC 2 — L'entité, le dépôt, l'API.** Le champ `default_rounding_account_id: Option` (JSON +`defaultRoundingAccountId`) traverse l'entité, la mise à jour, **toutes** les projections du dépôt +(`update`, les deux `insert_with_defaults*`, `get_or_create_default*`), le JSON d'audit et la comparaison +« rien n'a changé ». `GET`/`PUT /company/invoice-settings` le portent. `insert_with_defaults*` le laissent +`NULL` (la 25-4-c3-a2 le posera à l'onboarding). + +**AC 3 — La validation.** Le compte choisi doit appartenir à la société, être **actif**, être +**imputable** (`postable`) — les écritures automatiques passent `enforce_postable = false`, c'est donc +ici que la garde doit tenir (`invoice_settlements_write.rs:136-161`) —, et être de type **charge +(`Expense`) ou produit (`Revenue`)** : un écart d'arrondi est un résultat, dans un sens ou dans +l'autre. `validate_account` est étendue en conséquence (liste de types acceptés, contrôle de +`postable`) **sans changer** le comportement des champs existants — ⚠️ ne pas imposer `postable` aux +comptes TVA sans le dire : c'est hors périmètre, à signaler en issue si c'est un trou. + +**AC 4 — L'écran.** *Paramètres → Facturation* : un sélecteur « Compte de différences d'arrondi », +facultatif, listant les comptes actifs, imputables, de charge ou de produit ; un compte déjà choisi +reste visible même s'il sort du filtre (`withCurrentAccount`). Un texte d'aide dit à quoi il sert et +qu'on peut le **créer dans le plan comptable** puis le choisir ici. Libellés dans les 4 locales ; +`lint-i18n-ownership` et la garde `i18n-keys.test.ts` (compteur de sites recompté). + +**AC 5 — Les gardes voisines.** Un compte désigné ici ne peut pas disparaître en silence : +- **suppression** : bloquée par la FK (vérifier que l'API rend un refus lisible, pas un 500) ; +- **archivage** : vérifier comment l'archivage d'un compte TVA désigné est traité aujourd'hui, et + faire de même pour ce compte — s'il n'y a **aucune** garde pour les comptes TVA non plus, le dire et + ouvrir une issue plutôt que de l'inventer ici. + +**AC 6 — Export et sauvegarde.** L'export CSV de la table porte la colonne (garde d'exhaustivité verte). +Un `.keshbackup` produit **avant** cette migration s'importe toujours : la colonne manquante vaut `NULL` +— test existant à étendre ou test neuf (`admin_backup_e2e` / `admin_full_import_e2e`). + +**AC 7 — Tests.** Dépôt : le champ survit à `update` et à la relecture ; validation API : refus d'un +compte d'une autre société, archivé, **non imputable**, de type actif/passif ; acceptation d'une charge +et d'un produit ; Vitest de l'écran (options filtrées, compte courant conservé) ; E2E Playwright : +choisir un compte, enregistrer, relire. + +**AC 8 — Textes.** `admin-manual.tex` : à côté des comptes TVA, un paragraphe *Compte de différences +d'arrondi* — à quoi il sert (renvoi à l'écart d'un demi-centime qu'un paiement arrondi laisse ; la +25-4-c3-b le passera en écriture), qu'il est facultatif **tant qu'aucun écart ne se présente**, qu'on +peut le créer dans le plan comptable (charge ou produit, imputable) puis le choisir. PDF régénéré et +contrôlé aplati. `docs/api-external.md` si les réglages de facturation y sont décrits. CHANGELOG +`[0.12.1]` *Added*. + +## Tasks / Subtasks + +- [ ] **T1 — migration et squash** (AC 1). +- [ ] **T2 — entité, dépôt, route, validation** (AC 2, 3, 5). +- [ ] **T3 — export et sauvegarde** (AC 6). +- [ ] **T4 — écran et i18n** (AC 4). +- [ ] **T5 — tests** (AC 7). +- [ ] **T6 — textes** (AC 8). +- [ ] **T7 — gates** : backend complet (base remise à zéro ; ⛔ `kesh-db` touché ⇒ gate complet même en + cours de boucle), frontend complet, **E2E complet**. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- ⛔ **Un rôle de compte** : arbitrage de Guy — c'est un réglage. Un rôle serait de plus une migration + breaking (un binaire antérieur ne décode pas un rôle inconnu). +- ⛔ **Écrire des données dans la migration** (créer le compte 6940 chez les sociétés existantes) : les + sociétés existantes le créent dans le plan et le choisissent (arbitrage) ; les plans livrés, c'est la + c3-a2. +- ⛔ **Un numéro de compte dans le code applicatif** (`14-3a`, migration `20260722000001:3-8`). +- ⛔ **Utiliser le réglage pour écrire** : c'est la c3-b. +- ⚠️ **Les deux `insert_with_defaults*` sont miroirs** : les modifier ensemble. + +### Où regarder + +| Fichier | Pourquoi | +|---|---| +| `crates/kesh-db/migrations/20260614000001_vat_accounts_config.sql` | patron de la colonne | +| `crates/kesh-db/src/entities/company_invoice_settings.rs` | entité et mise à jour | +| `crates/kesh-db/src/repositories/company_invoice_settings.rs` | projections, `update`, audit | +| `crates/kesh-api/src/routes/company_invoice_settings.rs` | `validate_account`, `update_invoice_settings` | +| `crates/kesh-api/src/exports/csv_tables.rs:905-946` | export | +| `crates/kesh-db/src/backup.rs` | sauvegarde, import d'une sauvegarde antérieure | +| `frontend/src/routes/(app)/settings/invoicing/+page.svelte` | écran | +| `crates/kesh-db/tests/company_invoice_settings_repository.rs`, `crates/kesh-api/tests/idor_multi_tenant_e2e.rs` | tests existants qui énumèrent les champs | +| `docs/manual/fr/admin-manual.tex:1994` | manuel | + +### Gardes-fous du dépôt + +- Modules : `kesh-db`, `kesh-api`, `frontend`, `kesh-i18n` (+ `docs`) — sous le seuil. +- Migration : P5, P6, P8 ; P7 sans objet (DDL seul) ; squash et `test_schema_guard`. + +## Dev Agent Record + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List + +## Change Log + +- **2026-09-30** — Créée au découpage de la 25-4-c3 (sept modules), selon les arbitrages de Guy : réglage + dans les paramètres, compte créable dans le plan et choisi ici ; les plans livrés et l'onboarding à la + c3-a2 ; l'arrondi et l'écriture à la c3-b. + +[#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 8bffa24f..bc2efd58 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -361,7 +361,10 @@ development_status: 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: 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: done # 2026-09-30 PR #485 OUVERTE (closes #480), apres merge de #483 (merge -s ours, arbre identique). REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/4M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2516, 836, E2E 228/19/7 KF-029) ; non poussee, empilee sur #483 — PR closes #480 apres le merge de #483. # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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 # 2026-09-30 ARBITRAGE DE GUY : l ecart d arrondi au centime se PASSE EN ECRITURE, sur un compte de differences d arrondi (ni simple affichage, ni arrondi du TTC a la validation). # [#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-c3-arrondi-centime: split # 2026-09-30 DECOUPEE (sept modules > 5) en c3-a1 (reglage), c3-a2 (compte dans les plans + onboarding), c3-b (arrondi et ecriture d ecart). Arbitrages de Guy : reglage dans les parametres (pas un role) ; compte creable dans le plan et choisi dans les parametres ; 6940 « Differences d arrondi » (charge) dans les 3 plans, designe a l onboarding ; refus si non configure quand un ecart se presente.# 2026-09-30 ARBITRAGE DE GUY : l ecart d arrondi au centime se PASSE EN ECRITURE, sur un compte de differences d arrondi (ni simple affichage, ni arrondi du TTC a la validation). # [#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-c3-a1-reglage-compte-arrondi: ready-for-dev # [#476, refs] 2026-09-30 CREEE : colonne company_invoice_settings.default_rounding_account_id (non breaking), validation (actif, imputable, charge ou produit), ecran Parametres > Facturation, export, sauvegarde anterieure, manuel admin. + 25-4-c3-a2-compte-arrondi-plans: backlog # [#476, refs] 6940 « Differences d arrondi » (Expense) dans pme/independant/association (4 langues) ; marqueur de plan (pas de numero dans le code) ; onboarding et seed posent default_rounding_account_id. Apres c3-a1. + 25-4-c3-b-arrondi-au-centime: backlog # [#476, closes] le reste du arrondi au centime aux 8 comparaisons (helper sur Money::round_to_centimes, reminder_amount_due rebranche, dialogue) ; au solde, reglement enregistre au reste BRUT et ecart en 3e ligne sur le compte du reglage ; refus lisible si reglage absent. Apres c3-a1. 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 06c075beae4aa46f1d7d73401ad28d62bc64cce2 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 17:12:14 +0200 Subject: [PATCH 21/43] =?UTF-8?q?docs(25-4-c3-a1):=20validation=20P1=20?= =?UTF-8?q?=E2=80=94=202=20MED=20rem=C3=A9di=C3=A9s=20(refs=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-c3-a1-reglage-compte-arrondi.md | 32 +++++++++-- .../25-4-c3-a1-validate-prompt-p1.md | 56 +++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p1.md diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md index bdd9a538..837ee5b8 100644 --- a/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md @@ -80,14 +80,22 @@ qu'on peut le **créer dans le plan comptable** puis le choisir ici. Libellés d `lint-i18n-ownership` et la garde `i18n-keys.test.ts` (compteur de sites recompté). **AC 5 — Les gardes voisines.** Un compte désigné ici ne peut pas disparaître en silence : -- **suppression** : bloquée par la FK (vérifier que l'API rend un refus lisible, pas un 500) ; -- **archivage** : vérifier comment l'archivage d'un compte TVA désigné est traité aujourd'hui, et - faire de même pour ce compte — s'il n'y a **aucune** garde pour les comptes TVA non plus, le dire et - ouvrir une issue plutôt que de l'inventer ici. +- **suppression** : **sans objet** — aucune route ne supprime un compte (`crates/kesh-api/src/lib.rs:340-352` : + création, modification, archivage, réactivation seulement) ; la FK `ON DELETE RESTRICT` reste le + filet en base ; +- **archivage** : **aucune garde n'existe aujourd'hui pour les comptes TVA désignés** (validation P1). + Ne pas l'inventer ici : ouvrir une issue qui couvre les **quatre** comptes désignés dans les + réglages (trois TVA et l'arrondi), et le dire dans le Dev Agent Record. La c3-b refusera de toute + façon d'écrire sur un compte archivé ou non imputable (garde au moment de l'écriture). **AC 6 — Export et sauvegarde.** L'export CSV de la table porte la colonne (garde d'exhaustivité verte). -Un `.keshbackup` produit **avant** cette migration s'importe toujours : la colonne manquante vaut `NULL` -— test existant à étendre ou test neuf (`admin_backup_e2e` / `admin_full_import_e2e`). +La sauvegarde, elle, lit ses colonnes dans `INFORMATION_SCHEMA` (`export_table`, `column_constraints`) : +elle suit d'elle-même. Un `.keshbackup` produit **avant** cette migration s'importe toujours +(`check_schema_compat` : colonne absente, facultative) et la colonne vaut `NULL` — le prouver par un +test, patron `full_import_without_company_column_merges_archive_entries_as_null` +(`crates/kesh-api/tests/admin_full_import_e2e.rs:2167`, technique `strip_column`). Un `.keshbackup` +postérieur importé par un binaire antérieur est refusé en `400 IMPORT_SCHEMA_MISMATCH` (mécanisme +générique, déjà testé) : rien à faire. **AC 7 — Tests.** Dépôt : le champ survit à `update` et à la relecture ; validation API : refus d'un compte d'une autre société, archivé, **non imputable**, de type actif/passif ; acceptation d'une charge @@ -124,6 +132,12 @@ contrôlé aplati. `docs/api-external.md` si les réglages de facturation y sont - ⛔ **Un numéro de compte dans le code applicatif** (`14-3a`, migration `20260722000001:3-8`). - ⛔ **Utiliser le réglage pour écrire** : c'est la c3-b. - ⚠️ **Les deux `insert_with_defaults*` sont miroirs** : les modifier ensemble. +- **Sans objet** : `docs/optimistic-locking-patterns.md:48` énumère les champs comparés par `update` — + instantané de la Story 7-3, jamais tenu à jour depuis (il omet déjà les trois comptes TVA, + `credit_note_number_format` et `default_payable_account_id`). Ne pas le mettre à jour ici. +- ⚠️ `migrations_upgrade_path.rs` porte `assert_eq!(total, 69, …)` : il rougira — c'est son rôle + (P6) ; le passer à 70 **et** vérifier l'assertion de montage qui l'accompagne. +- ⚠️ Aucun E2E n'est dédié à *Paramètres → Facturation* : le spec de l'AC 7 est à créer. ### Où regarder @@ -159,5 +173,11 @@ contrôlé aplati. `docs/api-external.md` si les réglages de facturation y sont - **2026-09-30** — Créée au découpage de la 25-4-c3 (sept modules), selon les arbitrages de Guy : réglage dans les paramètres, compte créable dans le plan et choisi ici ; les plans livrés et l'onboarding à la c3-a2 ; l'arrondi et l'écriture à la c3-b. +- **2026-09-30** — Validation P1 (Sonnet, prompt `25-4-c3-a1-validate-prompt-p1.md`) : **2 MEDIUM**, + vérifiés. AC 5 visait une suppression de compte que l'API n'offre pas → sans objet ; l'archivage non + gardé des comptes TVA → issue couvrant les quatre comptes des réglages. `optimistic-locking-patterns.md:48` + → écrit sans objet (instantané figé). Faits utiles reportés : sauvegarde dynamique, précédent de test + `strip_column`, `assert_eq!(total, 69)`, pas d'E2E de l'écran. Toutes les références exactes ; P5 + sain (69 = 69, 8 + 61 + 0). [#476]: https://github.com/guycorbaz/kesh/issues/476 diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p1.md new file mode 100644 index 00000000..d728df6a --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p1.md @@ -0,0 +1,56 @@ +# Prompt — validation P1, Story 25-4-c3-a1 (le réglage du compte d'arrondi) + +*Versionné le 2026-09-30. **Une lentille** (Sonnet), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c3-arrondi-centime`. Fiche à valider : +`_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md`. Issue : `gh issue view 476` +(lire les commentaires : arbitrages de Guy). Registre : `sprint-status.yaml`, lignes `25-4-c3*`. Règles : +`CLAUDE.md` (§ *Migration breaking policy* P1–P8 en particulier). Checklist : +`.claude/skills/bmad-create-story/checklist.md`. + +Les arbitrages de Guy (réglage et non rôle ; compte créable dans le plan et choisi dans les paramètres ; +découpage a1 / a2 / b) 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 énumèrent les colonnes de `company_invoice_settings`** — pars du symptôme : + `grep -rn "default_vat_decompte_account_id\|defaultVatDecompteAccountId\|company_invoice_settings" crates/ frontend/src docs --include=*.rs --include=*.ts --include=*.svelte --include=*.sql --include=*.md --include=*.tex`. + Chaque site qui énumère les champs (projection SQL, `FromRow`, JSON d'audit, export, tests, types + frontend, fixtures E2E, seed, onboarding) doit apparaître dans la fiche ou être explicitement sans + objet. Un site oublié qui casse au **runtime** (`ColumnNotFound`) est au moins HIGH. +3. **La migration** : est-elle réellement non breaking au sens de P1 (un binaire antérieur lit-il une + table avec une colonne en plus — `SELECT *` quelque part ?) ; P5 (compteurs de l'audit) ; P6 (sites + positionnels) ; le squash et `test_schema_guard` ; y a-t-il un manifeste de sommes de contrôle des + migrations (`find crates -name "*.sha384"`) ; `migrations_upgrade_path.rs` a-t-il un `assert_eq!(total, N)` ? +4. **La sauvegarde (AC 6)** : lis `crates/kesh-db/src/backup.rs` et l'import. Un `.keshbackup` antérieur, + sans la colonne, s'importe-t-il (insertion par nom de colonnes ? `parse_and_verify` compare-t-il les + colonnes ?). Et un `.keshbackup` **postérieur** importé par un binaire antérieur ? +5. **La validation (AC 3)** : `validate_account` — l'étendre sans changer le comportement des champs + existants est-il faisable ? Les comptes TVA sont-ils contrôlés `postable` ailleurs ? Un compte de + groupe (`postable = false`) peut-il aujourd'hui être choisi comme compte TVA ? +6. **Les gardes voisines (AC 5)** : que se passe-t-il aujourd'hui quand on archive ou supprime un compte + désigné comme compte TVA dans les réglages (route d'archivage des comptes, message d'erreur de la FK) ? +7. **L'écran (AC 4)** : faisabilité dans `settings/invoicing/+page.svelte`, type frontend, garde i18n + (compteur exact), E2E existant de cet écran (`frontend/tests/e2e/`). +8. **Le manuel** : `docs/manual/fr/admin-manual.tex` autour de `:1994`, et **PDF aplati** + (`pdftotext docs/manual/fr/admin-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers + `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`). + `docs/api-external.md` décrit-il les réglages de facturation ? +9. **Périmètre** : modules recomptés depuis les tâches ; frontière avec la c3-a2 et la c3-b nette ? + +## 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 e52cde9ff4b0771fec9c0de86b1d92ef6c6ba815 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 17:15:59 +0200 Subject: [PATCH 22/43] =?UTF-8?q?docs(25-4-c3-a1):=20validation=20P2=20cib?= =?UTF-8?q?l=C3=A9e=20=E2=80=94=20close,=200=20>=20LOW=20(refs=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-c3-a1-reglage-compte-arrondi.md | 6 +++ .../25-4-c3-a1-validate-prompt-p2.md | 38 +++++++++++++++++++ .../sprint-status.yaml | 2 +- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 _bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p2.md diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md index 837ee5b8..0ad0a1c8 100644 --- a/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md @@ -179,5 +179,11 @@ contrôlé aplati. `docs/api-external.md` si les réglages de facturation y sont → écrit sans objet (instantané figé). Faits utiles reportés : sauvegarde dynamique, précédent de test `strip_column`, `assert_eq!(total, 69)`, pas d'E2E de l'écran. Toutes les références exactes ; P5 sain (69 = 69, 8 + 61 + 0). +- **2026-09-30** — Validation P2 **ciblée** (Haiku, prompt `25-4-c3-a1-validate-prompt-p2.md`, sur + `06c075be`) : **0 finding**, preuves jointes sur les quatre axes (routes d'archivage relues, + `INFORMATION_SCHEMA` et `is_required()` lus, test `strip_column` et `assert_eq!(total, 69)` trouvés, + P5 recompté). La remédiation ne touchait que la fiche : **validation close, 0 > LOW.** + + **Bilan** — P1 Sonnet 2 MEDIUM → P2 Haiku ciblée 0. Modèles : Sonnet, Haiku. [#476]: https://github.com/guycorbaz/kesh/issues/476 diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p2.md new file mode 100644 index 00000000..7518df4c --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p2.md @@ -0,0 +1,38 @@ +# Prompt — validation P2 ciblée, Story 25-4-c3-a1 (le réglage du compte d'arrondi) + +*Versionné le 2026-09-30. **Passe ciblée** : une lentille (Haiku 4.5), contexte frais, braquée sur la +remédiation P1 — `git show 06c075be -- _bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md` +(contexte ; les numéros de ligne font foi dans les fichiers courants). Fiche : +`_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md`, **état actuel**. Dépôt +`/home/gcorbaz/devel/kesh`, lecture seule.* + +⚠️ La fiche décrit du travail **à faire** : ne signale pas comme défaut que le code ne porte pas encore +ce qu'elle prévoit. Juge la fiche : exactitude de ce qu'elle affirme, faisabilité de ce qu'elle demande. + +## Axes — tous obligatoires + +1. **AC 5** : `crates/kesh-api/src/lib.rs:340-352` — est-il exact qu'aucune route ne supprime un compte + (`grep -rn "delete" crates/kesh-api/src/routes/accounts.rs crates/kesh-db/src/repositories/accounts.rs`) ? + Est-il exact que l'archivage d'un compte désigné comme compte TVA n'est gardé nulle part (lis la + route et le dépôt d'archivage des comptes) ? +2. **AC 6** : `crates/kesh-db/src/backup.rs` — `export_table` et `column_constraints` lisent-ils bien + `INFORMATION_SCHEMA` ? `check_schema_compat` accepte-t-il une colonne absente et facultative ? Le test + `full_import_without_company_column_merges_archive_entries_as_null` existe-t-il à + `crates/kesh-api/tests/admin_full_import_e2e.rs:2167` et utilise-t-il `strip_column` ? +3. **Dev Notes** : `migrations_upgrade_path.rs` porte-t-il `assert_eq!(total, 69` ? + `docs/optimistic-locking-patterns.md:48` est-il bien l'énumération décrite ? +4. **Cohérence interne** : AC ↔ tâches, Change Log. + +## Ce que tu rends + +- **Findings** : sévérité, endroit exact, **preuve** : la commande exécutée **et sa sortie copiée**, ou + l'extrait de code lu avec son numéro de ligne. 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é.** + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base +(`scripts/*`, `make`, `latexmk`, `git commit`/`push`/`add`/`stash`/`reset`/`rebase`/`checkout`/`switch`, +`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`, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index bc2efd58..f2755924 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -362,7 +362,7 @@ development_status: 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: done # 2026-09-30 PR #485 OUVERTE (closes #480), apres merge de #483 (merge -s ours, arbre identique). REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/4M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2516, 836, E2E 228/19/7 KF-029) ; non poussee, empilee sur #483 — PR closes #480 apres le merge de #483. # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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: split # 2026-09-30 DECOUPEE (sept modules > 5) en c3-a1 (reglage), c3-a2 (compte dans les plans + onboarding), c3-b (arrondi et ecriture d ecart). Arbitrages de Guy : reglage dans les parametres (pas un role) ; compte creable dans le plan et choisi dans les parametres ; 6940 « Differences d arrondi » (charge) dans les 3 plans, designe a l onboarding ; refus si non configure quand un ecart se presente.# 2026-09-30 ARBITRAGE DE GUY : l ecart d arrondi au centime se PASSE EN ECRITURE, sur un compte de differences d arrondi (ni simple affichage, ni arrondi du TTC a la validation). # [#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-c3-a1-reglage-compte-arrondi: ready-for-dev # [#476, refs] 2026-09-30 CREEE : colonne company_invoice_settings.default_rounding_account_id (non breaking), validation (actif, imputable, charge ou produit), ecran Parametres > Facturation, export, sauvegarde anterieure, manuel admin. + 25-4-c3-a1-reglage-compte-arrondi: ready-for-dev # [#476, refs] 2026-09-30 VALIDATION CLOSE en 2 passes (P1 2M -> P2 0). CREEE : colonne company_invoice_settings.default_rounding_account_id (non breaking), validation (actif, imputable, charge ou produit), ecran Parametres > Facturation, export, sauvegarde anterieure, manuel admin. 25-4-c3-a2-compte-arrondi-plans: backlog # [#476, refs] 6940 « Differences d arrondi » (Expense) dans pme/independant/association (4 langues) ; marqueur de plan (pas de numero dans le code) ; onboarding et seed posent default_rounding_account_id. Apres c3-a1. 25-4-c3-b-arrondi-au-centime: backlog # [#476, closes] le reste du arrondi au centime aux 8 comparaisons (helper sur Money::round_to_centimes, reminder_amount_due rebranche, dialogue) ; au solde, reglement enregistre au reste BRUT et ecart en 3e ligne sur le compte du reglage ; refus lisible si reglage absent. Apres c3-a1. 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 55d98555d348a649c2ca8cc01e0392380053d385 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 30 Sep 2026 17:56:34 +0200 Subject: [PATCH 23/43] =?UTF-8?q?feat(25-4-c3-a1):=20d=C3=A9signer=20le=20?= =?UTF-8?q?compte=20de=20diff=C3=A9rences=20d'arrondi=20dans=20les=20param?= =?UTF-8?q?=C3=A8tres=20(refs=20#476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle colonne company_invoice_settings.default_rounding_account_id (DDL seul, non breaking), validée charge ou produit, actif, imputable ; absente d'un PUT elle est préservée, à null effacée. Section Différences d'arrondi dans Paramètres > Facturation ; export ; import d'une sauvegarde antérieure prouvé. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../25-4-c3-a1-reglage-compte-arrondi.md | 91 +++++++++++- .../sprint-status.yaml | 2 +- crates/kesh-api/src/exports/csv_tables.rs | 2 + crates/kesh-api/src/helpers.rs | 15 ++ .../src/routes/company_invoice_settings.rs | 63 +++++++- .../src/routes/reconciliation_rules.rs | 18 +-- .../kesh-api/tests/admin_full_import_e2e.rs | 51 +++++++ .../kesh-api/tests/idor_multi_tenant_e2e.rs | 120 +++++++++++++++ crates/kesh-db/migrations.sha384 | 1 + ...0001_invoice_settings_rounding_account.sql | 22 +++ .../src/entities/company_invoice_settings.rs | 9 ++ .../repositories/company_invoice_settings.rs | 10 +- .../test-schema/0001_schema_squash.sql | 5 +- .../company_invoice_settings_repository.rs | 65 +++++++++ .../kesh-db/tests/migrations_upgrade_path.rs | 32 ++-- crates/kesh-i18n/locales/de-CH/messages.ftl | 3 + crates/kesh-i18n/locales/en-CH/messages.ftl | 3 + crates/kesh-i18n/locales/fr-CH/messages.ftl | 3 + crates/kesh-i18n/locales/it-CH/messages.ftl | 3 + docs/manual/fr/admin-manual.pdf | Bin 314317 -> 315591 bytes docs/manual/fr/admin-manual.tex | 2 + docs/migrations-idempotence-audit.md | 7 +- .../lib/features/invoices/invoices.types.ts | 4 + frontend/src/lib/shared/i18n-keys.test.ts | 8 +- .../(app)/settings/invoicing/+page.svelte | 46 +++++- .../invoicing/settings-invoicing-page.test.ts | 137 ++++++++++++++++++ .../e2e/settings-rounding-account.spec.ts | 59 ++++++++ 28 files changed, 732 insertions(+), 51 deletions(-) create mode 100644 crates/kesh-db/migrations/20260930000001_invoice_settings_rounding_account.sql create mode 100644 frontend/src/routes/(app)/settings/invoicing/settings-invoicing-page.test.ts create mode 100644 frontend/tests/e2e/settings-rounding-account.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 997a3817..94028e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ### Added +- **Désigner le compte de différences d'arrondi dans les paramètres ([#476](https://github.com/guycorbaz/kesh/issues/476)).** *Paramètres → Facturation* porte une nouvelle section, *Différences d'arrondi* : le compte — charge ou produit, actif et imputable — qui recevra l'écart d'un demi-centime au plus qu'un paiement arrondi au centime laisse sur une facture dont le total a plus de deux décimales. On le crée dans le plan comptable s'il n'existe pas, puis on le choisit ici. Le réglage n'est encore lu par aucune écriture : le passage des écarts en écriture suit. *(API : `defaultRoundingAccountId` dans `GET` et `PUT /company/invoice-settings` ; absent du corps d'un `PUT`, il est préservé ; à `null`, effacé.)* + - **Consulter le journal d'audit à l'écran.** La trace de chaque opération qui modifie les livres se lisait depuis la v0.12.0 par l'interface de programmation seulement, ce qui supposait un outil technique. Elle se consulte désormais dans l'application — *Administration → Journal d'audit*, pour les comptables et les administrateurs : filtres par période, type d'entité, numéro et action, actions et types traduits dans la langue de l'installation, détail de chaque entrée, export CSV. ⚠️ Les dates filtrent sur des **jours UTC** ; le tableau affiche l'heure locale. ([#378](https://github.com/guycorbaz/kesh/issues/378)) - **Annuler un règlement client.** Un règlement enregistré par erreur était jusqu'ici **définitivement incorrigible** : la contre-passation directe de son écriture était refusée, et la seule sortie — une écriture manuelle — laissait la facture se dire réglée quand le grand livre disait le contraire. La fiche facture liste désormais ses règlements, et chacun s'**annule par contre-passation** : une écriture inverse **datée du jour**, le règlement retiré de la facture, le montant redevenu dû. Les deux écritures restent visibles au grand livre. diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md index 0ad0a1c8..d8f895b8 100644 --- a/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md @@ -1,6 +1,6 @@ # Story 25.4-c3-a1 : Le réglage du compte de différences d'arrondi -Status: ready-for-dev +Status: review **Issue : [#476]** — ⛔ la PR porte `refs #476` (la sœur **25-4-c3-b** la fermera). @@ -111,13 +111,13 @@ contrôlé aplati. `docs/api-external.md` si les réglages de facturation y sont ## Tasks / Subtasks -- [ ] **T1 — migration et squash** (AC 1). -- [ ] **T2 — entité, dépôt, route, validation** (AC 2, 3, 5). -- [ ] **T3 — export et sauvegarde** (AC 6). -- [ ] **T4 — écran et i18n** (AC 4). -- [ ] **T5 — tests** (AC 7). -- [ ] **T6 — textes** (AC 8). -- [ ] **T7 — gates** : backend complet (base remise à zéro ; ⛔ `kesh-db` touché ⇒ gate complet même en +- [x] **T1 — migration et squash** (AC 1). +- [x] **T2 — entité, dépôt, route, validation** (AC 2, 3, 5). +- [x] **T3 — export et sauvegarde** (AC 6). +- [x] **T4 — écran et i18n** (AC 4). +- [x] **T5 — tests** (AC 7). +- [x] **T6 — textes** (AC 8). +- [x] **T7 — gates** : backend complet (base remise à zéro ; ⛔ `kesh-db` touché ⇒ gate complet même en cours de boucle), frontend complet, **E2E complet**. ## Dev Notes @@ -162,12 +162,84 @@ contrôlé aplati. `docs/api-external.md` si les réglages de facturation y sont ### Agent Model Used +Claude Opus 5.5 (`claude-opus-5-5`). + ### Debug Log References +- Mutations (restaurées, fichier touché ensuite) : garde `postable` neutralisée → `settings_rounding_account_is_validated` + rouge (« charge non imputable » acceptée) ; préservation de l'absent remplacée par `None` → + `settings_rounding_account_absent_preserves_null_clears` rouge. Le test d'import antérieur est + discriminant par construction : sans le retrait de la colonne, l'import restaurerait le compte. +- Le jeu E2E `with-company` ne porte que deux comptes de résultat (3000, 4000) : le Playwright choisit + la charge 4000, pas un compte du plan PME. +- Deux gardes frontend ont rougi et ont été suivies : le compteur de sites i18n (1752 → 1756, recompté + 32 → 36 dans la page) et `e2e-selecteurs-traduits` (le spec visait « Enregistrer » par son libellé) → + `data-testid="settings-invoicing-save"`. +- Gate E2E : 229 passed / 19 skipped / 7 failed — les 7 KF-029 (#97), rien d'autre (run à 15:55 UTC). + ### Completion Notes List +- **Migration** `20260930000001_invoice_settings_rounding_account.sql` : `ADD COLUMN + default_rounding_account_id BIGINT NULL` + FK `fk_cis_rounding` `ON DELETE RESTRICT`, DDL seul, non + breaking. P5 : ligne d'audit `tracked-by-sqlx`, compteurs **recomptés** (70 fichiers = 70 lignes ; + 8 + 62 + 0). P6 : `migrations_upgrade_path` 69 → 70 **et** soustracteur 35 → 36 (frontière 34 + inchangée), valeur grepée dans tout le fichier (sept sites mis à jour, les historiques laissés). P7 + sans objet. P8 : ligne ajoutée à `migrations.sha384`. Squash régénéré (`scripts/regen-test-schema.sh`, + rejeu vérifié). +- **Dépôt et entité** : le champ traverse `COLUMNS`, le JSON d'audit, `is_no_op_change`, l'`UPDATE` et + les deux projections miroirs de `insert_with_defaults*` (qui le laissent `NULL`). +- **API** : `GET`/`PUT /company/invoice-settings` portent `defaultRoundingAccountId`. **Absent du corps + d'un `PUT` : préservé** (comme `creditNoteNumberFormat`, #216) ; **à `null` : effacé** — par + `double_option`, déplacé de `reconciliation_rules.rs` vers `crate::helpers` (partagé, DRY). + `validate_account_of` généralise `validate_account` (plusieurs types, postabilité exigée ou non) sans + changer les champs existants ; le compte d'arrondi exige charge **ou** produit, actif, **imputable**, + de la société. +- **Export** : colonne ajoutée au CSV. **Sauvegarde** : dynamique (`INFORMATION_SCHEMA`), rien à changer ; + l'import d'un backup antérieur est prouvé par test. +- **Écran** : section *Différences d'arrondi* dans *Paramètres → Facturation* — sélecteur filtré (actifs, + imputables, charges et produits), compte courant conservé (`withCurrentAccount`), texte d'aide qui + renvoie au plan comptable ; trois clés dans les quatre catalogues. +- **AC 5** : aucune route ne supprime un compte ; l'archivage non gardé des comptes désignés (TVA et + arrondi) → **issue #486**. +- **Tests** (périmètre : `e52cde9f` → cette branche) : +1 dépôt, +2 API (validation sur sept cas, + absent / `null`), +1 import de sauvegarde, +3 Vitest (nouveau fichier), +1 Playwright (nouveau spec). +- **Textes** : `admin-manual.tex` — paragraphe *Compte de différences d'arrondi*, qui dit que le réglage + n'est encore lu par aucune écriture ; PDF régénéré, contrôlé aplati. CHANGELOG `[0.12.1]` *Added*. + `docs/api-external.md` ne décrit pas ces réglages : sans objet. + ### File List +- `CHANGELOG.md` +- `crates/kesh-api/src/exports/csv_tables.rs` +- `crates/kesh-api/src/helpers.rs` +- `crates/kesh-api/src/routes/company_invoice_settings.rs` +- `crates/kesh-api/src/routes/reconciliation_rules.rs` +- `crates/kesh-api/tests/admin_full_import_e2e.rs` +- `crates/kesh-api/tests/idor_multi_tenant_e2e.rs` +- `crates/kesh-db/migrations.sha384` +- `crates/kesh-db/migrations/20260930000001_invoice_settings_rounding_account.sql` +- `crates/kesh-db/src/entities/company_invoice_settings.rs` +- `crates/kesh-db/src/repositories/company_invoice_settings.rs` +- `crates/kesh-db/test-schema/0001_schema_squash.sql` +- `crates/kesh-db/tests/company_invoice_settings_repository.rs` +- `crates/kesh-db/tests/migrations_upgrade_path.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` +- `docs/manual/fr/admin-manual.pdf` +- `docs/manual/fr/admin-manual.tex` +- `docs/migrations-idempotence-audit.md` +- `frontend/src/lib/features/invoices/invoices.types.ts` +- `frontend/src/lib/shared/i18n-keys.test.ts` +- `frontend/src/routes/(app)/settings/invoicing/+page.svelte` +- `frontend/src/routes/(app)/settings/invoicing/settings-invoicing-page.test.ts` +- `frontend/tests/e2e/settings-rounding-account.spec.ts` +- `_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md` +- `_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p1.md` +- `_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p2.md` +- `_bmad-output/implementation-artifacts/sprint-status.yaml` + ## Change Log - **2026-09-30** — Créée au découpage de la 25-4-c3 (sept modules), selon les arbitrages de Guy : réglage @@ -185,5 +257,8 @@ contrôlé aplati. `docs/api-external.md` si les réglages de facturation y sont P5 recompté). La remédiation ne touchait que la fiche : **validation close, 0 > LOW.** **Bilan** — P1 Sonnet 2 MEDIUM → P2 Haiku ciblée 0. Modèles : Sonnet, Haiku. +- **2026-09-30** — Implémentée (`bmad-dev-story`). Gates **réellement exécutés** : backend complet sur base + remise à zéro **2520/2520** (4 ignorés), fmt, clippy ; frontend `check`, `lint-i18n-ownership`, + **839/839**, build ; **E2E 229 / 19 / 7** (les 7 KF-029). Issue #486 ouverte. Statut → `review`. [#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 f2755924..6e52521b 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -362,7 +362,7 @@ development_status: 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: done # 2026-09-30 PR #485 OUVERTE (closes #480), apres merge de #483 (merge -s ours, arbre identique). REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/4M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2516, 836, E2E 228/19/7 KF-029) ; non poussee, empilee sur #483 — PR closes #480 apres le merge de #483. # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#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: split # 2026-09-30 DECOUPEE (sept modules > 5) en c3-a1 (reglage), c3-a2 (compte dans les plans + onboarding), c3-b (arrondi et ecriture d ecart). Arbitrages de Guy : reglage dans les parametres (pas un role) ; compte creable dans le plan et choisi dans les parametres ; 6940 « Differences d arrondi » (charge) dans les 3 plans, designe a l onboarding ; refus si non configure quand un ecart se presente.# 2026-09-30 ARBITRAGE DE GUY : l ecart d arrondi au centime se PASSE EN ECRITURE, sur un compte de differences d arrondi (ni simple affichage, ni arrondi du TTC a la validation). # [#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-c3-a1-reglage-compte-arrondi: ready-for-dev # [#476, refs] 2026-09-30 VALIDATION CLOSE en 2 passes (P1 2M -> P2 0). CREEE : colonne company_invoice_settings.default_rounding_account_id (non breaking), validation (actif, imputable, charge ou produit), ecran Parametres > Facturation, export, sauvegarde anterieure, manuel admin. + 25-4-c3-a1-reglage-compte-arrondi: review # 2026-09-30 IMPLEMENTEE — gates 2520, 839, E2E 229/19/7 KF-029 ; #486 ouverte (archivage des comptes des reglages). # [#476, refs] 2026-09-30 VALIDATION CLOSE en 2 passes (P1 2M -> P2 0). CREEE : colonne company_invoice_settings.default_rounding_account_id (non breaking), validation (actif, imputable, charge ou produit), ecran Parametres > Facturation, export, sauvegarde anterieure, manuel admin. 25-4-c3-a2-compte-arrondi-plans: backlog # [#476, refs] 6940 « Differences d arrondi » (Expense) dans pme/independant/association (4 langues) ; marqueur de plan (pas de numero dans le code) ; onboarding et seed posent default_rounding_account_id. Apres c3-a1. 25-4-c3-b-arrondi-au-centime: backlog # [#476, closes] le reste du arrondi au centime aux 8 comparaisons (helper sur Money::round_to_centimes, reminder_amount_due rebranche, dialogue) ; au solde, reglement enregistre au reste BRUT et ecart en 3e ligne sur le compte du reglage ; refus lisible si reglage absent. Apres c3-a1. 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/exports/csv_tables.rs b/crates/kesh-api/src/exports/csv_tables.rs index 2cd74584..6f74932d 100644 --- a/crates/kesh-api/src/exports/csv_tables.rs +++ b/crates/kesh-api/src/exports/csv_tables.rs @@ -921,6 +921,7 @@ pub fn serialize_company_invoice_settings_csv( "journal_entry_description_template", "credit_note_number_format", "default_payable_account_id", + "default_rounding_account_id", "version", "created_at", "updated_at", @@ -939,6 +940,7 @@ pub fn serialize_company_invoice_settings_csv( txt(cis.journal_entry_description_template.clone()), txt(cis.credit_note_number_format.clone()), fmt_opt_i64(cis.default_payable_account_id), + fmt_opt_i64(cis.default_rounding_account_id), cis.version.to_string(), fmt_dt(cis.created_at), fmt_dt(cis.updated_at), diff --git a/crates/kesh-api/src/helpers.rs b/crates/kesh-api/src/helpers.rs index 5ad9b457..c7c4df15 100644 --- a/crates/kesh-api/src/helpers.rs +++ b/crates/kesh-api/src/helpers.rs @@ -72,6 +72,21 @@ pub async fn get_company_for( }) } +/// Deserializer « double option » (Story 19-5) : distingue un champ **absent** +/// (→ `None`, inchangé) d'un champ **présent à `null`** (→ `Some(None)`, +/// effacement). Sans ce helper, serde replie `null` sur le `None` externe et +/// un effacement via un corps partiel serait un no-op silencieux (bug HIGH +/// Pass 1 Blind Hunter, 19-5). À combiner avec `#[serde(default)]` pour gérer le +/// cas absent. Partagé par les règles de rapprochement et les réglages de +/// facturation (Story 25-4-c3-a1). +pub fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(Some) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/kesh-api/src/routes/company_invoice_settings.rs b/crates/kesh-api/src/routes/company_invoice_settings.rs index 3af42dc6..19d8c780 100644 --- a/crates/kesh-api/src/routes/company_invoice_settings.rs +++ b/crates/kesh-api/src/routes/company_invoice_settings.rs @@ -41,6 +41,8 @@ pub struct InvoiceSettingsResponse { pub journal_entry_description_template: String, pub credit_note_number_format: String, pub default_payable_account_id: Option, + /// Story 25-4-c3-a1 (#476) — compte de différences d'arrondi. + pub default_rounding_account_id: Option, pub version: i32, } @@ -58,6 +60,7 @@ impl From for InvoiceSettingsResponse { journal_entry_description_template: s.journal_entry_description_template, credit_note_number_format: s.credit_note_number_format, default_payable_account_id: s.default_payable_account_id, + default_rounding_account_id: s.default_rounding_account_id, version: s.version, } } @@ -79,6 +82,12 @@ pub struct UpdateInvoiceSettingsRequest { #[serde(default)] pub credit_note_number_format: Option, pub default_payable_account_id: Option, + /// Story 25-4-c3-a1 (#476) — compte de différences d'arrondi. **Absent du + /// corps : préservé** (comme `credit_note_number_format`, #216), pour qu'un + /// client qui ignore encore le champ — clé d'API, onglet ouvert avant la mise + /// à jour — ne l'efface pas en silence. **Présent à `null` : effacé.** + #[serde(default, deserialize_with = "crate::helpers::double_option")] + pub default_rounding_account_id: Option>, pub version: i32, } @@ -97,6 +106,33 @@ async fn validate_account( account_id: Option, expected: AccountType, field_label: &str, +) -> Result<(), AppError> { + validate_account_of( + state, + company_id, + account_id, + &[expected], + false, + field_label, + ) + .await +} + +/// Forme générale de [`validate_account`] (Story 25-4-c3-a1) : plusieurs types +/// acceptés, et la postabilité exigée ou non. +/// +/// ⚠️ `require_postable` est exigé pour un compte que des **écritures +/// automatiques** utiliseront : elles passent `enforce_postable = false` +/// (`journal_entries::create_in_tx`), si bien que la garde doit tenir ici, au +/// moment où le compte est désigné. Les champs historiques ne l'exigent pas +/// (comportement inchangé). +async fn validate_account_of( + state: &AppState, + company_id: i64, + account_id: Option, + accepted: &[AccountType], + require_postable: bool, + field_label: &str, ) -> Result<(), AppError> { let Some(id) = account_id else { return Ok(()); @@ -114,10 +150,16 @@ async fn validate_account( "{field_label} : compte archivé" ))); } - if account.account_type != expected { + if !accepted.contains(&account.account_type) { + let attendus: Vec<&str> = accepted.iter().map(|t| t.as_str()).collect(); return Err(AppError::Validation(format!( "{field_label} : type de compte incompatible (attendu {})", - expected.as_str() + attendus.join(" ou ") + ))); + } + if require_postable && !account.postable { + return Err(AppError::Validation(format!( + "{field_label} : compte non imputable (compte de regroupement ou de clôture)" ))); } Ok(()) @@ -219,6 +261,22 @@ pub async fn update_invoice_settings( ) .await?; + // Compte de différences d'arrondi (Story 25-4-c3-a1) : un écart d'arrondi est + // un résultat, dans un sens ou dans l'autre — charge ou produit, imputable. + // Absent du corps : la valeur en place est préservée. + let default_rounding_account_id = req + .default_rounding_account_id + .unwrap_or(current.default_rounding_account_id); + validate_account_of( + &state, + company.id, + default_rounding_account_id, + &[AccountType::Expense, AccountType::Revenue], + true, + "Compte de différences d'arrondi", + ) + .await?; + // 5. Persister. let update = CompanyInvoiceSettingsUpdate { invoice_number_format: req.invoice_number_format, @@ -231,6 +289,7 @@ pub async fn update_invoice_settings( journal_entry_description_template: req.journal_entry_description_template, credit_note_number_format, default_payable_account_id: req.default_payable_account_id, + default_rounding_account_id, }; let settings = company_invoice_settings::update( &state.pool, diff --git a/crates/kesh-api/src/routes/reconciliation_rules.rs b/crates/kesh-api/src/routes/reconciliation_rules.rs index 4fb9fcea..eac9e936 100644 --- a/crates/kesh-api/src/routes/reconciliation_rules.rs +++ b/crates/kesh-api/src/routes/reconciliation_rules.rs @@ -107,20 +107,6 @@ fn default_priority() -> i32 { DEFAULT_PRIORITY } -/// Deserializer « double option » (Story 19-5) : distingue un champ **absent** -/// (→ `None`, inchangé) d'un champ **présent à `null`** (→ `Some(None)`, -/// effacement). Sans ce helper, serde replie `null` sur le `None` externe et -/// l'effacement du projet par défaut via PATCH serait un no-op silencieux -/// (bug HIGH Pass 1 Blind Hunter). À combiner avec `#[serde(default)]` pour -/// gérer le cas absent. -fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> -where - T: serde::Deserialize<'de>, - D: serde::Deserializer<'de>, -{ - serde::Deserialize::deserialize(deserializer).map(Some) -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateRuleRequest { @@ -135,9 +121,9 @@ pub struct UpdateRuleRequest { /// Projet analytique par défaut (Story 19-5) — sémantique deux niveaux : /// absent = inchangé ; `null` = effacer ; `` = affecter. Mappé sur /// `UpdateReconciliationRule.default_project_id: Option>`. - /// `deserialize_with = "double_option"` est **obligatoire** pour que + /// `deserialize_with = "crate::helpers::double_option"` est **obligatoire** pour que /// `null` produise `Some(None)` (effacement) plutôt que `None` (inchangé). - #[serde(default, deserialize_with = "double_option")] + #[serde(default, deserialize_with = "crate::helpers::double_option")] pub default_project_id: Option>, } diff --git a/crates/kesh-api/tests/admin_full_import_e2e.rs b/crates/kesh-api/tests/admin_full_import_e2e.rs index cffd7ded..cbb22547 100644 --- a/crates/kesh-api/tests/admin_full_import_e2e.rs +++ b/crates/kesh-api/tests/admin_full_import_e2e.rs @@ -2338,3 +2338,54 @@ async fn characterization_full_import_keeps_company_ids_as_written(pool: MySqlPo assert_eq!(import_company, Some(u1.company_id)); assert_ne!(import_company, Some(u2.company_id)); } + +/// **Story 25-4-c3-a1 (#476) — un backup antérieur au compte de différences +/// d'arrondi reste importable, et le réglage arrive `NULL`.** +/// +/// La sauvegarde lit ses colonnes dans `INFORMATION_SCHEMA` : elle suit la +/// colonne d'elle-même. Ce test prouve l'autre sens — un `.keshbackup` produit +/// avant la migration `20260930000001`, donc sans la colonne, passe +/// `check_schema_compat` (colonne facultative) et laisse le réglage vide : la +/// société le désignera à nouveau dans les paramètres. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn full_import_without_rounding_account_column_leaves_the_setting_null(pool: MySqlPool) { + let app = spawn_app(pool.clone()).await; + let ctx = seed_admin(&pool, "arrondi_absent").await; + let charge = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type) \ + VALUES (?, '6940', 'Différences d''arrondi', 'Expense')", + ) + .bind(ctx.company_id) + .execute(&pool) + .await + .expect("compte de charge") + .last_insert_id() as i64; + sqlx::query( + "INSERT INTO company_invoice_settings (company_id, default_rounding_account_id) \ + VALUES (?, ?) ON DUPLICATE KEY UPDATE default_rounding_account_id = VALUES(default_rounding_account_id)", + ) + .bind(ctx.company_id) + .bind(charge) + .execute(&pool) + .await + .expect("réglage"); + + let backup = export_backup(&app, &ctx.jwt).await; + let (mut manifest, data) = unzip(&backup); + strip_column( + &mut manifest, + "company_invoice_settings", + "default_rounding_account_id", + ); + import_ok(&app, &ctx.jwt, &manifest, &data).await; + + let rounding: Vec> = + sqlx::query_scalar("SELECT default_rounding_account_id FROM company_invoice_settings") + .fetch_all(&pool) + .await + .expect("lecture du réglage"); + assert!( + !rounding.is_empty() && rounding.iter().all(Option::is_none), + "un backup sans la colonne laisse le réglage vide : {rounding:?}" + ); +} diff --git a/crates/kesh-api/tests/idor_multi_tenant_e2e.rs b/crates/kesh-api/tests/idor_multi_tenant_e2e.rs index 1a64adff..722a650b 100644 --- a/crates/kesh-api/tests/idor_multi_tenant_e2e.rs +++ b/crates/kesh-api/tests/idor_multi_tenant_e2e.rs @@ -804,3 +804,123 @@ async fn settings_update_without_credit_note_format_preserves_and_returns_200(po "le format d'avoir doit être préservé quand le champ est absent" ); } + +/// `PUT /company/invoice-settings` depuis la config courante, le compte de +/// différences d'arrondi posé à `rounding` — ou **absent** du corps si `None`. +/// Rend le statut et le corps de la réponse. +async fn put_rounding_account( + app: &TestApp, + token: &str, + rounding: Option, +) -> (u16, serde_json::Value) { + let settings: serde_json::Value = app + .client + .get(app.url("/api/v1/company/invoice-settings")) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .expect("get") + .json() + .await + .expect("json"); + let mut body = json!({ + "invoiceNumberFormat": settings["invoiceNumberFormat"], + "defaultReceivableAccountId": settings["defaultReceivableAccountId"], + "defaultRevenueAccountId": settings["defaultRevenueAccountId"], + "defaultVatPayableAccountId": settings["defaultVatPayableAccountId"], + "defaultVatRecoverableAccountId": settings["defaultVatRecoverableAccountId"], + "defaultVatDecompteAccountId": settings["defaultVatDecompteAccountId"], + "defaultSalesJournal": settings["defaultSalesJournal"], + "journalEntryDescriptionTemplate": settings["journalEntryDescriptionTemplate"], + "version": settings["version"], + }); + if let Some(value) = rounding { + body["defaultRoundingAccountId"] = value; + } + let resp = app + .client + .put(app.url("/api/v1/company/invoice-settings")) + .header("Authorization", format!("Bearer {token}")) + .json(&body) + .send() + .await + .expect("put"); + let status = resp.status().as_u16(); + (status, resp.json().await.unwrap_or(serde_json::Value::Null)) +} + +/// Story 25-4-c3-a1 (#476) — le compte de différences d'arrondi : charge **ou** +/// produit, actif, **imputable**, de la société. Tout autre compte est refusé en +/// 400 ; le refus du compte non imputable est la garde que les écritures +/// automatiques ne portent pas (`enforce_postable = false`). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn settings_rounding_account_is_validated(pool: MySqlPool) { + truncate_all(&pool).await.expect("truncate"); + let (company_id, accounts) = create_seeded_company(&pool).await; + let (_other_company, other_accounts) = create_seeded_company(&pool).await; + create_company_user_with_role(&pool, company_id, "alice", "password123", Role::Admin).await; + // Un compte de charge de regroupement (non imputable), et un archivé. + let mut extra = std::collections::HashMap::new(); + for (code, active, postable) in [("6000", true, false), ("6100", false, true)] { + let id = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type, active, postable) \ + VALUES (?, ?, 'Charge test', 'Expense', ?, ?)", + ) + .bind(company_id) + .bind(code) + .bind(active) + .bind(postable) + .execute(&pool) + .await + .expect("account insert") + .last_insert_id() as i64; + extra.insert(code, id); + } + let app = spawn_app(pool.clone()).await; + let token = login(&app, "alice", "password123").await; + + for (label, account, expected) in [ + ("charge imputable", accounts["4000"], 200), + ("produit imputable", accounts["3000"], 200), + ("actif", accounts["1000"], 400), + ("passif", accounts["2000"], 400), + ("charge non imputable", extra["6000"], 400), + ("charge archivée", extra["6100"], 400), + ("charge d'une autre société", other_accounts["4000"], 400), + ] { + let (status, body) = put_rounding_account(&app, &token, Some(json!(account))).await; + assert_eq!(status, expected, "{label} : {body}"); + if expected == 200 { + assert_eq!(body["defaultRoundingAccountId"], account, "{label}"); + } + } +} + +/// Story 25-4-c3-a1 — **absent** du corps, le compte d'arrondi est **préservé** +/// (un client qui ignore encore le champ ne l'efface pas) ; **présent à `null`**, +/// il est effacé. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn settings_rounding_account_absent_preserves_null_clears(pool: MySqlPool) { + truncate_all(&pool).await.expect("truncate"); + let (company_id, accounts) = create_seeded_company(&pool).await; + create_company_user_with_role(&pool, company_id, "alice", "password123", Role::Admin).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app, "alice", "password123").await; + + let (status, body) = put_rounding_account(&app, &token, Some(json!(accounts["4000"]))).await; + assert_eq!(status, 200, "{body}"); + + let (status, body) = put_rounding_account(&app, &token, None).await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + body["defaultRoundingAccountId"], accounts["4000"], + "absent du corps : préservé" + ); + + let (status, body) = put_rounding_account(&app, &token, Some(serde_json::Value::Null)).await; + assert_eq!(status, 200, "{body}"); + assert!( + body["defaultRoundingAccountId"].is_null(), + "présent à null : effacé ; corps = {body}" + ); +} diff --git a/crates/kesh-db/migrations.sha384 b/crates/kesh-db/migrations.sha384 index 68267e87..04dda8d2 100644 --- a/crates/kesh-db/migrations.sha384 +++ b/crates/kesh-db/migrations.sha384 @@ -82,3 +82,4 @@ 20260910000001 5b9000c3178f56136a67d57f402e709ae70ab9025ffa818853c614fd55df9521e8ecc5b83c1cfab934a681a8c85d1a5a 20260915000001 7cf8f78b3d9af607fcd2b52bb54f0190880c65df4062d3ce67a62f81bbdf5ef884a42533c579b1184684deaf75259d2b 20260917000001 ca59103a2ff33cde3ae613f14f6a1a54291315e9c91040ee4a393dee9180307d1b4b2a695f0ffa2f4ed28e5b40d0f023 +20260930000001 9fb2d6d97dd32fa924ed44cc9660d02a7b5c059704a6605f4b61f0a2e507e8ef8dfe977b5d3dfef5cd3df3e3ef7d07ac diff --git a/crates/kesh-db/migrations/20260930000001_invoice_settings_rounding_account.sql b/crates/kesh-db/migrations/20260930000001_invoice_settings_rounding_account.sql new file mode 100644 index 00000000..c0edf2c1 --- /dev/null +++ b/crates/kesh-db/migrations/20260930000001_invoice_settings_rounding_account.sql @@ -0,0 +1,22 @@ +-- Story 25-4-c3-a1 (#476) — le compte de différences d'arrondi, désigné dans +-- les paramètres de facturation. +-- +-- Arbitrage du Project Lead (2026-09-30) : l'écart d'arrondi au centime +-- qu'un paiement arrondi laisse sur une facture se PASSE EN ÉCRITURE, sur un +-- compte désigné par un RÉGLAGE (pas un rôle de compte). Le compte se crée +-- dans le plan comptable et se choisit ici ; les plans livrés en proposeront +-- un (Story 25-4-c3-a2). La Story 25-4-c3-b écrira l'écart en lisant ce réglage. +-- +-- Patron : les comptes TVA (`20260614000001_vat_accounts_config.sql`) — colonne +-- facultative, FK `ON DELETE RESTRICT`. +-- +-- DDL seul, AUCUNE donnée écrite : les sociétés existantes désignent leur +-- compte elles-mêmes (triage P7 sans objet). Non-breaking (`ADD COLUMN` +-- nullable, ignoré par les anciens binaires, qui listent leurs colonnes) → +-- PAS de bump `kesh_version_min_required`. Ligne ajoutée à +-- `docs/migrations-idempotence-audit.md` (politique P5). + +ALTER TABLE company_invoice_settings + ADD COLUMN default_rounding_account_id BIGINT NULL, + ADD CONSTRAINT fk_cis_rounding + FOREIGN KEY (default_rounding_account_id) REFERENCES accounts(id) ON DELETE RESTRICT; diff --git a/crates/kesh-db/src/entities/company_invoice_settings.rs b/crates/kesh-db/src/entities/company_invoice_settings.rs index 1113c05c..0d31464a 100644 --- a/crates/kesh-db/src/entities/company_invoice_settings.rs +++ b/crates/kesh-db/src/entities/company_invoice_settings.rs @@ -35,6 +35,12 @@ pub struct CompanyInvoiceSettings { /// Compte créanciers (2000) par défaut, contrepartie de l'écriture d'achat /// des factures fournisseurs, type Liability. Story 12.2. pub default_payable_account_id: Option, + /// Compte de **différences d'arrondi** — charge ou produit, actif et + /// imputable — qui reçoit l'écart d'un demi-centime au plus qu'un paiement + /// arrondi au centime laisse sur une facture (Story 25-4-c3-a1, #476). + /// Facultatif : il n'est lu que quand un écart se présente (Story 25-4-c3-b). + /// Un réglage et non un rôle de compte (arbitrage du 2026-09-30). + pub default_rounding_account_id: Option, pub version: i32, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, @@ -56,4 +62,7 @@ pub struct CompanyInvoiceSettingsUpdate { pub journal_entry_description_template: String, pub credit_note_number_format: String, pub default_payable_account_id: Option, + /// Story 25-4-c3-a1 — absent du corps, il vaut `None` (comme les comptes + /// TVA) : le remplacement est intégral. + pub default_rounding_account_id: Option, } diff --git a/crates/kesh-db/src/repositories/company_invoice_settings.rs b/crates/kesh-db/src/repositories/company_invoice_settings.rs index 2c2d01ee..ebf126e7 100644 --- a/crates/kesh-db/src/repositories/company_invoice_settings.rs +++ b/crates/kesh-db/src/repositories/company_invoice_settings.rs @@ -28,6 +28,7 @@ const COLUMNS: &str = "company_id, invoice_number_format, default_receivable_acc default_vat_recoverable_account_id, default_vat_decompte_account_id, \ default_sales_journal, journal_entry_description_template, \ credit_note_number_format, default_payable_account_id, \ + default_rounding_account_id, \ version, created_at, updated_at"; fn settings_snapshot_json(s: &CompanyInvoiceSettings) -> serde_json::Value { @@ -43,6 +44,7 @@ fn settings_snapshot_json(s: &CompanyInvoiceSettings) -> serde_json::Value { "journalEntryDescriptionTemplate": s.journal_entry_description_template, "creditNoteNumberFormat": s.credit_note_number_format, "defaultPayableAccountId": s.default_payable_account_id, + "defaultRoundingAccountId": s.default_rounding_account_id, "version": s.version, }) } @@ -122,6 +124,7 @@ fn is_no_op_change( && before.journal_entry_description_template == changes.journal_entry_description_template && before.credit_note_number_format == changes.credit_note_number_format && before.default_payable_account_id == changes.default_payable_account_id + && before.default_rounding_account_id == changes.default_rounding_account_id } /// Met à jour la config (tous les champs) avec verrou optimiste et audit. @@ -174,7 +177,7 @@ pub async fn update( default_vat_recoverable_account_id = ?, default_vat_decompte_account_id = ?, \ default_sales_journal = ?, \ journal_entry_description_template = ?, credit_note_number_format = ?, \ - default_payable_account_id = ?, \ + default_payable_account_id = ?, default_rounding_account_id = ?, \ version = version + 1 \ WHERE company_id = ? AND version = ?", ) @@ -188,6 +191,7 @@ pub async fn update( .bind(&changes.journal_entry_description_template) .bind(&changes.credit_note_number_format) .bind(changes.default_payable_account_id) + .bind(changes.default_rounding_account_id) .bind(company_id) .bind(expected_version) .execute(&mut *tx) @@ -358,7 +362,7 @@ pub async fn insert_with_defaults( cis.default_vat_recoverable_account_id, cis.default_vat_decompte_account_id, \ cis.default_sales_journal, \ cis.journal_entry_description_template, cis.credit_note_number_format, \ - cis.default_payable_account_id, \ + cis.default_payable_account_id, cis.default_rounding_account_id, \ cis.version, cis.created_at, cis.updated_at \ FROM company_invoice_settings cis \ JOIN accounts ar ON ar.id = cis.default_receivable_account_id AND ar.active = TRUE \ @@ -476,7 +480,7 @@ pub async fn insert_with_defaults_in_tx( cis.default_vat_recoverable_account_id, cis.default_vat_decompte_account_id, \ cis.default_sales_journal, \ cis.journal_entry_description_template, cis.credit_note_number_format, \ - cis.default_payable_account_id, \ + cis.default_payable_account_id, cis.default_rounding_account_id, \ cis.version, cis.created_at, cis.updated_at \ FROM company_invoice_settings cis \ JOIN accounts ar ON ar.id = cis.default_receivable_account_id AND ar.active = TRUE \ diff --git a/crates/kesh-db/test-schema/0001_schema_squash.sql b/crates/kesh-db/test-schema/0001_schema_squash.sql index 9fb9bca3..014a18cc 100644 --- a/crates/kesh-db/test-schema/0001_schema_squash.sql +++ b/crates/kesh-db/test-schema/0001_schema_squash.sql @@ -1,6 +1,6 @@ -- SQUASH DU SCHÉMA DE TEST — Story 22-5 (#251). GÉNÉRÉ, NE PAS ÉDITER. -- Régénérer : scripts/regen-test-schema.sh --- Équivalent des 69 migrations de crates/kesh-db/migrations/, +-- Équivalent des 70 migrations de crates/kesh-db/migrations/, -- rejouées en UN batch DDL par base éphémère de test. -- -- Le garde-fou crates/kesh-db/tests/test_schema_guard.rs compare ce schéma @@ -303,6 +303,7 @@ CREATE TABLE `company_invoice_settings` ( `default_vat_decompte_account_id` bigint(20) DEFAULT NULL, `credit_note_number_format` varchar(64) NOT NULL DEFAULT 'AV-{YEAR}-{SEQ:04}', `default_payable_account_id` bigint(20) DEFAULT NULL, + `default_rounding_account_id` bigint(20) DEFAULT NULL, PRIMARY KEY (`company_id`), KEY `fk_cis_receivable` (`default_receivable_account_id`), KEY `fk_cis_revenue` (`default_revenue_account_id`), @@ -311,10 +312,12 @@ CREATE TABLE `company_invoice_settings` ( KEY `fk_cis_vat_recoverable` (`default_vat_recoverable_account_id`), KEY `fk_cis_vat_decompte` (`default_vat_decompte_account_id`), KEY `fk_cis_payable_account` (`default_payable_account_id`), + KEY `fk_cis_rounding` (`default_rounding_account_id`), CONSTRAINT `fk_cis_company` FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`), CONSTRAINT `fk_cis_payable_account` FOREIGN KEY (`default_payable_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_receivable` FOREIGN KEY (`default_receivable_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_revenue` FOREIGN KEY (`default_revenue_account_id`) REFERENCES `accounts` (`id`), + CONSTRAINT `fk_cis_rounding` FOREIGN KEY (`default_rounding_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_vat_decompte` FOREIGN KEY (`default_vat_decompte_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_vat_payable` FOREIGN KEY (`default_vat_payable_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_vat_recoverable` FOREIGN KEY (`default_vat_recoverable_account_id`) REFERENCES `accounts` (`id`), diff --git a/crates/kesh-db/tests/company_invoice_settings_repository.rs b/crates/kesh-db/tests/company_invoice_settings_repository.rs index f6c530e3..c170fd0d 100644 --- a/crates/kesh-db/tests/company_invoice_settings_repository.rs +++ b/crates/kesh-db/tests/company_invoice_settings_repository.rs @@ -278,6 +278,7 @@ async fn update_no_op_returns_unchanged_entity_no_audit(pool: MySqlPool) { journal_entry_description_template: settings.journal_entry_description_template.clone(), credit_note_number_format: settings.credit_note_number_format.clone(), default_payable_account_id: settings.default_payable_account_id, + default_rounding_account_id: None, }, ) .await @@ -347,6 +348,7 @@ async fn update_partial_change_bumps_version(pool: MySqlPool) { journal_entry_description_template: settings.journal_entry_description_template.clone(), credit_note_number_format: settings.credit_note_number_format.clone(), default_payable_account_id: settings.default_payable_account_id, + default_rounding_account_id: None, }, ) .await @@ -438,6 +440,7 @@ async fn update_vat_accounts_round_trip(pool: MySqlPool) { journal_entry_description_template: settings.journal_entry_description_template.clone(), credit_note_number_format: settings.credit_note_number_format.clone(), default_payable_account_id: settings.default_payable_account_id, + default_rounding_account_id: None, }, ) .await @@ -531,6 +534,7 @@ async fn update_vat_account_foreign_id_rejected_by_fk(pool: MySqlPool) { .clone(), credit_note_number_format: settings_a.credit_note_number_format.clone(), default_payable_account_id: settings_a.default_payable_account_id, + default_rounding_account_id: None, }, ) .await; @@ -938,3 +942,64 @@ async fn insert_with_defaults_in_tx_fails_fast_when_no_receivable_role(pool: MyS // Le caller possède la tx → rollback explicite (la fonction ne le fait pas). tx.rollback().await.unwrap(); } + +/// Story 25-4-c3-a1 (#476) — le compte de différences d'arrondi traverse +/// `update` et la relecture (COLUMNS / FromRow / UPDATE), et changer **lui seul** +/// n'est pas un no-op : `version` bouge, l'audit est écrit. +#[sqlx::test(migrations = "./test-schema")] +async fn update_rounding_account_round_trip(pool: MySqlPool) { + let company = company_with_pme_chart(&pool, "Arrondi Co").await; + let admin_user_id = create_admin_user(&pool, company).await; + let settings = company_invoice_settings::insert_with_defaults(&pool, company) + .await + .unwrap(); + assert_eq!( + settings.default_rounding_account_id, None, + "l'onboarding ne le pose pas (Story 25-4-c3-a2)" + ); + // Un compte de charge du plan PME. + let rounding = account_id_by_number(&pool, company, "6900").await; + + let result = company_invoice_settings::update( + &pool, + company, + settings.version, + admin_user_id, + CompanyInvoiceSettingsUpdate { + invoice_number_format: settings.invoice_number_format.clone(), + default_receivable_account_id: settings.default_receivable_account_id, + default_revenue_account_id: settings.default_revenue_account_id, + default_vat_payable_account_id: settings.default_vat_payable_account_id, + default_vat_recoverable_account_id: settings.default_vat_recoverable_account_id, + default_vat_decompte_account_id: settings.default_vat_decompte_account_id, + default_sales_journal: settings.default_sales_journal, + journal_entry_description_template: settings.journal_entry_description_template.clone(), + credit_note_number_format: settings.credit_note_number_format.clone(), + default_payable_account_id: settings.default_payable_account_id, + default_rounding_account_id: Some(rounding), + }, + ) + .await + .unwrap(); + assert_eq!( + result.version, + settings.version + 1, + "changer le seul compte d'arrondi n'est pas un no-op" + ); + assert_eq!(result.default_rounding_account_id, Some(rounding)); + + let reread = company_invoice_settings::get_or_create_default(&pool, company) + .await + .unwrap(); + assert_eq!(reread.default_rounding_account_id, Some(rounding)); + + let audited: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE entity_type = 'company_invoice_settings' \ + AND JSON_EXTRACT(details_json, '$.after.defaultRoundingAccountId') = ?", + ) + .bind(rounding) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(audited, 1, "l'audit porte le compte d'arrondi"); +} diff --git a/crates/kesh-db/tests/migrations_upgrade_path.rs b/crates/kesh-db/tests/migrations_upgrade_path.rs index c2652147..6908f5ec 100644 --- a/crates/kesh-db/tests/migrations_upgrade_path.rs +++ b/crates/kesh-db/tests/migrations_upgrade_path.rs @@ -35,9 +35,9 @@ async fn apply_migrations_up_to( assert!( n <= all.len(), "apply_migrations_up_to: n={} > total={} — vérifier que le calcul \ - `total - 35` (fenêtre d'upgrade, FRONTIÈRE figée à 34) reste \ + `total - 36` (fenêtre d'upgrade, FRONTIÈRE figée à 34) reste \ cohérent avec l'ajout de migrations futures. Si une migration a été ajoutée à \ - la branche, l'assertion `total == 69` du test upgrade_path_preserves_data \ + la branche, l'assertion `total == 70` du test upgrade_path_preserves_data \ doit également échouer, c'est son rôle : elle signale qu'il faut décider \ explicitement si la fenêtre s'élargit (bumper `total` seul) ou si la \ frontière doit rester à 34 (bumper `total` ET la fenêtre). Cf. garde-fou \ @@ -54,8 +54,8 @@ async fn apply_migrations_up_to( sub.run(pool).await } -/// AC #15a — cas générique upgrade path : `total - 35` migrations appliquées -/// (**34** à ce jour) + seed + `MIGRATOR.run()` final, qui applique les **35** +/// AC #15a — cas générique upgrade path : `total - 36` migrations appliquées +/// (**34** à ce jour) + seed + `MIGRATOR.run()` final, qui applique les **36** /// dernières. Assertion : seed préservé à travers la fenêtre d'upgrade. /// /// ⚠️ Les nombres ci-dessus se recomptent, ils ne se relisent pas — cf. le @@ -95,14 +95,15 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { // + closing_accounts_not_postable (Story 24-5, #375) = 66, // + audit_log_actor_label (Story 25-1a, #376) = 67, // + audit_log_company_id (Story 25-1c-zero, refs #378) = 68, - // + journal_entry_number_sequences (Story 25-2-c, refs #381) = 69. + // + journal_entry_number_sequences (Story 25-2-c, refs #381) = 69, + // + invoice_settings_rounding_account (Story 25-4-c3-a1, refs #476) = 70. let total = kesh_db::MIGRATOR.migrations.len(); assert_eq!( - total, 69, - "69 migrations attendues (68 précédentes + Story 25-2-c : journal_entry_number_sequences)" + total, 70, + "70 migrations attendues (69 précédentes + Story 25-4-c3-a1 : invoice_settings_rounding_account)" ); - // Étape 1 : applique toutes les migrations sauf les 35 dernières. La + // Étape 1 : applique toutes les migrations sauf les 36 dernières. La // fenêtre d'upgrade démarre donc à la 35ᵉ, `20260614000001_vat_accounts_config` // (Story 18-1a), et court jusqu'à la dernière du dépôt. Ne pas ré-énumérer // ici les migrations de la fenêtre : une liste nominative se périme à chaque @@ -110,17 +111,17 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { // plus bas. Le seul nombre qui fait foi est celui du `total - N` ci-dessous. // // Note `total - N` : expression relative à la longueur totale, tandis que - // l'assertion `total == 69` ci-dessus est INTENTIONNELLEMENT codée en dur + // l'assertion `total == 70` ci-dessus est INTENTIONNELLEMENT codée en dur // pour fail-loud sur toute évolution non revue. À chaque migration ajoutée, // le mainteneur doit (1) bumper ce compte (2) incrémenter `N` du même pas, // de sorte que `total - N` — la frontière — reste **constant**. // // Frontière actuelle : **34**. Le test applique donc les 34 premières // migrations (jusqu'à `20260613000001_vat_rates_crud` incluse), seede des - // données, puis joue les 35 restantes comme « fenêtre d’upgrade ». + // données, puis joue les 36 restantes comme « fenêtre d’upgrade ». // - // ⚠️ `N` DOIT être incrémenté en même temps que `total`. Le laisser à 34 - // avec `total = 69` porterait la frontière à 35 : le test continuerait de + // ⚠️ `N` DOIT être incrémenté en même temps que `total`. Le laisser à 35 + // avec `total = 70` porterait la frontière à 35 : le test continuerait de // passer en testant une fenêtre plus étroite d'une migration. // Story 16-1a : 21 → 22, frontière inchangée (56 - 22 = 55 - 21 = 34). // Story 16-1a-bis : 22 → 23, frontière inchangée (57 - 23 = 34). @@ -165,16 +166,17 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { // s'élargit donc d'un cran à chaque migration ajoutée, ce qui est le sens // voulu : « depuis un socle figé, jusqu'à la dernière du dépôt ». // Story 25-2-c : 34 → 35, frontière inchangée (69 - 35 = 34). + // Story 25-4-c3-a1 : 35 → 36, frontière inchangée (70 - 36 = 34). // // ⚠️ J'ai d'abord porté `total` à 69 SANS toucher à ce soustracteur : le socle // est alors passé de 34 à 35 migrations, la fenêtre a glissé d'un cran, et le // test a rougi non plus sur son compteur mais sur `COUNT(accounts) : expected // 4, got 2` — un échec qui ne ressemble en rien à sa cause. *Les deux nombres // se bougent du même pas, et le commentaire ci-dessus le disait.* - let n_before_upgrade_window = total - 35; + let n_before_upgrade_window = total - 36; apply_migrations_up_to(&pool, n_before_upgrade_window) .await - .expect("apply_migrations_up_to(total - 35) failed"); + .expect("apply_migrations_up_to(total - 36) failed"); // Étape 2 : seed 1 company + 1 user + 2 accounts + 1 invoice + 1 contact. let company_id: i64 = sqlx::query_scalar( @@ -243,7 +245,7 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { .await .expect("INSERT invoice failed"); - // Étape 3 : appliquer les 35 migrations restantes via MIGRATOR.run(). + // Étape 3 : appliquer les 36 migrations restantes via MIGRATOR.run(). kesh_db::MIGRATOR .run(&pool) .await diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 96d451e3..5f5cf114 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -1613,6 +1613,9 @@ settings-invoicing-numbering-title = Nummerierung settings-invoicing-seq-range = (NN zwischen 1 und 10) settings-invoicing-description-help = Platzhalter: {"{"}YEAR{"}"}, {"{"}INVOICE_NUMBER{"}"}, {"{"}CONTACT_NAME{"}"}. settings-invoicing-description-invalid = Ungültiger Buchungstext +settings-invoicing-rounding-title = Rundungsdifferenzen +settings-invoicing-rounding-hint = Konto, das die Differenz von höchstens einem halben Rappen aufnimmt, die eine auf den Rappen gerundete Zahlung auf einer Rechnung hinterlässt. Aufwand oder Ertrag, buchbar. Falls es noch nicht existiert, legen Sie es im Kontenplan an und wählen Sie es dann hier aus. +settings-invoicing-rounding-account = Konto für Rundungsdifferenzen settings-invoicing-default-accounts-title = Standardkonten settings-invoicing-select-none = — Auswählen — settings-invoicing-load-error = Fehler beim Laden diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 7bbf7ca8..b08af60e 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -1620,6 +1620,9 @@ settings-invoicing-numbering-title = Numbering settings-invoicing-seq-range = (NN between 1 and 10) settings-invoicing-description-help = Placeholders: {"{"}YEAR{"}"}, {"{"}INVOICE_NUMBER{"}"}, {"{"}CONTACT_NAME{"}"}. settings-invoicing-description-invalid = Invalid entry label +settings-invoicing-rounding-title = Rounding differences +settings-invoicing-rounding-hint = Account that receives the difference of at most half a centime that a payment rounded to the centime leaves on an invoice. Expense or revenue, postable. If it does not exist yet, create it in the chart of accounts, then choose it here. +settings-invoicing-rounding-account = Rounding differences account settings-invoicing-default-accounts-title = Default accounts settings-invoicing-select-none = — Select — settings-invoicing-load-error = Loading error diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 59fdad51..0e17c9cf 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -600,6 +600,9 @@ settings-invoicing-numbering-title = Numérotation settings-invoicing-seq-range = (NN entre 1 et 10) settings-invoicing-description-help = Placeholders : {"{"}YEAR{"}"}, {"{"}INVOICE_NUMBER{"}"}, {"{"}CONTACT_NAME{"}"}. settings-invoicing-description-invalid = Libellé invalide +settings-invoicing-rounding-title = Différences d'arrondi +settings-invoicing-rounding-hint = Compte qui reçoit l'écart d'un demi-centime au plus qu'un paiement arrondi au centime laisse sur une facture. Charge ou produit, imputable. S'il n'existe pas encore, créez-le dans le plan comptable, puis choisissez-le ici. +settings-invoicing-rounding-account = Compte de différences d'arrondi settings-invoicing-default-accounts-title = Comptes par défaut settings-invoicing-select-none = — Sélectionner — settings-invoicing-load-error = Erreur de chargement diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 5bcd832b..55c658f3 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -1614,6 +1614,9 @@ settings-invoicing-numbering-title = Numerazione settings-invoicing-seq-range = (NN tra 1 e 10) settings-invoicing-description-help = Segnaposto: {"{"}YEAR{"}"}, {"{"}INVOICE_NUMBER{"}"}, {"{"}CONTACT_NAME{"}"}. settings-invoicing-description-invalid = Testo di registrazione non valido +settings-invoicing-rounding-title = Differenze di arrotondamento +settings-invoicing-rounding-hint = Conto che riceve la differenza di al massimo mezzo centesimo che un pagamento arrotondato al centesimo lascia su una fattura. Costo o ricavo, registrabile. Se non esiste ancora, createlo nel piano dei conti e poi sceglietelo qui. +settings-invoicing-rounding-account = Conto delle differenze di arrotondamento settings-invoicing-default-accounts-title = Conti predefiniti settings-invoicing-select-none = — Selezionare — settings-invoicing-load-error = Errore di caricamento diff --git a/docs/manual/fr/admin-manual.pdf b/docs/manual/fr/admin-manual.pdf index ae3e9545fddf8cffbeec584fa46f1ac9b38ba6fa..d4c8452e276657dcb3f17a7cccb8c32e598932f9 100644 GIT binary patch delta 52548 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 diff --git a/docs/manual/fr/admin-manual.tex b/docs/manual/fr/admin-manual.tex index 1e662d59..bad334b9 100644 --- a/docs/manual/fr/admin-manual.tex +++ b/docs/manual/fr/admin-manual.tex @@ -2001,6 +2001,8 @@ \subsection{Loi sur la TVA (LTVA)} Ces comptes sont ajoutés automatiquement aux plans comptables (PME, indépendant, association) ; les installations existantes sont complétées sans modifier les comptes déjà utilisés. Tant que le compte \emph{TVA due} n'est pas configuré, la validation d'une facture portant de la TVA est refusée (message « configuration requise »). L'usage côté comptable (décompte, réconciliation) est décrit dans le \emph{manuel utilisateur}, section \emph{Décompte TVA}. +\paragraph{Compte de différences d'arrondi.} Une facture dont le total porte plus de deux décimales --- le cas d'une quantité ou d'un prix unitaire fractionnaire --- laisse, une fois payée au centime, un écart d'un demi-centime au plus. Cet écart est destiné au \textbf{compte de différences d'arrondi}, désigné depuis \emph{Paramètres} → \emph{Facturation}, section \emph{Différences d'arrondi}. Ce doit être un compte de \textbf{charge ou de produit}, actif et \textbf{imputable} (pas un compte de regroupement). S'il n'existe pas encore dans votre plan comptable, créez-le depuis le plan comptable, puis choisissez-le dans les paramètres. Le réglage est facultatif tant qu'aucun écart ne se présente. \textbf{Dans cette version, il n'est encore lu par aucune écriture} : le passage des écarts en écriture arrive avec une version prochaine, qui le dira ici. + Le format de décompte officiel AFC (e-décompte ESTV) et la conformité LTVA Art.~70 (factures électroniques signées) restent hors périmètre à ce stade. \subsection{Documentation à conserver} diff --git a/docs/migrations-idempotence-audit.md b/docs/migrations-idempotence-audit.md index e55daa80..72874a56 100644 --- a/docs/migrations-idempotence-audit.md +++ b/docs/migrations-idempotence-audit.md @@ -14,7 +14,7 @@ Cet audit est **purement informationnel** : les fichiers `.sql` historiques ne s - **`tracked-by-sqlx`** — l'idempotence est garantie uniquement par le tracking `_sqlx_migrations`. Re-exécution manuelle hors sqlx échouerait avec un code d'erreur MariaDB précisé en justification (1050 table exists, 1060 duplicate column, 1061 duplicate key, 1091 index not found, etc.). - **`no`** — re-exécution échouerait toujours (non utilisé sur le repo actuel). -## Table d'audit (69 migrations) +## Table d'audit (70 migrations) > Une ligne par fichier de `crates/kesh-db/migrations/`, **triée par version croissante**. Toute nouvelle migration s'ajoute **à l'intérieur de ce tableau**, jamais en fin de fichier — cf. la note de la section « Maintenance future ». @@ -89,12 +89,13 @@ Cet audit est **purement informationnel** : les fichiers `.sql` historiques ne s | `20260910000001_audit_log_actor_label.sql` | yes | `ALTER TABLE audit_log ADD COLUMN actor_label VARCHAR(64) NOT NULL DEFAULT '' , DROP FOREIGN KEY fk_audit_log_user` + un `UPDATE` de backfill. **Verdict `yes`** : le backfill est gardé par `actor_label = ''`, donc une re-exécution manuelle hors sqlx est **sans effet** — c'est le critère du verdict, et non l'absence de DDL *(le DDL, lui, échouerait en `1060 Duplicate column name` ; c'est le backfill qui porte le verdict, cf. la mise en garde de `20260909000001`)*. ⚠️ **ELLE ÉCRIT DES DONNÉES** ⇒ **inscrite au registre `POST_RESTORE_BACKFILLS` en CLASSE B** (garde-fou P7), sentinelle `(audit_log, actor_label)` — valide, la colonne étant ajoutée par le **MÊME** `ALTER TABLE` que l'`UPDATE`. **Prépare la conservation de la piste au restore** (Story 25-1a) : `user_id` devient un **pointeur logique sans FK**, comme `actor_api_key_id` et `entity_id` avant lui, et `actor_label` porte le nom de l'acteur **au moment de l'écriture** — un instantané, pas une jointure. Sans lui, retirer la FK transformerait un mensonge (un `user_id` réattribué à quelqu'un d'autre après import) en trou. **Non-breaking** (`ADD COLUMN` avec défaut + `DROP FOREIGN KEY` — un binaire antérieur ignore la colonne et n'a pas besoin de la FK pour insérer) → pas de bump `kesh_version_min_required`, donc pas de bump de version Cargo (P1/P2/P2-bis) — Story 25-1a, #376. | | `20260915000001_audit_log_company_id.sql` | yes | `ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS company_id BIGINT NULL, ADD INDEX IF NOT EXISTS idx_audit_log_company_date (company_id, created_at)` + un `UPDATE … JOIN users` de backfill gardé `company_id IS NULL`. **Verdict `yes`** : le DDL est conditionnel — rejoué, il rend les notes 1060 et 1061 sans erreur — et le backfill est gardé, donc une re-exécution manuelle hors sqlx est **sans effet**. ⚠️ **ELLE ÉCRIT DES DONNÉES** ⇒ triée en **`EXEMPT_MIGRATIONS`** (garde-fou P7) avec `ExemptionBasis::PerishableSince(20260827000001)` : fondement de **parc** et non de fenêtre — aucune version publiée dans `[20260827000001 .. 20260915000001)`. **Pas** au registre de rejeu, par arbitrage du 2026-09-11 : ni `COALESCE` ni rejeu, `NULL` signifiant « société indéterminable », état permanent. `company_id` est un **pointeur logique sans FK**, rempli pour les entrées futures par sous-SELECT dans `insert_in_tx` ; elle prépare la consultation par société de la Story 25-1c (#378). **Non-breaking** (`ADD COLUMN` nullable + `ADD INDEX`) → pas de bump `kesh_version_min_required`, donc pas de bump de version Cargo (P1/P2/P2-bis) — Story 25-1c-zero, refs #378. | | `20260917000001_journal_entry_number_sequences.sql` | tracked-by-sqlx | `CREATE TABLE journal_entry_number_sequences` sans `IF NOT EXISTS` ; re-exécution hors sqlx échouerait erreur 1050. ⚠️ *C'est le DDL qui porte le verdict : l'`INSERT … SELECT` d'amorçage qui le suit n'est atteignable qu'une fois la table créée, donc jamais rejouable seul — à la différence de `20260909000001`, où un `UPDATE` nu et gardé décide du verdict `yes`.* **Le `MAX(entry_number) + 1` des écritures était l'EXCEPTION du dépôt** : `invoice_number_sequences` (20260417000001) et `credit_note_number_sequences` sont déjà des compteurs persistants de portée `(company_id, fiscal_year_id)` — exactement celle d'`entry_number`. Ferme la **réattribution** d'un numéro d'écriture (supprimer la dernière libérait son numéro pour une écriture au contenu différent — symptôme **muet**) ; le **trou** subsiste et reste explicable, assumé par un critère de la story. ⚠️ **ELLE ÉCRIT DES DONNÉES** (amorçage depuis l'existant, sans lequel le compteur repartirait de 1 et heurterait aussitôt l'`UNIQUE`) ⇒ triée en **`EXEMPT_MIGRATIONS`** (garde-fou P7), fondement **couverture** (`ExemptionBasis::Durable`) — et **non** `Hors fenêtre`, que le contrôle symétrique a refusé : cette migration *est* la fenêtre, et le marqueur est réservé à un fait de chronologie. La migration crée elle-même la table, donc un backup dépourvu de ses lignes est un backup dépourvu de la table — refusé au contrôle de couverture de `parse_and_verify`. Même motif que `20260428000001` (vat_rates). ⛔ **Sa création de table REFERME la fenêtre d'importabilité** et périme les deux entrées alors au registre (`20260828000001`, `20260910000001`), déplacées vers `RETIRED_BACKFILLS` et exemptées — le module annonçait ce cas : *« toute nouvelle table applicative aura le même effet »*. **Non-breaking** (`CREATE TABLE` d'une table qu'un binaire antérieur ignore, continuant de servir son `MAX + 1`) → pas de bump `kesh_version_min_required` ni de version Cargo (P1/P2/P2-bis) — Story 25-2-c, refs #381. | +| `20260930000001_invoice_settings_rounding_account.sql` | tracked-by-sqlx | `ALTER TABLE company_invoice_settings ADD COLUMN default_rounding_account_id BIGINT NULL` + FK `fk_cis_rounding` (`ON DELETE RESTRICT`), sans `IF NOT EXISTS` ; re-exécution hors sqlx échouerait erreur 1060. **DDL seul, aucune donnée écrite** : les sociétés existantes désignent leur compte de différences d'arrondi dans *Paramètres → Facturation* (arbitrage du 2026-09-30) — triage P7 sans objet. Patron des comptes TVA (`20260614000001`). **Non-breaking** (`ADD COLUMN` nullable ignoré par un binaire antérieur, qui liste ses colonnes) → pas de bump `kesh_version_min_required` ni de version Cargo (P1/P2/P2-bis) — Story 25-4-c3-a1, refs #476. | ## Statistiques -- **Total** : 69 migrations (26 historiques + 1 Story 10-2 + 1 Story v011-2 + 1 Story v014-1 + 2 Story 17-2a + 2 Story 17-4a + 1 Story 11-1 + 1 Story 18-1a + 1 Story 12-1 + 1 Story 12-2 + 1 Story 12-3 + 1 Story 12-5b + 1 Story 19-1 + 1 Story 19-3 + 1 Story 19-4 + 1 Story 19-5 + 3 #213 + 1 Story 20-1 + 3 Story 20-3b1 + 1 Story 21-1 + 2 Story 21-3 + 1 Story 21-5a + 1 Story 14-3a + 1 Story 16-1a + 1 Story 16-1a-bis + 1 Story 16-2a + 1 Story 16-3a + 1 Story 16-3b + 1 Story 22-1 + 1 Story 24-2 + 1 Story 24-3 + 1 Story 24-4a + 1 Story 24-4c + 1 Story 24-5 + 1 Story 25-1a + 1 Story 25-1c-zero + 1 Story 25-2-c). +- **Total** : 70 migrations (26 historiques + 1 Story 10-2 + 1 Story v011-2 + 1 Story v014-1 + 2 Story 17-2a + 2 Story 17-4a + 1 Story 11-1 + 1 Story 18-1a + 1 Story 12-1 + 1 Story 12-2 + 1 Story 12-3 + 1 Story 12-5b + 1 Story 19-1 + 1 Story 19-3 + 1 Story 19-4 + 1 Story 19-5 + 3 #213 + 1 Story 20-1 + 3 Story 20-3b1 + 1 Story 21-1 + 2 Story 21-3 + 1 Story 21-5a + 1 Story 14-3a + 1 Story 16-1a + 1 Story 16-1a-bis + 1 Story 16-2a + 1 Story 16-3a + 1 Story 16-3b + 1 Story 22-1 + 1 Story 24-2 + 1 Story 24-3 + 1 Story 24-4a + 1 Story 24-4c + 1 Story 24-5 + 1 Story 25-1a + 1 Story 25-1c-zero + 1 Story 25-2-c + 1 Story 25-4-c3-a1). - **Idempotence `yes`** : 8 (`country_code`, `invoice_paid_at`, `bank_imports_relax_hash_unique`, `users_email`, `invoice_lines_revenue_account_backfill`, `closing_accounts_not_postable`, `audit_log_actor_label`, `audit_log_company_id`). -- **Idempotence `tracked-by-sqlx`** : 61 (toutes les autres). +- **Idempotence `tracked-by-sqlx`** : 62 (toutes les autres). - **Idempotence `no`** : 0. > Ces compteurs se **recomptent depuis le tableau**, ils ne s'incrémentent pas de confiance. Précédent : jusqu'à la Story 16-1a (2026-07-28), `tracked-by-sqlx` annonçait 45 pour 52 réelles — 11 lignes de migration des Epics 20/21 avaient été ajoutées **sous** la section « Maintenance future » au lieu du tableau, où elles ne se rendaient même pas en markdown et échappaient à tout comptage. Sept passes de `validate` avaient « re-vérifié » ce compteur sans le recompter. Lignes remontées et compteurs rétablis en revue de code de la 16-1a. diff --git a/frontend/src/lib/features/invoices/invoices.types.ts b/frontend/src/lib/features/invoices/invoices.types.ts index c2abf9f6..ad7aef1d 100644 --- a/frontend/src/lib/features/invoices/invoices.types.ts +++ b/frontend/src/lib/features/invoices/invoices.types.ts @@ -112,6 +112,8 @@ export interface InvoiceSettingsResponse { defaultVatDecompteAccountId: number | null; defaultSalesJournal: JournalCode; journalEntryDescriptionTemplate: string; + /** Compte de différences d'arrondi — charge ou produit (Story 25-4-c3-a1). */ + defaultRoundingAccountId: number | null; version: number; } @@ -124,6 +126,8 @@ export interface UpdateInvoiceSettingsRequest { defaultVatDecompteAccountId: number | null; defaultSalesJournal: JournalCode; journalEntryDescriptionTemplate: string; + /** Compte de différences d'arrondi — charge ou produit (Story 25-4-c3-a1). */ + defaultRoundingAccountId: number | null; version: number; } diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts index d68b723a..723844b0 100644 --- a/frontend/src/lib/shared/i18n-keys.test.ts +++ b/frontend/src/lib/shared/i18n-keys.test.ts @@ -343,7 +343,13 @@ const ATTENDU = { // `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, + // + // Story 25-4-c3-a1 (#476) : **1752 → 1756**, recompté par ce test et par + // `grep -o "i18nMsg("` aux deux bornes de `settings/invoicing/+page.svelte` + // (+4) — la section « Différences d'arrondi » : titre, aide, libellé du + // sélecteur (trois clés neuves `settings-invoicing-rounding-*` dans les quatre + // catalogues) et son option vide, qui réemploie `settings-invoicing-select-none`. + sitesTotal: 1756, sitesNonResolus: 34, relais: 7, sitesGabarit: 10, diff --git a/frontend/src/routes/(app)/settings/invoicing/+page.svelte b/frontend/src/routes/(app)/settings/invoicing/+page.svelte index 6c6bb7db..e6d752d1 100644 --- a/frontend/src/routes/(app)/settings/invoicing/+page.svelte +++ b/frontend/src/routes/(app)/settings/invoicing/+page.svelte @@ -42,6 +42,7 @@ let vatPayableId = $state(null); let vatRecoverableId = $state(null); let vatDecompteId = $state(null); + let roundingId = $state(null); let salesJournal = $state('Ventes'); let version = $state(0); @@ -56,6 +57,13 @@ let liabilityAccounts = $derived( accounts.filter((a) => a.active && a.postable && a.accountType === 'Liability'), ); + // Story 25-4-c3-a1 : un écart d'arrondi est un résultat — charge ou produit. + let resultAccounts = $derived( + accounts.filter( + (a) => + a.active && a.postable && (a.accountType === 'Expense' || a.accountType === 'Revenue'), + ), + ); // Issue #271 : chaque ` + + {#each roundingOptions as a (a.id)} + + {/each} + + + +
-