diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eff70add..b6eb4302e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,14 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **La balance âgée et l'échéancier comptaient le montant facturé, et non ce que le client doit encore ([#416](https://github.com/guycorbaz/kesh/issues/416)).** Depuis le règlement partiel, une facture de 1'000.— réglée à 900.— y pesait toujours 1'000.—. ⚠️ La balance âgée est l'outil qui dit **qui relancer et pour combien** : elle surévaluait chaque créance de tout ce qui avait déjà été encaissé, sans que rien ne le signale, et ne concordait plus avec le compte clients du grand livre. Elle répartit désormais le **reste dû**, dans la tranche de l'échéance de la facture — un acompte ne rajeunit pas la créance. L'échéancier montre une colonne **« Reste dû »** à côté du total, le statut **« Partiellement payée »**, des totaux au reste dû, et son dialogue de règlement se **pré-remplit** au reste dû ; l'export CSV porte la même colonne. Le manuel dit à quelles conditions le total de la balance âgée égale le solde du compte clients. +- **Un rappel réclamait le montant entier d'une facture déjà réglée en partie ([#416](https://github.com/guycorbaz/kesh/issues/416)).** Une facture de 1 081.— réglée de 900.— était relancée pour **1 081.— plus les frais**, et le PDF joint — la facture elle-même — portait une QR-facture à 1 081.— : le client qui la scannait **payait une seconde fois** ce qu'il avait déjà réglé. Le rappel réclame désormais le **reste dû** : le courrier l'annonce (plus les frais s'il y en a), et le PDF joint est un **rappel** — il en porte le titre, dit ce qui est déjà réglé et ce qui reste à payer, et sa QR demande le reste, avec la **même référence** que la facture, pour que le rapprochement reconnaisse le paiement. + + **Un règlement arrivé pendant que l'aperçu est ouvert fait refuser l'envoi** : le courrier validé annoncerait un autre montant que la QR jointe. Il suffit de rouvrir l'aperçu. + + **Les frais de rappel ne s'affichent plus quand ils sont nuls** — les modèles fournis écrivaient « frais de rappel de 0.00 ». Ils ne sont **jamais** dans la QR : ils ne sont pas comptabilisés ([#401](https://github.com/guycorbaz/kesh/issues/401)), et un versement qui les inclurait serait refusé comme trop-perçu ; le PDF les mentionne sur une ligne à part. + + ⚠️ **Le rappel PDF ne part qu'avec un rappel envoyé par e-mail.** Pour une mise en demeure envoyée hors de Kesh ou un contact sans e-mail, le PDF imprimable reste celui de la facture, au montant total ([#477](https://github.com/guycorbaz/kesh/issues/477)). ⚠️ **Un modèle de rappel personnalisé** qui écrit `{reminderFee}` en toutes lettres affiche toujours « 0.00 » sans frais : la nouvelle variable `{feeNotice}` est vide dans ce cas. + ### 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-b2-residuel-aux-rappels.md b/_bmad-output/implementation-artifacts/25-4-b2-residuel-aux-rappels.md new file mode 100644 index 000000000..8a69584be --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b2-residuel-aux-rappels.md @@ -0,0 +1,472 @@ +# Story 25.4-b2 : Le résiduel aux rappels — le rappel réclame le reste dû + +Status: done + +**Issue : [#416]** — cette story en livre la partie **rappels** ; la partie agrégats est la 25-4-b1 +(PR #475). ⛔ **La PR de b2 porte `closes #416`**, titre ET corps (§ *Issue Tracking Rule*). + +**Mère : `25-4-propager-le-residuel.md`** (`split`) — **source des faits et des arbitrages**, en +particulier § *Arbitrages du 2026-09-26/27 (Q1)*. **Sœurs** : 25-4-a (mergée, #472), 25-4-b1 (`done`, +PR #475). ⚠️ Branche `story/25-4-b2-residuel-aux-rappels` **empilée** sur celle de b1 : intégrer +`main` par merge après celui de #475. + +⚠️ **Ne pas contester les arbitrages en validation** : en contester la mise en œuvre. + +**Noms de fichier nus** — plusieurs existent dans deux crates ; dans cette fiche, sauf chemin +explicite : `pdf.rs` et `types.rs` = `crates/kesh-qrbill/src/` (pas `kesh-report`, pas +`kesh-import`) ; `dunning_eligibility.rs` = `crates/kesh-db/src/repositories/` (pas le fichier de +tests homonyme) ; `dunning_levels.rs` = **les deux** fichiers de ce nom, route et dépôt. *(Inventaire +fait en validation P5 : `find crates -name ` sur chaque nom nu de la fiche.)* + +## Story + +En tant que gérant d'une PME, +je veux qu'un rappel réclame ce que le client **doit encore** — dans le texte, dans la QR-facture et +sur le PDF joint —, +afin qu'un client qui a déjà payé une partie ne soit ni relancé pour le montant entier, ni amené à +le payer une seconde fois en scannant la QR. + +## Le défaut, vérifié dans le code + +Depuis la 24-2, une facture se règle en plusieurs fois. Les rappels l'ignorent : + +| Site | Ce qu'il réclame | +|---|---| +| Texte — `render_reminder`, `crates/kesh-api/src/routes/invoice_email.rs:313-360` | `total_due = ttc + other_fees + *level_fee` (`:334`) — **TTC**, ni règlements ni avoir ; doc-comment `:312` idem | +| QR du PDF joint — `invoice_pdf_service::render`, `crates/kesh-api/src/routes/invoice_pdf_service.rs:90` | `amount: Some(total_ttc)` (`:243`) : un client qui scanne la QR paie **à nouveau le montant entier** | +| PDF joint — rappel unitaire `invoice_email.rs:499-500`, lot `:1140-1143` | la **facture telle quelle** : titre « Facture », aucun bloc réglé / reste (`kesh-qrbill/src/pdf.rs:682-698` n'imprime que « Total TTC ») | +| Gabarits par défaut — `crates/kesh-db/src/entities/email_template_defaults.rs:71-215` | phrases de frais **inconditionnelles** : niveau 2 « Des frais de rappel de {reminderFee} ont été ajoutés » (`:87`), niveau 3 « (frais de rappel de {reminderFee} inclus) » (`:96-97`) — à frais nuls, « frais de 0.00 » | + +Une facture de 1 081.— réglée de 900.— est relancée pour **1 081.— + frais**, avec une QR à +1 081.—. Aucun test ne combine règlement partiel et rappel (vérifié : aucun E2E ni test Rust). + +### Ce que l'inventaire a établi, et qui fixe les choix + +1. **Les frais de rappel ne sont PAS comptabilisés** — aucune écriture dans `invoice_email.rs`, + `dunning_reminders.rs`, `dunning_levels.rs`, `invoice_reminders.rs`. C'est **documenté** : + `admin-manual.tex:1200` (« affichés mais non comptabilisés … ne figurent pas sur la QR-facture ») + et `dunning-cgv-hint` (`fr-CH/messages.ftl:1528`). Un virement « reste + frais » serait d'ailleurs + **refusé** comme trop-perçu (`invoice_settlements_write.rs:165-171`, + `routes/reconciliation.rs:1326-1345`). ⇒ **La QR porte le reste dû, sans les frais.** Le point + ouvert de la mère (§ *Point ouvert … les frais de rappel*) est ainsi **clos par la règle + existante** ; comptabiliser les frais n'est pas dans cette story. +2. **Le PDF d'un rappel est la facture elle-même** : quatre appelants de `render` (`invoice_email.rs:500`, `:722`, `:1141`, `invoice_pdf.rs:37`), tous + `(pool, i18n, locale, company, invoice_id)`, aucun montant ni variante. Précédent de variante : + l'**avoir**, qui surcharge `invoice-pdf-title` et `invoice-pdf-number` dans `i18n.entries` + (`routes/credit_notes.rs:334-347`) — ⚠️ dans la langue de l'**installation**, qui ne convient pas + à un rappel (AC 5). +3. **Le message non structuré de la QR vaut `Facture {n}`** (`invoice_pdf_service.rs:246-250`), pas + le numéro seul comme l'écrit la mère (`:115-117`). Il est émis même avec une QRR. **Il reste + identique**, comme la référence (`build_qrr(company.id, invoice.id)`, `:218-229`) : c'est ce que + lit le rapprochement. +4. **La QR refuse un montant ≤ 0** (`kesh-qrbill/src/validation.rs:22-29`, `:435-445`) → + `InvoiceNotPdfReady` (400), `INVOICE_NOT_PDF_READY` en lot (`invoice_email.rs:944-946`). Un reste dû + ≤ 0 sur une facture `validated` sans `paid_at` n'existe que par données héritées (25-4-b1, revue + P1) — mais il ferait échouer le rappel sur un message trompeur. +5. **L'envoi unitaire ne recalcule pas le texte** : il envoie le sujet et le corps que le client a + pu modifier depuis l'aperçu (`invoice_email.rs:486-487`). Corriger `render_reminder` corrige + l'**aperçu** et le **lot** ; l'unitaire suit par l'aperçu. +6. **Aucun archivage** du PDF envoyé ([#387], ouverte). Le rappel pour le montant restant n'est pas + la facture réémise (recadrage de Guy, mère `:105-109`) : #387 n'est pas touchée. +7. **Le moteur de gabarits ne connaît aucune condition** (`kesh-core/src/email_template_engine.rs`, + substitution `{var}` en une passe). « À zéro, rien ne s'affiche » ne peut donc passer que par une + **variable rendue vide** par le serveur. + +## Acceptance Criteria + +### Volet 1 — le texte du rappel + +**AC 1** — `render_reminder` calcule `total_due = reste dû + frais des autres niveaux + frais du +niveau`, le reste dû venant d'`invoice_settlements::amount_due` (forme **scalaire** : une facture à +la fois). ⛔ Aucune réécriture de la formule. Le doc-comment `:312` suit. + +**AC 2** — Une variable **`{feeNotice}`** rejoint la liste blanche du type `InvoiceReminder` +(`entities/email_template.rs:63-74`) : phrase localisée (4 langues, **en Rust indexé par +`Language`**, sur le patron de `salutation_line`, `invoice_email.rs:109-150` — `build_reminder_vars` +est pur et n'a pas le bundle Fluent) disant le montant des frais +**cumulés** inclus dans `{totalDue}`, rendue **vide** quand ce cumul est nul. Les gabarits par défaut +des 4 langues et de tous les niveaux n'écrivent plus aucune phrase de frais en dur : ils emploient +`{feeNotice}`. `{reminderFee}` reste disponible pour les gabarits personnalisés. ⚠️ **Test qui rougira** : +`reminder_vars_ajoute_les_4_variables_rappel` (`invoice_email.rs:1494`) exige que les clés rendues +égalent la liste blanche (`:1507-1514`) — `build_reminder_vars` doit donc insérer `feeNotice`, et le +test est mis à jour, pas contourné. + +**AC 3** — À frais cumulés nuls, le texte rendu par un gabarit **par défaut** ne contient ni le mot +« frais » (et ses équivalents de/it/en), ni un montant de frais. ⚠️ Un gabarit **personnalisé** qui +écrit `{reminderFee}` en toutes lettres reste de la responsabilité de l'administrateur : le manuel +admin le dit. + +**AC 4** — `{amount}` reste le **TTC** de la facture (« montant de la facture ») : il n'est pas +réinterprété. + +### Volet 2 — le PDF joint au rappel + +**AC 5** — Le PDF joint à un rappel (unitaire et lot) est un **rappel**, pas la facture ; la pièce +jointe se nomme `rappel-{n}.pdf` et non plus `facture-{n}.pdf` (`invoice_email.rs:509`, `:1164` ; `:733`, l'envoi de facture, reste `facture-`) — +⚠️ l'E2E `dunning-roundtrip.spec.ts:81-82` (`^facture-.*\.pdf$`) suit. Titre +localisé « Rappel » (4 locales), sur le patron de surcharge de l'avoir. ⛔ **Dans la locale déjà +résolue** que `render` reçoit (langue du contact, `resolve_language`) — **pas** `state.config.locale` +comme l'avoir (`crates/kesh-api/src/routes/credit_notes.rs:335-346`), qui produirait un titre dans la langue de l'installation +sur un PDF dans celle du contact. Le téléchargement de la +facture (`GET …/pdf`) et l'envoi de facture (`POST …/send-email`) sont **inchangés**. + +**AC 6** — Le PDF du rappel nomme **en toutes lettres le numéro de la facture d'origine** et, sous le +total TTC, porte : **déjà réglé**, puis **reste à payer** en gras. ⛔ **Pas de ligne « avoir »** : une facture +qui porte un avoir est `cancelled` (`crates/kesh-db/src/repositories/credit_notes.rs:586`), un avoir est refusé sur une +facture réglée même en partie (même fichier, `:325`), et le rendu comme l'éligibilité exigent `validated` +(`invoice_pdf_service.rs:102`, `dunning_eligibility.rs:86`) — une facture créditée ne reçoit jamais +de rappel ; la ligne serait du code mort. Si #471 lève un jour le refus, elle y reviendra. Les +lignes nulles ne s'affichent pas (même règle que les frais) ; une facture sans règlement ne +montre que le total, qui **est** le reste à payer *(arbitrage Q2, Guy, 2026-09-27)*. + +**AC 7** — La QR du PDF de rappel porte le **reste dû** — le TTC s'il n'y a aucun règlement. +⛔ **Un seul arrondi** : `amount_due` (DECIMAL(19,4)) est arrondi **une fois**, à 2 décimales, +`MidpointAwayFromZero` (la stratégie de `pdf.rs:682-684` et `generator.rs:38-39`), et **cette même +valeur** nourrit le « reste à payer » imprimé, la QR et `{totalDue}`. Le refus de l'AC 9 porte sur la +valeur **arrondie** (≤ 0.00) : un reste brut de 0.004 est refusé, pas envoyé à une QR invalide. +⚠️ **Limite connue, préexistante, non corrigée ici** : les montants ont jusqu'à 4 décimales +(`routes/limits.rs:28`), et un reste brut de 10.0050 donne une QR à 10.01 ; le trop-perçu se juge sur +la valeur **brute** (`invoice_settlements_write.rs:167`, `routes/reconciliation.rs:1336`) : le paiement +exact de la QR serait refusé. La QR de **facture** a le même défaut (`generator.rs:38-39`). Tracé par +**[#476]**, à rattacher à la 25-4-c (#420, comparaison du rapprochement). +⛔ La **référence** (QRR ou aucune) et le **message non structuré** (`Facture {n}`) sont **identiques** +à ceux du PDF de facture, octet pour octet. Les frais **ne** sont **pas** dans la QR. + +**AC 8** — Les frais cumulés figurent sur le PDF du rappel par une ligne **« Frais de rappel »** +(4 locales) portant la mention qu'ils **ne sont pas compris dans le bulletin de versement** ; +**absente** quand les frais sont nuls *(arbitrage Q1, Guy, 2026-09-27)*. ⚠️ **Largeur** : la colonne +des libellés du récapitulatif n'a que 50 mm (`col_unit` → `col_tot`, `pdf.rs:532-534`), soit ~23 +caractères à 9 pt (calibrage `IDENTITY_MAX_CHARS`, `:202`) ; la mention en compte ~58 en français, +plus en allemand. Le libellé court (« Frais de rappel ») reste dans la colonne ; la **mention** part +sur une ligne à elle depuis `col_desc`, comptée dans la réserve. Un test borne sa longueur dans les +4 locales, sur le patron de `IDENTITY_MAX_CHARS` — les gardes ne surveillent que l'ordonnée. + +**AC 9** — Reste dû ≤ 0 : le rappel est **refusé** avec un code dédié `REMINDER_NOTHING_DUE` +(unitaire : erreur HTTP ; lot : échec **par facture** dans la réponse, jamais d'erreur globale — +§ *Pattern batch*), au lieu d'un `INVOICE_NOT_PDF_READY` trompeur. L'aperçu le signale aussi. Le +frontend traduit le code (`frontend/src/lib/features/reminders/reminder-error-label.ts`). +⛔ **En lot, le refus doit être CLASSÉ** : aujourd'hui `send_one_batch_reminder` range **toute** +erreur de `render_reminder` en panne (`.map_err(|e| BatchItemError::infra("render reminder", …))`, +`invoice_email.rs:1118`), qui rend `DATABASE_ERROR` et journalise en `error!`. Le refus y sortirait +donc en fausse alerte d'infrastructure. Il sort en `BatchItemError::failed("REMINDER_NOTHING_DUE")` +(`:885`), sur le patron de `classify_render_error` (`:937`) et de son test +`classify_render_error_ne_deguise_pas_un_refus_en_panne` (`:1557`) — un test symétrique l'établit. +⛔ **Et sur le second site** : le lot recalcule le reste dans le rendu PDF (`:1140-1143`, +`classify_render_error`) ; un règlement enregistré entre les deux appels y ferait tomber le refus +dans le bras final `other => BatchItemError::infra("render pdf", …)` (`:947`). Le variant du refus +rejoint le `match` de `classify_render_error` **et** le tableau `metier` de son test (`:1557`) — +le doc-comment `:922-936` l'impose. +**Où vit le refus** : dans `render_reminder` (aperçu, lot) **et** dans la variante PDF — l'envoi +unitaire n'appelle pas `render_reminder` (le texte vient de l'aperçu, `:486-487`), seul son rendu PDF +(`:499-500`) recalcule le reste. Un variant `AppError::ReminderNothingDue` et sa clé +`error-reminder-nothing-due` (4 locales), sur le patron de `DunningPaused` (`crates/kesh-api/src/errors.rs:1312`) : +l'aperçu et l'unitaire affichent `err.message` ; `reminder-error-label.ts` ne sert qu'au compte-rendu +du lot (`ReminderBatchReport.svelte:30`). +L'éligibilité (`dunning_eligibility.rs:85-89`) **n'est pas** modifiée : l'état est hérité et rare +(25-4-b1, revue P1), et un refus nommé à l'envoi le rend visible, là où une exclusion en amont le +ferait disparaître de la liste sans dire pourquoi. + +### Volet 3 — tests, textes + +**AC 10** — Tests qui auraient échoué avant le patch, chacun avec un cas **partiellement réglé** et +une TVA **non nulle** : +- `render_reminder` : `totalDue` = reste + frais (et non TTC + frais) — la formule elle-même, pas + une valeur passée à la main comme `reminder_vars_ajoute_les_4_variables_rappel` + (`invoice_email.rs:1494-1520`) ; +- `{feeNotice}` vide à frais nuls, non vide sinon ; gabarits par défaut sans « frais » à zéro ; +- construction des entrées QR du rappel, **sans base** (patron `invoice_pdf_service.rs:553-655`) : + `qr.amount` = reste dû ; `qr.reference` et `unstructured_message` **égaux** à ceux de la facture ; +- PDF de rappel : génération `Ok`, et le bloc réglé / reste mesuré selon la doctrine du dépôt + (§ *Comment tester un PDF*, `16-3a-coordonnees-emetteur-pdf.md:416-429`) ; +- `REMINDER_NOTHING_DUE` en unitaire et en lot — en lot, **classé** en échec par facture et non en + `DATABASE_ERROR` ; un reste brut de 0.004 est refusé ; +- le rendu du PDF de rappel au **nombre maximal de lignes** avec le bloc complet (trois lignes, dont + la mention des frais — AC 8) ; +- un E2E Playwright : facture réglée en partie → aperçu du rappel montre le reste + frais, pas le TTC. + +**AC 11** — Manuels : `user-manual.tex` § rappels (`:990-997`) dit ce que réclame un rappel et ce +que porte le PDF joint ; `admin-manual.tex:1200` n'écrit plus que la QR porte « le total TTC de la +facture d'origine », mais le **reste dû**, frais toujours exclus. ⚠️ **Les variables de rappel ne +sont documentées NULLE PART** : les deux manuels ne listent que les six variables de la facture +(`admin-manual.tex:1163-1167`, `user-manual.tex:906-909`). Les deux sites gagnent la liste des +variables propres au rappel — `{reminderLevel}`, `{reminderFee}` (frais du niveau), `{totalDue}` +(**reste dû + frais cumulés**), `{daysOverdue}`, `{feeNotice}` (vide sans frais) — et disent que +`{amount}` reste le TTC de la facture. Le rappel des CGV `dunning-cgv-hint` (`fr-CH/messages.ftl:1528`, +3 autres locales, repli `settings/dunning/+page.svelte:263`) ne dit plus « le QR de la facture +jointe » mais celui du **rappel joint**. ⚠️ Le manuel dit aussi, sans le corriger, qu'un client qui +paie `{totalDue}` (reste + frais) sera refusé en trop-perçu : conséquence des frais non comptabilisés, +tracée par **[#401]**. Et il dit la **limite du papier** : le PDF « Rappel » ne part qu'avec un rappel +**envoyé par e-mail** ; pour une sommation envoyée hors de Kesh (`user-manual.tex:1003`) ou un contact +sans e-mail, le PDF de la **facture** porte toujours le TTC complet dans sa QR — ne pas le joindre tel +quel à une facture partiellement réglée (**[#477]**). PDF régénérés et contrôlés **aplatis**. CHANGELOG `[0.12.1]` *Fixed*. + +## Tasks / Subtasks + +- [x] **T1 — texte** (AC 1-4) : `render_reminder`, `{feeNotice}` (liste blanche, phrase Rust par `Language` ×4, + rendu serveur), gabarits par défaut ×4 langues × niveaux. +- [x] **T2 — PDF de rappel** (AC 5-8) : variante de `render` (paramètre explicite, pas de booléen + muet, qui **porte les montants déjà calculés** — la construction des entrées QR reste testable sans + base), champs optionnels dans `InvoicePdfData` sur le patron d'`origin_reference`, bloc sous le + total dans `pdf.rs`, clés `I18N_KEYS`/`DEFAULT_EN` ajoutées **en fin** des deux tableaux, les deux + appelants de rappel branchés. +- [x] **T3 — refus du reste nul** (AC 9) : variant `AppError`, clé `error-*` ×4, `render_reminder` + et variante PDF, **deux** sites classés en lot, libellé du compte-rendu ×4. +- [x] **T4 — tests et mutations** (AC 10). +- [x] **T5 — textes** (AC 11). +- [x] **T6 — gates** : backend complet (base remise à zéro), frontend complet, **E2E complet**. + +### Review Findings + +*Passe 1 de `bmad-code-review` (2026-09-27) — Blind Hunter, Edge Case Hunter, Acceptance Auditor, tous +Sonnet, sur `46081964..7b20d687`. Brut : 4 + 3 + 0 ; après dédoublonnage et tri : 1 décision, 2 patch, +1 defer, 2 écartés.* + +- [x] [Review][Decision] **HIGH — à l'envoi unitaire, le texte et la QR peuvent diverger** — `send_reminder` envoie le sujet et le corps de l'aperçu (`invoice_email.rs:515-516`) mais recalcule les montants de la QR au moment de l'envoi (`:531`) : un règlement enregistré entre l'aperçu et le clic fait annoncer au courrier l'ancien reste, et à la QR le nouveau. Le lot n'a pas le défaut (montants calculés une fois). Convergé Blind + Edge. Options : refuser l'envoi si le reste a changé depuis l'aperçu (l'aperçu rend le reste, l'envoi le renvoie, écart → 409 à rouvrir) ; ou l'accepter et le documenter. ✅ **Tranché (Guy, « ok, continue » sur la recommandation)** : refuser — `409 REMINDER_AMOUNTS_CHANGED`. +- [x] [Review][Patch] **MEDIUM — `reminder_amounts` lit reste, réglé et frais en trois requêtes hors transaction** [crates/kesh-api/src/routes/invoice_pdf_service.rs] — un règlement inséré entre la lecture du reste et celle du réglé rend « déjà réglé » et « reste à payer » incohérents entre eux, et la QR peut réclamer un reste d'avant le paiement. Risque introduit par la story (avant, le TTC se calculait des lignes). Blind Hunter. +- [x] [Review][Patch] **LOW — les libellés courts du bloc (déjà réglé, reste à payer, frais de rappel) ne sont bornés ni testés en largeur** [crates/kesh-qrbill/src/pdf.rs] — seule la mention pleine largeur l'est ; la colonne n'a que ~23 caractères. Edge Case Hunter. +- [x] [Review][Defer] **près du seuil de capacité, `payment_terms` se clampe sur le bas du bloc** [crates/kesh-qrbill/src/pdf.rs:728-736] — deferred, pre-existing : `payment_terms` n'est pas réservé et se clampe à `content_floor + 5` ; avant la story il pouvait déjà chevaucher la ligne du total au seuil, le bloc de rappel reproduit la même géométrie sans l'aggraver. Le réserver changerait la capacité des factures. + +*Passe 2 (2026-09-27) — Haiku ×3, protocole complet (la passe 1 touchait plusieurs modules), diff aplati +`46081964..75417026`. Brut : 6 + 0 + 0 ; après vérification : **0 au-dessus de LOW**.* Écartés : décimaux +en chaîne JSON (comparaison de valeur, testée) ; double arrondi (idempotent, défense documentée) ; +attendus absents acceptés (voulu : clients d'API, documenté) ; transaction de lecture annulée +(usage correct pour un instantané) ; commentaires français des `.ftl` (convention du dépôt) ; sens de +`{reminderFee}` (documenté dans les deux manuels). ⚠️ L'Edge Case Hunter rendait 0 finding avec deux +affirmations inexactes (décimaux « en nombres », classement de `ReminderAmountsChanged` en lot, qui +n'y survient pas) ; l'axe du **changement de niveau dans le dialogue** repris par l'orchestrateur : +`refetchPreview` remplace `sendPreview`, relu par `confirmSend`, et le dialogue ré-hydrate son texte +depuis ce même aperçu (`ReminderSendDialog.svelte:60-65`) — cohérent. + +Écartés : montant imprimé sur le bulletin (Blind F3) — il vient de `data.amount`, le montant de la QR +(`pdf.rs:819-822`, `:947-950`) ; borne en caractères d'une police proportionnelle (Blind F4) — +limite documentée, même méthode qu'`IDENTITY_MAX_CHARS`. L'Acceptance Auditor : 0 finding, recomptes +concordants. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- ⛔ **Mettre les frais dans la QR** : ils ne sont pas comptabilisés ; le virement serait refusé comme + trop-perçu (§ inventaire, point 1). +- ⛔ **Toucher la référence ou le message de la QR** : le rapprochement les lit. +- ⛔ **Modifier le PDF de la facture** (téléchargement, envoi, renvoi) : seule la pièce jointe d'un + **rappel** change. +- ⛔ **Réécrire le reste dû à la main** : `amount_due` existe ; il ne prend pas de `company_id` — le + scoping est déjà fait par le chargement de la facture (`find_by_id_with_lines(pool, company.id, …)`). +- ⛔ **Un booléen `is_reminder` passé à `render`** : un paramètre qui dit ce qu'il porte (enum ou + struct d'options), sans quoi l'appel `render(…, true)` ne se relit pas. +- ⚠️ **Géométrie du PDF** : le bloc (jusqu'à trois lignes : déjà réglé, reste, frais) + ajoute de la hauteur ; il **entre dans `recap_reserve`** (calculée avant la boucle des lignes, + `pdf.rs:579-586`) et respecte les gardes `HeaderOverflow` / `TooManyLines` (`:527-529`, + `:582-595`). ⛔ **Ne pas copier le bloc `payment_terms`** (`:700-708`), le plus proche en apparence : + il n'est pas réservé et se contente de **clamper** à `content_floor + 5.0`, d'où un tassement + silencieux au lieu d'un refus. Un test à nombre de lignes maximal **avec** le bloc complet le prouve. +- ⚠️ **`I18N_KEYS.len() == DEFAULT_EN.len()`** (`types.rs:276-279`) ne garantit pas l'appariement : + ajouter en fin des deux tableaux, et le test positionnel `pdf.rs:1835-1852` doit suivre. + +### Où regarder + +| Fichier | Pourquoi | +|---|---| +| `crates/kesh-api/src/routes/invoice_email.rs:161-230, 313-404, 412-562, 1029-1190` | variables, `render_reminder`, aperçu, unitaire, lot | +| `crates/kesh-api/src/routes/invoice_pdf_service.rs:90-345, 553-655` | `render`, entrées QR, tests sans base | +| `crates/kesh-api/src/routes/credit_notes.rs:334-347` | patron de surcharge du titre — ⚠️ **sauf sa locale** (`state.config.locale`), cf. AC 5 | +| `crates/kesh-qrbill/src/types.rs:120-159, 216-315` ; `pdf.rs:388-712` | données PDF, clés, rendu | +| `crates/kesh-db/src/entities/email_template.rs:63-74` ; `email_template_defaults.rs:71-215` | liste blanche, gabarits par défaut | +| `crates/kesh-db/src/repositories/invoice_settlements.rs:187-220` | `amount_due`, `amount_settled` — suffisants : aucune ligne « avoir » (AC 6) | +| `crates/kesh-db/src/repositories/invoice_reminders.rs:65-89` | frais cumulés | +| `docs/manual/fr/user-manual.tex:990-997` ; `admin-manual.tex:1183-1200` | textes | + +### Gardes-fous du dépôt + +- Aucune migration prévue. Si une devenait nécessaire : § *Migration breaking policy* (P5 à P8). +- `crates/kesh-db/` touché (entités) : gate complet en fin de boucle de revue. +- Modules touchés : `kesh-qrbill`, `kesh-api`, `kesh-db`, `kesh-i18n`, `frontend` — **cinq**, à la + limite de la règle de découpage (> 5) : ne pas en ajouter sans le signaler. + +## ✅ Arbitrages de Guy (2026-09-27) + +- **Q1 — les frais sur le PDF du rappel** : *« oui, rajouter une ligne "Frais de rappel" »*. Une + ligne « Frais de rappel : 20.— (non compris dans le bulletin de versement) », absente à zéro — la + QR reste au reste dû (AC 7, AC 8). +- **Q2 — « déjà réglé » à zéro** : *« ok »*. Sans règlement ni avoir, le PDF ne montre que le total, + qui est le reste à payer (AC 6). +- **Signal de découpage P2 → P3 (MED → MED)** : *« ne découpe pas »* (Guy, 2026-09-27). +- **M5 — le rappel papier** : *« les 1er et 2ème rappels n'ont pas besoin d'être en recommandé »*, + puis *« le troisième rappel, la sommation, sera envoyé par un autre moyen, pas par kesh (pour + l'instant) »* (Guy, 2026-09-27). ⇒ **Hors périmètre** : l'impression du PDF de rappel (sommation, + contact sans e-mail) est tracée par **[#477]** ; le manuel dit la limite (AC 11). + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5 (`claude-opus-5-5`). + +### Debug Log References + +- Clippy `too_many_arguments` sur `build_qrbill_inputs` (8 arguments avec `PdfDocument`) : `allow` + explicite, comme `build_reminder_vars`. +- Débordement LaTeX de 6 pt sur le paragraphe des variables (`user-manual.tex`) : `sloppypar`. Celui de + `admin-manual.tex:1163` (60 pt) est **préexistant** — premier élément de la liste, non touché. +- Le signe moins typographique (U+2212) devant « déjà réglé » n'existe pas dans l'encodage WinAnsi des + polices intégrées : tiret ASCII. + +### Completion Notes List + +- **Deux écarts de mise en œuvre, qui servent l'intention mieux que la lettre** : + - **Le titre « Rappel » ne passe pas par une surcharge de `i18n.entries`** (patron de l'avoir) mais + par un champ `reminder: Option` d'`InvoicePdfData` : `draw_invoice_section` choisit + `invoice-pdf-reminder-title`, résolu par `build_i18n` dans la locale **passée à `render_document`** + (celle du contact). Le piège de locale de l'AC 5 disparaît **par construction**. + - **Les montants se calculent une fois** : `render_reminder` rend `(sujet, corps, ReminderAmounts)`, + et le lot passe **ces mêmes montants** au PDF — le texte et la QR ne peuvent pas diverger, même si + un règlement arrive entre les deux. Le variant reste classé dans `classify_render_error` (AC 9, + second site), en défense : `build_qrbill_inputs` refait `reminder_amount_due` sur les montants reçus. +- **Où vit le refus** : `reminder_amount_due` (arrondi `MidpointAwayFromZero`, puis refus ≤ 0.00) — + appelé par `reminder_amounts` (aperçu, lot, et unitaire **avant le SMTP**) et par + `build_qrbill_inputs`. `AppError::ReminderNothingDue` (422, `REMINDER_NOTHING_DUE`). +- **`{feeNotice}`** : Rust indexé par `Language` (patron `salutation_line`), précédé d'une espace, + vide à frais cumulés nuls ; les 16 bras des gabarits par défaut l'emploient, plus aucune phrase de + frais en dur. `{totalDue}` passe de « montant total dû » à « montant dû ». +- **Mutation exécutée** : retirer la hauteur du bloc de `recap_reserve` fait rougir + `reminder_block_is_reserved_in_the_capacity_guard` (« la réserve ne sert à rien ») — tuée, + restaurée par `sed` (qui date le fichier du présent, cf. l'incident de mutation de la b1). Les + autres tests échoueraient **par construction** sur le code d'avant (montants 128.10 / 972.90, + `facture-…pdf`, `INVOICE_NOT_PDF_READY`) ; non mutés un à un. +- **Tests ajoutés**, recomptés (`#[test]`/`#[sqlx::test]`/`it(` ajoutés dans le diff) : `kesh-db` 1, + `kesh-qrbill` 4, `kesh-api` 5 unitaires (dont `reminder_vars_ajoute_les_4_variables_rappel` + **renommé** et étendu, non compté) + 3 d'intégration, `kesh-i18n` 1 — **14** backend ; frontend 1 + Vitest ; Playwright 1 test ajouté (`reminders.spec.ts`), 1 assertion changée + (`dunning-roundtrip.spec.ts`). +- **Sept clés i18n × 4 locales** : cinq `invoice-pdf-*`, `error-reminder-nothing-due`, + `reminders-error-nothing-due` ; `dunning-cgv-hint` ×4 et son repli Svelte rectifiés. + +### Gates + +- Backend : base remise à zéro, `scripts/test-fast.sh` **2508 / 2508** (2494 + 14). +- Frontend : `check` 0 erreur (27 avertissements, préexistants), `lint-i18n-ownership` PASS, + `test:unit` **835 / 835** (834 + 1), build OK. +- E2E : ciblé `reminders.spec.ts` + `dunning-roundtrip.spec.ts` **10 / 10**. Complet, deux runs sur + `kesh_e2e` reconstruite : + - run 1 : **214 / 20 / 19** — les 7 KF-029, `product-revenue-account:133` (pollution répertoriée) et + **12 hors liste**, tous `page.fill('#username')` sur un `/login` en « Erreur 500 » : **12 / 12 verts + rejoués seuls**. Second passage du symptôme vu en b1 ⇒ **KF-053 ouverte ([#478])**, ajoutée à + `docs/testing.md` § « Les échecs attendus » ; + - run 2 : **224 / 10 / 19** — les 7 KF-029, `sidebar-navigation:75` (**KF-046, #424** : rouge rejoué + seul, déterministe), `bank-accounts-crud:112` et `setup:75` (**verts rejoués seuls** : pollution). + Conforme à la baseline. + +### File List + +| Fichier | Nature | +|---|---| +| `crates/kesh-qrbill/src/types.rs`, `lib.rs` | `ReminderPdf`, champ `reminder`, 5 clés `I18N_KEYS`/`DEFAULT_EN` | +| `crates/kesh-qrbill/src/pdf.rs` | titre, bloc sous le total, réserve, `REMINDER_NOTE_MAX_CHARS`, 4 tests | +| `crates/kesh-qrbill/tests/golden_test.rs` | `reminder: None` | +| `crates/kesh-api/src/routes/invoice_pdf_service.rs` | `PdfDocument`, `ReminderAmounts`, `reminder_amount_due`, `reminder_amounts`, `render_document`, 3 tests | +| `crates/kesh-api/src/routes/invoice_email.rs` | `fee_notice`, `render_reminder`, unitaire, lot, classement, pièce jointe `rappel-`, 2 tests | +| `crates/kesh-api/src/routes/credit_notes.rs` | `reminder: None` | +| `crates/kesh-api/src/errors.rs` | `ReminderNothingDue` | +| `crates/kesh-api/tests/invoice_send_email_e2e.rs` | 3 tests d'intégration | +| `crates/kesh-db/src/entities/email_template.rs` | `feeNotice` en liste blanche | +| `crates/kesh-db/src/entities/email_template_defaults.rs` | 16 bras, 1 test | +| `crates/kesh-i18n/locales/{fr,de,it,en}-CH/messages.ftl`, `src/loader.rs` | 7 clés, `dunning-cgv-hint`, 1 test | +| `frontend/src/lib/features/reminders/reminder-error-label.ts` / `.test.ts` | `REMINDER_NOTHING_DUE` | +| `frontend/src/routes/(app)/settings/dunning/+page.svelte` | repli `dunning-cgv-hint` | +| `frontend/tests/e2e/reminders.spec.ts`, `dunning-roundtrip.spec.ts` | reste dû ; `rappel-…pdf` | +| `docs/manual/fr/user-manual.tex` / `.pdf`, `admin-manual.tex` / `.pdf` | ce que réclame un rappel, variables, QR, #401, #477 | +| `CHANGELOG.md` | *Fixed* | + +## Change Log + +- **2026-09-27** — **Revue de code CLOSE en 2 passes, story `done`.** Tendance : passe 1 `1H/1M/1L` + (Sonnet ×3 ; HIGH tranché par Guy) → passe 2 **0 au-dessus de LOW** (Haiku ×3, protocole complet ; 6 + points écartés à la vérification, un axe repris par l'orchestrateur). Gates au dernier commit de + code : backend **2510 / 2510**, frontend **835 / 835** ; E2E complet sur `kesh_e2e` reconstruite + **223 / 11 / 19** — les 7 KF-029, `product-revenue-account:133` (pollution répertoriée) et trois + **verts rejoués seuls** : `reminders.spec.ts:84` (le test de la story — tombé sur le symptôme + **KF-053, #478**, `#username` introuvable), `invoices.spec.ts:415` et `:536`. Conforme. +- **2026-09-27** — **Revue de code, passe 1** (Sonnet ×3, `46081964..7b20d687`) — **1 HIGH (décision), + 1 MEDIUM, 1 LOW** corrigés, 1 différé, 2 écartés (§ *Review Findings*) : + - HIGH, tranché par Guy : l'aperçu rend `amountDue` et `fees` ; l'envoi unitaire les renvoie + (`expectedAmountDue`, `expectedFees`, optionnels pour un client d'API) et un écart → + `409 REMINDER_AMOUNTS_CHANGED`, rien ne part. Test d'intégration + `send_reminder_refuses_amounts_changed_since_preview` (aperçu → règlement → envoi refusé → nouvel + aperçu → envoi) — ⛔ **mutation vérifiée** : sans l'appel à la garde, 201 au lieu de 409. Test + unitaire `check_amounts_unchanged_compare_des_valeurs`. Page des rappels, types, clé + `error-reminder-amounts-changed` ×4, manuel et CHANGELOG ; + - MEDIUM : `reminder_amounts` lit reste, réglé et frais dans **une** transaction (instantané + REPEATABLE READ) ; `sum_fees_deduped_excluding` devient générique sur l'exécuteur. ⚠️ **Pas de + test** : la course demande une concurrence réelle, non reproductible de façon déterministe — + correctif structurel ; + - LOW : `REMINDER_LABEL_MAX_CHARS = 23`, troncature défensive au dessin, borne testée dans les 4 + locales. + Tests, recomptés depuis le diff de `7b20d687` : **+2** backend (1 unitaire, 1 intégration). + ⛔ Un repository de `kesh-db` touché ⇒ **gate complet** : base remise à zéro, **2510 / 2510** ; + frontend `check` 0 erreur, **835 / 835**, build OK ; E2E ciblé `reminders` + `dunning-roundtrip` + **10 / 10** ; E2E complet au dernier commit de la boucle. PDF du manuel utilisateur régénéré, + contrôlé aplati. +- **2026-09-27** — **Dev** : T1-T5 faits (§ *Completion Notes*) ; deux écarts de mise en œuvre écrits + (titre par champ plutôt que par surcharge de locale ; montants calculés une fois et partagés). + Gates verts (§ *Gates*) ; **KF-053 (#478) ouverte** pour le symptôme E2E revenu. Story en `review`. +- **2026-09-27** — **M5 arbitré** (Guy) : la sommation part hors de Kesh pour l'instant ; l'impression du + PDF de rappel sort du périmètre, tracée par **#477** (ouverte) ; l'AC 11 ajoute la limite au manuel. + Changement de texte seul, issu d'un arbitrage et non d'un finding : pas de passe supplémentaire. +- **2026-09-27** — **Validation P5 ciblée** (Haiku, `d06b5c6c`, prompt `25-4-b2-validate-prompt-p5.md`) + — 2 findings rendus MEDIUM, **reclassés LOW** par l'orchestrateur : `pdf.rs` et `dunning_levels.rs` + nus et homonymes, mais sans numéro de ligne, et le contexte désigne le bon fichier (`pdf.rs` est + qualifié `kesh-qrbill` dans « Où regarder » ; « aucune écriture » vaut pour les deux + `dunning_levels.rs`). Traités **comme classe** : inventaire de tous les noms nus de la fiche et de + leurs homonymes (`pdf.rs`, `types.rs`, `dunning_eligibility.rs`, `dunning_levels.rs` ; `errors.rs` + et `credit_notes.rs` ne restent nus que dans le Change Log), et une convention en tête de fiche. + ⛔ **Boucle close en 5 passes** : `1H/3M/2L → 0 (+1M orchestrateur) → 5M/5L → 1M/2L → 0 >LOW`, + rotation Sonnet → Haiku → Opus → Sonnet → Haiku, deux passes ciblées ; toutes les corrections sur la + fiche. ⚠️ **M5 (le rappel papier) reste en attente d'arbitrage** : s'il ajoute du périmètre, une + passe de plus sera due. +- **2026-09-27** — **Validation P4 ciblée** (Sonnet, `6197bffd`, prompt `25-4-b2-validate-prompt-p4.md`) + — **1 MEDIUM, 2 LOW**, tous de référence : `credit_notes.rs:586`/`:325` sans chemin, alors que deux + fichiers portent ce nom (le contenu est dans `kesh-db/src/repositories/`) ; `invoice_email.rs:510`/ + `:1161` décalés (`:509`/`:1164`) ; `[#401]` défini mais jamais invoqué. Symptôme grepé (nom de + fichier nu) : **deux sites de plus** que la passe, `credit_notes.rs:335-346` et `errors.rs:1312`, + eux aussi présents dans deux crates — qualifiés. Cinq axes exercés, commandes citées ; les + corrections de P3 jugées justes et sans contradiction. +- **2026-09-27** — **Validation P3** (Opus, protocole complet, prompt `25-4-b2-validate-prompt-p3.md`) + — **5 MEDIUM, 5 LOW**, tous sur la fiche ; les citations clés vérifiées par `grep -nF`. Corrigés : + M1 la ligne « avoir » et `amount_credited` retirées — une facture créditée est `cancelled` et ne + reçoit jamais de rappel ; M2 la largeur de la mention des frais (50 mm de colonne, ~58 caractères) ; + M3 le refus classé aussi dans `classify_render_error` (second site du lot) ; M4 l'arrondi loin de + zéro fait refuser le paiement exact de la QR — limite préexistante écrite dans l'AC 7, tracée par + **#476** (ouverte) ; L1 où vit le refus, variant et clé ; L2 `{feeNotice}` en Rust par `Language`, + test qui rougira nommé ; L3 quatre appelants de `render`, pas trois ; L4 nom de la pièce jointe et + `dunning-cgv-hint` ; L5 la conséquence de `{totalDue}` = reste + frais, citée avec **#401**. + ⚠️ **Deux points remontés à Guy** : M5 (le rappel papier n'a aucun PDF juste) et le **signal de + découpage** — P2 (1M, orchestrateur) → P3 (5M) : sévérité égale (§ *Règle de splitting + préventif*). +- **2026-09-27** — **Validation P2** (Haiku, prompt `25-4-b2-validate-prompt-p2.md`) — **0 finding** + rendu, sans aucune commande citée ; l'axe 10 lisait de travers `amount_credited` (« LOW accepté », + alors que la fiche prescrit de la créer). ⛔ **Repris par l'orchestrateur** (§ *un 0 finding se + vérifie comme un finding*) : forme de `BatchItemError::failed` conforme (`invoice_email.rs:885`) ; + aucun autre site ne dit `totalDue` hors des six fichiers connus ; mais **1 MEDIUM** sur l'axe 8 + qu'elle déclarait exercé — l'AC 11 renvoyait à une documentation des variables de rappel qui + **n'existe pas** (les manuels ne listent que celles de la facture). AC 11 rectifié. +- **2026-09-27** — **Validation P1** (Sonnet, prompt `25-4-b2-validate-prompt-p1.md`) — **1 HIGH, + 3 MEDIUM, 2 LOW**, tous des défauts de la fiche. HIGH : en lot, le refus du reste nul serait sorti + en `DATABASE_ERROR` — `render_reminder` y est classé en panne (`invoice_email.rs:1118`) ; AC 9 + exige désormais la classification. MEDIUM : la locale du titre (le précédent de l'avoir prend celle + de l'installation) ; la géométrie (le bloc le plus proche, `payment_terms`, clampe au lieu de + réserver) ; l'arrondi (un seul, partagé par le texte, le PDF et la QR). LOW : l'éligibilité reste + inchangée, motif écrit ; l'avoir n'a pas de fonction scalaire. Neuf axes exercés, références toutes + exactes ; périmètre recompté à cinq modules. +- **2026-09-27** — Créée. Inventaire vérifié par deux explorations et contrôle direct des citations ; + le point ouvert de la mère sur les frais est clos par la règle existante (non comptabilisés, hors + QR) ; message de la QR rectifié (`Facture {n}`, pas le numéro seul) ; deux questions à Guy. + Tranchées le jour même : ligne « Frais de rappel » sur le PDF (Q1), « déjà réglé » masqué à zéro + (Q2). + +[#387]: https://github.com/guycorbaz/kesh/issues/387 +[#416]: https://github.com/guycorbaz/kesh/issues/416 +[#476]: https://github.com/guycorbaz/kesh/issues/476 +[#401]: https://github.com/guycorbaz/kesh/issues/401 +[#477]: https://github.com/guycorbaz/kesh/issues/477 +[#478]: https://github.com/guycorbaz/kesh/issues/478 diff --git a/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p1.md new file mode 100644 index 000000000..6b120833d --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p1.md @@ -0,0 +1,59 @@ +# Prompt — validation P1, Story 25-4-b2 (le résiduel aux rappels) + +*Versionné le 2026-09-27. **Une lentille** (Sonnet), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b2-residuel-aux-rappels` (empilée sur la 25-4-b1, +PR #475). Fiche à valider : `_bmad-output/implementation-artifacts/25-4-b2-residuel-aux-rappels.md`. +Mère : `25-4-propager-le-residuel.md` (arbitrages de Guy, et ceux de la fiche Q1/Q2 : **ne pas les +contester**, en contester la mise en œuvre). Sœur : `25-4-b1-residuel-aux-agregats.md`. Issue : +`gh issue view 416`. Contexte : `24-2-encaissement-client.md`, les stories de l'Epic 21 sur les +rappels (`ls _bmad-output/implementation-artifacts/21-*`). Règles : `CLAUDE.md`. Checklist : +`.claude/skills/bmad-create-story/checklist.md`. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme. +2. **Inventaire des sites qui disent un montant au client à l'occasion d'un rappel** — pars du + symptôme, pas de la liste de la fiche : `grep -rn "total_due\|totalDue\|invoice_total_ttc\|total_ttc\|reminderFee\|fee_amount" 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 d'envoi ou d'impression d'un rappel (manuel, renvoi, lot, aperçu, + export, page d'accueil) ? +3. **La QR (AC 7)** : la référence et le message sont-ils vraiment indépendants du montant ? Le + montant de la QR d'un rappel peut-il différer du total imprimé sans que le PDF se contredise ? + Arrondi : `amount_due` est en DECIMAL(19,4), la QR exige 2 décimales — où arrondir, et que + devient un reste de 0.004 ? +4. **Les frais (AC 2, 3, 8)** : « frais cumulés » = autres niveaux + niveau courant — cohérent avec + `sum_fees_deduped_excluding` et avec ce que dit `{totalDue}` ? `{feeNotice}` : la liste blanche, + la validation des gabarits personnalisés existants (un gabarit enregistré avec `{reminderFee}` + reste-t-il valide ?), l'éditeur de gabarits du frontend liste-t-il les variables (où ?). +5. **Le refus du reste nul (AC 9)** : le pattern batch du `CLAUDE.md` est-il respecté par la forme + actuelle de la réponse du lot (lis-la) ? L'éligibilité (`dunning_eligibility.rs`) devrait-elle + exclure ces factures en amont plutôt ? +6. **Le PDF (AC 5, 6, 8)** : géométrie (réserve, gardes), clés i18n et test positionnel, patron de + surcharge de l'avoir — la locale utilisée (contact vs configuration) est-elle la bonne ? + L'archivage #387 est-il vraiment hors d'atteinte ? +7. **Les tests (AC 10)** : chacun est-il faisable et prouve-t-il ce qu'il annonce ? L'E2E : peut-on + monter un règlement partiel puis un aperçu de rappel depuis Playwright ? Mutations tuables ? +8. **Le manuel** : lignes citées, et **PDF aplati** + (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '`, idem `admin-manual.pdf`, + vers `/tmp/claude-1000/-home-gcorbaz-devel-kesh/4fb1e50e-3db4-4da6-9933-bcee4a186408/scratchpad/`). + D'autres textes (manuels de/en/it, api-external, README, website) disent-ils que le rappel ou sa + QR réclament le TTC ? +9. **Le périmètre** : cinq modules annoncés — recompte-les depuis les tâches. Quelque chose de la + b1 ou de la 25-4-c/d 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-b2-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p2.md new file mode 100644 index 000000000..ea1362ea7 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p2.md @@ -0,0 +1,69 @@ +# Prompt — validation P2, Story 25-4-b2 (le résiduel aux rappels) + +*Versionné le 2026-09-27. **Une lentille** (Haiku), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b2-residuel-aux-rappels` (empilée sur la 25-4-b1, +PR #475). Fiche à valider : `_bmad-output/implementation-artifacts/25-4-b2-residuel-aux-rappels.md`. +Mère : `25-4-propager-le-residuel.md` (arbitrages de Guy, et ceux de la fiche Q1/Q2 : **ne pas les +contester**, en contester la mise en œuvre). Sœur : `25-4-b1-residuel-aux-agregats.md`. Issue : +`gh issue view 416`. Contexte : `24-2-encaissement-client.md`, les stories de l'Epic 21 sur les +rappels (`ls _bmad-output/implementation-artifacts/21-*`). Règles : `CLAUDE.md`. Checklist : +`.claude/skills/bmad-create-story/checklist.md`. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme. +2. **Inventaire des sites qui disent un montant au client à l'occasion d'un rappel** — pars du + symptôme, pas de la liste de la fiche : `grep -rn "total_due\|totalDue\|invoice_total_ttc\|total_ttc\|reminderFee\|fee_amount" 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 d'envoi ou d'impression d'un rappel (manuel, renvoi, lot, aperçu, + export, page d'accueil) ? +3. **La QR (AC 7)** : la référence et le message sont-ils vraiment indépendants du montant ? Le + montant de la QR d'un rappel peut-il différer du total imprimé sans que le PDF se contredise ? + Arrondi : `amount_due` est en DECIMAL(19,4), la QR exige 2 décimales — où arrondir, et que + devient un reste de 0.004 ? +4. **Les frais (AC 2, 3, 8)** : « frais cumulés » = autres niveaux + niveau courant — cohérent avec + `sum_fees_deduped_excluding` et avec ce que dit `{totalDue}` ? `{feeNotice}` : la liste blanche, + la validation des gabarits personnalisés existants (un gabarit enregistré avec `{reminderFee}` + reste-t-il valide ?), l'éditeur de gabarits du frontend liste-t-il les variables (où ?). +5. **Le refus du reste nul (AC 9)** : le pattern batch du `CLAUDE.md` est-il respecté par la forme + actuelle de la réponse du lot (lis-la) ? L'éligibilité (`dunning_eligibility.rs`) devrait-elle + exclure ces factures en amont plutôt ? +6. **Le PDF (AC 5, 6, 8)** : géométrie (réserve, gardes), clés i18n et test positionnel, patron de + surcharge de l'avoir — la locale utilisée (contact vs configuration) est-elle la bonne ? + L'archivage #387 est-il vraiment hors d'atteinte ? +7. **Les tests (AC 10)** : chacun est-il faisable et prouve-t-il ce qu'il annonce ? L'E2E : peut-on + monter un règlement partiel puis un aperçu de rappel depuis Playwright ? Mutations tuables ? +8. **Le manuel** : lignes citées, et **PDF aplati** + (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '`, idem `admin-manual.pdf`, + vers `/tmp/claude-1000/-home-gcorbaz-devel-kesh/4fb1e50e-3db4-4da6-9933-bcee4a186408/scratchpad/`). + D'autres textes (manuels de/en/it, api-external, README, website) disent-ils que le rappel ou sa + QR réclament le TTC ? +9. **Le périmètre** : cinq modules annoncés — recompte-les depuis les tâches. Quelque chose de la + b1 ou de la 25-4-c/d appartient-il ici, ou l'inverse ? + +10. **Les corrections de la passe 1** (Change Log, entrée « Validation P1 », commit `ac38508f` : + `git show ac38508f`) : chacune est-elle juste, et a-t-elle introduit une contradiction ailleurs + dans la fiche ? En particulier : le refus classé en lot (AC 9) — `BatchItemError::failed` rend-il + bien la forme attendue par la réponse du lot ? La règle « un seul arrondi » (AC 7) est-elle + compatible avec `{totalDue}` qui ajoute des frais en DECIMAL(7,2) ? La fonction `amount_credited` + à créer : même contrat que ses deux voisines (scoping, signe) ? + +⚠️ Pour toute affirmation qu'un code manque ou qu'une ligne dit autre chose, cite la sortie de +`grep -nF` ou de `sed -n` qui l'établit — sinon le finding ne sera pas retenu. + +## 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-b2-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p3.md new file mode 100644 index 000000000..37aca4414 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p3.md @@ -0,0 +1,75 @@ +# Prompt — validation P3, Story 25-4-b2 (le résiduel aux rappels) + +*Versionné le 2026-09-27. **Une lentille** (Opus), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b2-residuel-aux-rappels` (empilée sur la 25-4-b1, +PR #475). Fiche à valider : `_bmad-output/implementation-artifacts/25-4-b2-residuel-aux-rappels.md`. +Mère : `25-4-propager-le-residuel.md` (arbitrages de Guy, et ceux de la fiche Q1/Q2 : **ne pas les +contester**, en contester la mise en œuvre). Sœur : `25-4-b1-residuel-aux-agregats.md`. Issue : +`gh issue view 416`. Contexte : `24-2-encaissement-client.md`, les stories de l'Epic 21 sur les +rappels (`ls _bmad-output/implementation-artifacts/21-*`). Règles : `CLAUDE.md`. Checklist : +`.claude/skills/bmad-create-story/checklist.md`. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme. +2. **Inventaire des sites qui disent un montant au client à l'occasion d'un rappel** — pars du + symptôme, pas de la liste de la fiche : `grep -rn "total_due\|totalDue\|invoice_total_ttc\|total_ttc\|reminderFee\|fee_amount" 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 d'envoi ou d'impression d'un rappel (manuel, renvoi, lot, aperçu, + export, page d'accueil) ? +3. **La QR (AC 7)** : la référence et le message sont-ils vraiment indépendants du montant ? Le + montant de la QR d'un rappel peut-il différer du total imprimé sans que le PDF se contredise ? + Arrondi : `amount_due` est en DECIMAL(19,4), la QR exige 2 décimales — où arrondir, et que + devient un reste de 0.004 ? +4. **Les frais (AC 2, 3, 8)** : « frais cumulés » = autres niveaux + niveau courant — cohérent avec + `sum_fees_deduped_excluding` et avec ce que dit `{totalDue}` ? `{feeNotice}` : la liste blanche, + la validation des gabarits personnalisés existants (un gabarit enregistré avec `{reminderFee}` + reste-t-il valide ?), l'éditeur de gabarits du frontend liste-t-il les variables (où ?). +5. **Le refus du reste nul (AC 9)** : le pattern batch du `CLAUDE.md` est-il respecté par la forme + actuelle de la réponse du lot (lis-la) ? L'éligibilité (`dunning_eligibility.rs`) devrait-elle + exclure ces factures en amont plutôt ? +6. **Le PDF (AC 5, 6, 8)** : géométrie (réserve, gardes), clés i18n et test positionnel, patron de + surcharge de l'avoir — la locale utilisée (contact vs configuration) est-elle la bonne ? + L'archivage #387 est-il vraiment hors d'atteinte ? +7. **Les tests (AC 10)** : chacun est-il faisable et prouve-t-il ce qu'il annonce ? L'E2E : peut-on + monter un règlement partiel puis un aperçu de rappel depuis Playwright ? Mutations tuables ? +8. **Le manuel** : lignes citées, et **PDF aplati** + (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '`, idem `admin-manual.pdf`, + vers `/tmp/claude-1000/-home-gcorbaz-devel-kesh/4fb1e50e-3db4-4da6-9933-bcee4a186408/scratchpad/`). + D'autres textes (manuels de/en/it, api-external, README, website) disent-ils que le rappel ou sa + QR réclament le TTC ? +9. **Le périmètre** : cinq modules annoncés — recompte-les depuis les tâches. Quelque chose de la + b1 ou de la 25-4-c/d appartient-il ici, ou l'inverse ? + +10. **Les corrections des passes 1 et 2** (Change Log ; `git show ac38508f` et `git show 9c5c8a60`) : chacune est-elle juste, et a-t-elle introduit une contradiction ailleurs + dans la fiche ? En particulier : le refus classé en lot (AC 9) — `BatchItemError::failed` rend-il + bien la forme attendue par la réponse du lot ? La règle « un seul arrondi » (AC 7) est-elle + compatible avec `{totalDue}` qui ajoute des frais en DECIMAL(7,2) ? La fonction `amount_credited` + à créer : même contrat que ses deux voisines (scoping, signe) ? + +11. ⚠️ **La passe 2 a rendu 0 finding sans preuve** et a manqué un MEDIUM sur l'axe 8 : ne te fie + à aucune de ses conclusions. En particulier, refais les axes 2, 4, 6 et 7 comme s'ils n'avaient + jamais été exercés. Cherche aussi ce que la fiche **n'énumère pas** : les tests existants que le + changement de `{totalDue}`, des gabarits par défaut et du PDF joint va casser (grep des + assertions sur ces textes dans `crates/*/tests`, `crates/*/src` et `frontend/tests/e2e`) — + la fiche doit les nommer. + +⚠️ Pour toute affirmation qu'un code manque ou qu'une ligne dit autre chose, cite la sortie de +`grep -nF` ou de `sed -n` qui l'établit — sinon le finding ne sera pas retenu. + +## 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-b2-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p4.md new file mode 100644 index 000000000..9c6a910a3 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p4.md @@ -0,0 +1,43 @@ +# Prompt — validation P4 CIBLÉE, Story 25-4-b2 (le résiduel aux rappels) + +*Versionné le 2026-09-27. **Une lentille** (Sonnet), contexte frais, **passe ciblée** (§ *La passe +ciblée* du `CLAUDE.md`) : elle ne relit pas la story, elle relit **la dernière remédiation**.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b2-residuel-aux-rappels`. Fiche : +`_bmad-output/implementation-artifacts/25-4-b2-residuel-aux-rappels.md`. **Périmètre : le seul commit +`6197bffd`** (`git show 6197bffd`) — la remédiation de la validation P3, dont le rapport est résumé au +Change Log (entrée « Validation P3 »). Arbitrages de Guy : **ne pas les contester**. + +## Axes — tous obligatoires + +1. **Chaque phrase ajoutée ou modifiée par `6197bffd`** : chaque `fichier:ligne` cité existe et dit ce + que la fiche affirme (`sed -n`, `grep -nF`). Cite la sortie. +2. **Contradictions introduites** : une correction de P3 contredit-elle un autre passage de la fiche + (AC, tâches, Dev Notes, « Où regarder », tests de l'AC 10) ? En particulier : le retrait de la + ligne « avoir » a-t-il laissé des résidus (« quatre lignes », `amount_credited`, « avoir » dans un + AC ou un test) ? Le choix Rust-par-`Language` pour `{feeNotice}` contredit-il une mention de clés + Fluent ailleurs ? Le nom `rappel-{n}.pdf` est-il cohérent avec tous les tests cités ? +3. **Justesse des corrections** : la mention des frais sur une ligne à elle (AC 8) tient-elle dans la + réserve et dans la largeur (`pdf.rs`, `col_desc`, largeur utile de la page) ? Le variant ajouté à + `classify_render_error` : la forme proposée est-elle compatible avec le `match` actuel + (`invoice_email.rs:937-950`) et avec le test `:1557` ? Le variant `AppError::ReminderNothingDue` + et sa clé : le patron `DunningPaused` (`errors.rs`) est-il bien celui décrit ? +4. **Ce que la remédiation n'a pas traité** : les dix findings de P3 (M1-M5, L1-L5) sont-ils tous + traités ou explicitement renvoyés (M5 est en attente d'arbitrage, c'est voulu) ? +5. **#476** (`gh issue view 476`) dit-il ce que l'AC 7 lui fait dire ? + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact, **preuve** (commande et + résultat), correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un « 0 finding » sans + cette liste et sans commandes citées 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/edit/comment`. Autorisés : lecture, `grep`, `sed -n`, +`git log`/`show`/`diff`, `gh issue view`, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p5.md b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p5.md new file mode 100644 index 000000000..a87d4774e --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-b2-validate-prompt-p5.md @@ -0,0 +1,37 @@ +# Prompt — validation P5 CIBLÉE, Story 25-4-b2 (le résiduel aux rappels) + +*Versionné le 2026-09-27. **Une lentille** (Haiku), contexte frais, **passe ciblée** (§ *La passe +ciblée* du `CLAUDE.md`) : elle ne relit pas la story, elle relit **la dernière remédiation**.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-b2-residuel-aux-rappels`. Fiche : +`_bmad-output/implementation-artifacts/25-4-b2-residuel-aux-rappels.md`. **Périmètre : le seul commit +`d06b5c6c`** (`git show d06b5c6c`) — la remédiation de la validation P4, dont le rapport est résumé au +Change Log (entrée « Validation P4 ciblée »). Arbitrages de Guy : **ne pas les contester**. + +## Axes — tous obligatoires + +1. **Chaque chemin et chaque `fichier:ligne` ajouté ou modifié par `d06b5c6c`** existe et dit ce que + la fiche affirme — vérifie avec `sed -n 'p' ` et cite la sortie. +2. **Toutes les citations de fichier NU restantes de la fiche** (un nom de fichier sans chemin) : + liste-les (`grep -noE '\(`[a-z_]+\.(rs|ts|svelte)[:0-9, -]*`' `), et pour chacune + `find crates frontend -name ` — un nom présent dans plusieurs dossiers est ambigu. Dis + lesquels restent ambigus (MEDIUM si la ligne citée n'existe que dans l'un des fichiers). +3. **Contradictions introduites** par `d06b5c6c` avec le reste de la fiche. +4. Les liens Markdown `[#NNN]` de la fiche ont-ils tous une définition, et chaque définition est-elle + invoquée ? + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact, **preuve** (commande et + résultat), correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un « 0 finding » sans + cette liste et sans commandes citées 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/edit/comment`. Autorisés : lecture, `grep`, `sed -n`, +`git log`/`show`/`diff`, `gh issue view`, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 4876d9b9b..544e7e311 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -144,3 +144,7 @@ Pass 1 Opus 4.8 × 3 reviewers (Blind Hunter + Edge Case Hunter + Acceptance Aud - **La balance âgée déduit tous les règlements, quelle que soit `as_of`** (`crates/kesh-report/src/aged_receivables.rs`, `generate`). Depuis la 25-4-b1 le montant sommé est le reste dû, qui soustrait les règlements et avoirs **existants** sans comparer leur date à `as_of` : une balance « au jour J » passé serait sous-évaluée. **Différé** : la limite est écrite dans le doc-comment de `generate`, et la seule route appelante fixe `as_of` à aujourd'hui. À reprendre si une balance âgée historique (audit, clôture) est demandée. - **Trois tests préexistants de `invoice_echeancier_e2e.rs` déstructurent `seed_base` en `(company_id, admin_id)`** alors qu'elle rend `(admin_id, company_id)`. Ils passent parce que les deux valent 1 sur une base neuve. **Différé** : préexistant, relevé au Debug Log de la 25-4-b1 ; les deux tests hérités de la 25-4-a ont été rectifiés. + +## Deferred from: code review of 25-4-b2-residuel-aux-rappels (2026-09-27) + +- **Près du seuil de capacité, les conditions de paiement se clampent sur la dernière ligne du bloc de rappel** (`crates/kesh-qrbill/src/pdf.rs`, bloc `payment_terms`). `payment_terms` n'entre pas dans `recap_reserve` et se contente de `ty.max(content_floor + 5.0)` : au seuil, il se dessine à ~2 mm de la dernière ligne au-dessus. **Différé** : préexistant — sur une facture au seuil, il chevauchait déjà la ligne du total ; la 25-4-b2 réserve son propre bloc et reproduit la même géométrie sans l'aggraver. Le réserver lui aussi réduirait la capacité en lignes de toutes les factures portant des conditions de paiement : décision à prendre, pas correctif mécanique. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 439e699d8..3d6be1dd3 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -357,7 +357,7 @@ development_status: 25-3-annuler-reglement-et-rapprochement: split # [#414] [#418] — **l'Epic 24 a livre l'encaissement et le rapprochement ; il n'a PAS livre le moyen de les DEFAIRE.** Une erreur d'imputation est aujourd'hui incorrigible autrement qu'a la main — et le gel de la 24-4b interdit precisement la main. ⚠️ *C'est le prix d'une story qui ferme une porte sans ouvrir la suivante : le gel est juste, et il rend cette story-ci necessaire.* 25-4-a-residuel-juste: done # 2026-09-27 REVUE CLOSE EN 1 PASSE ; PR #472 OUVERTE (closes #455 #456), attend « merge ». # IMPLEMENTEE — 7/7 mutations tuees ; gates 2484/2484, 826/826, E2E 222/10/19 sans regression ; prochaine etape : code review. # 2026-09-26 [#455] [#456] — l'avoir soustrait HT d'un TTC (masque a l'ecran, mais rendu par GET /invoices/{id} aux cles API : amountDue = la TVA sur une facture creditee) ; un avoir accepte sur une facture partiellement reglee rend la creance creditrice. CREEE ; validation : P1 Sonnet 1H/3M/1L -> P2 Haiku 1M retenu -> P3 ciblee Opus 2M/5L -> P4 ciblee Sonnet 0 > LOW (1M reclasse LOW) : VALIDEE en 4 passes. Arbitrage 2026-09-27 : refus maintenu, levee tracee par #471. 25-4-b1-residuel-aux-agregats: done # 2026-09-27 revue CLOSE en 2 passes (2M/1L -> 0 >LOW), gates complets verts (2494, 834, E2E 225/8). # [#416, refs] balance agee + echeancier (totaux, colonne reste du, CSV, statut partiel, dialogue pre-rempli) + invariant balance agee = grand livre. CREEE ; validation P1 Sonnet 2M/3L -> P2 Haiku 0 (repris : 1M) -> P3 ciblee Opus 3M/3L : portee de l'invariant REECRITE PAR UNE REGLE ; issues #473 #474 ouvertes -> P4 1M -> P5 0 : VALIDEE en 5 passes. Signal MED->MED : pas de decoupage (Guy, 2026-09-27). Branche EMPILEE sur 25-4-a. - 25-4-b2-residuel-aux-rappels: backlog # [#416, closes] le rappel reclame le reste du (texte, QR, PDF montant initial / deja regle / reste), frais configurables masques a zero ; point ouvert : frais non comptabilises. + 25-4-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-d-solder-le-reste: backlog # [#384] ramenee de la 25-6 le 2026-09-27 (Guy) — perte sur debiteur, escompte, frais : clore une facture partiellement reglee ; la part TVA reduit la TVA due. diff --git a/crates/kesh-api/src/errors.rs b/crates/kesh-api/src/errors.rs index a93bc1f63..3b2b5723d 100644 --- a/crates/kesh-api/src/errors.rs +++ b/crates/kesh-api/src/errors.rs @@ -448,6 +448,17 @@ pub enum AppError { /// Story 21-5b — rappel sur une facture aux rappels suspendus. 422. #[error("Rappels suspendus pour cette facture")] DunningPaused, + /// Story 25-4-b2 (#416) — rappel d'une facture dont le reste dû, arrondi au + /// centime, est nul ou négatif (état hérité : trop-perçu d'avant la 0.12.1). + /// Refus NOMMÉ plutôt qu'un `INVOICE_NOT_PDF_READY` trompeur (la QR refuse + /// un montant ≤ 0). 422. + #[error("Rien à réclamer sur cette facture")] + ReminderNothingDue, + /// Story 25-4-b2 (#416) — les montants d'un rappel ont changé entre l'aperçu et + /// l'envoi (un règlement est arrivé) : le texte validé ne correspond plus à la + /// QR. Rien n'est envoyé ; rouvrir l'aperçu. 409. + #[error("Montants du rappel modifiés depuis l'aperçu")] + ReminderAmountsChanged, /// Story 21-5b — envoi d'un niveau de rappel > prochain attendu (saut interdit, /// ou niveau déjà couvert par un envoi concurrent). 409. #[error("Niveau de rappel déjà couvert")] @@ -1317,6 +1328,22 @@ impl IntoResponse for AppError { "Les rappels sont suspendus pour cette facture.", ), ), + AppError::ReminderAmountsChanged => build_response( + StatusCode::CONFLICT, + "REMINDER_AMOUNTS_CHANGED", + &t( + "error-reminder-amounts-changed", + "Le montant dû a changé depuis l'aperçu (un règlement est arrivé) : rouvrez l'aperçu avant d'envoyer.", + ), + ), + AppError::ReminderNothingDue => build_response( + StatusCode::UNPROCESSABLE_ENTITY, + "REMINDER_NOTHING_DUE", + &t( + "error-reminder-nothing-due", + "Il ne reste rien à réclamer sur cette facture : aucun rappel à envoyer.", + ), + ), AppError::LevelAlreadySent => build_response( StatusCode::CONFLICT, "LEVEL_ALREADY_SENT", diff --git a/crates/kesh-api/src/routes/credit_notes.rs b/crates/kesh-api/src/routes/credit_notes.rs index 83ccb3952..bc931807d 100644 --- a/crates/kesh-api/src/routes/credit_notes.rs +++ b/crates/kesh-api/src/routes/credit_notes.rs @@ -258,6 +258,8 @@ fn build_credit_note_pdf_data( total: ttc, currency: Currency::Chf, origin_reference, + // Un avoir n'est jamais un rappel (Story 25-4-b2). + reminder: None, } } diff --git a/crates/kesh-api/src/routes/invoice_email.rs b/crates/kesh-api/src/routes/invoice_email.rs index 68e4bde89..5b5c852ce 100644 --- a/crates/kesh-api/src/routes/invoice_email.rs +++ b/crates/kesh-api/src/routes/invoice_email.rs @@ -205,10 +205,34 @@ pub(crate) fn days_overdue(due_date: Option, today: chrono::N due_date.map(|d| (today - d).num_days().max(0)).unwrap_or(0) } +/// `{feeNotice}` (Story 25-4-b2, #416) : la phrase qui dit les frais **cumulés** +/// compris dans `{totalDue}`, **précédée d'une espace** — elle se colle à la fin de +/// la phrase du montant dans les gabarits par défaut. **Vide** quand il n'y a pas +/// de frais : le moteur de gabarits n'a pas de condition, c'est la seule façon de +/// ne rien afficher à zéro (arbitrage de Guy : « si 0, ne rien afficher »). +/// +/// En Rust indexé par `Language`, comme [`salutation_line`] : le builder est pur +/// et n'a pas le bundle Fluent. +fn fee_notice(fees: &rust_decimal::Decimal, language: Language) -> String { + if *fees <= rust_decimal::Decimal::ZERO { + return String::new(); + } + let amount = format_money(fees); + match language { + Language::Fr => format!(" Ce montant comprend des frais de rappel de {amount}."), + Language::De => format!(" Dieser Betrag enthält Mahngebühren von {amount}."), + Language::It => format!(" Questo importo comprende spese di sollecito di {amount}."), + Language::En => format!(" This amount includes reminder fees of {amount}."), + } +} + /// Variables de substitution pour un rappel débiteur (Story 21-5b) : les 6 variables -/// de base de [`build_invoice_vars`] (`amount` = TTC facture) + les 4 spécifiques rappel. -/// `total_due`/`reminder_fee`/`days_overdue`/`level_number` sont **pré-calculés** par -/// l'appelant (le builder reste pur, sans accès DB). +/// de base de [`build_invoice_vars`] (`amount` = TTC facture) + les 5 spécifiques rappel. +/// `total_due`/`reminder_fee`/`fees_total`/`days_overdue`/`level_number` sont +/// **pré-calculés** par l'appelant (le builder reste pur, sans accès DB). +/// +/// ⛔ Story 25-4-b2 : `total_due` est le **reste dû** plus les frais cumulés, pas le +/// TTC ; `{amount}` reste le TTC de la facture. #[allow(clippy::too_many_arguments)] pub(crate) fn build_reminder_vars( invoice: &kesh_db::entities::Invoice, @@ -218,12 +242,14 @@ pub(crate) fn build_reminder_vars( language: Language, level_number: i16, reminder_fee: &rust_decimal::Decimal, + fees_total: &rust_decimal::Decimal, total_due: &rust_decimal::Decimal, days_overdue: i64, ) -> HashMap { let mut vars = build_invoice_vars(invoice, lines, contact, company, language); vars.insert("reminderLevel".to_string(), level_number.to_string()); vars.insert("reminderFee".to_string(), format_money(reminder_fee)); + vars.insert("feeNotice".to_string(), fee_notice(fees_total, language)); vars.insert("totalDue".to_string(), format_money(total_due)); vars.insert("daysOverdue".to_string(), days_overdue.to_string()); vars @@ -282,6 +308,12 @@ pub struct ReminderPreviewResponse { pub level: i16, pub subject: String, pub body: String, + /// Story 25-4-b2 (#416) — le reste dû (arrondi) et les frais cumulés sur + /// lesquels le texte a été rendu. Le client les renvoie à l'envoi : s'ils ont + /// changé entre-temps (un règlement est arrivé), l'envoi est refusé — sans quoi + /// le courrier annoncerait l'ancien reste et la QR le nouveau. + pub amount_due: rust_decimal::Decimal, + pub fees: rust_decimal::Decimal, } /// Corps de l'envoi unitaire d'un rappel (Story 21-5b). **PAS de champ `to`** @@ -292,6 +324,15 @@ pub struct SendReminderRequest { pub level_number: i16, pub subject: String, pub body: String, + /// Story 25-4-b2 (#416) — les montants de l'aperçu (`amountDue`, `fees`). Présents, + /// ils sont comparés aux montants recalculés à l'envoi : un écart → + /// `409 REMINDER_AMOUNTS_CHANGED`, rien n'est envoyé. Absents (client d'API qui + /// ne passe pas par l'aperçu), aucune comparaison — le texte est alors de la + /// seule responsabilité de l'appelant. + #[serde(default)] + pub expected_amount_due: Option, + #[serde(default)] + pub expected_fees: Option, } /// Prochain niveau de rappel à envoyer = plus petit `level_number > current_level` @@ -307,9 +348,13 @@ fn next_reminder_level( .min() } -/// Calcule `{totalDue}`, `{reminderFee}`, `{daysOverdue}` puis rend subject+body d'un -/// rappel de niveau `level` pour la facture (helper partagé preview/preview-lot). -/// `total_due` = TTC + Σ frais non-annulés dédupliqués (hors niveau courant) + frais du niveau. +/// Calcule `{totalDue}`, `{reminderFee}`, `{feeNotice}`, `{daysOverdue}` puis rend +/// subject+body d'un rappel de niveau `level` pour la facture (helper partagé +/// aperçu / lot). Rend aussi les **montants** : le lot les passe au PDF, si bien +/// que le texte et la QR ne peuvent pas diverger. +/// +/// `total_due` = **reste dû** (arrondi au centime) + frais cumulés — Story 25-4-b2 +/// (#416) ; avant, le TTC. Reste ≤ 0 → `AppError::ReminderNothingDue`. #[allow(clippy::too_many_arguments)] async fn render_reminder( state: &AppState, @@ -320,18 +365,16 @@ async fn render_reminder( language: Language, level_number: i16, level_fee: &rust_decimal::Decimal, -) -> Result<(String, String), AppError> { - let ttc = kesh_core::accounting::vat::invoice_total_ttc( - lines.iter().map(|l| (l.line_total, l.vat_rate)), - ); - let other_fees = invoice_reminders::sum_fees_deduped_excluding( +) -> Result<(String, String, invoice_pdf_service::ReminderAmounts), AppError> { + let amounts = invoice_pdf_service::reminder_amounts( &state.pool, company.id, invoice.id, level_number, + *level_fee, ) .await?; - let total_due = ttc + other_fees + *level_fee; + let total_due = amounts.amount_due + amounts.fees; let today = chrono::Utc::now().naive_utc().date(); let days = days_overdue(invoice.due_date, today); @@ -351,12 +394,13 @@ async fn render_reminder( language, level_number, level_fee, + &amounts.fees, &total_due, days, ); let subject = kesh_core::email_template_engine::render(&template.subject, &vars); let body = kesh_core::email_template_engine::render(&template.body, &vars); - Ok((subject, body)) + Ok((subject, body, amounts)) } /// `GET /api/v1/invoices/{id}/reminder-preview?level=N` — Comptable+ (Story 21-5b). @@ -382,7 +426,7 @@ pub async fn preview_reminder_email( let contact = load_active_contact(&state.pool, invoice.contact_id, company.id).await?; let language = resolve_language(&contact, &company); - let (subject, body) = render_reminder( + let (subject, body, amounts) = render_reminder( &state, &company, &invoice, @@ -400,6 +444,8 @@ pub async fn preview_reminder_email( level: q.level, subject, body, + amount_due: amounts.amount_due, + fees: amounts.fees, })) } @@ -496,8 +542,27 @@ pub async fn send_reminder( validate_text_len(&body, REMINDER_BODY_MAX, "corps")?; let language = resolve_language(&contact, &company); let locale = kesh_i18n::Locale::from(language.as_str()); - let rendered = - invoice_pdf_service::render(&state.pool, &state.i18n, locale, &company, id).await?; + // Story 25-4-b2 (#416) : le texte vient de l'aperçu (déjà édité), mais le PDF + // est un RAPPEL dont la QR porte le reste dû — calculé ICI, avant le SMTP, ce + // qui refuse aussi un reste nul (`ReminderNothingDue`) avant tout envoi. + let amounts = invoice_pdf_service::reminder_amounts( + &state.pool, + company.id, + id, + req.level_number, + level.fee_amount, + ) + .await?; + check_amounts_unchanged(&amounts, req.expected_amount_due, req.expected_fees)?; + let rendered = invoice_pdf_service::render_document( + &state.pool, + &state.i18n, + locale, + &company, + id, + invoice_pdf_service::PdfDocument::Reminder(amounts), + ) + .await?; let email = OutgoingEmail { to: to.clone(), @@ -506,7 +571,7 @@ pub async fn send_reminder( from_display_name: Some(company.name.clone()), reply_to: company.email.clone(), attachment: Some(EmailAttachment { - filename: format!("facture-{}.pdf", rendered.filename_base), + filename: format!("rappel-{}.pdf", rendered.filename_base), content_type: "application/pdf".to_string(), bytes: rendered.bytes, }), @@ -944,10 +1009,42 @@ fn classify_render_error(e: AppError, invoice_id: i64) -> BatchItemError { AppError::InvoiceNotPdfReady(_) | AppError::InvoiceTooManyLinesForPdf(_) | AppError::InvoicePdfHeaderOverflow => BatchItemError::failed("INVOICE_NOT_PDF_READY"), + // Story 25-4-b2 (#416) — reste dû nul : un refus métier, pas une panne. + AppError::ReminderNothingDue => BatchItemError::failed("REMINDER_NOTHING_DUE"), other => BatchItemError::infra("render pdf", invoice_id, other), } } +/// Refuse l'envoi unitaire si les montants ont changé depuis l'aperçu (revue de +/// code 25-4-b2, P1 — HIGH). Le texte envoyé vient de l'aperçu, la QR des montants +/// recalculés : sans cette garde, un règlement arrivé entre les deux faisait +/// annoncer au courrier l'ancien reste et à la QR le nouveau. Comparaison de +/// VALEUR (`Decimal` : 68.1 = 68.1000). Un attendu absent n'est pas comparé. +fn check_amounts_unchanged( + amounts: &invoice_pdf_service::ReminderAmounts, + expected_amount_due: Option, + expected_fees: Option, +) -> Result<(), AppError> { + let changed = expected_amount_due.is_some_and(|e| e != amounts.amount_due) + || expected_fees.is_some_and(|e| e != amounts.fees); + if changed { + return Err(AppError::ReminderAmountsChanged); + } + Ok(()) +} + +/// Classe une erreur de [`render_reminder`] pour la boucle du lot (Story 25-4-b2, +/// #416). Même règle que [`classify_render_error`] : le refus métier est énuméré, +/// le bras final est réservé aux pannes. ⛔ Avant cette story, TOUTE erreur de +/// `render_reminder` partait en `infra` — le refus du reste nul y serait ressorti +/// en `DATABASE_ERROR`, avec une fausse alerte d'infrastructure. +fn classify_reminder_render_error(e: AppError, invoice_id: i64) -> BatchItemError { + match e { + AppError::ReminderNothingDue => BatchItemError::failed("REMINDER_NOTHING_DUE"), + other => BatchItemError::infra("render reminder", invoice_id, other), + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SendReminderBatchResponse { @@ -1111,11 +1208,11 @@ async fn send_one_batch_reminder( .ok_or_else(|| BatchItemError::failed("CONTACT_EMAIL_MISSING"))?; let language = resolve_language(&contact, company); - let (subject, body) = render_reminder( + let (subject, body, amounts) = render_reminder( state, company, &invoice, &lines, &contact, language, next_level, &level_fee, ) .await - .map_err(|e| BatchItemError::infra("render reminder", invoice_id, e))?; + .map_err(|e| classify_reminder_render_error(e, invoice_id))?; if subject.trim().is_empty() || body.trim().is_empty() { return Err(BatchItemError::failed("REMINDER_CONTENT_EMPTY")); } @@ -1137,10 +1234,18 @@ async fn send_one_batch_reminder( // toutes les écraser sur `INVOICE_NOT_PDF_READY` (AC 12 énumère les codes // séparément). Le bras final ne fourre PAS les pannes d'infra dans un code // métier (review Pass 2) : les causes métier sont énumérées explicitement. - let rendered = - invoice_pdf_service::render(&state.pool, &state.i18n, locale, company, invoice_id) - .await - .map_err(|e| classify_render_error(e, invoice_id))?; + // Story 25-4-b2 : le PDF reçoit les montants DU TEXTE — texte et QR ne + // peuvent pas diverger, même si un règlement arrive entre-temps. + let rendered = invoice_pdf_service::render_document( + &state.pool, + &state.i18n, + locale, + company, + invoice_id, + invoice_pdf_service::PdfDocument::Reminder(amounts), + ) + .await + .map_err(|e| classify_render_error(e, invoice_id))?; // Rate-limit : consomme 1 slot par e-mail (le pré-check a garanti la capacité, mais un // envoi concurrent peut avoir consommé entre-temps → RATE_LIMITED per-facture). @@ -1161,7 +1266,7 @@ async fn send_one_batch_reminder( from_display_name: Some(company.name.clone()), reply_to: company.email.clone(), attachment: Some(EmailAttachment { - filename: format!("facture-{}.pdf", rendered.filename_base), + filename: format!("rappel-{}.pdf", rendered.filename_base), content_type: "application/pdf".to_string(), bytes: rendered.bytes, }), @@ -1491,7 +1596,7 @@ mod tests { } #[test] - fn reminder_vars_ajoute_les_4_variables_rappel() { + fn reminder_vars_ajoute_les_variables_rappel() { let invoice = sample_invoice(Some("F-2026-0042"), NaiveDate::from_ymd_opt(2026, 6, 30)); let vars = build_reminder_vars( &invoice, @@ -1501,10 +1606,13 @@ mod tests { Language::Fr, 2, &Decimal::new(2000, 2), // 20.00 (frais niveau 2) + &Decimal::new(2500, 2), // 25.00 (frais cumulés) &Decimal::new(135456, 2), // 1354.56 (total dû) 45, ); - // 10 variables = les 6 base + 4 rappel (allowed_variables InvoiceReminder). + // 11 variables = les 6 base + 5 rappel (allowed_variables InvoiceReminder) — + // dont `feeNotice` (Story 25-4-b2) : la liste blanche et le builder vont + // ensemble, ce test les tient d'accord. let mut keys: Vec<&str> = vars.keys().map(String::as_str).collect(); keys.sort_unstable(); let mut expected = EmailTemplateType::InvoiceReminder @@ -1517,6 +1625,64 @@ mod tests { assert_eq!(vars["totalDue"], "1\u{2019}354.56", "apostrophe U+2019"); assert_eq!(vars["daysOverdue"], "45", "entier brut, pas format_money"); assert_eq!(vars["amount"], "1\u{2019}334.56", "TTC facture inchangé"); + assert_eq!( + vars["feeNotice"], " Ce montant comprend des frais de rappel de 25.00.", + "les frais CUMULÉS, pas ceux du seul niveau" + ); + } + + /// Story 25-4-b2 (AC 2, 3) — `{feeNotice}` est VIDE sans frais, dans les 4 + /// langues, et dit le montant sinon : « à zéro, rien ne s'affiche ». + #[test] + fn fee_notice_est_vide_sans_frais() { + for language in [Language::Fr, Language::De, Language::It, Language::En] { + assert_eq!(fee_notice(&Decimal::ZERO, language), "", "{language:?}"); + let notice = fee_notice(&Decimal::new(2000, 2), language); + assert!( + notice.starts_with(' '), + "{language:?} : colle à la phrase précédente" + ); + assert!(notice.contains("20.00"), "{language:?} : {notice}"); + } + } + + /// Revue de code 25-4-b2, P1 (HIGH) — l'envoi unitaire refuse si les montants + /// ont changé depuis l'aperçu ; la comparaison porte sur la VALEUR, et un + /// attendu absent n'est pas comparé. + #[test] + fn check_amounts_unchanged_compare_des_valeurs() { + let amounts = invoice_pdf_service::ReminderAmounts { + amount_settled: Decimal::new(4000, 2), + amount_due: Decimal::new(6810, 2), + fees: Decimal::new(2000, 2), + }; + let v = |s: &str| Some(s.parse::().unwrap()); + assert!(check_amounts_unchanged(&amounts, v("68.1000"), v("20")).is_ok()); + assert!(check_amounts_unchanged(&amounts, None, None).is_ok()); + assert!(matches!( + check_amounts_unchanged(&amounts, v("108.10"), v("20.00")), + Err(AppError::ReminderAmountsChanged) + )); + assert!(matches!( + check_amounts_unchanged(&amounts, v("68.10"), v("40.00")), + Err(AppError::ReminderAmountsChanged) + )); + } + + /// Story 25-4-b2 (AC 9) — ⛔ en lot, le refus du reste nul est un échec MÉTIER + /// par facture ; avant la story, toute erreur de `render_reminder` partait en + /// `DATABASE_ERROR`. Symétrique de `classify_render_error_ne_deguise_pas_un_refus_en_panne`. + #[test] + fn classify_reminder_render_error_ne_deguise_pas_le_reste_nul_en_panne() { + assert_eq!( + classify_reminder_render_error(AppError::ReminderNothingDue, 1).code(), + "REMINDER_NOTHING_DUE" + ); + assert_eq!( + classify_reminder_render_error(AppError::Internal("pool fermé".into()), 1).code(), + "DATABASE_ERROR", + "une panne d'infrastructure doit rester détectée comme telle" + ); } #[test] @@ -1567,6 +1733,8 @@ mod tests { "INVOICE_NOT_PDF_READY", ), (AppError::InvoicePdfHeaderOverflow, "INVOICE_NOT_PDF_READY"), + // Story 25-4-b2 (#416) — second site du lot : le rendu du PDF de rappel. + (AppError::ReminderNothingDue, "REMINDER_NOTHING_DUE"), ]; for (err, attendu) in metier { let libelle = format!("{err:?}"); diff --git a/crates/kesh-api/src/routes/invoice_pdf_service.rs b/crates/kesh-api/src/routes/invoice_pdf_service.rs index 9cd5aeacb..04c75eae2 100644 --- a/crates/kesh-api/src/routes/invoice_pdf_service.rs +++ b/crates/kesh-api/src/routes/invoice_pdf_service.rs @@ -15,14 +15,16 @@ use kesh_db::entities::{BankAccount, Company, Invoice, InvoiceLine, contact::Contact}; use kesh_db::errors::DbError; -use kesh_db::repositories::{bank_accounts, contacts, invoices}; +use kesh_db::repositories::{ + bank_accounts, contacts, invoice_reminders, invoice_settlements, invoices, +}; use kesh_i18n::Locale; use kesh_qrbill::{ Address, AddressType, Currency, InvoiceLinePdf, InvoicePdfData, InvoiceVatLinePdf, QrBillData, - QrBillError, QrBillI18n, Reference, + QrBillError, QrBillI18n, Reference, ReminderPdf, validation::{build_qrr, normalize_iban}, }; -use rust_decimal::Decimal; +use rust_decimal::{Decimal, RoundingStrategy}; use std::collections::HashMap; use crate::errors::AppError; @@ -74,6 +76,91 @@ pub struct RenderedInvoicePdf { pub filename_base: String, } +/// Le document à produire (Story 25-4-b2, #416) : la facture, ou un **rappel** +/// pour le montant restant. Un paramètre qui dit ce qu'il porte, plutôt qu'un +/// booléen : `render_document(…, PdfDocument::Invoice)` se relit. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PdfDocument { + /// La facture : titre « Facture », QR au TTC — inchangée (téléchargement, + /// envoi et renvoi de facture). + Invoice, + /// Un rappel : titre « Rappel », bloc réglé / reste / frais, QR au **reste + /// dû**. Les montants sont **déjà calculés** par [`reminder_amounts`] — la + /// construction des entrées QR reste testable sans base. + Reminder(ReminderAmounts), +} + +/// Les montants d'un rappel, calculés **une fois** et partagés par le texte +/// (`{totalDue}`), le PDF et la QR (Story 25-4-b2, AC 7). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ReminderAmounts { + /// Total des règlements enregistrés. + pub amount_settled: Decimal, + /// Reste dû **arrondi au centime** (`MidpointAwayFromZero`), strictement + /// positif — le montant de la QR. + pub amount_due: Decimal, + /// Frais cumulés : ceux des niveaux déjà émis (dédupliqués, hors niveau + /// courant) plus ceux du niveau envoyé. ⛔ Jamais dans la QR : ils ne sont pas + /// comptabilisés (#401), un virement qui les inclurait serait refusé. + pub fees: Decimal, +} + +/// Arrondit un reste dû brut au centime et refuse ce qui ne se réclame pas +/// (Story 25-4-b2, AC 7 et 9). Le refus porte sur la valeur **arrondie** : un +/// reste de 0.004 est refusé ici, au lieu d'atteindre une QR invalide et de +/// ressortir en `INVOICE_NOT_PDF_READY`. +pub fn reminder_amount_due(raw: Decimal) -> Result { + let due = raw.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero); + if due <= Decimal::ZERO { + return Err(AppError::ReminderNothingDue); + } + Ok(due) +} + +/// Calcule les montants d'un rappel de niveau `level_number` (Story 25-4-b2). +/// +/// Le reste dû vient d'`invoice_settlements::amount_due` (forme scalaire : une +/// facture à la fois) — jamais réécrit. ⚠️ `amount_due` et `amount_settled` ne +/// prennent pas de `company_id` : l'appelant DOIT avoir chargé la facture par +/// `find_by_id_with_lines(pool, company.id, …)`, qui porte le scoping. +/// +/// ⛔ Les trois lectures se font dans UNE transaction (revue de code 25-4-b2, P1) : +/// sous REPEATABLE READ, elles voient le même instantané. Lues séparément, un +/// règlement inséré entre deux d'entre elles rendait « déjà réglé » et « reste à +/// payer » incohérents entre eux, et la QR pouvait réclamer un reste d'avant le +/// paiement. +pub async fn reminder_amounts( + pool: &sqlx::MySqlPool, + company_id: i64, + invoice_id: i64, + level_number: i16, + level_fee: Decimal, +) -> Result { + let mut tx = pool + .begin() + .await + .map_err(|e| AppError::Internal(format!("begin tx: {e}")))?; + let raw_due = invoice_settlements::amount_due(&mut *tx, invoice_id).await?; + let amount_settled = invoice_settlements::amount_settled(&mut *tx, invoice_id).await?; + let other_fees = invoice_reminders::sum_fees_deduped_excluding( + &mut *tx, + company_id, + invoice_id, + level_number, + ) + .await?; + // Lecture seule : rien à valider, la transaction ne sert qu'à l'instantané. + tx.rollback() + .await + .map_err(|e| AppError::Internal(format!("rollback tx: {e}")))?; + let amount_due = reminder_amount_due(raw_due)?; + Ok(ReminderAmounts { + amount_settled, + amount_due, + fees: other_fees + level_fee, + }) +} + /// Génère le PDF QR-facture d'une facture **validée**, scopée à /// `company` (anti-IDOR). /// @@ -93,6 +180,28 @@ pub async fn render( locale: Locale, company: &Company, invoice_id: i64, +) -> Result { + render_document( + pool, + i18n, + locale, + company, + invoice_id, + PdfDocument::Invoice, + ) + .await +} + +/// [`render`], pour un document au choix : la facture, ou un rappel (Story +/// 25-4-b2). Le titre du rappel est résolu dans `locale` — celle du contact +/// pour un envoi —, pas dans celle de l'installation. +pub async fn render_document( + pool: &sqlx::MySqlPool, + i18n: &kesh_i18n::I18nBundle, + locale: Locale, + company: &Company, + invoice_id: i64, + document: PdfDocument, ) -> Result { // Chargement facture + lignes (scopé company). let (invoice, lines) = invoices::find_by_id_with_lines(pool, company.id, invoice_id) @@ -143,6 +252,7 @@ pub async fn render( &primary_bank, &creditor_country, &debtor_country, + document, )?; let qr_i18n = build_i18n(i18n, locale); @@ -159,6 +269,7 @@ pub async fn render( } /// Convertit les entités DB en `QrBillData` + `InvoicePdfData`. +#[allow(clippy::too_many_arguments)] fn build_qrbill_inputs( invoice: &Invoice, lines: &[InvoiceLine], @@ -167,6 +278,7 @@ fn build_qrbill_inputs( primary_bank: &BankAccount, creditor_country: &str, debtor_country: &str, + document: PdfDocument, ) -> Result<(QrBillData, InvoicePdfData), AppError> { // Adresse créancier — STRUCTURÉE type S (#213, conformité SIX 21.11.2025). let ca = company.structured_address(); @@ -236,11 +348,31 @@ fn build_qrbill_inputs( lines.iter().map(|l| (l.line_total, l.vat_rate)), ); + // Story 25-4-b2 (#416, AC 7) : la QR d'un rappel porte le RESTE DÛ — la même + // valeur, déjà arrondie, que le texte et le bloc imprimé. ⛔ La référence et + // le message ci-dessous ne dépendent PAS du document : c'est ce que lit le + // rapprochement. + let (qr_amount, reminder) = match document { + PdfDocument::Invoice => (total_ttc, None), + PdfDocument::Reminder(a) => { + // Défense : `reminder_amounts` ne produit jamais un reste ≤ 0. + let due = reminder_amount_due(a.amount_due)?; + ( + due, + Some(ReminderPdf { + amount_settled: a.amount_settled, + amount_due: due, + fees: a.fees, + }), + ) + } + }; + let qr_data = QrBillData { creditor_iban: iban, creditor: creditor.clone(), ultimate_debtor: Some(debtor.clone()), - amount: Some(total_ttc), + amount: Some(qr_amount), currency: Currency::Chf, reference, unstructured_message: invoice.invoice_number.as_ref().map(|n| { @@ -301,6 +433,7 @@ fn build_qrbill_inputs( total: total_ttc, currency: Currency::Chf, origin_reference: None, + reminder, }; Ok((qr_data, pdf_data)) @@ -561,6 +694,7 @@ mod tests { &primary_bank(), "CH", "CH", + PdfDocument::Invoice, ) .expect("le montage doit produire un PDF exploitable"); @@ -600,6 +734,7 @@ mod tests { &primary_bank(), "CH", "CH", + PdfDocument::Invoice, ) .expect("le montage doit produire un PDF exploitable"); @@ -627,6 +762,7 @@ mod tests { &primary_bank(), "CH", "CH", + PdfDocument::Invoice, ) .expect("le montage doit produire un PDF exploitable"); @@ -648,9 +784,173 @@ mod tests { &primary_bank(), "CH", "CH", + PdfDocument::Invoice, ) .expect("le montage doit produire un PDF exploitable"); assert!(data.debtor_client_number.is_none()); } + + // ─── Story 25-4-b2 (#416) — le PDF de rappel ────────────────────────── + + /// Une ligne à TVA non nulle : 1 000.— HT à 8,1 % = 1 081.— TTC. + fn lines_1081() -> Vec { + vec![kesh_db::entities::InvoiceLine { + id: 1, + invoice_id: 1, + position: 1, + description: "Prestation".into(), + quantity: dec!(1), + unit_price: dec!(1000.00), + vat_rate: dec!(8.10), + line_total: dec!(1000.00), + revenue_account_id: None, + created_at: chrono::NaiveDateTime::default(), + }] + } + + fn qr_bank() -> kesh_db::entities::BankAccount { + kesh_db::entities::BankAccount { + qr_iban: Some("CH4431999123000889012".into()), + ..primary_bank() + } + } + + fn inputs( + bank: &kesh_db::entities::BankAccount, + document: PdfDocument, + ) -> (QrBillData, InvoicePdfData) { + build_qrbill_inputs( + &invoice(), + &lines_1081(), + &contact_with_structured_address(), + &company_with_contact_details(), + bank, + "CH", + "CH", + document, + ) + .expect("montage exploitable") + } + + /// AC 7 — ⛔ la QR du rappel d'une facture réglée en partie porte le RESTE + /// DÛ ; la référence (QRR ou aucune) et le message sont ceux de la facture, + /// à l'identique — c'est ce que lit le rapprochement. Les frais n'y sont pas. + #[test] + fn reminder_qr_carries_the_amount_due_and_keeps_the_reference() { + let amounts = ReminderAmounts { + amount_settled: dec!(900.00), + amount_due: dec!(181.00), + fees: dec!(20.00), + }; + for bank in [primary_bank(), qr_bank()] { + let (facture_qr, facture_pdf) = inputs(&bank, PdfDocument::Invoice); + let (rappel_qr, rappel_pdf) = inputs(&bank, PdfDocument::Reminder(amounts)); + + assert_eq!( + facture_qr.amount, + Some(dec!(1081.00)), + "la facture reste au TTC" + ); + assert_eq!( + rappel_qr.amount, + Some(dec!(181.00)), + "le rappel au reste, sans frais" + ); + assert_eq!( + rappel_qr.reference, facture_qr.reference, + "référence identique" + ); + assert_eq!( + rappel_qr.unstructured_message, facture_qr.unstructured_message, + "message identique" + ); + assert!( + facture_pdf.reminder.is_none(), + "la facture n'est pas un rappel" + ); + let r = rappel_pdf.reminder.expect("le rappel porte son bloc"); + assert_eq!(r.amount_due, dec!(181.00), "même valeur que la QR"); + assert_eq!(r.amount_settled, dec!(900.00)); + assert_eq!(r.fees, dec!(20.00)); + assert_eq!( + rappel_pdf.total, + dec!(1081.00), + "le total imprimé reste le TTC" + ); + } + // Anti-vacuité : la branche QR-IBAN porte bien une QRR. + assert!(matches!( + inputs(&qr_bank(), PdfDocument::Invoice).0.reference, + Reference::Qrr(_) + )); + } + + /// AC 7 et 9 — un seul arrondi, au centime, loin de zéro ; le refus porte sur + /// la valeur ARRONDIE. + #[test] + fn reminder_amount_due_rounds_once_and_refuses_nothing_due() { + assert_eq!(reminder_amount_due(dec!(181.0000)).unwrap(), dec!(181.00)); + assert_eq!(reminder_amount_due(dec!(10.0050)).unwrap(), dec!(10.01)); + for rien in [dec!(0), dec!(0.0040), dec!(-40.00)] { + assert!( + matches!(reminder_amount_due(rien), Err(AppError::ReminderNothingDue)), + "{rien} ne se réclame pas" + ); + } + // Défense : des montants de rappel à reste nul ne passent pas non plus + // au montage des entrées QR. + let nul = ReminderAmounts { + amount_settled: dec!(1081.00), + amount_due: dec!(0.00), + fees: dec!(0), + }; + assert!(matches!( + build_qrbill_inputs( + &invoice(), + &lines_1081(), + &contact_with_structured_address(), + &company_with_contact_details(), + &primary_bank(), + "CH", + "CH", + PdfDocument::Reminder(nul), + ), + Err(AppError::ReminderNothingDue) + )); + } + + /// AC 8 — ⚠️ la mention des frais court sur toute la largeur utile ; les + /// gardes du PDF ne surveillent que l'ordonnée. Sa traduction, dans les 4 + /// locales réelles, tient dans `REMINDER_NOTE_MAX_CHARS` — l'allemand est le + /// plus long. + #[test] + fn reminder_fees_note_fits_its_width_in_all_four_locales() { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../kesh-i18n/locales"); + let bundle = kesh_i18n::I18nBundle::load(&dir).unwrap(); + for locale in [Locale::FrCh, Locale::DeCh, Locale::ItCh, Locale::EnCh] { + let note = build_i18n(&bundle, locale) + .get("invoice-pdf-reminder-fees-note") + .to_string(); + assert!( + note.chars().count() <= kesh_qrbill::REMINDER_NOTE_MAX_CHARS, + "{locale:?} : « {note} » dépasse {} caractères", + kesh_qrbill::REMINDER_NOTE_MAX_CHARS + ); + // Revue de code 25-4-b2, P1 — les libellés COURTS, dans leur colonne de + // 50 mm : la troncature au dessin est une défense, pas une mise en page. + for key in [ + "invoice-pdf-settled", + "invoice-pdf-amount-due", + "invoice-pdf-reminder-fees", + ] { + let label = build_i18n(&bundle, locale).get(key).to_string(); + assert!( + label.chars().count() <= kesh_qrbill::REMINDER_LABEL_MAX_CHARS, + "{locale:?} : « {label} » dépasse {} caractères", + kesh_qrbill::REMINDER_LABEL_MAX_CHARS + ); + } + } + } } diff --git a/crates/kesh-api/tests/invoice_send_email_e2e.rs b/crates/kesh-api/tests/invoice_send_email_e2e.rs index 67f443f78..fce37a43a 100644 --- a/crates/kesh-api/tests/invoice_send_email_e2e.rs +++ b/crates/kesh-api/tests/invoice_send_email_e2e.rs @@ -2104,3 +2104,234 @@ async fn send_reminder_batch_partial_success(pool: MySqlPool) { "BATCH_TOO_LARGE" ); } + +// ─── Story 25-4-b2 (#416) — le rappel réclame le reste dû ───────────────────── + +/// Règle `amount` sur la facture par compte interne (caisse 1000), à une date +/// postérieure à la facture du montage (14.04.2026). +async fn settle( + pool: &MySqlPool, + admin_id: i64, + company_id: i64, + invoice_id: i64, + amount: rust_decimal::Decimal, +) { + let (cash,): (i64,) = + sqlx::query_as("SELECT id FROM accounts WHERE company_id = ? AND number = '1000'") + .bind(company_id) + .fetch_one(pool) + .await + .unwrap(); + kesh_db::repositories::invoice_settlements_write::settle_invoice( + pool, + admin_id, + company_id, + invoice_id, + kesh_db::entities::SettlementChoice::InternalAccount { account_id: cash }, + amount, + NaiveDate::from_ymd_opt(2026, 5, 20).unwrap(), + ) + .await + .expect("règlement"); +} + +/// AC 1, 2, 10 — l'aperçu d'un rappel sur une facture RÉGLÉE EN PARTIE réclame +/// le reste dû plus les frais : 108.10 réglée de 40 → reste 68.10 ; niveau 2 +/// (frais 20.00) → `{totalDue}` = 88.10, et non 128.10 (TTC + frais). Niveau 1 +/// (frais nuls) : 68.10, et aucune phrase de frais. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn reminder_preview_claims_the_amount_due(pool: MySqlPool) { + let (admin_id, company_id, invoice_id) = seed_sendable(&pool).await; + seed_dunning(&pool, company_id).await; + settle(&pool, admin_id, company_id, invoice_id, dec!(40.00)).await; + let app = spawn_app(pool.clone(), MockMailer::new(), true, 20).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + let preview = |level: i16| { + app.client + .get(app.url(&format!( + "/api/v1/invoices/{invoice_id}/reminder-preview?level={level}" + ))) + .bearer_auth(&token) + .send() + }; + let body: serde_json::Value = preview(2).await.unwrap().json().await.unwrap(); + let text = body["body"].as_str().unwrap(); + assert!(text.contains("88.10"), "reste 68.10 + frais 20.00 : {text}"); + assert!(!text.contains("128.10"), "plus le TTC + frais : {text}"); + assert!( + text.contains("108.10"), + "{{amount}} reste le TTC de la facture : {text}" + ); + assert!( + text.contains("frais de rappel de 20.00"), + "{{feeNotice}} dit les frais : {text}" + ); + + let body: serde_json::Value = preview(1).await.unwrap().json().await.unwrap(); + let text = body["body"].as_str().unwrap(); + assert!( + text.contains("68.10"), + "niveau 1 sans frais : le reste seul : {text}" + ); + assert!( + !text.to_lowercase().contains("frais"), + "à zéro, rien ne s'affiche : {text}" + ); +} + +/// AC 5 — l'envoi unitaire joint un RAPPEL, nommé comme tel. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn send_reminder_attaches_a_reminder_pdf(pool: MySqlPool) { + let mock = MockMailer::new(); + let (admin_id, company_id, invoice_id) = seed_sendable(&pool).await; + seed_dunning(&pool, company_id).await; + settle(&pool, admin_id, company_id, invoice_id, dec!(40.00)).await; + let app = spawn_app(pool.clone(), mock.clone(), true, 20).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + let resp = app + .client + .post(app.url(&format!("/api/v1/invoices/{invoice_id}/reminders/send"))) + .bearer_auth(&token) + .json(&json!({ "levelNumber": 1, "subject": "Rappel", "body": "Corps." })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let sent = mock.sent_emails(); + let name = sent[0].attachment_filename.as_deref().unwrap(); + assert!( + name.starts_with("rappel-") && name.ends_with(".pdf"), + "la pièce jointe est un rappel : {name}" + ); +} + +/// AC 9 — reste dû nul sur une facture validée sans `paid_at` (état hérité, +/// fabriqué en SQL) : refus NOMMÉ `REMINDER_NOTHING_DUE` à l'aperçu, à l'envoi +/// unitaire — avant le SMTP — et, en lot, en échec PAR FACTURE, pas en +/// `DATABASE_ERROR`. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn reminder_with_nothing_due_is_refused_by_name(pool: MySqlPool) { + let mock = MockMailer::new(); + let (admin_id, company_id, invoice_id) = seed_sendable(&pool).await; + seed_dunning(&pool, company_id).await; + settle(&pool, admin_id, company_id, invoice_id, dec!(108.10)).await; + sqlx::query("UPDATE invoices SET paid_at = NULL WHERE id = ?") + .bind(invoice_id) + .execute(&pool) + .await + .unwrap(); + let app = spawn_app(pool.clone(), mock.clone(), true, 20).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + + let preview = app + .client + .get(app.url(&format!( + "/api/v1/invoices/{invoice_id}/reminder-preview?level=1" + ))) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(preview.status(), 422); + let body: serde_json::Value = preview.json().await.unwrap(); + assert_eq!(body["error"]["code"], "REMINDER_NOTHING_DUE", "{body}"); + + let unit = app + .client + .post(app.url(&format!("/api/v1/invoices/{invoice_id}/reminders/send"))) + .bearer_auth(&token) + .json(&json!({ "levelNumber": 1, "subject": "Rappel", "body": "Corps." })) + .send() + .await + .unwrap(); + assert_eq!(unit.status(), 422); + assert_eq!(mock.sent_emails().len(), 0, "refusé AVANT le SMTP"); + + let batch = app + .client + .post(app.url("/api/v1/dunning/reminders/send-batch")) + .bearer_auth(&token) + .json(&json!({ "invoiceIds": [invoice_id] })) + .send() + .await + .unwrap(); + assert_eq!(batch.status(), 200); + let body: serde_json::Value = batch.json().await.unwrap(); + let failed = body["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "{body}"); + assert_eq!( + failed[0]["errorCode"], "REMINDER_NOTHING_DUE", + "pas DATABASE_ERROR" + ); + assert_eq!(mock.sent_emails().len(), 0); + assert_eq!(reminder_count(&pool, invoice_id).await, 0); +} + +/// Revue de code 25-4-b2, P1 (HIGH) — ⛔ un règlement arrivé entre l'aperçu et +/// l'envoi unitaire : le texte validé annonce l'ancien reste, la QR recalculée le +/// nouveau. L'envoi qui renvoie les montants de l'aperçu est REFUSÉ (409), rien +/// ne part ; un nouvel aperçu permet l'envoi. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn send_reminder_refuses_amounts_changed_since_preview(pool: MySqlPool) { + let mock = MockMailer::new(); + let (admin_id, company_id, invoice_id) = seed_sendable(&pool).await; + seed_dunning(&pool, company_id).await; + let app = spawn_app(pool.clone(), mock.clone(), true, 20).await; + let token = login(&app, "admin", TEST_ADMIN_PASSWORD).await; + let preview = || async { + let v: serde_json::Value = app + .client + .get(app.url(&format!( + "/api/v1/invoices/{invoice_id}/reminder-preview?level=1" + ))) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + v + }; + let send = |p: serde_json::Value| { + app.client + .post(app.url(&format!("/api/v1/invoices/{invoice_id}/reminders/send"))) + .bearer_auth(&token) + .json(&json!({ + "levelNumber": 1, + "subject": p["subject"], + "body": p["body"], + "expectedAmountDue": p["amountDue"], + "expectedFees": p["fees"], + })) + .send() + }; + + let avant = preview().await; + assert_eq!( + avant["amountDue"] + .as_str() + .unwrap() + .parse::() + .unwrap(), + dec!(108.10), + "l'aperçu dit le reste sur lequel il a rendu le texte : {avant}" + ); + // Un règlement arrive pendant que l'aperçu est ouvert. + settle(&pool, admin_id, company_id, invoice_id, dec!(40.00)).await; + + let refus = send(avant).await.unwrap(); + assert_eq!(refus.status(), 409); + let body: serde_json::Value = refus.json().await.unwrap(); + assert_eq!(body["error"]["code"], "REMINDER_AMOUNTS_CHANGED", "{body}"); + assert_eq!(mock.sent_emails().len(), 0, "rien n'est parti"); + assert_eq!(reminder_count(&pool, invoice_id).await, 0); + + // Un nouvel aperçu : le texte et la QR disent le même reste. + let apres = preview().await; + assert!(apres["body"].as_str().unwrap().contains("68.10"), "{apres}"); + assert_eq!(send(apres).await.unwrap().status(), 201); + assert_eq!(mock.sent_emails().len(), 1); +} diff --git a/crates/kesh-db/src/entities/email_template.rs b/crates/kesh-db/src/entities/email_template.rs index 40b0040ef..6ab79e5bb 100644 --- a/crates/kesh-db/src/entities/email_template.rs +++ b/crates/kesh-db/src/entities/email_template.rs @@ -71,6 +71,11 @@ impl EmailTemplateType { "reminderFee", "totalDue", "daysOverdue", + // Story 25-4-b2 (#416) : phrase disant les frais cumulés compris + // dans `{totalDue}`, précédée d'une espace — VIDE sans frais. Le + // moteur n'a pas de condition : c'est la seule façon de ne rien + // afficher à zéro. + "feeNotice", ], } } diff --git a/crates/kesh-db/src/entities/email_template_defaults.rs b/crates/kesh-db/src/entities/email_template_defaults.rs index a2b49a88c..f446496e2 100644 --- a/crates/kesh-db/src/entities/email_template_defaults.rs +++ b/crates/kesh-db/src/entities/email_template_defaults.rs @@ -68,6 +68,10 @@ fn invoice_send_default(language: Language) -> (&'static str, &'static str) { /// (courtois → ferme → mise en demeure avant poursuite) ; le bras `_` (niveau 0 /// générique ou ≥ 4) est un rappel neutre-ferme. N'utilise que des tokens déclarés /// dans `allowed_variables(InvoiceReminder)` (`reminderLevel` volontairement non utilisé). +/// +/// ⛔ Story 25-4-b2 (#416) : aucune phrase de frais n'est écrite en dur — les +/// frais passent par `{feeNotice}`, vide quand ils sont nuls. `{totalDue}` est le +/// **reste dû** plus les frais cumulés ; `{amount}` reste le TTC de la facture. fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'static str) { match (language, level_number) { // ---- Français ---- @@ -76,7 +80,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ Sauf erreur de notre part, la facture {invoiceNumber} d'un montant de {amount}, \ échue le {dueDate}, demeure impayée à ce jour ({daysOverdue} jours de retard).\n\n\ - Nous vous remercions de bien vouloir régler le montant dû de {totalDue} dans les meilleurs délais.\n\n\ + Nous vous remercions de bien vouloir régler le montant dû de {totalDue} dans les meilleurs délais.{feeNotice}\n\n\ Avec nos meilleures salutations,\n{companyName}", ), (Language::Fr, 2) => ( @@ -84,7 +88,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ Malgré notre premier rappel, la facture {invoiceNumber} de {amount}, échue le {dueDate}, \ reste impayée ({daysOverdue} jours de retard).\n\n\ - Des frais de rappel de {reminderFee} ont été ajoutés ; le montant total dû s'élève désormais à {totalDue}. \ + Le montant dû s'élève à {totalDue}.{feeNotice} \ Nous vous invitons à le régler sous huitaine.\n\n\ Avec nos salutations,\n{companyName}", ), @@ -93,8 +97,8 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ La facture {invoiceNumber} de {amount}, échue le {dueDate}, demeure impayée malgré nos rappels \ ({daysOverdue} jours de retard).\n\n\ - Nous vous mettons en demeure de régler le montant total dû de {totalDue} \ - (frais de rappel de {reminderFee} inclus) sans délai. À défaut de paiement, \ + Nous vous mettons en demeure de régler le montant dû de {totalDue} sans délai.{feeNotice} \ + À défaut de paiement, \ nous engagerons une procédure de recouvrement sans autre avis.\n\n\ {companyName}", ), @@ -103,7 +107,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ La facture {invoiceNumber} d'un montant de {amount}, échue le {dueDate}, \ reste impayée ({daysOverdue} jours de retard).\n\n\ - Nous vous prions de régler le montant total dû de {totalDue} dans les meilleurs délais.\n\n\ + Nous vous prions de régler le montant dû de {totalDue} dans les meilleurs délais.{feeNotice}\n\n\ Avec nos meilleures salutations,\n{companyName}", ), // ---- Deutsch ---- @@ -112,7 +116,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation}\n\n\ Sofern sich unsere Angaben mit den Ihren decken, ist die Rechnung {invoiceNumber} über {amount}, \ fällig am {dueDate}, bis heute unbeglichen ({daysOverdue} Tage überfällig).\n\n\ - Wir bitten Sie, den offenen Betrag von {totalDue} baldmöglichst zu begleichen.\n\n\ + Wir bitten Sie, den offenen Betrag von {totalDue} baldmöglichst zu begleichen.{feeNotice}\n\n\ Freundliche Grüsse\n{companyName}", ), (Language::De, 2) => ( @@ -120,7 +124,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation}\n\n\ Trotz unserer ersten Erinnerung ist die Rechnung {invoiceNumber} über {amount}, \ fällig am {dueDate}, weiterhin offen ({daysOverdue} Tage überfällig).\n\n\ - Wir haben Mahngebühren von {reminderFee} erhoben; der Gesamtbetrag beläuft sich nun auf {totalDue}. \ + Der offene Betrag beläuft sich auf {totalDue}.{feeNotice} \ Wir bitten um Begleichung innert acht Tagen.\n\n\ Freundliche Grüsse\n{companyName}", ), @@ -129,8 +133,8 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation}\n\n\ Die Rechnung {invoiceNumber} über {amount}, fällig am {dueDate}, ist trotz mehrfacher Mahnung \ unbeglichen ({daysOverdue} Tage überfällig).\n\n\ - Wir fordern Sie letztmals auf, den Gesamtbetrag von {totalDue} (inkl. Mahngebühren von {reminderFee}) \ - unverzüglich zu begleichen. Andernfalls leiten wir ohne weitere Ankündigung die Betreibung ein.\n\n\ + Wir fordern Sie letztmals auf, den offenen Betrag von {totalDue} \ + unverzüglich zu begleichen.{feeNotice} Andernfalls leiten wir ohne weitere Ankündigung die Betreibung ein.\n\n\ {companyName}", ), (Language::De, _) => ( @@ -138,7 +142,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation}\n\n\ Die Rechnung {invoiceNumber} über {amount}, fällig am {dueDate}, ist offen \ ({daysOverdue} Tage überfällig).\n\n\ - Wir bitten Sie, den Gesamtbetrag von {totalDue} baldmöglichst zu begleichen.\n\n\ + Wir bitten Sie, den offenen Betrag von {totalDue} baldmöglichst zu begleichen.{feeNotice}\n\n\ Freundliche Grüsse\n{companyName}", ), // ---- Italiano ---- @@ -147,7 +151,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ Salvo errore da parte nostra, la fattura {invoiceNumber} di {amount}, scaduta il {dueDate}, \ risulta ancora non saldata ({daysOverdue} giorni di ritardo).\n\n\ - La preghiamo di saldare l'importo dovuto di {totalDue} al più presto.\n\n\ + La preghiamo di saldare l'importo dovuto di {totalDue} al più presto.{feeNotice}\n\n\ Distinti saluti,\n{companyName}", ), (Language::It, 2) => ( @@ -155,7 +159,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ Nonostante il nostro primo sollecito, la fattura {invoiceNumber} di {amount}, scaduta il {dueDate}, \ risulta ancora non saldata ({daysOverdue} giorni di ritardo).\n\n\ - Sono state applicate spese di sollecito di {reminderFee}; l'importo totale dovuto ammonta ora a {totalDue}. \ + L'importo dovuto ammonta a {totalDue}.{feeNotice} \ La invitiamo a saldarlo entro otto giorni.\n\n\ Distinti saluti,\n{companyName}", ), @@ -164,8 +168,8 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ La fattura {invoiceNumber} di {amount}, scaduta il {dueDate}, risulta non saldata nonostante i nostri solleciti \ ({daysOverdue} giorni di ritardo).\n\n\ - La diffidiamo a saldare l'importo totale dovuto di {totalDue} (spese di sollecito di {reminderFee} incluse) \ - senza indugio. In mancanza di pagamento, avvieremo una procedura esecutiva senza ulteriore avviso.\n\n\ + La diffidiamo a saldare l'importo dovuto di {totalDue} \ + senza indugio.{feeNotice} In mancanza di pagamento, avvieremo una procedura esecutiva senza ulteriore avviso.\n\n\ {companyName}", ), (Language::It, _) => ( @@ -173,7 +177,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ La fattura {invoiceNumber} di {amount}, scaduta il {dueDate}, risulta non saldata \ ({daysOverdue} giorni di ritardo).\n\n\ - La preghiamo di saldare l'importo totale dovuto di {totalDue} al più presto.\n\n\ + La preghiamo di saldare l'importo dovuto di {totalDue} al più presto.{feeNotice}\n\n\ Distinti saluti,\n{companyName}", ), // ---- English ---- @@ -182,7 +186,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ Unless our records are mistaken, invoice {invoiceNumber} for {amount}, due on {dueDate}, \ remains unpaid ({daysOverdue} days overdue).\n\n\ - We kindly ask you to settle the amount due of {totalDue} at your earliest convenience.\n\n\ + We kindly ask you to settle the amount due of {totalDue} at your earliest convenience.{feeNotice}\n\n\ Kind regards,\n{companyName}", ), (Language::En, 2) => ( @@ -190,7 +194,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ Despite our first reminder, invoice {invoiceNumber} for {amount}, due on {dueDate}, \ remains unpaid ({daysOverdue} days overdue).\n\n\ - A reminder fee of {reminderFee} has been added; the total amount due is now {totalDue}. \ + The amount due is {totalDue}.{feeNotice} \ Please settle it within eight days.\n\n\ Kind regards,\n{companyName}", ), @@ -199,8 +203,8 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ Invoice {invoiceNumber} for {amount}, due on {dueDate}, remains unpaid despite our reminders \ ({daysOverdue} days overdue).\n\n\ - We formally request payment of the total amount due of {totalDue} (including a reminder fee of {reminderFee}) \ - without delay. Failing payment, we will initiate debt-collection proceedings without further notice.\n\n\ + We formally request payment of the amount due of {totalDue} \ + without delay.{feeNotice} Failing payment, we will initiate debt-collection proceedings without further notice.\n\n\ {companyName}", ), (Language::En, _) => ( @@ -208,7 +212,7 @@ fn reminder_default(language: Language, level_number: i16) -> (&'static str, &'s "{salutation},\n\n\ Invoice {invoiceNumber} for {amount}, due on {dueDate}, remains unpaid \ ({daysOverdue} days overdue).\n\n\ - Please settle the total amount due of {totalDue} at your earliest convenience.\n\n\ + Please settle the amount due of {totalDue} at your earliest convenience.{feeNotice}\n\n\ Kind regards,\n{companyName}", ), } @@ -245,4 +249,36 @@ mod tests { } } } + /// Story 25-4-b2 (AC 3) — à frais nuls (`{feeNotice}` vide), aucun rappel par + /// défaut ne parle de frais, dans aucune langue ni à aucun niveau. Avant la + /// story, les niveaux 2 et 3 écrivaient « frais de rappel de {reminderFee} » + /// en dur — soit « frais de 0.00 » sans frais configurés. + #[test] + fn reminder_defaults_say_nothing_of_fees_when_there_are_none() { + let mut vars = std::collections::HashMap::new(); + for name in EmailTemplateType::InvoiceReminder.allowed_variables() { + vars.insert(name.to_string(), "X".to_string()); + } + vars.insert("feeNotice".to_string(), String::new()); + for language in [Language::Fr, Language::De, Language::It, Language::En] { + for level in 0..=4 { + let (subject, body) = + default_template(EmailTemplateType::InvoiceReminder, language, level); + let text = + kesh_core::email_template_engine::render(&format!("{subject} {body}"), &vars) + .to_lowercase(); + for word in ["frais", "gebühr", "spese", "fee"] { + assert!( + !text.contains(word), + "{language:?}/niv{level} parle de frais sans frais : « {word} »" + ); + } + // Anti-vacuité : le gabarit porte bien la variable. + assert!( + format!("{subject} {body}").contains("{feeNotice}"), + "{language:?}/niv{level} n'emploie pas {{feeNotice}}" + ); + } + } + } } diff --git a/crates/kesh-db/src/repositories/invoice_reminders.rs b/crates/kesh-db/src/repositories/invoice_reminders.rs index 1461e0069..0d0375ca7 100644 --- a/crates/kesh-db/src/repositories/invoice_reminders.rs +++ b/crates/kesh-db/src/repositories/invoice_reminders.rs @@ -66,12 +66,18 @@ pub async fn list_all_by_company( /// ré-émis — D18 — ne compte le frais qu'UNE fois : `MAX(fee_amount)`), en **excluant** /// `exclude_level` (le niveau en cours d'envoi, dont le frais de config est ajouté à part /// par l'appelant — évite le double comptage H2). Pour `{totalDue}` (21-5b). 0 si aucun. -pub async fn sum_fees_deduped_excluding( - pool: &MySqlPool, +/// +/// Générique sur l'exécuteur (Story 25-4-b2) : les montants d'un rappel se lisent +/// dans UNE transaction, avec le reste dû et le réglé, pour voir le même instantané. +pub async fn sum_fees_deduped_excluding<'e, E>( + executor: E, company_id: i64, invoice_id: i64, exclude_level: i16, -) -> Result { +) -> Result +where + E: sqlx::Executor<'e, Database = MySql>, +{ let sum: Option = sqlx::query_scalar( "SELECT SUM(fee) FROM ( \ SELECT level_number, MAX(fee_amount) AS fee FROM invoice_reminders \ @@ -82,7 +88,7 @@ pub async fn sum_fees_deduped_excluding( .bind(company_id) .bind(invoice_id) .bind(exclude_level) - .fetch_one(pool) + .fetch_one(executor) .await .map_err(map_db_error)?; Ok(sum.unwrap_or(rust_decimal::Decimal::ZERO)) diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 883ca0377..0bb1a1957 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -594,6 +594,12 @@ invoice-pdf-phone = Tel. invoice-pdf-email = E-Mail invoice-pdf-website = Web invoice-pdf-client-number = Kundennummer +# Story 25-4-b2 (#416) — le PDF joint à un rappel. +invoice-pdf-reminder-title = Mahnung +invoice-pdf-settled = Bereits bezahlt +invoice-pdf-amount-due = Noch zu bezahlen +invoice-pdf-reminder-fees = Mahngebühren +invoice-pdf-reminder-fees-note = Die Mahngebühren sind im Einzahlungsschein nicht enthalten. invoice-pdf-recipient = Empfänger invoice-pdf-description = Beschreibung invoice-pdf-quantity = Menge @@ -1432,7 +1438,7 @@ dunning-edit = Bearbeiten dunning-delete = Löschen dunning-example-heading = Voraussichtlicher Zeitplan dunning-example-line = { $level }. Mahnung { $days } Tage nach Fälligkeit vorgeschlagen -dunning-cgv-hint = Mahngebühren sind nur mit einer vertraglichen Grundlage (AGB) geschuldet. Sie sind nicht im QR der beigefügten Rechnung enthalten. +dunning-cgv-hint = Mahngebühren sind nur mit einer vertraglichen Grundlage (AGB) geschuldet. Sie sind nicht im QR der beigefügten Mahnung enthalten. dunning-delay-label = Frist (Tage) dunning-delay-help = Tage seit dem vorherigen Schritt (Fälligkeit + Karenz für die 1.). dunning-fee-label = Gebühr (CHF) @@ -1505,6 +1511,9 @@ reminders-error-contact-email-missing = Kontakt ohne E-Mail-Adresse reminders-error-content-empty = Mahnvorlage leer reminders-error-content-too-long = Mahninhalt zu lang reminders-error-not-pdf-ready = Rechnung nicht als PDF druckbar +reminders-error-nothing-due = Nichts offen +error-reminder-nothing-due = Auf dieser Rechnung ist nichts mehr offen: keine Mahnung zu versenden. +error-reminder-amounts-changed = Der offene Betrag hat sich seit der Vorschau geändert (eine Zahlung ist eingegangen): Öffnen Sie die Vorschau erneut, bevor Sie senden. reminders-error-rate-limited = Sendelimit erreicht reminders-error-database-error = Technischer Fehler reminders-error-smtp-failed = E-Mail-Versand fehlgeschlagen diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 7842a8f6e..eb833fb7a 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -594,6 +594,12 @@ invoice-pdf-phone = Phone invoice-pdf-email = Email invoice-pdf-website = Web invoice-pdf-client-number = Client no. +# Story 25-4-b2 (#416) — le PDF joint à un rappel. +invoice-pdf-reminder-title = Reminder +invoice-pdf-settled = Already paid +invoice-pdf-amount-due = Amount due +invoice-pdf-reminder-fees = Reminder fees +invoice-pdf-reminder-fees-note = Reminder fees are not included in the payment slip. invoice-pdf-recipient = Recipient invoice-pdf-description = Description invoice-pdf-quantity = Qty @@ -1437,7 +1443,7 @@ dunning-edit = Edit dunning-delete = Delete dunning-example-heading = Projected schedule dunning-example-line = Reminder { $level } proposed { $days } days after the due date -dunning-cgv-hint = Reminder fees are only due with a contractual basis (T&Cs). They are not included in the QR of the attached invoice. +dunning-cgv-hint = Reminder fees are only due with a contractual basis (T&Cs). They are not included in the QR of the attached reminder. dunning-delay-label = Delay (days) dunning-delay-help = Days since the previous step (due date + grace for the 1st). dunning-fee-label = Fee (CHF) @@ -1510,6 +1516,9 @@ reminders-error-contact-email-missing = Contact without e-mail address reminders-error-content-empty = Reminder template empty reminders-error-content-too-long = Reminder content too long reminders-error-not-pdf-ready = Invoice not printable as PDF +reminders-error-nothing-due = Nothing due +error-reminder-nothing-due = Nothing is left to claim on this invoice: no reminder to send. +error-reminder-amounts-changed = The amount due has changed since the preview (a payment came in): reopen the preview before sending. reminders-error-rate-limited = Send limit reached reminders-error-database-error = Technical error reminders-error-smtp-failed = E-mail sending failed diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index efec23fed..425c0dd81 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -623,6 +623,12 @@ invoice-pdf-phone = Tél. invoice-pdf-email = E-mail invoice-pdf-website = Web invoice-pdf-client-number = N° client +# Story 25-4-b2 (#416) — le PDF joint à un rappel. +invoice-pdf-reminder-title = Rappel +invoice-pdf-settled = Déjà réglé +invoice-pdf-amount-due = Reste à payer +invoice-pdf-reminder-fees = Frais de rappel +invoice-pdf-reminder-fees-note = Les frais de rappel ne sont pas compris dans le bulletin de versement. invoice-pdf-recipient = Destinataire invoice-pdf-description = Description invoice-pdf-quantity = Qté @@ -1525,7 +1531,7 @@ dunning-edit = Modifier dunning-delete = Supprimer dunning-example-heading = Échéancier prévisionnel dunning-example-line = { $level }. rappel proposé { $days } j après l'échéance -dunning-cgv-hint = Les frais de rappel ne sont exigibles qu'avec une base contractuelle (CGV). Ils ne sont pas inclus dans le QR de la facture jointe. +dunning-cgv-hint = Les frais de rappel ne sont exigibles qu'avec une base contractuelle (CGV). Ils ne sont pas inclus dans le QR du rappel joint. dunning-delay-label = Délai (jours) dunning-delay-help = Jours depuis l'étape précédente (échéance + grâce pour le 1er). dunning-fee-label = Frais (CHF) @@ -1602,6 +1608,9 @@ reminders-error-contact-email-missing = Contact sans adresse e-mail reminders-error-content-empty = Modèle de rappel vide reminders-error-content-too-long = Contenu du rappel trop long reminders-error-not-pdf-ready = Facture non imprimable en PDF +reminders-error-nothing-due = Rien à réclamer +error-reminder-nothing-due = Il ne reste rien à réclamer sur cette facture : aucun rappel à envoyer. +error-reminder-amounts-changed = Le montant dû a changé depuis l'aperçu (un règlement est arrivé) : rouvrez l'aperçu avant d'envoyer. reminders-error-rate-limited = Limite d'envoi atteinte reminders-error-database-error = Erreur technique reminders-error-smtp-failed = Échec de l'envoi e-mail diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 627734b42..25711aad1 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -594,6 +594,12 @@ invoice-pdf-phone = Tel. invoice-pdf-email = E-mail invoice-pdf-website = Web invoice-pdf-client-number = N. cliente +# Story 25-4-b2 (#416) — le PDF joint à un rappel. +invoice-pdf-reminder-title = Sollecito +invoice-pdf-settled = Già pagato +invoice-pdf-amount-due = Rimanenza da pagare +invoice-pdf-reminder-fees = Spese di sollecito +invoice-pdf-reminder-fees-note = Le spese di sollecito non sono comprese nella polizza di versamento. invoice-pdf-recipient = Destinatario invoice-pdf-description = Descrizione invoice-pdf-quantity = Qtà @@ -1430,7 +1436,7 @@ dunning-edit = Modifica dunning-delete = Elimina dunning-example-heading = Scadenzario previsionale dunning-example-line = { $level }° sollecito proposto { $days } g dopo la scadenza -dunning-cgv-hint = Le spese di sollecito sono esigibili solo con una base contrattuale (CGC). Non sono incluse nel QR della fattura allegata. +dunning-cgv-hint = Le spese di sollecito sono esigibili solo con una base contrattuale (CGC). Non sono incluse nel QR del sollecito allegato. dunning-delay-label = Scadenza (giorni) dunning-delay-help = Giorni dalla fase precedente (scadenza + tolleranza per il 1°). dunning-fee-label = Spese (CHF) @@ -1503,6 +1509,9 @@ reminders-error-contact-email-missing = Contatto senza indirizzo e-mail reminders-error-content-empty = Modello di sollecito vuoto reminders-error-content-too-long = Contenuto del sollecito troppo lungo reminders-error-not-pdf-ready = Fattura non stampabile in PDF +reminders-error-nothing-due = Nulla da esigere +error-reminder-nothing-due = Su questa fattura non resta nulla da esigere: nessun sollecito da inviare. +error-reminder-amounts-changed = L'importo dovuto è cambiato dall'anteprima (è arrivato un pagamento): riaprire l'anteprima prima di inviare. reminders-error-rate-limited = Limite di invio raggiunto reminders-error-database-error = Errore tecnico reminders-error-smtp-failed = Invio e-mail non riuscito diff --git a/crates/kesh-i18n/src/loader.rs b/crates/kesh-i18n/src/loader.rs index 0c27528ea..1b81a7b58 100644 --- a/crates/kesh-i18n/src/loader.rs +++ b/crates/kesh-i18n/src/loader.rs @@ -276,6 +276,32 @@ mod tests { } } + /// **Story 25-4-b2 (#416)** — les libellés du PDF de rappel et le refus du + /// reste nul existent dans les quatre locales, et ne replient pas sur le + /// français (même mécanisme que la KF #283, cf. le test précédent). + #[test] + fn reminder_pdf_labels_are_translated_in_all_four_locales() { + let bundle = I18nBundle::load(&locales_dir()).unwrap(); + for key in [ + "invoice-pdf-reminder-title", + "invoice-pdf-settled", + "invoice-pdf-amount-due", + "invoice-pdf-reminder-fees", + "invoice-pdf-reminder-fees-note", + "error-reminder-nothing-due", + "error-reminder-amounts-changed", + "reminders-error-nothing-due", + ] { + let fr = bundle.format(&Locale::FrCh, key, None); + assert_ne!(fr, key, "{key} doit exister en fr-CH"); + for locale in [Locale::DeCh, Locale::ItCh, Locale::EnCh] { + let msg = bundle.format(&locale, key, None); + assert_ne!(msg, key, "{key} doit exister en {locale:?}"); + assert_ne!(msg, fr, "{key} en {locale:?} replie sur le français"); + } + } + } + /// **Story 22-2b (#301)** — les quatre libellés des sondes anti-doublon /// existent dans les quatre locales. /// diff --git a/crates/kesh-qrbill/src/lib.rs b/crates/kesh-qrbill/src/lib.rs index 40fef2d8b..3218c44c1 100644 --- a/crates/kesh-qrbill/src/lib.rs +++ b/crates/kesh-qrbill/src/lib.rs @@ -18,11 +18,11 @@ pub mod validation; pub use generator::build_payload; pub use parser::{ScannedAddress, ScannedQrBill, ScannedReference, parse_spc_payload}; pub use pdf::{ - generate_credit_note_pdf, generate_credit_note_pdf_with_date, generate_qr_bill_pdf, - generate_qr_bill_pdf_with_date, + REMINDER_LABEL_MAX_CHARS, REMINDER_NOTE_MAX_CHARS, generate_credit_note_pdf, + generate_credit_note_pdf_with_date, generate_qr_bill_pdf, generate_qr_bill_pdf_with_date, }; pub use types::{ Address, AddressType, Currency, InvoiceLinePdf, InvoicePdfData, InvoiceVatLinePdf, QrBillData, - QrBillError, QrBillI18n, Reference, + QrBillError, QrBillI18n, Reference, ReminderPdf, }; pub use validation::validate; diff --git a/crates/kesh-qrbill/src/pdf.rs b/crates/kesh-qrbill/src/pdf.rs index 263ce8bf7..c1fbc4299 100644 --- a/crates/kesh-qrbill/src/pdf.rs +++ b/crates/kesh-qrbill/src/pdf.rs @@ -9,7 +9,7 @@ //! Uses `BuiltinFont::Helvetica` (PDF standard 14) — no external font embedding. use crate::generator::{build_payload, render_qr_image}; -use crate::types::{InvoicePdfData, QrBillData, QrBillError, QrBillI18n, Reference}; +use crate::types::{InvoicePdfData, QrBillData, QrBillError, QrBillI18n, Reference, ReminderPdf}; use chrono::{Datelike, NaiveDate}; use kesh_core::text::is_invisible; use printpdf::{ @@ -463,8 +463,15 @@ fn draw_invoice_section( // Title + metadata (right). let meta_x = 120.0; let meta_title_y = PAGE_H - 20.0; + // Story 25-4-b2 (#416) : un rappel n'est pas la facture réémise — il le dit + // en titre. La clé est résolue dans la locale que l'appelant a passée. + let title_key = if inv.reminder.is_some() { + "invoice-pdf-reminder-title" + } else { + "invoice-pdf-title" + }; layer.use_text( - i18n.get("invoice-pdf-title"), + i18n.get(title_key), 18.0, Mm(meta_x), Mm(meta_title_y), @@ -583,7 +590,7 @@ fn draw_invoice_section( 0.0 } else { 4.5 + 4.5 * inv.vat_lines.len() as f32 + 1.0 - }; + } + reminder_block_height(inv.reminder.as_ref()); for line in &inv.lines { if ty < content_floor + 15.0 + recap_reserve { @@ -697,6 +704,27 @@ fn draw_invoice_section( helv_bold, ); + // Story 25-4-b2 (#416) — le bloc du rappel, sous le total. Sa hauteur est + // RÉSERVÉE dans `recap_reserve` (garde `TooManyLines`) : ⛔ ne pas le traiter + // comme `payment_terms` ci-dessous, qui se contente de clamper et tasserait + // le bloc sur la zone de paiement au lieu de refuser. + if let Some(r) = &inv.reminder { + for line in reminder_lines(r, i18n, inv.currency.code()) { + ty -= REMINDER_LINE_STEP; + let x = if line.full_width { col_desc } else { col_unit }; + let font = if line.bold { helv_bold } else { helv }; + let label = if line.full_width { + truncate_display(&line.label, REMINDER_NOTE_MAX_CHARS) + } else { + truncate_display(&line.label, REMINDER_LABEL_MAX_CHARS) + }; + layer.use_text(label, 9.0, Mm(x), Mm(ty), font); + if let Some(amount) = line.amount { + layer.use_text(amount, 9.0, Mm(col_tot), Mm(ty), font); + } + } + } + if let Some(terms) = &inv.payment_terms { ty -= 8.0; layer.use_text( @@ -1111,6 +1139,93 @@ fn format_date_ch(d: NaiveDate) -> String { // ne pas dessiner une ligne blanche qui consommerait un `META_LINE_STEP` pour // une valeur faite de ZWSP/BOM/word-joiner, vides à l'impression. +/// Pas vertical entre deux lignes du bloc de rappel, en mm (Story 25-4-b2). +const REMINDER_LINE_STEP: f32 = 4.5; + +/// Longueur maximale, en caractères, de la **mention** des frais de rappel, qui +/// court sur toute la largeur utile (170 mm, de `col_desc` à la marge droite). +/// Même calibrage que [`IDENTITY_MAX_CHARS`] (46 caractères pour 100 mm à 9 pt), +/// avec la même limite : une borne en caractères, pas une largeur mesurée. +/// +/// ⚠️ Les gardes de capacité ne surveillent que l'ORDONNÉE : sans cette borne, +/// une traduction plus longue déborderait à droite sans que rien ne rougisse. +pub const REMINDER_NOTE_MAX_CHARS: usize = 78; + +/// Longueur maximale des **libellés courts** du bloc de rappel (déjà réglé, reste +/// à payer, frais de rappel), dessinés dans la colonne des libellés : 50 mm de +/// `col_unit` à `col_tot`, soit 23 caractères au calibrage d'[`IDENTITY_MAX_CHARS`]. +/// Au-delà, le libellé chevaucherait le montant (revue de code 25-4-b2, P1). +pub const REMINDER_LABEL_MAX_CHARS: usize = 23; + +/// Une ligne du bloc de rappel, **construite** avant d'être dessinée — c'est ce +/// qui rend son contenu testable (le texte d'un PDF est hex-encodé dans les +/// opérateurs `Tj` et ne se compare pas). +#[derive(Debug, Clone, PartialEq)] +struct ReminderLine { + label: String, + amount: Option, + bold: bool, + /// `true` ⇒ la ligne part de `col_desc` et court sur toute la largeur (la + /// mention des frais, trop longue pour la colonne des libellés). + full_width: bool, +} + +/// Les lignes du bloc de rappel, dans l'ordre (Story 25-4-b2, AC 6 et 8) : +/// « déjà réglé » et « reste à payer » **seulement s'il y a eu un règlement** +/// (sinon le total est le reste — arbitrage Q2), puis « frais de rappel » et sa +/// mention **seulement si les frais sont non nuls** (arbitrage Q1). +fn reminder_lines(r: &ReminderPdf, i18n: &QrBillI18n, currency: &str) -> Vec { + let money = |d: Decimal| { + format!( + "{} {}", + currency, + format_ch( + d.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero), + 2 + ) + ) + }; + let mut out = Vec::new(); + if r.amount_settled > Decimal::ZERO { + out.push(ReminderLine { + label: i18n.get("invoice-pdf-settled").to_string(), + amount: Some(format!("-{}", money(r.amount_settled))), + bold: false, + full_width: false, + }); + out.push(ReminderLine { + label: i18n.get("invoice-pdf-amount-due").to_string(), + amount: Some(money(r.amount_due)), + bold: true, + full_width: false, + }); + } + if r.fees > Decimal::ZERO { + out.push(ReminderLine { + label: i18n.get("invoice-pdf-reminder-fees").to_string(), + amount: Some(money(r.fees)), + bold: false, + full_width: false, + }); + out.push(ReminderLine { + label: i18n.get("invoice-pdf-reminder-fees-note").to_string(), + amount: None, + bold: false, + full_width: true, + }); + } + out +} + +/// Hauteur du bloc de rappel, à réserver dans la garde de capacité. +fn reminder_block_height(r: Option<&ReminderPdf>) -> f32 { + r.map(|r| { + // Le `QrBillI18n` par défaut suffit : seul le NOMBRE de lignes compte. + reminder_lines(r, &QrBillI18n::default(), "CHF").len() as f32 * REMINDER_LINE_STEP + }) + .unwrap_or(0.0) +} + fn truncate_display(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { s.to_string() @@ -1191,6 +1306,7 @@ mod tests { total: dec!(1292.40), // 1200.00 + 92.40 currency: Currency::Chf, origin_reference: None, + reminder: None, }; (data, invoice, QrBillI18n::default()) } @@ -1389,6 +1505,128 @@ mod tests { ); } + fn reminder(settled: Decimal, due: Decimal, fees: Decimal) -> ReminderPdf { + ReminderPdf { + amount_settled: settled, + amount_due: due, + fees, + } + } + + /// Story 25-4-b2 (AC 6, 8) — le CONTENU du bloc, mesuré sur les lignes + /// construites (le texte d'un PDF ne se compare pas, cf. `golden_test.rs`) : + /// réglé et reste seulement après un règlement, frais et mention seulement + /// avec des frais, montants au centime. + #[test] + fn reminder_lines_show_only_what_is_not_zero() { + let i18n = QrBillI18n::default(); + let full = reminder_lines( + &reminder(dec!(900.00), dec!(181.0000), dec!(20.00)), + &i18n, + "CHF", + ); + let labels: Vec<&str> = full.iter().map(|l| l.label.as_str()).collect(); + assert_eq!( + labels, + [ + "Already paid", + "Amount due", + "Reminder fees", + "Reminder fees are not included in the payment slip." + ] + ); + assert_eq!(full[0].amount.as_deref(), Some("-CHF 900.00")); + assert_eq!(full[1].amount.as_deref(), Some("CHF 181.00")); + assert!(full[1].bold, "le reste à payer est en gras"); + assert_eq!(full[2].amount.as_deref(), Some("CHF 20.00")); + assert!(full[3].full_width && full[3].amount.is_none()); + + // Q2 : sans règlement, le total EST le reste — ni réglé, ni reste. + let unpaid = reminder_lines(&reminder(dec!(0), dec!(1081.00), dec!(20.00)), &i18n, "CHF"); + assert_eq!(unpaid.len(), 2); + assert_eq!(unpaid[0].label, "Reminder fees"); + // Q1 : sans frais, ni ligne de frais, ni mention. + let no_fees = reminder_lines(&reminder(dec!(900.00), dec!(181.00), dec!(0)), &i18n, "CHF"); + assert_eq!(no_fees.len(), 2); + assert!(no_fees.iter().all(|l| !l.label.contains("fee"))); + assert!( + reminder_lines(&reminder(dec!(0), dec!(1081.00), dec!(0)), &i18n, "CHF").is_empty() + ); + } + + /// Story 25-4-b2 (AC 5) — un rappel se rend, et n'est pas la facture : titre + /// et bloc ajoutent du texte (delta de taille, date figée). + #[test] + fn reminder_pdf_renders_and_differs_from_the_invoice() { + let (data, base, i18n) = invoice_fixture(); + let facture = generate_qr_bill_pdf_with_date(&data, &base, &i18n, fixed_date()) + .unwrap() + .len(); + let rappel = InvoicePdfData { + reminder: Some(reminder(dec!(900.00), dec!(392.40), dec!(20.00))), + ..base + }; + let bytes = generate_qr_bill_pdf_with_date(&data, &rappel, &i18n, fixed_date()).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.")); + assert!( + bytes.len() > facture, + "le rappel porte titre et bloc : {} ≤ {facture}", + bytes.len() + ); + } + + /// Story 25-4-b2 (AC 10) — ⛔ la hauteur du bloc est RÉSERVÉE : il existe un + /// nombre de lignes que la facture tient et que le rappel complet (trois + /// lignes et la mention) refuse en `TooManyLines`, au lieu de se tasser sur + /// la zone de paiement. Et un rappel ne tient jamais là où la facture ne + /// tient pas. + #[test] + fn reminder_block_is_reserved_in_the_capacity_guard() { + let (data, base, i18n) = invoice_fixture(); + let with_lines = |n: usize, r: Option| InvoicePdfData { + lines: (0..n) + .map(|i| InvoiceLinePdf { + description: format!("Ligne {i}"), + quantity: dec!(1), + unit_price: dec!(100.00), + vat_rate: dec!(7.70), + line_total: dec!(100.00), + }) + .collect(), + reminder: r, + ..base.clone() + }; + let full = || Some(reminder(dec!(100.00), dec!(50.00), dec!(20.00))); + let mut discriminated = false; + for n in 1..=12 { + let facture_ok = generate_qr_bill_pdf(&data, &with_lines(n, None), &i18n).is_ok(); + let rappel = generate_qr_bill_pdf(&data, &with_lines(n, full()), &i18n); + if rappel.is_ok() { + assert!( + facture_ok, + "{n} lignes : le rappel tient là où la facture ne tient pas" + ); + } else { + assert!(matches!(rappel, Err(QrBillError::TooManyLines(m)) if m == n)); + discriminated |= facture_ok; + } + } + assert!( + discriminated, + "aucun nombre de lignes ne sépare facture et rappel : la réserve ne sert à rien" + ); + } + + /// Story 25-4-b2 (AC 8) — la mention de repli tient dans sa largeur. Les + /// traductions sont bornées côté API, sur les 4 locales. + #[test] + fn reminder_fees_note_fallback_fits_its_width() { + let note = QrBillI18n::default() + .get("invoice-pdf-reminder-fees-note") + .to_string(); + assert!(note.chars().count() <= REMINDER_NOTE_MAX_CHARS); + } + /// #151 (code-review HIGH) : une facture dont les lignes **plus** le bloc /// récap TVA multi-taux ne tiennent pas au-dessus du séparateur QR /// (`SEP_Y`) doit être **refusée** (`Err`) plutôt que rendue avec le récap @@ -1810,6 +2048,7 @@ mod tests { let lines = build_meta_lines( &InvoicePdfData { origin_reference: Some(origin.into()), + reminder: None, ..base }, &i18n, diff --git a/crates/kesh-qrbill/src/types.rs b/crates/kesh-qrbill/src/types.rs index b68a91bac..d83f2fef3 100644 --- a/crates/kesh-qrbill/src/types.rs +++ b/crates/kesh-qrbill/src/types.rs @@ -156,6 +156,29 @@ pub struct InvoicePdfData { /// Référence à la facture d'origine, affichée uniquement sur les avoirs /// (Story 12.1). `None` pour une facture normale (pas de régression). pub origin_reference: Option, + /// Story 25-4-b2 (#416) — `Some` fait du document un **rappel** : titre + /// « Rappel », et sous le total, ce qui est déjà réglé, le reste à payer et + /// les frais de rappel. `None` pour une facture ou un avoir (pas de + /// régression). Même patron conditionnel qu'`origin_reference`. + pub reminder: Option, +} + +/// Les montants d'un rappel (Story 25-4-b2, #416), **déjà calculés et arrondis** +/// par l'appelant — le crate reste présentationnel. +/// +/// ⛔ La QR du rappel porte `amount_due`, jamais `total` ni `amount_due + fees` : +/// les frais ne sont pas comptabilisés, un virement qui les inclurait serait +/// refusé comme trop-perçu. C'est à l'appelant de passer ce même montant dans +/// `QrBillData::amount`. +#[derive(Debug, Clone)] +pub struct ReminderPdf { + /// Total des règlements enregistrés. Nul ⇒ les lignes « déjà réglé » et + /// « reste à payer » ne s'affichent pas : le total **est** le reste. + pub amount_settled: Decimal, + /// Reste dû, arrondi au centime — le montant de la QR. + pub amount_due: Decimal, + /// Frais de rappel cumulés. Nuls ⇒ ni ligne de frais, ni mention. + pub fees: Decimal, } #[derive(Debug, Clone)] @@ -255,6 +278,13 @@ pub const I18N_KEYS: &[&str] = &[ // seul filet contre le décalage d'appariement (cf. le doc-comment de // l'assertion ci-dessous). "invoice-pdf-client-number", + // Story 25-4-b2 (#416) — le rappel. Ajoutées EN FIN, mêmes positions que + // dans `DEFAULT_EN`. + "invoice-pdf-reminder-title", + "invoice-pdf-settled", + "invoice-pdf-amount-due", + "invoice-pdf-reminder-fees", + "invoice-pdf-reminder-fees-note", ]; /// ⚠️ Invariant tenu **à la compilation** : `I18N_KEYS` et `DEFAULT_EN` ont @@ -312,6 +342,12 @@ const DEFAULT_EN: &[&str] = &[ "Web", // Story 16-3b (#151) — MÊME POSITION que la dernière de `I18N_KEYS`. "Client no.", + // Story 25-4-b2 (#416) — MÊMES POSITIONS que les cinq dernières de `I18N_KEYS`. + "Reminder", + "Already paid", + "Amount due", + "Reminder fees", + "Reminder fees are not included in the payment slip.", ]; #[derive(Debug, Error)] diff --git a/crates/kesh-qrbill/tests/golden_test.rs b/crates/kesh-qrbill/tests/golden_test.rs index da15949eb..ea1cfa081 100644 --- a/crates/kesh-qrbill/tests/golden_test.rs +++ b/crates/kesh-qrbill/tests/golden_test.rs @@ -82,6 +82,7 @@ fn sample_invoice() -> InvoicePdfData { total: dec!(1329.62), // 1234.56 + 95.06 currency: Currency::Chf, origin_reference: None, + reminder: None, } } diff --git a/docs/manual/fr/admin-manual.pdf b/docs/manual/fr/admin-manual.pdf index 7ae58b9fc..ae3e9545f 100644 Binary files a/docs/manual/fr/admin-manual.pdf and b/docs/manual/fr/admin-manual.pdf differ diff --git a/docs/manual/fr/admin-manual.tex b/docs/manual/fr/admin-manual.tex index eb7aad8c5..1e662d596 100644 --- a/docs/manual/fr/admin-manual.tex +++ b/docs/manual/fr/admin-manual.tex @@ -1166,6 +1166,18 @@ \subsection{Mod\`eles d'e-mail}\label{sec:email-templates-admin} \texttt{\{companyName\}} — remplac\'ees automatiquement \`a l'envoi (montants et dates au format suisse). Une variable inconnue est refus\'ee \`a l'enregistrement avec un message explicite. + \item \textbf{Variables propres aux rappels} : + \texttt{\{reminderLevel\}} (niveau), \texttt{\{reminderFee\}} + (frais du seul niveau envoy\'e), \texttt{\{totalDue\}} (\textbf{reste + d\^u} de la facture \textbf{plus} les frais de rappel cumul\'es), + \texttt{\{daysOverdue\}} (jours de retard) et \texttt{\{feeNotice\}} + (phrase disant les frais compris dans \texttt{\{totalDue\}}, + pr\'ec\'ed\'ee d'une espace, \textbf{vide} sans frais) ; + \texttt{\{amount\}} reste le montant total de la facture. Les mod\`eles + fournis n'\'ecrivent aucune phrase de frais en dur et passent par + \texttt{\{feeNotice\}} : \`a frais nuls, ils n'en parlent pas. Un mod\`ele + \textbf{personnalis\'e} qui \'ecrit \texttt{\{reminderFee\}} en toutes + lettres affichera « 0.00 » sans frais — c'est \`a vous de l'\'eviter. \item \textbf{Z\'ero configuration requise} : un mod\`ele par d\'efaut soign\'e est fourni pour chaque langue — une PME peut envoyer une facture correcte sans jamais ouvrir cette section. @@ -1197,7 +1209,7 @@ \subsubsection{Période de grâce} \end{keshwarning} \begin{keshnote} -En v0.7, les frais de rappel sont \textbf{affichés mais non comptabilisés} (produit accessoire) et \textbf{ne figurent pas sur la QR-facture} : le montant demandé par la QR-facture reste le \textbf{total TTC de la facture d'origine}, jamais le total augmenté des frais. Les frais apparaissent dans le courrier de rappel et dans l'historique, à titre de créance accessoire. +Les frais de rappel sont \textbf{affichés mais non comptabilisés} (produit accessoire, issue \#401) et \textbf{ne figurent pas sur la QR-facture} du rappel : depuis la version \textbf{0.12.1}, celle-ci demande le \textbf{reste dû} de la facture (son total TTC diminué des règlements partiels), jamais augmenté des frais. Les frais apparaissent dans le courrier de rappel, sur une ligne à part du rappel PDF et dans l'historique, à titre de créance accessoire. \end{keshnote} \subsection{J'ai oublié mon mot de passe administrateur (recovery break-glass)} diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index 44a073f25..bb5b4dd8f 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 a10c56b34..eaee416f0 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -901,15 +901,24 @@ \subsection{Envoyer une facture par e-mail}\label{sec:envoi-email} \subsubsection{Personnaliser les modèles d'e-mail} +\begin{sloppypar} \emph{Paramètres → Modèles d'e-mail} (réservé à l'administrateur) : l'objet et le corps du message se personnalisent par langue (onglets FR/DE/IT/EN), avec des variables remplacées automatiquement à l'envoi (\texttt{\{salutation\}}, \texttt{\{contactName\}}, \texttt{\{invoiceNumber\}}, \texttt{\{amount\}}, \texttt{\{dueDate\}}, -\texttt{\{companyName\}} — la liste est affichée à côté de l'éditeur). Un +\texttt{\{companyName\}} — la liste est affichée à côté de l'éditeur). Les +modèles de \textbf{rappel} ont en plus : \texttt{\{reminderLevel\}} (le niveau), +\texttt{\{reminderFee\}} (les frais du seul niveau envoyé), +\texttt{\{totalDue\}} (le \textbf{reste dû} de la facture \textbf{plus} les frais +de rappel cumulés), \texttt{\{daysOverdue\}} (les jours de retard) et +\texttt{\{feeNotice\}} (une phrase qui dit les frais compris dans +\texttt{\{totalDue\}}, \textbf{vide} quand il n'y en a pas) ; +\texttt{\{amount\}} y reste le montant total de la facture. Un modèle par défaut est fourni pour chaque langue ; \emph{Restaurer le défaut} y revient à tout moment. Sans aucune personnalisation, les envois utilisent les modèles par défaut — rien à configurer. +\end{sloppypar} \subsubsection{Langue et civilité du contact, adresse de réponse} @@ -992,13 +1001,23 @@ \subsection{Relancer les débiteurs}\label{sec:relances-user} \keshscreenshot{rappels-liste.png}{Écran Rappels : factures à relancer groupées par débiteur, prochain niveau, envoi unitaire ou par lot.} % TODO capture: page /invoices/reminders \begin{itemize} - \item \textbf{Envoi unitaire} : un aperçu du courrier (dans la langue et le ton du niveau — 1er rappel courtois jusqu'à la mise en demeure) s'affiche, modifiable avant envoi ; la \textbf{QR-facture PDF} est jointe. Le destinataire est \textbf{verrouillé} sur l'e-mail de la fiche contact (sécurité). + \item \textbf{Envoi unitaire} : un aperçu du courrier (dans la langue et le ton du niveau — 1er rappel courtois jusqu'à la mise en demeure) s'affiche, modifiable avant envoi ; un \textbf{rappel PDF} est joint (voir ci-dessous). Le destinataire est \textbf{verrouillé} sur l'e-mail de la fiche contact (sécurité). \item \textbf{Envoi par lot} : sélectionnez plusieurs factures (jusqu'à 20) et envoyez-les d'un coup, chacune à son prochain niveau ; un compte-rendu liste les envois réussis et les échecs par facture. \item \textbf{Rappel manuel} : enregistrez un rappel déjà envoyé hors Kesh (courrier, recommandé) — utile pour tracer un envoi papier. Le saut direct à un niveau élevé (mise en demeure) est autorisé. \item Un contact \textbf{sans adresse e-mail} reste visible (badge « sans e-mail ») : l'envoi e-mail est impossible, mais le rappel papier reste enregistrable. \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. + +\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). +\end{keshwarning} + +\begin{keshnote} +Le rappel PDF ne part qu'avec un rappel \textbf{envoyé par e-mail}. Pour une mise en demeure envoyée hors de Kesh, ou pour un contact sans adresse e-mail, le seul PDF imprimable est celui de la \textbf{facture}, dont la QR porte toujours le \textbf{montant total} : ne le joignez pas tel quel à une facture déjà réglée en partie (issue \#477). +\end{keshnote} + \begin{keshtip} Pour le \textbf{dernier niveau} (mise en demeure), privilégiez un envoi \textbf{recommandé} : il constitue une preuve de la mise en demeure en cas de poursuite. Envoyez le recommandé hors Kesh, puis enregistrez-le via \textbf{« Rappel manuel »} pour en garder la trace dans l'historique. \end{keshtip} diff --git a/docs/testing.md b/docs/testing.md index b8074e2ff..f2032cd8d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -325,6 +325,15 @@ re-dériver à la main quels échecs sont connus. Le risque n'est pas le temps p l'inverse : conclure « ce sont les échecs habituels » sans regarder, et laisser passer une vraie régression au milieu. +⛔ **Une DOUZAINE d'échecs d'un coup, tous en `page.fill('#username')` : KF-053 ([#478]).** +Observé deux fois le 2026-09-27 (214 / 19, puis 214 / 20). `/login` rend la page SvelteKit +« Erreur 500 — Internal Error » au lieu du formulaire, le backend ne journalise rien, et le run +dure ~14,7 min au lieu de ~9. Les specs touchées changent d'un run à l'autre et **passent toutes +rejouées seules** ; une suite relancée sur base reconstruite revient à la baseline. **Ce n'est pas +une régression — mais cela ne se conclut qu'après le rejeu isolé, jamais au nombre.** + +[#478]: https://github.com/guycorbaz/kesh/issues/478 + **Relevé du 2026-08-26** (montage complet de la § précédente, base `kesh_e2e` reconstruite) : | Test | Cause | diff --git a/frontend/src/lib/features/reminders/reminder-error-label.test.ts b/frontend/src/lib/features/reminders/reminder-error-label.test.ts index 9ad1c6b86..96d40fb6a 100644 --- a/frontend/src/lib/features/reminders/reminder-error-label.test.ts +++ b/frontend/src/lib/features/reminders/reminder-error-label.test.ts @@ -14,6 +14,10 @@ describe('reminderErrorLabel', () => { expect(reminderErrorLabel('INVOICE_ALREADY_PAID')).toBe('Facture déjà payée'); }); + it('libelle le refus du reste nul (Story 25-4-b2), pas le fallback brut', () => { + expect(reminderErrorLabel('REMINDER_NOTHING_DUE')).toBe('Rien à réclamer'); + }); + it('retombe sur le fallback avec le code brut pour un code inconnu', () => { expect(reminderErrorLabel('SOMETHING_NEW')).toContain('SOMETHING_NEW'); }); diff --git a/frontend/src/lib/features/reminders/reminder-error-label.ts b/frontend/src/lib/features/reminders/reminder-error-label.ts index aff1eae5e..e5f700f7b 100644 --- a/frontend/src/lib/features/reminders/reminder-error-label.ts +++ b/frontend/src/lib/features/reminders/reminder-error-label.ts @@ -18,6 +18,8 @@ const LABELS: Record = { REMINDER_CONTENT_EMPTY: ['content-empty', 'Modèle de rappel vide'], REMINDER_CONTENT_TOO_LONG: ['content-too-long', 'Contenu du rappel trop long'], INVOICE_NOT_PDF_READY: ['not-pdf-ready', 'Facture non imprimable en PDF'], + // Story 25-4-b2 (#416) : reste dû nul — rien à réclamer. + REMINDER_NOTHING_DUE: ['nothing-due', 'Rien à réclamer'], RATE_LIMITED: ['rate-limited', "Limite d'envoi atteinte"], DATABASE_ERROR: ['database-error', 'Erreur technique'], // Codes « e-mail parti » : le message dit explicitement qu'il a été envoyé. diff --git a/frontend/src/lib/features/reminders/reminders.types.ts b/frontend/src/lib/features/reminders/reminders.types.ts index 1539d859a..4651b6229 100644 --- a/frontend/src/lib/features/reminders/reminders.types.ts +++ b/frontend/src/lib/features/reminders/reminders.types.ts @@ -47,6 +47,13 @@ export interface ReminderPreviewResponse { level: number; subject: string; body: string; + /** + * Story 25-4-b2 (#416) — reste dû (arrondi) et frais cumulés sur lesquels le + * texte a été rendu (décimales string). À renvoyer à l'envoi : le serveur refuse + * (409 `REMINDER_AMOUNTS_CHANGED`) s'ils ont changé entre-temps. + */ + amountDue: string; + fees: string; } // --- Envoi unitaire (POST /api/v1/invoices/{id}/reminders/send) --- @@ -56,6 +63,9 @@ export interface SendReminderRequest { levelNumber: number; subject: string; body: string; + /** Montants de l'aperçu (Story 25-4-b2) — cf. `ReminderPreviewResponse`. */ + expectedAmountDue?: string; + expectedFees?: string; } /** Un rappel enregistré (réponse d'envoi unitaire / manuel, 201). */ diff --git a/frontend/src/routes/(app)/invoices/reminders/+page.svelte b/frontend/src/routes/(app)/invoices/reminders/+page.svelte index ac2735b24..7dc3f1817 100644 --- a/frontend/src/routes/(app)/invoices/reminders/+page.svelte +++ b/frontend/src/routes/(app)/invoices/reminders/+page.svelte @@ -196,7 +196,17 @@ sendingUnit = true; // (C) sendError = ''; try { - await sendReminder(sendTarget.invoiceId, { levelNumber, subject, body }); + // Story 25-4-b2 (#416) : les montants de l'aperçu repartent avec le texte — + // si un règlement est arrivé entre-temps, le serveur refuse (409 + // REMINDER_AMOUNTS_CHANGED) plutôt que d'envoyer un courrier dont le + // montant ne serait plus celui de la QR jointe. + await sendReminder(sendTarget.invoiceId, { + levelNumber, + subject, + body, + expectedAmountDue: sendPreview?.amountDue, + expectedFees: sendPreview?.fees, + }); notifySuccess(i18nMsg('reminders-send-success', 'Rappel envoyé')); sendOpen = false; await load(); diff --git a/frontend/src/routes/(app)/settings/dunning/+page.svelte b/frontend/src/routes/(app)/settings/dunning/+page.svelte index 0df9e67f3..20d9ef970 100644 --- a/frontend/src/routes/(app)/settings/dunning/+page.svelte +++ b/frontend/src/routes/(app)/settings/dunning/+page.svelte @@ -260,7 +260,7 @@

- {i18nMsg('dunning-cgv-hint', 'Les frais de rappel ne sont exigibles qu’avec une base contractuelle (CGV). Ils ne sont pas inclus dans le QR de la facture jointe.')} + {i18nMsg('dunning-cgv-hint', 'Les frais de rappel ne sont exigibles qu’avec une base contractuelle (CGV). Ils ne sont pas inclus dans le QR du rappel joint.')}

diff --git a/frontend/tests/e2e/dunning-roundtrip.spec.ts b/frontend/tests/e2e/dunning-roundtrip.spec.ts index 6f3fed0c7..3df256981 100644 --- a/frontend/tests/e2e/dunning-roundtrip.spec.ts +++ b/frontend/tests/e2e/dunning-roundtrip.spec.ts @@ -78,8 +78,9 @@ test('round-trip rappels : config → envoi unitaire + lot → historique → su .toBe(before + 1); const unit = (await fetchSentEmails(page)).at(-1)!; expect(unit.to).toBe('debiteur-a@example.ch'); - // La PJ du rappel est la QR-facture PDF (backend `facture-{base}.pdf`). - expect(String(unit.attachmentFilename)).toMatch(/^facture-.*\.pdf$/); + // La PJ du rappel est un RAPPEL (Story 25-4-b2, #416 : backend `rappel-{base}.pdf`), + // dont la QR porte le reste dû — plus la facture réémise. + expect(String(unit.attachmentFilename)).toMatch(/^rappel-.*\.pdf$/); // (5) ENVOI LOT (facture B) — la facture A a quitté la liste après son envoi // niveau 1 ; B reste sélectionnable. diff --git a/frontend/tests/e2e/reminders.spec.ts b/frontend/tests/e2e/reminders.spec.ts index 8b31edde4..1d0233362 100644 --- a/frontend/tests/e2e/reminders.spec.ts +++ b/frontend/tests/e2e/reminders.spec.ts @@ -11,7 +11,13 @@ import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; -import { seedTestState, clearAuthStorage, fetchSentEmails } from './helpers/test-state'; +import { + seedTestState, + clearAuthStorage, + fetchSentEmails, + authedApiContext, + disposeContextSafe, +} from './helpers/test-state'; import { createContactWithAddressViaApi, ensurePrimaryBankAccountViaApi, @@ -70,6 +76,51 @@ test.describe('Page Rappels (Story 21-6b)', () => { expect(String(sent.attachmentFilename)).toMatch(/\.pdf$/); }); + // Story 25-4-b2 (#416, AC 10) — une facture RÉGLÉE EN PARTIE : l'aperçu du + // rappel réclame le reste dû, pas le TTC. 4.5 × 200.— à 8.1 % = 972.90 ; + // réglée de 400.— → reste 572.90, montant qui n'existait nulle part avant la + // story (le texte réclamait 972.90 + frais). Seule la frontière HTTP réelle + // dit que la grandeur traverse jusqu'à l'écran. + test('facture réglée en partie : l’aperçu réclame le reste dû', async ({ page }) => { + await login(page); + await ensurePrimaryBankAccountViaApi(page); + const name = uniq('Partiel SA'); + const contact = await createContactWithAddressViaApi(page, name, 'partiel@example.ch', 'Madame'); + const invoiceId = await createAndValidateInvoiceViaApi(page, contact, overdueDate()); + const ctx = await authedApiContext(page); + try { + const accounts = await ctx.get('/api/v1/accounts?includeArchived=false'); + expect(accounts.ok(), `comptes: ${accounts.status()}`).toBeTruthy(); + const cash = ( + (await accounts.json()) as { + id: number; + number: string; + accountType: string; + active: boolean; + postable: boolean; + }[] + ).find((a) => a.active && a.postable && a.accountType === 'Asset' && a.number.startsWith('10')); + expect(cash, 'un compte de liquidités imputable').toBeTruthy(); + const settled = await ctx.post(`/api/v1/invoices/${invoiceId}/settlements`, { + data: { + settlementType: 'internal_account', + accountId: cash!.id, + amount: '400.00', + settledOn: new Date().toISOString().slice(0, 10), + }, + }); + expect(settled.ok(), `règlement: ${settled.status()}`).toBeTruthy(); + } finally { + await disposeContextSafe(ctx); + } + + await page.goto('/invoices/reminders'); + const group = page.locator('div.rounded', { hasText: name }); + await group.getByTestId('reminder-row').first().getByTestId('reminder-send-open').click(); + await expect(page.getByTestId('reminder-send-confirm')).toBeEnabled(); + await expect(page.getByRole('dialog').locator('textarea')).toHaveValue(/572\.90/); + }); + test('contact sans e-mail : badge + case désactivée + envoi absent, manuel possible', async ({ page, }) => {