diff --git a/CHANGELOG.md b/CHANGELOG.md index b6eb4302e..588bdb7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ⚠️ **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 new file mode 100644 index 000000000..bf6e3f88f --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c-residuel-au-rapprochement.md @@ -0,0 +1,384 @@ +# Story 25.4-c : Le résiduel au rapprochement bancaire + +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 +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) ; **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 + +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 — 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. + +## 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 — 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û**, 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 — 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) **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, `: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 +à 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 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 + +- [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). +- [x] **T5 — 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. +- ⛔ **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. +- ⚠️ `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 | +| `frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte:285`, `reconciliation.types.ts:30` | affichage | +| `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 (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) ?** 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 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. + +**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 + +### 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` +- `_bmad-output/implementation-artifacts/25-4-c-review-prompt-p1.md` +- `_bmad-output/implementation-artifacts/25-4-c-review-prompt-p2.md` + +## 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. +- **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. +- **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é. +- **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). +- **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. +- **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). +- **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. +- **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`. +- **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. +- **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 +[#476]: https://github.com/guycorbaz/kesh/issues/476 +[#480]: https://github.com/guycorbaz/kesh/issues/480 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 000000000..0f12bab6e --- /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/_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 000000000..41a8445d1 --- /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/25-4-c-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c-validate-prompt-p1.md new file mode 100644 index 000000000..883a4bc76 --- /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`. 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 000000000..b63a47a86 --- /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`. 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 000000000..e9595b452 --- /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`. 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 000000000..f309e1cae --- /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`. 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 000000000..9cfa42509 --- /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 3d6be1dd3..a1b1a1933 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: 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: 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. 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). diff --git a/crates/kesh-api/src/routes/reconciliation.rs b/crates/kesh-api/src/routes/reconciliation.rs index 595f3b5c9..e0403484c 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 28f9ec5b8..3a0e05f9a 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 ad0b36566..bcf9b03d2 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 4f8f7637a..280c326d7 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 0bb1a1957..96d451e3b 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 = 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 eb833fb7a..7bbf7ca85 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 = remaining out 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 425c0dd81..59fdad516 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 25711aad1..5bcd832b7 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 6ae09c1b7..aed947d07 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 02f4f5d0f..6f6048dc5 100644 Binary files a/docs/manual/fr/marketing-brochure.pdf and b/docs/manual/fr/marketing-brochure.pdf differ diff --git a/docs/manual/fr/marketing-brochure.tex b/docs/manual/fr/marketing-brochure.tex index 8aa98ed2e..a9d3a6d9c 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 bb5b4dd8f..f68c17a33 100644 Binary files a/docs/manual/fr/user-manual.pdf and b/docs/manual/fr/user-manual.pdf differ diff --git a/docs/manual/fr/user-manual.tex b/docs/manual/fr/user-manual.tex index eaee416f0..fe5ed9fcb 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -1008,10 +1008,10 @@ \subsection{Relancer les débiteurs}\label{sec:relances-user} \item Une \textbf{protection anti-double-envoi} empêche qu'un double-clic n'envoie deux fois le même rappel. Une facture arrivée au \textbf{dernier niveau} reste affichée avec le badge « Dernier niveau atteint » (poursuite à envisager) — elle ne disparaît jamais silencieusement. \end{itemize} -\paragraph{Ce que réclame un rappel.} Un rappel réclame ce que le client \textbf{doit encore} : le \textbf{reste dû} de la facture, après ses règlements partiels --- le montant total s'il n'a rien payé. Le courrier annonce ce reste, augmenté des frais de rappel s'il y en a ; à frais nuls, il ne parle pas de frais. Si un règlement est enregistré \textbf{entre l'ouverture de l'aperçu et l'envoi}, l'envoi est \textbf{refusé} : le courrier validé annoncerait un autre montant que la QR-facture jointe. Fermez puis rouvrez l'aperçu, qui reprend le nouveau reste. Le PDF joint n'est pas la facture réémise, c'est un \textbf{rappel} : il en porte le titre, rappelle le numéro de la facture d'origine et, sous le total, ce qui est \textbf{déjà réglé} puis le \textbf{reste à payer} (ces deux lignes n'apparaissent qu'après un règlement). Sa \textbf{QR-facture porte le reste dû}, avec la même référence que la facture, ce qui permet au rapprochement bancaire de reconnaître le paiement. Les \textbf{frais de rappel n'y sont jamais inclus} : ils figurent sur une ligne à part, avec la mention qu'ils ne sont pas compris dans le bulletin de versement. +\paragraph{Ce que réclame un rappel.} Un rappel réclame ce que le client \textbf{doit encore} : le \textbf{reste dû} de la facture, après ses règlements partiels --- le montant total s'il n'a rien payé. Le courrier annonce ce reste, augmenté des frais de rappel s'il y en a ; à frais nuls, il ne parle pas de frais. Si un règlement est enregistré \textbf{entre l'ouverture de l'aperçu et l'envoi}, l'envoi est \textbf{refusé} : le courrier validé annoncerait un autre montant que la QR-facture jointe. Fermez puis rouvrez l'aperçu, qui reprend le nouveau reste. Le PDF joint n'est pas la facture réémise, c'est un \textbf{rappel} : il en porte le titre, rappelle le numéro de la facture d'origine et, sous le total, ce qui est \textbf{déjà réglé} puis le \textbf{reste à payer} (ces deux lignes n'apparaissent qu'après un règlement). Sa \textbf{QR-facture porte le reste dû}, avec la même référence que la facture : le rapprochement bancaire propose la facture pour un paiement de ce montant, même après des règlements partiels. Les \textbf{frais de rappel n'y sont jamais inclus} : ils figurent sur une ligne à part, avec la mention qu'ils ne sont pas compris dans le bulletin de versement. \begin{keshwarning} -Les frais de rappel ne sont \textbf{pas comptabilisés} par Kesh. Un client qui paie le montant du courrier (reste dû \emph{et} frais) verra son versement \textbf{refusé comme trop-perçu} au rapprochement comme au règlement manuel ; seul le montant de la QR-facture s'enregistre (issue \#401). +Les frais de rappel ne sont \textbf{pas comptabilisés} par Kesh. Un client qui paie le montant du courrier (reste dû \emph{et} frais) ne verra pas son versement \textbf{proposé} au rapprochement, puisqu'il dépasse ce qui reste à payer ; saisi comme règlement manuel, il est \textbf{refusé comme trop-perçu}. Seul le montant de la QR-facture s'enregistre (issue \#401). \end{keshwarning} \begin{keshnote} @@ -1378,16 +1378,17 @@ \subsection{Réconciliation automatique} \subsubsection{Algorithme de matching} -Lors de l'import, Kesh propose automatiquement des correspondances entre transactions bancaires et factures impayées / écritures comptables. L'algorithme utilise un score combinant : +À l'ouverture de la page de réconciliation, Kesh cherche, pour chaque transaction bancaire en attente (créditrice, en CHF), les factures qui pourraient lui correspondre. Une facture est \textbf{candidate} si elle est validée et pas encore payée, datée à 30 jours au plus de la transaction, et si \textbf{ce qui reste à payer} --- son \textbf{reste dû} : le montant total, moins l'avoir éventuel, moins les règlements déjà enregistrés --- égale le montant de la transaction à 5 centimes près. Un virement qui \textbf{solde} une facture déjà réglée en partie est donc reconnu : la proposition porte sur le reste, pas sur le montant d'origine. + +Chaque candidate reçoit un score, somme de trois critères : \begin{itemize} - \item \textbf{Montant} : correspondance exacte = score maximal, écart < 1 CHF = score moyen. - \item \textbf{Date} : transaction bancaire dans les 30 jours de l'échéance facture = bon score. - \item \textbf{Référence} : matching exact de la référence QR Bill = score très élevé. - \item \textbf{Libellé} : matching partiel du nom du client = score moyen. + \item \textbf{Montant} (50\,\%) : le montant de la transaction égale exactement le reste dû. Il n'y a pas de score intermédiaire : un écart d'un centime donne zéro sur ce critère. + \item \textbf{Référence} (40\,\%) : la référence de la transaction contient le numéro de la facture, ou l'inverse (score plein) ; ou les deux commencent par les mêmes quatre caractères au moins (demi-score). + \item \textbf{Contact} (10\,\%) : le nom de la contrepartie contient celui du client de la facture, ou l'inverse. \end{itemize} -Le score total détermine la confiance du match. Au-dessus d'un seuil paramétrable (par défaut 80\%), le match est proposé pour acceptation. Au-dessus de 95\%, le match peut être pré-validé automatiquement (option \emph{auto-accept high confidence}). +La meilleure candidate est affichée avec son score et le \textbf{montant qui reste à payer}. Si la facture est déjà réglée en partie, ce montant est suivi du total d'origine --- par exemple « 600, reste dû sur 1000 » --- pour que vous la reconnaissiez. Une candidate dont le score vaut zéro n'est pas proposée. Rien n'est jamais rapproché sans votre acceptation : il n'y a ni seuil de confiance réglable, ni acceptation automatique. \subsubsection{Acceptation des propositions} diff --git a/frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte b/frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte index a4bcf4ee8..e46b0ac40 100644 --- a/frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte +++ b/frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte @@ -282,7 +282,12 @@ {:else} {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 0b3962208..155e0f2f9 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 5dde40730..9274c2df1 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 70eb906fc..d68b723a3 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 000000000..2dbd458b2 --- /dev/null +++ b/frontend/tests/e2e/reconciliation-amount-due.spec.ts @@ -0,0 +1,170 @@ +/** + * 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`. + * 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). + */ + +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 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'); + + 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; + + // 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(), + 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), + ); +});