diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bd35c911..95299d6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **Un rapprochement et un règlement manuel enregistrés au même moment pouvaient régler deux fois la même facture ([#480](https://github.com/guycorbaz/kesh/issues/480)).** Un règlement manuel **partiel** ne marquait pas la facture comme modifiée : une acceptation de rapprochement en cours, qui avait lu le reste dû juste avant, l'encaissait une seconde fois — une facture de 1 000.— finissait réglée 1 400.—, et le compte clients devenait créditeur sans que rien ne le signale. Chaque règlement marque désormais la facture, et l'acceptation qui arrive trop tard est refusée sans rien écrire (« la facture a changé ») : il suffit de relire les propositions. Un interblocage entre deux opérations simultanées, qui rendait une erreur interne, est maintenant rejoué par le serveur. +- **Une facture dont le total a plus de deux décimales ne pouvait pas être soldée au centime ([#476](https://github.com/guycorbaz/kesh/issues/476)).** Une ligne à prix ou quantité fractionnaire donne un reste dû comme 10.0050, alors que la QR-facture, les rappels et le dialogue de règlement réclament 10.01. Ce virement de 10.01 n'était pas proposé au rapprochement (score de montant nul), et, saisi à la main, il était **refusé comme trop-perçu** — le dialogue refusait le montant qu'il venait de proposer. Un paiement de 10.00 laissait la facture « partiellement réglée » pour un demi-centime. Désormais, toute comparaison se fait **au centime** ; un paiement égal au reste arrondi **solde** la facture, le règlement est enregistré au reste exact, et l'écart passe en **troisième ligne** d'écriture sur le compte de différences d'arrondi — la créance se ferme à zéro. Sans compte utilisable désigné, ce paiement est **refusé sans rien écrire** (`ROUNDING_ACCOUNT_NOT_CONFIGURED`, 400 au règlement manuel, refus par proposition au rapprochement). Un règlement manuel à plus de deux décimales est refusé (`VALIDATION_ERROR`). ⚠️ Reste ouvert : un reste exact inférieur au demi-centime (avoir de 10.00 sur 10.004) ne peut toujours pas être soldé ([#490](https://github.com/guycorbaz/kesh/issues/490)). + ### Changed - **`invoices.csv` de l'export de souveraineté gagne trois colonnes** — `pdf_sha256`, `pdf_frozen_at`, `pdf_language`, l'empreinte, la date et la langue du PDF émis ([#387](https://github.com/guycorbaz/kesh/issues/387)). Elles s'insèrent **avant** `version`, `created_at` et `updated_at` : un outil qui lit ce fichier par position de colonne doit être ajusté. Le PDF lui-même n'est pas dans l'archive. L'API expose `pdfFrozenAt` et `pdfLanguage` sur la facture, et une route d'administration `POST /api/v1/invoices/{id}/pdf/refreeze` refige un document dont le fichier a disparu. @@ -90,7 +92,7 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ### Added -- **Désigner le compte de différences d'arrondi dans les paramètres ([#476](https://github.com/guycorbaz/kesh/issues/476)).** *Paramètres → Facturation* porte une nouvelle section, *Différences d'arrondi* : le compte — charge ou produit, actif et imputable — qui recevra l'écart d'un demi-centime au plus qu'un paiement arrondi au centime laisse sur une facture dont le total a plus de deux décimales. Les plans livrés (PME, indépendant, association) le proposent — **6940 *Différences d'arrondi***, une charge — et le **désignent d'office** à la création d'une société ; une société créée auparavant le crée dans le plan comptable, puis le choisit ici. Le réglage n'est encore lu par aucune écriture : le passage des écarts en écriture suit. *(API : `defaultRoundingAccountId` dans `GET` et `PUT /company/invoice-settings` ; absent du corps d'un `PUT`, il est préservé ; à `null`, effacé.)* +- **Désigner le compte de différences d'arrondi dans les paramètres ([#476](https://github.com/guycorbaz/kesh/issues/476)).** *Paramètres → Facturation* porte une nouvelle section, *Différences d'arrondi* : le compte — charge ou produit, actif et imputable — qui recevra l'écart d'un demi-centime au plus qu'un paiement arrondi au centime laisse sur une facture dont le total a plus de deux décimales. Les plans livrés (PME, indépendant, association) le proposent — **6940 *Différences d'arrondi***, une charge — et le **désignent d'office** à la création d'une société ; une société créée auparavant le crée dans le plan comptable, puis le choisit ici. L'écart y passe en écriture (cf. *Fixed*, même issue). *(API : `defaultRoundingAccountId` dans `GET` et `PUT /company/invoice-settings` ; absent du corps d'un `PUT`, il est préservé ; à `null`, effacé.)* - **Consulter le journal d'audit à l'écran.** La trace de chaque opération qui modifie les livres se lisait depuis la v0.12.0 par l'interface de programmation seulement, ce qui supposait un outil technique. Elle se consulte désormais dans l'application — *Administration → Journal d'audit*, pour les comptables et les administrateurs : filtres par période, type d'entité, numéro et action, actions et types traduits dans la langue de l'installation, détail de chaque entrée, export CSV. ⚠️ Les dates filtrent sur des **jours UTC** ; le tableau affiche l'heure locale. ([#378](https://github.com/guycorbaz/kesh/issues/378)) diff --git a/_bmad-output/implementation-artifacts/25-4-c3-b-arrondi-au-centime.md b/_bmad-output/implementation-artifacts/25-4-c3-b-arrondi-au-centime.md new file mode 100644 index 000000000..830c258e6 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-b-arrondi-au-centime.md @@ -0,0 +1,351 @@ +# Story 25.4-c3-b : L'arrondi au centime, et l'écart passé en écriture + +Status: done + +**Issue : [#476]** — ⛔ la PR porte `closes #476`, titre ET corps (§ *Issue Tracking Rule*). + +**Dernière des trois stories de la 25-4-c3.** Arbitrage de Guy (2026-09-30) : l'écart d'arrondi au +centime se **passe en écriture** sur le **compte de différences d'arrondi** désigné dans les paramètres +(`company_invoice_settings.default_rounding_account_id`, **c3-a1**, mergée #487), que les plans livrés +proposent et désignent d'office (`6940`, **c3-a2**, PR #489). **Empilée sur la c3-a2** : branche +`story/25-4-c3-b-arrondi-au-centime`. + +Les findings de la 25-4-c (validation P1 F1–F3, P3 F4) et de sa validation sont la source des faits, +recontrôlés ci-dessous. + +## Story + +En tant que comptable, +je veux qu'un paiement du montant arrondi au centime solde exactement une facture dont le total a plus de +deux décimales, +afin que la créance se ferme à zéro, sans reste d'un demi-centime ni solde créditeur, et que l'écart soit +visible au grand livre. + +## Le défaut, vérifié dans le code + +`line_total` est à 4 décimales (`invoices.rs`), la TVA seule arrondie à 2 : le **reste dû peut porter 4 +décimales** (10.0050). La QR, les rappels (`reminder_amount_due`, `invoice_pdf_service.rs:112`) et le +dialogue de règlement (`SettleInvoiceDialog.svelte:75`, `toFixed(2)`) réclament **10.01**. Les comparaisons, +elles, lisent le **brut** : + +| # | Site | Aujourd'hui | Effet sur un reste brut de 10.0050 | +|---|---|---|---| +| 1 | Filtre des candidats (`reconciliation.rs` dépôt, `HAVING amount_due BETWEEN`) | brut ± 0.05 | candidat — **juste**, à garder (écart ≤ 0.005 < 0.05) | +| 2 | Score — triplet des propositions (`reconciliation.rs:578`) et re-score (`:1304`) | brut | 10.01 ≠ 10.005 : score de montant **0** | +| 3 | Montant affiché (`invoiceAmount`, `:602`) et mention TTC | brut | « 10.005 » | +| 4 | Garde de trop-perçu de l'acceptation (`:1444`) | `tx.amount > due_before` brut | 10.01 **refusé** en trop-perçu | +| 5 | Solde de l'acceptation (`:1559`) | `due_after <= 0` brut | — (4 refuse d'abord) | +| 6 | Garde de trop-perçu du règlement manuel (`invoice_settlements_write.rs:167`) | brut | 10.01 **refusé** | +| 7 | Solde du règlement manuel (`:246`) | brut | un paiement de 10.00 laisse 0.0050 : « partiellement réglée » pour un demi-centime | +| 8 | Contrôle de saisie du dialogue (`SettleInvoiceDialog.svelte:111`) | `n > Number(amountDue)` | le **10.01 qu'il pré-remplit lui-même** est refusé | + +Et **payer 10.01 une facture de 10.0050 en arrondissant seulement la comparaison** laisserait la créance +**créditrice de 0.0050** au grand livre (lignes en `DECIMAL(19,4)`) — d'où l'arbitrage : l'écart s'écrit. + +L'arrondi existe déjà : `Money::round_to_centimes()` (`crates/kesh-core/src/types/money.rs:66`, +`MidpointAwayFromZero`) et `reminder_amount_due` (`invoice_pdf_service.rs:112`, qui arrondit et refuse un +reste nul). + +## Acceptance Criteria + +**AC 1 — Une seule définition du « reste dû au centime ».** Un helper, à côté d'`amount_due` +(`invoice_settlements.rs`), rend le reste dû arrondi au centime en s'appuyant sur +`Money::round_to_centimes()` — sans recopier la stratégie. `reminder_amount_due` l'appelle (son refus du +reste nul inchangé). ⛔ Le reste dû **calculé** (`amount_due`, `INVOICE_AMOUNT_DUE_DERIVED_SQL`) n'est pas +modifié : seules les comparaisons et l'affichage arrondissent. + +**AC 2 — Le rapprochement.** Sites 2 et 3 : le triplet des propositions et le re-score reçoivent le reste +dû **arrondi** ; `invoiceAmount` affiche le reste arrondi, et la mention `invoiceTotalTtc` compare et +affiche au centime. Site 1 inchangé (le doc-comment dit pourquoi la tolérance suffit). Site 4 : la garde +de trop-perçu compare au reste **arrondi**. + +**AC 3 — Le solde exact, avec la troisième ligne.** Quand un paiement (rapprochement **ou** règlement +manuel) **égale le reste dû arrondi** et que le reste **brut** en diffère : +- le règlement est enregistré pour le reste **brut** (`invoice_settlements.amount`, `DECIMAL(19,4)`), si + bien que le reste dû tombe à **zéro exactement** et que `paid_at` se pose ; +- l'écriture porte **trois lignes** : débit contrepartie (banque, caisse…) du montant **payé** ; crédit + créance du reste **brut** ; et l'**écart** sur le compte de différences d'arrondi — au **crédit** si le + paiement dépasse le brut (10.01 contre 10.0050), au **débit** sinon (10.00 contre 10.004). Écriture + équilibrée, aucune ligne à zéro (`chk_jel_debit_credit_exclusive`). +- ⛔ **La classification tient compte des DEUX bornes, dans cet ordre** (brut `b`, arrondi `r`, payé `p`) : + 1. `p == r` et `r != b` → solde avec écart (ci-dessus) — y compris quand `r < b` (10.00 sur 10.004) ; + 2. `p > b` → **trop-perçu refusé** — y compris un montant **strictement entre** `b` et `r` (10.008 sur + 10.0050) : sans cette borne, la garde arrondie de l'AC 2 le laisserait passer en « partiel », `due_after` + deviendrait négatif, `paid_at` se poserait **sans écriture d'écart** et la créance resterait créditrice — + le défaut que la story ferme (validation P1, F1) ; + 3. sinon (`p <= b`) → règlement ordinaire au montant payé, deux lignes (partiel, ou solde exact si `p == b`). + La garde de trop-perçu des sites 4 et 6 **est** cette règle : `p > b && p != r` → refus. L'AC 2 (« compare + au reste arrondi ») se lit ainsi. +- **Le règlement manuel refuse un montant à plus de deux décimales** (400, `scale_within(&amount.normalize(), 2)` dans + `SettleInvoiceRequest::into_parts`, `invoices.rs:1141`, patron de `dunning_levels.rs:82`) : un paiement + se fait au centime. Aujourd'hui aucun contrôle d'échelle n'existe (`invoices.rs:1118-1125`). La double + borne reste la garde de fond (la couche dépôt ne présume pas du handler). Le rapprochement n'est pas + exposé : `bank_transactions.amount` est `DECIMAL(18,2)`. ⚠️ **`normalize()` d'abord** : `scale_within` lit + `Decimal::scale()` (`limits.rs:31`), et « 10.000 » a une échelle de 3 pour une valeur au centime — sans + normalisation, une saisie correcte serait refusée (validation P2). `normalize()` retire **tous** les + zéros de fin (« 10.000 » → « 10 », « 10.10 » → « 10.1 ») : c'est un **contrôle**, pas une mise à + l'échelle. `into_parts` rend le montant **tel que reçu** — la comparaison `Decimal` est insensible à + l'échelle, et `invoice_settlements.amount` le stocke en `DECIMAL(19,4)` (validation P3, F1). +- Reste brut déjà au centime : deux lignes, comme aujourd'hui. + +**AC 4 — Le compte d'arrondi manquant.** L'écart ne s'écrit que sur le compte désigné, **actif, imputable, +charge ou produit, de la société** (vérifié **au moment d'écrire** : il a pu être archivé depuis, #486). +Absent ou invalide → refus lisible, **sans rien écrire** : règlement manuel en 400 avec un code dédié (par +ex. `ROUNDING_ACCOUNT_NOT_CONFIGURED`) et un message qui renvoie à *Paramètres → Facturation* ; +rapprochement en `FailedProposal` du même code (pattern batch). Un paiement qui ne produit **pas** d'écart +n'exige rien. + +**AC 5 — Le règlement manuel et le dialogue.** Sites 6 et 7 : garde et solde au centime (AC 3). Site 8 : +le dialogue compare la saisie au reste **arrondi** (big.js, arrondi loin de zéro — l'équivalent de +`MidpointAwayFromZero` sur un positif) ; le 10.01 pré-rempli est accepté. Il **refuse aussi une saisie à +plus de deux décimales** (valeur normalisée : « 10.000 » passe, « 10.008 » non), avec un message propre +(clé neuve, 4 locales) — sans quoi 10.008 passerait le contrôle client (≤ 10.01) pour tomber sur le 400 +d'échelle du serveur. Avec ce contrôle, « saisie > reste arrondi » équivaut côté client à la double borne de +l'AC 3 (démontré en validation P3 : `r` vaut toujours le centime inférieur ou supérieur de `b`). +⛔ **Compter les décimales sur la forme canonique big.js** (`new Big(saisie).toString()`, partie après le +point ; big.js n'a pas de `.dp()`), **jamais par une regex sur la saisie brute** : le champ est du texte +libre, et « 1e-3 » n'a pas de point littéral pour trois décimales réelles (validation P3, F2). Le code d'erreur de l'AC 4 a son +libellé dans les 4 locales (`reminder-error-label.ts` / libellés d'erreur existants : suivre le patron du +dépôt). + +**AC 6 — L'annulation.** Annuler un règlement à trois lignes (règlement manuel annulé, rapprochement +annulé) contre-passe **les trois lignes** et rend le reste dû brut d'avant — à vérifier sur +`journal_entries::reverse_in_tx` (`:1437`) et par test. Le site de réouverture +(`invoice_settlements_write.rs:486`, `due_after > 0`) reste juste sur le brut — le doc-comment le dit. + +**AC 7 — Tests.** Chacun aurait échoué avant le patch : +- facture à reste brut **10.0050** : virement de **10.01** candidat, score de montant 1, **accepté**, facture + soldée (`paid_at`, audit `invoice.paid`), règlement de 10.0050, écriture à trois lignes (écart **crédit** + 0.0050 sur le compte d'arrondi), **créance soldée à zéro** au grand livre ; virement de 10.02 refusé en + trop-perçu ; +- règlement manuel de **10.008** sur reste brut 10.0050 → 400 avec le **code d'erreur d'échelle** asserté + (et non le seul statut : la garde actuelle le refuse déjà en 400 *trop-perçu*, un test sur le statut + serait vert avant le patch — validation P3, F4), rien d'écrit ; et, au niveau du dépôt (`settle_invoice` + appelé directement), **trop-perçu refusé**, `paid_at` non posé. ⚠️ Ce second test est vert avant le patch + aussi : c'est un **garde de non-régression de la double borne**, discriminé par la mutation « borne + `p > b` retirée », non par l'état d'avant ; +- facture à reste brut **10.004** : règlement manuel de **10.00** → soldée, écart **débit** 0.004 ; +- règlement manuel de 10.01 sur 10.0050 → soldé (API) ; « 10.000 » accepté (normalisation) ; Vitest du + dialogue : 10.01 accepté, 10.008 et « 1e-3 » refusés avec le message d'échelle ; +- règlements successifs 5.00 puis 5.01 sur 10.0050 → le premier partiel, le second solde avec écart ; +- compte d'arrondi absent, puis archivé → refus, rien d'écrit (manuel et rapprochement) ; +- annulation d'un règlement à trois lignes → reste dû restauré, créance et compte d'arrondi revenus ; +- `reminder_amount_due` inchangé (tests existants verts) ; +- mutations : helper court-circuité, troisième ligne retirée, borne `p > b` retirée (le test 10.008 rougit). + +**AC 8 — Textes.** `admin-manual.tex` (*Compte de différences d'arrondi*) : retirer « n'est encore lu par +aucune écriture » ; dire ce que fait l'écart et quand un règlement est refusé faute de compte. +`user-manual.tex` : là où il décrit le règlement manuel et le rapprochement, une phrase sur le solde au +centime et la troisième ligne. PDF régénérés, contrôlés aplatis (attention aux ligatures). CHANGELOG +`[0.12.1]` *Fixed* (#476). + +## Tasks / Subtasks + +- [x] **T1 — le helper** (AC 1) : `invoice_settlements`, `reminder_amount_due` rebranché. +- [x] **T2 — le rapprochement** (AC 2, 3, 4) : triplet, re-score, affichage, garde, solde et troisième ligne. +- [x] **T3 — le règlement manuel** (AC 3, 4, 5) : garde, solde, troisième ligne, code d'erreur. +- [x] **T4 — le dialogue et l'i18n** (AC 5). +- [x] **T5 — l'annulation** (AC 6). +- [x] **T6 — tests et mutations** (AC 7). +- [x] **T7 — textes** (AC 8). +- [x] **T8 — gates** : backend complet (base remise à zéro ; ⛔ `kesh-db` touché ⇒ gate complet même en + cours de boucle), frontend complet, **E2E complet**. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- ⛔ **Arrondir le reste dû calculé** ou le TTC stocké : seules les comparaisons et l'affichage arrondissent. +- ⛔ **Recopier la stratégie d'arrondi** (`round_dp_with_strategy(2, MidpointAwayFromZero)` à la main) : le + helper, sur `Money::round_to_centimes()`. +- ⛔ **Enregistrer le règlement au montant payé quand il solde** : le reste brut resterait à ±0.0050 et la + créance ne se fermerait pas. +- ⛔ **Arrondir le filtre SQL** (`ROUND` dans le `HAVING`) : la tolérance couvre l'écart, et ce serait une + seconde définition. +- ⛔ **Écrire l'écart sur un compte déduit** (produit par défaut, charges diverses) quand le réglage manque : + refuser (AC 4). +- ⚠️ **Le verrou optimiste de la 25-4-c2** (`UPDATE invoices … version = ?`) et l'ordre des gardes (score + avant trop-perçu) : ne rien y changer. + +### Sites hors des huit, triés (validation P1, F2) + +- **Échéancier** (`invoices/due-dates/+page.svelte:456`) et **en-tête de la fiche facture** + (`invoices/[id]/+page.svelte:1040`) affichent `amountDue` brut via `formatInvoiceTotal` → + `formatSwissAmount` → `big.toFixed(2)` (`journal-entries/balance.ts:93`). Le mode par défaut de big.js, + `roundHalfUp`, arrondit l'équidistant **loin de zéro** : c'est `MidpointAwayFromZero`, sur un positif comme + sur un négatif. **Hors périmètre, légitimes** : le frontend ne peut pas appeler le helper Rust, et son + formatage donne déjà le même centime. Rien à changer ; le dialogue (site 8) suit la même règle big.js. + +### Limite connue — le reste inférieur au demi-centime ([#490]) + +Un reste **brut** strictement entre 0 et 0.005 (avoir de 10.00 sur 10.004 ; ou règlement partiel de 10.00 +sur 10.004 **déjà enregistré** sous l'ancien code, qu'aucune migration ne rejoue) a un arrondi nul : aucun paiement ne l'égale, 0.01 le dépasse. La facture ne peut être soldée. +**Défaut antérieur** (0.01 est déjà refusé aujourd'hui), **ni créé ni fermé ici** : ne pas l'élargir dans +cette story. Un reste brut négatif ou nul reste inchangé (tout paiement `p > 0` est un trop-perçu). + +### Pièges + +- ⚠️ **`DbError::ConfigurationRequired` n'est PAS le patron de l'AC 4** (validation P1, F3). C'est le voisin + le plus proche (compte de banque manquant, `invoice_settlements_write.rs:126`), mais son mapping + (`kesh-api/src/errors.rs:2913-2922`) jette le champ dans un `warn!` et rend un code générique + `CONFIGURATION_REQUIRED`. L'AC 4 exige un code **dédié** : une variante propre (DbError → AppError → + `ROUNDING_ACCOUNT_NOT_CONFIGURED`), et son pendant `FailedProposal` au rapprochement. +- **La contre-passation d'un compte d'arrondi archivé est déjà couverte** (validation P1, F4) — non par la + garde `active` de `create_in_tx`, mais par le pré-contrôle `archived_accounts_in_tx` + (`journal_entries.rs:1723`, dans `reverse_in_tx_inner` `:1518`), qui rend un 400 + `ReversalAccountsArchived` **nommant** les comptes à réactiver. Rien à écrire pour l'AC 6 de ce côté ; + un test le confirme (annulation après archivage du compte d'arrondi → 400 nommant 6940). + +### Où regarder + +| Fichier | Pourquoi | +|---|---| +| `crates/kesh-db/src/repositories/invoice_settlements.rs` | `amount_due`, le helper à poser | +| `crates/kesh-core/src/types/money.rs:66` | `round_to_centimes` | +| `crates/kesh-api/src/routes/invoice_pdf_service.rs:112` | `reminder_amount_due` | +| `crates/kesh-api/src/routes/reconciliation.rs:570-610, 1300-1580` | propositions, re-score, garde, écriture, solde | +| `crates/kesh-db/src/repositories/invoice_settlements_write.rs:160-260, 470-500` | règlement manuel, annulation | +| `crates/kesh-db/src/repositories/journal_entries.rs:1437` | `reverse_in_tx` | +| `crates/kesh-db/src/repositories/company_invoice_settings.rs` | le réglage (c3-a1) | +| `crates/kesh-api/src/routes/invoices.rs:1183` | `settle_invoice_handler`, mapping d'erreur | +| `frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte:70-115` | dialogue | + +### Gardes-fous du dépôt + +- Aucune migration. Modules : `kesh-db`, `kesh-api`, `frontend`, `kesh-i18n` (+ `docs`) — sous le seuil. + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5 (`claude-opus-5-5`). + +### Debug Log References + +- Clippy `explicit_auto_deref` sur l'appel de `rounding_account_for_write` au rapprochement (`&mut **tx` → `tx`), + au premier gate complet. +- Mutation M1 d'abord **jouée à vide** : le remplacement, sans `assert`, ne trouvait plus la ligne après + `cargo fmt`. Rejouée avec contrôle (regex + `assert n == 1`). + +### Completion Notes List + +- **Le cœur, une seule fois** (`invoice_settlements.rs`) : `amount_due_to_centime` (sur + `Money::round_to_centimes`), `classify_payment` (double borne, trois cas dans l'ordre de l'AC 3) et + `settlement_journal_lines` (deux ou trois lignes, écart au crédit ou au débit). Rapprochement et règlement + manuel les appellent tous deux ; `reminder_amount_due` appelle le helper. +- **Le compte d'arrondi au moment d'écrire** : `company_invoice_settings::rounding_account_for_write` + (réglage + compte actif, imputable, charge ou produit, de la société, `FOR UPDATE`), appelé **seulement** + quand la classification produit un écart. Variante dédiée `DbError::RoundingAccountNotConfigured` → + 400 `ROUNDING_ACCOUNT_NOT_CONFIGURED` (message qui renvoie à *Paramètres → Facturation*) ; au rapprochement, + `FailedProposal` du même code. +- **Échelle** : `SettleInvoiceRequest::into_parts` refuse `scale_within(&amount.normalize(), 2)` en + `VALIDATION_ERROR` ; le montant est rendu tel que reçu. +- **Audits** : `settled_amount` est désormais le montant **réglé** ; `paid_amount` et `rounding_difference` + s'y ajoutent (règlement manuel, `reconciliation.accepted`, `invoice.paid`/`partially_settled`). +- **Dialogue** : `dueToCentime` (big.js `toFixed(2)`, `roundHalfUp`) pour le pré-remplissage et la + comparaison ; `centimesDigits` compte les décimales sur `new Big(saisie).toFixed()` ; la saisie envoyée est + nettoyée (`trim`). Clé `invoice-error-amount-scale` (4 locales, exemption #30 comme ses voisines), + `sitesTotal` 1756 → 1757 recompté (`SettleInvoiceDialog.svelte` 18 → 19 `i18nMsg(`). +- **AC 6** : aucun code — `reverse_in_tx` retourne toutes les lignes, `archived_accounts_in_tx` nomme le compte + d'arrondi archivé ; doc-comment de la réouverture mis à jour (il disait encore que l'encaissement ne bumpait + `version` qu'au solde, faux depuis la 25-4-c2). +- **Tests** (périmètre : ce commit contre `e44b2b1a`…`262100d2`, la branche avant dev) : **13** Rust neufs + (3 unitaires dans `invoice_settlements.rs`, 4 dans `reconciliation_e2e.rs`, 6 dans + `invoice_echeancier_e2e.rs`) et **6** Vitest (`SettleInvoiceDialog.test.ts`, fichier neuf) — recomptés par + `git diff | grep -cE '^\+\s*#\[(sqlx::)?test'` et `grep -c "it("`. +- **Mutations**, chacune tuée : M1 helper court-circuité (8 tests rouges) ; M2 troisième ligne retirée (6) ; + M3 borne `p > b` retirée (1 — le test d'échelle, volet dépôt) ; M4 refus d'échelle serveur retiré (1) ; + F1 dialogue comparé au brut (1) ; F2 contrôle d'échelle client retiré (2) ; F3 décimales comptées sur la + saisie brute (2). +- **Gates** : backend complet sur base remise à zéro, `scripts/test-fast.sh` (fmt + clippy + nextest) — + **2544/2544** ; frontend complet — check 0 erreur, lint i18n PASS, **845/845** Vitest, build ; **E2E + complet** sur `kesh_e2e` reconstruite, 18:26 UTC (KF-045 hors fenêtre) — **227 passés, 19 ignorés, + 9 échecs**, tous expliqués : les 7 KF-029, et `product-revenue-account:133` + `products:185`, pollution + d'état entre specs produits (rejoués seuls : un rouge au premier passage, les deux verts au second). + +### File List + +- `crates/kesh-db/src/errors.rs` +- `crates/kesh-db/src/repositories/invoice_settlements.rs` +- `crates/kesh-db/src/repositories/invoice_settlements_write.rs` +- `crates/kesh-db/src/repositories/company_invoice_settings.rs` +- `crates/kesh-db/src/repositories/reconciliation.rs` +- `crates/kesh-api/src/errors.rs` +- `crates/kesh-api/src/routes/invoices.rs` +- `crates/kesh-api/src/routes/invoice_pdf_service.rs` +- `crates/kesh-api/src/routes/reconciliation.rs` +- `crates/kesh-api/tests/reconciliation_e2e.rs` +- `crates/kesh-api/tests/invoice_echeancier_e2e.rs` +- `crates/kesh-i18n/locales/{fr-CH,de-CH,it-CH,en-CH}/messages.ftl` +- `frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte` +- `frontend/src/lib/features/invoices/SettleInvoiceDialog.test.ts` (neuf) +- `frontend/src/lib/shared/i18n-keys.test.ts` +- `frontend/scripts/lint-i18n-ownership.js` +- `docs/manual/fr/admin-manual.tex` + `.pdf` +- `docs/manual/fr/user-manual.tex` + `.pdf` +- `CHANGELOG.md` +- `_bmad-output/implementation-artifacts/sprint-status.yaml` + +## Change Log + +- **2026-09-30** — Créée : huit sites de comparaison recontrôlés ; au solde, règlement au reste **brut** et + écart en **troisième ligne** sur le compte désigné (arbitrage de Guy) ; refus lisible si le compte manque ou + est devenu invalide. +- **2026-09-30** — Validation P1 (Sonnet) : 1 HIGH, 1 MEDIUM, 2 LOW, tous retenus après contrôle dans le + code. **F1** (HIGH) : la garde arrondie de l'AC 2 × la classification binaire de l'AC 3 laissaient un + paiement manuel entre brut et arrondi (10.008 sur 10.0050) solder la facture sans écart — classification à + double borne, refus d'échelle > 2 au règlement manuel, test et mutation dédiés. **F2** (MED) : échéancier et + en-tête de facture triés hors périmètre (big.js `roundHalfUp` = loin de zéro). **F3** (LOW) : + `ConfigurationRequired` signalé comme faux patron. **F4** (LOW) : mécanisme d'archivage à la + contre-passation précisé (`archived_accounts_in_tx`), test ajouté à l'AC 7 par les Pièges. +- **2026-09-30** — Validation P2 (Haiku) : 2 findings rendus (MED, LOW), **tous deux réfutés** — ils + reprochent aux manuels de ne pas encore porter le texte que l'AC 8 demande d'écrire, ce que le prompt + excluait. La passe déclarait l'axe 3 non exercé et ne rendait rien sur l'impact du refus d'échelle : + **repris par l'orchestrateur**, qui y trouve trois défauts réels. (1) MED : `scale_within` lit l'échelle + et non la valeur (« 10.000 » refusé) → `normalize()` prescrit. (2) MED : le dialogue laissait passer + 10.008 jusqu'au 400 serveur → contrôle client des deux décimales, clé i18n neuve. (3) MED hors périmètre : + reste brut entre 0 et 0.005 insoldable, défaut antérieur → issue [#490], limite écrite. Tests ajoutés + (normalisation, deux paiements successifs). Aucune occurrence de règlement à plus de deux décimales dans + les tests existants (`grep` sur `crates/*/tests`, `frontend/tests`). +- **2026-09-30** — Validation P3 ciblée (Sonnet) sur la remédiation P2 : 3 MED, 1 LOW, tous retenus. + F1 (LOW) : `into_parts` rend le montant reçu, `normalize()` n'est qu'un contrôle. F2 (MED) : décimales + comptées sur la forme canonique big.js, pas par regex (« 1e-3 ») ; test ajouté. F3 (MED) : la seconde + voie d'arrivée de #490 décrivait le cas que la story **ferme** — reformulée (règlement historique non + rejoué), fiche et issue. F4 (MED) : le test 10.008 asserte le code d'échelle ; le test de dépôt déclaré + garde de mutation. Équivalence client ⇔ double borne démontrée par la lentille (algèbre + 10⁶ cas). +- **2026-09-30** — Validation P4 ciblée (Haiku) sur `b1853bb4` : **0 finding**, trois axes déclarés et + prouvés (six affirmations recontrôlées contre le code et big.js). Remarque non retenue : « `p > b && p != r` + → refus » est la condition exacte du refus, `p == r` relevant du cas 1. **Boucle close** : trend + 1 HIGH/1 MED/2 LOW → 3 MED (orchestrateur ; 2 réfutés à la lentille) → 3 MED/1 LOW → 0 ; lentilles + Sonnet → Haiku → Sonnet → Haiku ; P3 et P4 ciblées ; aucune remédiation ne touche de code. +- **2026-09-30** — Implémentée (T1–T8) : cœur partagé (`amount_due_to_centime`, `classify_payment`, + `settlement_journal_lines`), compte d'arrondi revérifié au moment d'écrire, code dédié, refus d'échelle, + dialogue au centime ; 13 tests Rust et 6 Vitest neufs, 7 mutations tuées ; gates backend 2544/2544, + frontend 845/845, E2E 227/19/9 expliqués. +- **2026-09-30** — Revue de code P1 (Sonnet ×3, prompt `25-4-c3-b-review-prompt-p1.md`) : A 1 MED, B 1 MED/2 LOW, + C 1 MED. **A-MED réfuté** (interblocage contrepartie × compte d'arrondi) : inventaire des verrous sur + `accounts` — le compte d'arrondi, unique par société, est toujours pris en DERNIER ; `validate_lines_accounts_in_tx` + lit sans verrou ; aucun chemin ne prend R puis un autre compte. **B-F1 reclassé LOW, préexistant** : le cycle + règlement manuel × rapprochement sur la même facture existe déjà par le verrou d'exercice ; R s'y insère dans + le même sens ; seul le règlement manuel n'est pas rejoué → issue [#491]. **C-MED retenu** : l'invariant de + `settlement_journal_lines` au rapprochement sortait en `DATABASE_ERROR` sans trace → `tracing::error!` et code + `INTERNAL_ERROR` (la signature `FailedProposal` interdit le 500 sans refondre le flux d'acceptation). **B-F2 + retenu** : test du compte d'arrondi de type produit (mutation « Revenue retiré du filtre » tuée). **B-F3** : + compte d'arrondi = contrepartie, deux lignes sur le même compte, documenté comme accepté. Gate complet + (kesh-db touché) : **2545/2545**. +- **2026-09-30** — Revue de code P2 ciblée (Haiku, `25-4-c3-b-review-prompt-p2.md`, sur `1e1aaebf`) : 3 MED rendus, + **aucun ne touche la production**. (1) Code `INTERNAL_ERROR` affiché brut : vrai, mais **toute** la liste des + refus du rapprochement affiche ses codes bruts (`ReconciliationProposals.svelte:362`), `RECONCILIATION_OVERPAYMENT` + compris ; l'AC 4 n'exigeait le message que pour le règlement manuel → **reclassé LOW**, issue [#492]. (2) Le test + du compte produit changeait aussi le numéro, inatteignable par l'écran → **retenu**, retypage seul (atteignable + par l'API). (3) « R pris en dernier » jugé inexact : **réfuté**, l'argument de la P1 porte sur les verrous de la + table `accounts` — l'exercice, pris après, est le cycle de #491, déjà tracé. Remédiation : un test et ce Change + Log, **aucune ligne de production** ⇒ **boucle close** (§ *La passe ciblée*). Trend : P1 3 MED/2 LOW (dont + 1 réfuté, 1 reclassé) → P2 3 MED (1 reclassé, 1 réfuté, 1 test) ; lentilles Sonnet ×3 → Haiku. Gate backend complet au + dernier commit, base remise à zéro : **2545/2545** ; frontend et E2E inchangés depuis leur run (aucun fichier + frontend touché par les deux passes). + +[#476]: https://github.com/guycorbaz/kesh/issues/476 +[#490]: https://github.com/guycorbaz/kesh/issues/490 +[#491]: https://github.com/guycorbaz/kesh/issues/491 +[#492]: https://github.com/guycorbaz/kesh/issues/492 diff --git a/_bmad-output/implementation-artifacts/25-4-c3-b-review-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c3-b-review-prompt-p1.md new file mode 100644 index 000000000..3db0bdbf4 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-b-review-prompt-p1.md @@ -0,0 +1,45 @@ +# Prompt — revue de code P1, Story 25-4-c3-b (le reste dû au centime, l'écart en écriture) + +*Versionné le 2026-09-30. Trois lentilles (Sonnet), contexte frais chacune.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c3-b-arrondi-au-centime`. **Diff à revoir : +`git diff 262100d2 e4a9419b`** (un seul commit d'implémentation, aplati). Fiche : +`_bmad-output/implementation-artifacts/25-4-c3-b-arrondi-au-centime.md` (AC 1–8, Dev Notes, Change Log des +quatre passes de validation). Règles : `CLAUDE.md` (§ *Pattern batch*, § *Review Iteration Rule*, +§ *Le prompt d'une passe doit NOMMER le manuel*). + +## Lentilles + +- **A — Blind hunter** : lis le diff seul, sans la fiche, et cherche ce qui est faux : arithmétique + décimale, signe de l'écart, lignes d'écriture, erreurs avalées, verrous, ordre des gardes. +- **B — Edge-case hunter** : diff + code environnant. Restes négatifs ou nuls, reste sous le demi-centime + (#490), second paiement après un partiel, transaction bancaire à deux décimales, compte d'arrondi d'une + AUTRE société, réglage absent vs ligne `company_invoice_settings` absente, compte de type Revenue, écart + quand le compte d'arrondi est aussi le compte de contrepartie, annulation après archivage, la + concurrence (verrou optimiste de la 25-4-c2, `FOR UPDATE` du compte d'arrondi : risque de deadlock ou de + désarmement du verrou ?), `DATABASE_ERROR` vs code dédié. +- **C — Acceptance auditor** : diff + fiche. Chaque AC tenu ? Chaque site des huit touché — et **pars du + symptôme** : `grep -rn "amount_due\|amountDue\|due_before\|due_after" crates/ frontend/src` — reste-t-il + une comparaison au brut ? Les tests de l'AC 7 prouvent-ils ce qu'ils disent (rouges avant le patch, ou + déclarés gardes de mutation) ? **Le manuel** : `docs/manual/fr/admin-manual.tex` (*Compte de + différences d'arrondi*) et `user-manual.tex` (règlement, rapprochement) disent-ils vrai contre le code — + **PDF aplati** (`pdftotext docs/manual/fr/.pdf - | tr '\n' ' ' | tr -s ' '`, attention aux ligatures + ff/fi) vers `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`. + CHANGELOG, i18n (4 locales), compteur `sitesTotal`, exemption `lint-i18n-ownership`. + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (commande exécutée et sa + sortie, ou code cité relu), scénario d'échec concret, correction proposée. Pour tout finding affirmant + qu'un code est absent ou présent : la sortie d'un `grep -nF`. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un « 0 finding » 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/*` (dont `scripts/prepare-release.sh`, `scripts/test-fast.sh`, `scripts/regen-test-schema.sh`), +`make`, `latexmk`, `git commit`/`add`/`stash`/`reset`/`checkout`/`switch`/`rebase`, `sqlx migrate`, +`cargo test`/`nextest`, `npm run`, `npx playwright`/`vitest`, `gh issue create`/`comment`/`edit`. +Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, `cargo check`, `pdftotext` et +expériences (`node -e`) dans le scratchpad. diff --git a/_bmad-output/implementation-artifacts/25-4-c3-b-review-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-c3-b-review-prompt-p2.md new file mode 100644 index 000000000..1ab896341 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-b-review-prompt-p2.md @@ -0,0 +1,38 @@ +# Prompt — revue de code P2 ciblée, Story 25-4-c3-b + +*Versionné le 2026-09-30. **Une lentille** (Haiku), contexte frais, **passe ciblée** sur la seule +remédiation P1 : `git show 1e1aaebf` (un seul commit, diff aplati).* + +Dépôt `/home/gcorbaz/devel/kesh`. Fiche : `_bmad-output/implementation-artifacts/25-4-c3-b-arrondi-au-centime.md` +(Change Log, entrée « Revue de code P1 »). Issue ouverte en route : `gh issue view 491`. + +## Axes — tous obligatoires + +1. **Le bras d'erreur de `settlement_journal_lines` au rapprochement** (`crates/kesh-api/src/routes/reconciliation.rs`, + `grep -nF "INTERNAL_ERROR" crates/kesh-api/src/routes/reconciliation.rs`) : le code `INTERNAL_ERROR` + est-il déjà émis ailleurs dans un `FailedProposal`, ou connu du frontend + (`grep -rn "INTERNAL_ERROR" frontend/src`) ? Un code inconnu de l'écran s'affiche-t-il lisiblement + (`ReconciliationProposals.svelte`, table des libellés d'erreur) ? Le `tracing::error!` fuit-il une donnée + sensible ? +2. **Le test `a_revenue_rounding_account_receives_the_gap`** (`crates/kesh-api/tests/invoice_echeancier_e2e.rs`) : + prouve-t-il ce qu'il dit ? Changer `account_type` et `number` d'un compte déjà désigné contourne-t-il une + garde que l'écran appliquerait (le test mime-t-il un état atteignable) ? +3. **Le doc-comment ajouté** à `settlement_journal_lines` (`crates/kesh-db/src/repositories/invoice_settlements.rs`) : + vrai ? (une écriture à deux lignes sur le même compte passe-t-elle `create_in_tx` — + `grep -n "account_id" crates/kesh-db/src/repositories/journal_entries.rs | head -40` ; un contrôle de + doublon existe-t-il ?) +4. **Les réfutations et reclassements de la P1**, écrits au Change Log : l'argument « R toujours pris en + dernier » tient-il contre `grep -rn "FROM accounts.*FOR UPDATE\|UPDATE accounts" crates/kesh-db/src crates/kesh-api/src` ? + L'issue #491 décrit-elle exactement le cycle ? + +## Ce que tu rends + +Findings avec sévérité, `fichier:ligne`, **preuve** (sortie de `grep -nF` ou code cité relu), correction. +⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande mutante (`scripts/*` dont `scripts/prepare-release.sh`, +`make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, `cargo test`/`nextest`, `npm run`, `npx`, +`gh issue create`/`comment`/`edit`). Autorisés : lecture, `grep`, `git show`/`diff`/`log`, `gh issue view`, +`cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p1.md new file mode 100644 index 000000000..538f87dba --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p1.md @@ -0,0 +1,58 @@ +# Prompt — validation P1, Story 25-4-c3-b (l'arrondi au centime, et l'écart en écriture) + +*Versionné le 2026-09-30. **Une lentille** (Sonnet), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c3-b-arrondi-au-centime` (empilée sur la c3-a2, PR +#489). Fiche : `_bmad-output/implementation-artifacts/25-4-c3-b-arrondi-au-centime.md`. Sœurs : +`25-4-c3-a1-reglage-compte-arrondi.md`, `25-4-c3-a2-compte-arrondi-plans.md`, et la source des faits +`25-4-c-residuel-au-rapprochement.md` (Change Log : validation P1 F1–F3, P3 F4). Issue : `gh issue view 476` +(commentaires : arbitrages). Règles : `CLAUDE.md` (§ *Pattern batch*). Checklist : +`.claude/skills/bmad-create-story/checklist.md`. + +L'arbitrage (l'écart s'écrit sur le compte désigné dans les paramètres) est **retenu** : en contester la +mise en œuvre, pas le principe. ⚠️ La fiche décrit du travail **à faire** : ne reproche pas au code de ne pas +encore le porter. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme. +2. **L'inventaire des sites** — pars du symptôme, pas de la liste des huit : + `grep -rn "amount_due\|amountDue\|due_before\|due_after\|OVERPAYMENT\|overpayment" crates/ frontend/src`. + Toute comparaison d'un reste dû à un montant payé, ou tout affichage d'un reste, hors des huit sites : + légitime ou oublié ? Existe-t-il d'autres chemins qui **règlent** une facture client (import, avoir, + écran d'échéancier, API) ? +3. **La mécanique de l'AC 3** : règlement au reste **brut**, trois lignes. Vérifie qu'elle ferme bien la + créance à zéro (la ligne de créance = reste brut) et que `invoice_settlements.amount` (précision, CHECK + `amount > 0`) l'accepte. Que deviennent les règlements **partiels** antérieurs (10.00 puis 0.01 sur un + reste de 10.0050) : le second paiement déclenche-t-il bien l'écart ? Et un reste brut **négatif** hérité ? + Le montant rapproché de la **transaction bancaire** (`bank_transactions.amount`) reste-t-il cohérent avec + la ligne de banque de l'écriture ? +4. **L'AC 4** (compte manquant) : où lire le réglage (dans la transaction ? sous verrou ?) ; le code + d'erreur respecte-t-il le pattern batch pour le rapprochement et le mapping d'erreurs pour le règlement + manuel (`crates/kesh-api/src/routes/invoices.rs:1183`, `crates/kesh-api/src/errors.rs`) ? +5. **L'AC 6** (annulation) : `journal_entries::reverse_in_tx` (`:1437`) contre-passe-t-il toutes les lignes + d'une écriture quelconque ? L'annulation d'un rapprochement (`reconciliation_cancel.rs`) passe-t-elle par + le même chemin ? Le compte d'arrondi, archivé entre-temps, bloque-t-il la contre-passation (garde `active` + de `create_in_tx`) — et est-ce acceptable ? +6. **Le verrou optimiste de la 25-4-c2** : l'écriture de l'écart change-t-elle quoi que ce soit à + l'invariant « tout écrit qui change le reste dû incrémente `version` » ? +7. **Faisabilité des tests (AC 7)** : peut-on fabriquer un reste brut de 10.0050 et de 10.004 (lignes, TVA, + `line_total` à 4 décimales) ? Mutations tuables ? +8. **Le manuel** : `docs/manual/fr/admin-manual.tex` et `user-manual.tex` (règlement, rapprochement), + **PDF aplati** (`pdftotext … | tr '\n' ' ' | tr -s ' '` vers + `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`). +9. **Périmètre** : modules recomptés depuis les tâches (seuil : plus de 5 → découpage). + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact, **preuve** (commande exécutée et sa + sortie, ou code lu cité), correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base +(`scripts/*`, `make`, `latexmk`, `git commit`/`push`/`add`/`stash`/`reset`/`rebase`/`checkout`/`switch`, +`sqlx migrate`, `cargo test`/`nextest`, `npm run`, `npx playwright`, `gh issue create`/`comment`/`edit`). +Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers le scratchpad, +`cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p2.md new file mode 100644 index 000000000..077c17f92 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p2.md @@ -0,0 +1,74 @@ +# Prompt — validation P2, Story 25-4-c3-b (l'arrondi au centime, et l'écart en écriture) + +*Versionné le 2026-09-30. **Une lentille** (Haiku), passe complète après une remédiation qui change une règle métier, contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c3-b-arrondi-au-centime` (empilée sur la c3-a2, PR +#489). Fiche : `_bmad-output/implementation-artifacts/25-4-c3-b-arrondi-au-centime.md`. Sœurs : +`25-4-c3-a1-reglage-compte-arrondi.md`, `25-4-c3-a2-compte-arrondi-plans.md`, et la source des faits +`25-4-c-residuel-au-rapprochement.md` (Change Log : validation P1 F1–F3, P3 F4). Issue : `gh issue view 476` +(commentaires : arbitrages). Règles : `CLAUDE.md` (§ *Pattern batch*). Checklist : +`.claude/skills/bmad-create-story/checklist.md`. + +L'arbitrage (l'écart s'écrit sur le compte désigné dans les paramètres) est **retenu** : en contester la +mise en œuvre, pas le principe. ⚠️ La fiche décrit du travail **à faire** : ne reproche pas au code de ne pas +encore le porter. + +La passe 1 a fait amender l'AC 3 (classification à **double borne** brut/arrondi, refus d'une échelle > 2 +au règlement manuel) et ajouté aux Dev Notes « Sites hors des huit » et « Pièges » (Change Log, entrée P1). + +## Axe 0 — la remédiation P1, en premier + +- La règle en trois cas (`p == r && r != b` → écart ; `p > b` → refus ; sinon ordinaire) est-elle **complète + et sans recouvrement** ? Essaie-la sur : b=10.0050/r=10.01 ; b=10.004/r=10.00 ; b=10.00 ; un reste brut + **négatif** ou nul ; un second paiement après un partiel ; `p` à 2 décimales dans tous les cas. +- Contredit-elle une autre phrase de la fiche (AC 2 « la garde compare au reste arrondi », AC 5, Dev Notes + « l'ordre des gardes ») ? Greppe la fiche. +- Le refus d'échelle au règlement manuel : `scale_within` existe-t-il et s'importe-t-il dans `invoices.rs` ? + Casse-t-il un test ou un appel existant (frontend qui enverrait 3 décimales, tests `settle` à 4 décimales) ? + `grep -rn "settlements" crates/kesh-api/tests frontend/src frontend/tests | grep -i amount`. +- L'affirmation « big.js `roundHalfUp` arrondit l'équidistant loin de zéro » : vérifie dans + `frontend/node_modules/big.js` (constante `RM`, doc de `round`), et qu'aucun code ne modifie `Big.RM`. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme. +2. **L'inventaire des sites** — pars du symptôme, pas de la liste des huit : + `grep -rn "amount_due\|amountDue\|due_before\|due_after\|OVERPAYMENT\|overpayment" crates/ frontend/src`. + Toute comparaison d'un reste dû à un montant payé, ou tout affichage d'un reste, hors des huit sites : + légitime ou oublié ? Existe-t-il d'autres chemins qui **règlent** une facture client (import, avoir, + écran d'échéancier, API) ? +3. **La mécanique de l'AC 3** : règlement au reste **brut**, trois lignes. Vérifie qu'elle ferme bien la + créance à zéro (la ligne de créance = reste brut) et que `invoice_settlements.amount` (précision, CHECK + `amount > 0`) l'accepte. Que deviennent les règlements **partiels** antérieurs (10.00 puis 0.01 sur un + reste de 10.0050) : le second paiement déclenche-t-il bien l'écart ? Et un reste brut **négatif** hérité ? + Le montant rapproché de la **transaction bancaire** (`bank_transactions.amount`) reste-t-il cohérent avec + la ligne de banque de l'écriture ? +4. **L'AC 4** (compte manquant) : où lire le réglage (dans la transaction ? sous verrou ?) ; le code + d'erreur respecte-t-il le pattern batch pour le rapprochement et le mapping d'erreurs pour le règlement + manuel (`crates/kesh-api/src/routes/invoices.rs:1183`, `crates/kesh-api/src/errors.rs`) ? +5. **L'AC 6** (annulation) : `journal_entries::reverse_in_tx` (`:1437`) contre-passe-t-il toutes les lignes + d'une écriture quelconque ? L'annulation d'un rapprochement (`reconciliation_cancel.rs`) passe-t-elle par + le même chemin ? Le compte d'arrondi, archivé entre-temps, bloque-t-il la contre-passation (garde `active` + de `create_in_tx`) — et est-ce acceptable ? +6. **Le verrou optimiste de la 25-4-c2** : l'écriture de l'écart change-t-elle quoi que ce soit à + l'invariant « tout écrit qui change le reste dû incrémente `version` » ? +7. **Faisabilité des tests (AC 7)** : peut-on fabriquer un reste brut de 10.0050 et de 10.004 (lignes, TVA, + `line_total` à 4 décimales) ? Mutations tuables ? +8. **Le manuel** : `docs/manual/fr/admin-manual.tex` et `user-manual.tex` (règlement, rapprochement), + **PDF aplati** (`pdftotext … | tr '\n' ' ' | tr -s ' '` vers + `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`). +9. **Périmètre** : modules recomptés depuis les tâches (seuil : plus de 5 → découpage). + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact, **preuve** (commande exécutée et sa + sortie, ou code lu cité), correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base +(`scripts/*`, `make`, `latexmk`, `git commit`/`push`/`add`/`stash`/`reset`/`rebase`/`checkout`/`switch`, +`sqlx migrate`, `cargo test`/`nextest`, `npm run`, `npx playwright`, `gh issue create`/`comment`/`edit`). +Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers le scratchpad, +`cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p3.md new file mode 100644 index 000000000..f2438f627 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p3.md @@ -0,0 +1,40 @@ +# Prompt — validation P3 ciblée, Story 25-4-c3-b + +*Versionné le 2026-09-30. **Une lentille** (Sonnet), contexte frais, **passe ciblée** sur la seule +remédiation P2 (§ *La passe ciblée*, `CLAUDE.md`).* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c3-b-arrondi-au-centime`. Fiche : +`_bmad-output/implementation-artifacts/25-4-c3-b-arrondi-au-centime.md`. Remédiation à relire : +`git show 58938564` (et, pour le contexte de la règle, `git show aee52a8d`). Issue neuve : `gh issue view 490`. +⚠️ La fiche décrit du travail **à faire** : ne reproche ni au code ni aux manuels de ne pas encore le porter. + +## Axes — tous obligatoires + +1. **Normalisation** : `scale_within(&amount.normalize(), 2)` — `rust_decimal::Decimal::normalize` existe-t-il + dans la version du dépôt (`Cargo.lock`) et fait-il ce qu'on dit (« 10.000 » → échelle 0 ou 2 ? « 10.10 » + → « 10.1 ») ? Le montant **enregistré** doit-il être normalisé, ou seulement contrôlé ? +2. **Contrôle client** : l'équivalence affirmée (« avec deux décimales, saisie > reste arrondi ⇔ double borne + de l'AC 3 ») est-elle vraie dans **tous** les cas (r > b, r < b, r = b) ? Démontre ou donne un contre-exemple. + Le contrôle d'échelle en JS (`Number`, big.js) tient-il sur « 10.000 », « 10,01 », « 1e-3 », « 10. » ? + Comment le dialogue parse-t-il aujourd'hui la saisie (`SettleInvoiceDialog.svelte:107`) ? +3. **La limite #490** : l'affirmation « défaut antérieur, ni créé ni fermé » est-elle exacte ? La story + rend-elle ce cas **plus fréquent** (un acompte qui soldait hier… ou l'inverse) ? Les deux voies d'arrivée + citées sont-elles réelles (un avoir peut-il laisser un reste brut de 0.004 : `credit_notes`, calcul du + reste dû) ? +4. **Propagation** : les phrases modifiées contredisent-elles une autre phrase de la fiche ? Greppe + `décimales`, `échelle`, `scale`, `arrondi` dans la fiche. +5. **Tests ajoutés à l'AC 7** : faisables et discriminants (chacun rougit-il avant le patch) ? + +## Ce que tu rends + +Findings avec sévérité, endroit, **preuve** (commande et sortie, ou code cité), correction. ⛔ La liste des +axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base +(`scripts/*` dont `scripts/prepare-release.sh`, `make`, `latexmk`, `git commit`/`add`/`checkout`/…, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx playwright`, `gh issue create`/`comment`/`edit`). Autorisés : +lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, `cargo check`, un script `node -e` ou +`evcxr`/petit test **hors dépôt** dans le scratchpad +`/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`. diff --git a/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p4.md new file mode 100644 index 000000000..697922749 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-b-validate-prompt-p4.md @@ -0,0 +1,32 @@ +# Prompt — validation P4 ciblée, Story 25-4-c3-b + +*Versionné le 2026-09-30. **Une lentille** (Haiku), contexte frais, **passe ciblée** sur la seule +remédiation P3 : `git show b1853bb4` (un seul commit, diff aplati).* + +Dépôt `/home/gcorbaz/devel/kesh`. Fiche : `_bmad-output/implementation-artifacts/25-4-c3-b-arrondi-au-centime.md`. +Issue : `gh issue view 490`. ⚠️ La fiche décrit du travail **à faire** : ne reproche ni au code ni aux +manuels de ne pas encore le porter (un tel finding sera rejeté). + +## Axes — tous obligatoires + +1. **Chaque phrase ajoutée par `b1853bb4`** est-elle vraie contre le code ? En particulier : la garde + actuelle refuse-t-elle 10.008 sur 10.0050 en 400 (`invoice_settlements_write.rs:165-170`, + `crates/kesh-api/src/errors.rs`, branche `InvalidInput`) ; big.js (`frontend/node_modules/big.js`) n'a-t-il + pas de `.dp()` ; `new Big("1e-3").toString()` rend-il « 0.001 » (essai `node -e` dans le scratchpad) ; + `invoice_settlements.amount` est-il `DECIMAL(19,4)` (migrations). +2. **Contradictions** : une phrase ajoutée contredit-elle une autre phrase de la fiche ? Greppe `into_parts`, + `normalize`, `décimales`, `garde`, `mutation`. +3. **Tests de l'AC 7** : chaque test, tel qu'écrit maintenant, est-il soit rouge avant le patch, soit + explicitement déclaré garde de mutation ? + +## Ce que tu rends + +Findings avec sévérité, endroit, **preuve** (commande et sortie, ou code cité `fichier:ligne`), correction. +⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande mutante (`scripts/*` dont `scripts/prepare-release.sh`, +`make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, `cargo test`, `npm run`, `npx playwright`, +`gh issue create`/`comment`/`edit`). Expériences uniquement dans +`/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index cee8ecebf..2195e199d 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -362,9 +362,9 @@ development_status: 25-4-c-residuel-au-rapprochement: done # 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/7M -> P2 Haiku x2 0 >LOW apres verification) ; gate complet vert (2513, 836, E2E 226/19/9 attendus) ; #482 ouverte (signe a l acceptation) ; non poussee — PR closes #420 sur accord de Guy. # 2026-09-30 IMPLEMENTEE — gates backend 2513/2513, frontend 836/836, E2E 226/19/9 (9 attendus) ; #481 ouverte (manuel). # 2026-09-29 VALIDATION CLOSE en 5 passes (P4 1M/1L, P5 ciblee 0 >LOW) — prete pour dev. # 2026-09-29 PERIMETRE REDUIT au reste du dans le rapprochement (verrou -> c2 #480, arrondi -> c3 #476) ; validation P1 3H, P2 2M (orchestrateur), P3 1C/3H -> decoupage ; validation reprend en P4. # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. 25-4-c2-verrou-acceptation: done # 2026-09-30 PR #485 OUVERTE (closes #480), apres merge de #483 (merge -s ours, arbre identique). REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/4M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2516, 836, E2E 228/19/7 KF-029) ; non poussee, empilee sur #483 — PR closes #480 apres le merge de #483. # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#480] 2026-09-29 NEE du decoupage de la 25-4-c (validation P3, severite P2 -> P3 non decroissante ; accord de Guy) — accept_one_invoice ne verrouille pas la facture : double reglement possible contre un reglement manuel partiel (settle_invoice ne bumpe version qu'au solde). ⛔ un simple FOR UPDATE au chargement desarme le controle optimiste (REPEATABLE READ : version lue a jour, amount_due perime) — lecture verrouillante du reste du, ou bump de version au reglement partiel. Interblocage lot/exercice -> 500 sans rejeu (retry_with ?). Findings P3 F1-F3 dans le Change Log de la 25-4-c. DOIT suivre la 25-4-c. 25-4-c3-arrondi-centime: split # 2026-09-30 DECOUPEE (sept modules > 5) en c3-a1 (reglage), c3-a2 (compte dans les plans + onboarding), c3-b (arrondi et ecriture d ecart). Arbitrages de Guy : reglage dans les parametres (pas un role) ; compte creable dans le plan et choisi dans les parametres ; 6940 « Differences d arrondi » (charge) dans les 3 plans, designe a l onboarding ; refus si non configure quand un ecart se presente.# 2026-09-30 ARBITRAGE DE GUY : l ecart d arrondi au centime se PASSE EN ECRITURE, sur un compte de differences d arrondi (ni simple affichage, ni arrondi du TTC a la validation). # [#476] 2026-09-29 NEE du decoupage de la 25-4-c — le reste du peut porter 4 decimales ; comparer au centime : 8 sites (filtre, score, gardes de trop-percu :1336 et settlements_write:167, soldes :1451 et :233, reouverture :464, dialogue SettleInvoiceDialog:111) ; helper sur Money::round_to_centimes, reminder_amount_due rebranche. ⚠️ QUESTION COMPTABLE A GUY : payer 10.01 une facture de 10.0050 laisse la creance crediteur de 0.0050 au grand livre (DECIMAL(19,4)) et affiche « Reste du -0.01 » — assumer, afficher arrondi, ecriture d'ecart, ou arrondir le TTC a la validation. Findings P1 F1-F3, P3 F4/F8/F10/F12 dans le Change Log de la 25-4-c. - 25-4-c3-a1-reglage-compte-arrondi: done # 2026-09-30 PR #487 OUVERTE (refs #476). 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/2M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2521, 839, E2E 228/19/8 expliques) ; non poussee. # 2026-09-30 IMPLEMENTEE — gates 2520, 839, E2E 229/19/7 KF-029 ; #486 ouverte (archivage des comptes des reglages). # [#476, refs] 2026-09-30 VALIDATION CLOSE en 2 passes (P1 2M -> P2 0). CREEE : colonne company_invoice_settings.default_rounding_account_id (non breaking), validation (actif, imputable, charge ou produit), ecran Parametres > Facturation, export, sauvegarde anterieure, manuel admin. - 25-4-c3-a2-compte-arrondi-plans: done # 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 4M -> P2 Haiku ciblee 0) ; gate complet vert (2531, 839, E2E 227/19/9 expliques) ; non poussee, empilee sur #487. # 2026-09-30 IMPLEMENTEE — gates 2530, 839, E2E 228/19/8 (KF-029 + KF-046). # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 3M -> P2 0). CREEE (empilee sur c3-a1, PR #487) : 6940 dans les 3 plans, marqueur de plan roundingDifference, designation par les deux insert_with_defaults* (NULL sans erreur si absent), rien chez les societes existantes. [#476, refs] 6940 « Differences d arrondi » (Expense) dans pme/independant/association (4 langues) ; marqueur de plan (pas de numero dans le code) ; onboarding et seed posent default_rounding_account_id. Apres c3-a1. - 25-4-c3-b-arrondi-au-centime: backlog # [#476, closes] le reste du arrondi au centime aux 8 comparaisons (helper sur Money::round_to_centimes, reminder_amount_due rebranche, dialogue) ; au solde, reglement enregistre au reste BRUT et ecart en 3e ligne sur le compte du reglage ; refus lisible si reglage absent. Apres c3-a1. + 25-4-c3-a1-reglage-compte-arrondi: done # 2026-09-30 PR #487 MERGEE (refs #476). 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/2M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2521, 839, E2E 228/19/8 expliques) ; non poussee. # 2026-09-30 IMPLEMENTEE — gates 2520, 839, E2E 229/19/7 KF-029 ; #486 ouverte (archivage des comptes des reglages). # [#476, refs] 2026-09-30 VALIDATION CLOSE en 2 passes (P1 2M -> P2 0). CREEE : colonne company_invoice_settings.default_rounding_account_id (non breaking), validation (actif, imputable, charge ou produit), ecran Parametres > Facturation, export, sauvegarde anterieure, manuel admin. + 25-4-c3-a2-compte-arrondi-plans: done # 2026-09-30 PR #489 OUVERTE (refs #476). 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 4M -> P2 Haiku ciblee 0) ; gate complet vert (2531, 839, E2E 227/19/9 expliques) ; non poussee, empilee sur #487. # 2026-09-30 IMPLEMENTEE — gates 2530, 839, E2E 228/19/8 (KF-029 + KF-046). # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 3M -> P2 0). CREEE (empilee sur c3-a1, PR #487) : 6940 dans les 3 plans, marqueur de plan roundingDifference, designation par les deux insert_with_defaults* (NULL sans erreur si absent), rien chez les societes existantes. [#476, refs] 6940 « Differences d arrondi » (Expense) dans pme/independant/association (4 langues) ; marqueur de plan (pas de numero dans le code) ; onboarding et seed posent default_rounding_account_id. Apres c3-a1. + 25-4-c3-b-arrondi-au-centime: done # 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 3M/2L -> P2 Haiku ciblee 3M, aucune sur la production) ; gate 2545 ; #491 #492 ouvertes ; non poussee, empilee sur #489 (PR closes #476). 2026-09-30 IMPLEMENTEE — gates 2544, 845, E2E 227/19/9 (KF-029 + pollution produits). 2026-09-30 VALIDATION CLOSE en 4 passes (P1 1H/1M -> P2 3M orchestrateur -> P3 3M -> P4 0) ; #490 ouverte (reste sous le demi-centime). 2026-09-30 CREEE (empilee sur c3-a2, PR #489) : helper sur round_to_centimes, 8 sites, reglement au reste BRUT + 3e ligne d ecart, refus lisible si compte absent ou invalide. [#476, closes] le reste du arrondi au centime aux 8 comparaisons (helper sur Money::round_to_centimes, reminder_amount_due rebranche, dialogue) ; au solde, reglement enregistre au reste BRUT et ecart en 3e ligne sur le compte du reglage ; refus lisible si reglage absent. Apres c3-a1. 25-4-d-solder-le-reste: backlog # [#384] ramenee de la 25-6 le 2026-09-27 (Guy) — perte sur debiteur, escompte, frais : clore une facture partiellement reglee ; la part TVA reduit la TVA due. 25-4-propager-le-residuel: split # 2026-09-26 DECOUPEE (six modules > 5) en 25-4-a / 25-4-b / 25-4-c, puis 25-4-d (#384, 2026-09-27) ; #455 et #456 rattachees. # [#416] [#420] — la 24-2 a introduit le reglement PARTIEL ; le residuel n'est propage ni a la balance agee ni a l'echeancier, et **le score de rapprochement compare au TTC et non au residuel** : le virement du solde d'une facture partiellement reglee **score 0**. 25-5-a-export-souverainete: done # 2026-09-26 **MERGEE (PR #452, #386 fermee) SANS REVUE DE CODE** ; revue a posteriori CLOSE en 3 passes sur `story/25-5-a-revue-code` (4 HIGH P1, 1 HIGH orchestrateur P2 — colonnes manquantes, garde de colonnes —, P3 ciblee test seul) ; gates 2466/2466, E2E 221/8/19 sans regression ; reports #465, #466. # 2026-09-23 **SPECIFIEE et VALIDEE en 1 passe** (0 CRITICAL, 4 HIGH, 3 MEDIUM, 3 LOW, tous corriges). [#386] — issue du DECOUPAGE de la 25-5 (arbitrage de Guy) : #385 (Balance vs Bilan) et #386 n'ont en commun que le mot « etat », et touchent des modules disjoints. ⛔ **Trois faits qui corrigent l'issue** : 21 tables absentes et non 10 ; le trou s'est CREUSE depuis son ecriture (`invoice_settlements`, Epic 24) ; et SIX repositories sur huit n'ont aucune lecture exhaustive — le vrai cout, absent de l'issue. Arbitrage de Guy : l'export CSV ne porte que les donnees COMPTABLES (11 dedans), les backups portent tout ; 10 exclusions AVEC motif ecrit, dont 3 tables de SECRETS. Les justificatifs (fichiers) partent en story separee sur la SAUVEGARDE. ⛔ La vraie livraison est la GARDE D'EXHAUSTIVITE (patron D4-ter + P7), non les 11 exports : trois epics ont rouvert le trou en silence. ⚠️ Pieges releves par la validation : le nombre 19 code en dur a SEPT endroits dont deux `debug_assert_eq!` qui PANIQUENT ; `audit_log` n'a AUCUN dump exhaustif (ses fonctions sont bornees, la route rejette au-dela de 10 000 lignes) ; `csv_sanitize` existe et n'est pas employee par cet export ; deux sites de manuel DEJA faux (un dossier `reports/` invente, un « 38 » pour 39). diff --git a/crates/kesh-api/src/errors.rs b/crates/kesh-api/src/errors.rs index 160501e61..7ed83efd1 100644 --- a/crates/kesh-api/src/errors.rs +++ b/crates/kesh-api/src/errors.rs @@ -2952,6 +2952,16 @@ impl IntoResponse for AppError { "Un ou plusieurs comptes sont archivés ou invalides.", ), ), + // Story 25-4-c3-b (#476) — un écart d'arrondi à écrire, et pas de + // compte utilisable pour le recevoir. Le message dit OÙ agir. + DbError::RoundingAccountNotConfigured => build_response( + StatusCode::BAD_REQUEST, + "ROUNDING_ACCOUNT_NOT_CONFIGURED", + &t( + "error-rounding-account-not-configured", + "Ce paiement solde la facture au centime, mais aucun compte de différences d'arrondi utilisable n'est désigné : choisissez-en un dans Paramètres → Facturation.", + ), + ), // Story 16-1a (#152) — comptes de produit de ligne de facture. // Le générique `INACTIVE_OR_INVALID_ACCOUNTS` ci-dessus ne nomme // aucune ligne ; sur une facture pouvant en porter 200, ce diff --git a/crates/kesh-api/src/routes/invoice_pdf_service.rs b/crates/kesh-api/src/routes/invoice_pdf_service.rs index 2e20b46fb..9f89c7133 100644 --- a/crates/kesh-api/src/routes/invoice_pdf_service.rs +++ b/crates/kesh-api/src/routes/invoice_pdf_service.rs @@ -24,7 +24,7 @@ use kesh_qrbill::{ QrBillError, QrBillI18n, Reference, ReminderPdf, validation::{build_qrr, normalize_iban}, }; -use rust_decimal::{Decimal, RoundingStrategy}; +use rust_decimal::Decimal; use std::collections::HashMap; use crate::errors::AppError; @@ -114,7 +114,8 @@ pub struct ReminderAmounts { /// 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); + // Story 25-4-c3-b (AC 1) : la définition unique du reste dû au centime. + let due = kesh_db::repositories::invoice_settlements::amount_due_to_centime(raw); if due <= Decimal::ZERO { return Err(AppError::ReminderNothingDue); } diff --git a/crates/kesh-api/src/routes/invoices.rs b/crates/kesh-api/src/routes/invoices.rs index f98ff1d56..a8576c7e3 100644 --- a/crates/kesh-api/src/routes/invoices.rs +++ b/crates/kesh-api/src/routes/invoices.rs @@ -1168,6 +1168,16 @@ impl SettleInvoiceRequest { ))); } }; + // Story 25-4-c3-b (AC 3) : un paiement se fait au centime. Sans ce refus, + // un montant à trois décimales pouvait tomber entre le reste brut et son + // arrondi. `normalize()` d'abord : « 10.000 » est un montant au centime, et + // `scale_within` lit l'échelle, pas la valeur. Le montant est rendu TEL + // QUE REÇU — `normalize()` n'est qu'un contrôle. + if !scale_within(&self.amount.normalize(), 2) { + return Err(AppError::Validation( + "Le montant d'un règlement ne peut avoir plus de 2 décimales.".into(), + )); + } Ok((choice, self.amount, self.settled_on)) } } diff --git a/crates/kesh-api/src/routes/reconciliation.rs b/crates/kesh-api/src/routes/reconciliation.rs index 9c798dedb..3930f23b1 100644 --- a/crates/kesh-api/src/routes/reconciliation.rs +++ b/crates/kesh-api/src/routes/reconciliation.rs @@ -24,11 +24,12 @@ use kesh_db::entities::audit_log::NewAuditLogEntry; use kesh_db::entities::bank_transaction::{BankTransaction, BankTransactionStatus}; use kesh_db::entities::invoice::Invoice; use kesh_db::errors::DbError; +use kesh_db::repositories::invoice_settlements::PaymentAgainstDue; use kesh_db::repositories::reconciliation::UnpaidInvoiceCandidate; use kesh_db::repositories::{ - accounts as accounts_repo, audit_log, bank_accounts, contacts as contacts_repo, fiscal_years, - invoice_settlements, journal_entries, projects, reconciliation as reconciliation_repo, - reconciliation_rules, + accounts as accounts_repo, audit_log, bank_accounts, company_invoice_settings, + contacts as contacts_repo, fiscal_years, invoice_settlements, journal_entries, projects, + reconciliation as reconciliation_repo, reconciliation_rules, }; use kesh_reconciliation::{ MatchScore, ReconciliationError, SplitDetail, build_journal_entry_for_counterparty, @@ -565,6 +566,8 @@ pub async fn get_proposals( .map(|(tx, candidate_invoices)| { // #420 (25-4-c) : le triplet candidat porte le RESTE DÛ — le TTC // excluait le virement qui soldait une facture réglée en partie. + // #476 (25-4-c3-b) : AU CENTIME — un virement de 10.01 sur un reste + // de 10.0050 marquait 0 au score de montant. let candidates_with_contacts: Vec<( Invoice, Option, @@ -575,7 +578,7 @@ pub async fn get_proposals( ( c.invoice.clone(), contacts_map.get(&c.invoice.contact_id).cloned(), - c.amount_due, + invoice_settlements::amount_due_to_centime(c.amount_due), ) }) .collect(); @@ -599,9 +602,18 @@ pub async fn get_proposals( // = reste dû, la grandeur comparée à la tx (après le // HT, #246, puis le TTC, qui affichait 1 000 pour un // solde de 600). Le TTC ne suit que si les deux diffèrent. - invoice_amount: Some(cand.amount_due.normalize().to_string()), - invoice_total_ttc: (cand.amount_due != cand.total_ttc) - .then(|| cand.total_ttc.normalize().to_string()), + // #476 (25-4-c3-b) : l'un et l'autre AU CENTIME, comparés + // comme affichés — « 10.005 » ne se lit sur aucun relevé. + invoice_amount: Some( + invoice_settlements::amount_due_to_centime(cand.amount_due) + .normalize() + .to_string(), + ), + invoice_total_ttc: { + let due = invoice_settlements::amount_due_to_centime(cand.amount_due); + let ttc = invoice_settlements::amount_due_to_centime(cand.total_ttc); + (due != ttc).then(|| ttc.normalize().to_string()) + }, invoice_date: Some(inv.date), rule_id: None, rule_label: None, @@ -1312,7 +1324,12 @@ async fn accept_one_invoice( Invoice, Option, rust_decimal::Decimal, - )> = vec![(invoice.clone(), contact.clone(), invoice_amount_due)]; + )> = vec![( + invoice.clone(), + contact.clone(), + // #476 (25-4-c3-b) : au centime, comme le triplet des propositions. + invoice_settlements::amount_due_to_centime(invoice_amount_due), + )]; let proposals_score = propose_matches(&bank_transaction, &candidates_for_score); let score = proposals_score .first() @@ -1434,6 +1451,11 @@ async fn accept_one_invoice( // (c) ⛔ Le trop-perçu est REFUSÉ, il ne s'écrit pas. Sans ce garde, le // compte de créance passerait CRÉDITEUR — un solde contre nature que le // grand livre signalerait, mais après coup. + // + // #476 (25-4-c3-b) : à DOUBLE BORNE, par la classification partagée avec + // le règlement manuel. Une transaction égale au reste arrondi au centime + // solde la facture : le règlement s'enregistre au reste BRUT et l'écart + // passe sur le compte de différences d'arrondi. let due_before = invoice_settlements::amount_due(&mut **tx, invoice_id) .await .map_err(|e| FailedProposal { @@ -1441,16 +1463,47 @@ async fn accept_one_invoice( error_code: "DATABASE_ERROR".to_string(), details: Some(serde_json::json!({ "message": e.to_string() })), })?; - if bank_transaction.amount > due_before { - return Err(FailedProposal { - bank_transaction_id, - error_code: "RECONCILIATION_OVERPAYMENT".to_string(), - details: Some(serde_json::json!({ - "amountDue": due_before, - "transactionAmount": bank_transaction.amount, - })), - }); - } + let settled_amount = + match invoice_settlements::classify_payment(bank_transaction.amount, due_before) { + PaymentAgainstDue::Overpayment => { + return Err(FailedProposal { + bank_transaction_id, + error_code: "RECONCILIATION_OVERPAYMENT".to_string(), + details: Some(serde_json::json!({ + "amountDue": due_before, + "transactionAmount": bank_transaction.amount, + })), + }); + } + PaymentAgainstDue::Ordinary => bank_transaction.amount, + PaymentAgainstDue::SettlesWithRounding { raw_due } => raw_due, + }; + // (c-bis) Le compte d'arrondi, exigé SEULEMENT s'il y a un écart, et vérifié + // au moment d'écrire (AC 4) — refus per-proposal, pattern batch. + let rounding_account_id = if settled_amount != bank_transaction.amount { + match company_invoice_settings::rounding_account_for_write(tx, company_id).await { + Ok(id) => Some(id), + Err(DbError::RoundingAccountNotConfigured) => { + return Err(FailedProposal { + bank_transaction_id, + error_code: "ROUNDING_ACCOUNT_NOT_CONFIGURED".to_string(), + details: Some(serde_json::json!({ + "amountDue": due_before, + "transactionAmount": bank_transaction.amount, + })), + }); + } + Err(e) => { + return Err(FailedProposal { + bank_transaction_id, + error_code: "DATABASE_ERROR".to_string(), + details: Some(serde_json::json!({ "message": e.to_string() })), + }); + } + } + } else { + None + }; // (d) Exercice OUVERT couvrant la date de valeur. Jamais d'écriture dans un // exercice clos — c'est le verrou de bouclement, pas une commodité. @@ -1475,7 +1528,29 @@ async fn accept_one_invoice( // (e) `D banque / C créance`, du MONTANT DE LA TRANSACTION — jamais du total // de la facture. L'écriture existe pour que le compte bancaire de Kesh - // égale le relevé. + // égale le relevé. #476 : la créance est créditée du montant RÉGLÉ, et + // l'écart éventuel passe en troisième ligne. + let lines = match invoice_settlements::settlement_journal_lines( + bank_ledger_account_id, + receivable_account_id, + bank_transaction.amount, + settled_amount, + rounding_account_id, + ) { + Ok(lines) => lines, + // Seule erreur possible : un écart sans compte d'arrondi — impossible + // tant que (c-bis) exige le compte dès que réglé ≠ payé. Si un refactor + // défaisait ce couplage, c'est un BUG STRUCTUREL : tracé, et rendu sous + // un code qui ne se confond pas avec une panne de base (revue P1, C). + Err(e) => { + tracing::error!("encaissement : lignes d'écriture impossibles à construire : {e}"); + return Err(FailedProposal { + bank_transaction_id, + error_code: "INTERNAL_ERROR".to_string(), + details: None, + }); + } + }; let label = invoice .invoice_number .clone() @@ -1489,20 +1564,7 @@ async fn accept_one_invoice( // Le règlement hérite du projet de la facture (cohérence analytique, // patron `pay_in_tx`). project_id: invoice.project_id, - lines: vec![ - kesh_db::entities::NewJournalEntryLine { - account_id: bank_ledger_account_id, - debit: bank_transaction.amount, - credit: Decimal::ZERO, - project_id: None, - }, - kesh_db::entities::NewJournalEntryLine { - account_id: receivable_account_id, - debit: Decimal::ZERO, - credit: bank_transaction.amount, - project_id: None, - }, - ], + lines, }; // Flux automatique (réconciliation) : garde de postabilité désactivée // (Story 14-3b, D-A0) — cohérent `pay_in_tx` et chemin `split`. @@ -1527,7 +1589,8 @@ async fn accept_one_invoice( company_id, invoice_id, journal_entry_id, - amount: bank_transaction.amount, + // #476 : le reste BRUT quand la transaction solde avec un écart. + amount: settled_amount, settled_on: paid_at_candidate, // La réconciliation bancaire est, par définition, un virement — et // le compte est celui de l'import, pas un choix de l'utilisateur @@ -1659,7 +1722,10 @@ async fn accept_one_invoice( // Story 24-2 : l'écriture d'ENCAISSEMENT, plus celle de vente. "journal_entry_id": journal_entry_id, "sale_journal_entry_id": sale_entry_id, - "settled_amount": bank_transaction.amount, + // #476 : réglé ≠ payé quand la transaction solde avec un écart d'arrondi. + "settled_amount": settled_amount, + "paid_amount": bank_transaction.amount, + "rounding_difference": bank_transaction.amount - settled_amount, "amount_due_after": due_after, "fully_settled": fully_settled, }); @@ -1698,7 +1764,10 @@ async fn accept_one_invoice( "paid_via": "reconciliation", "reconciliation_audit_id": entry_accepted.id, "settlement_journal_entry_id": journal_entry_id, - "settled_amount": bank_transaction.amount, + // #476 : réglé ≠ payé quand la transaction solde avec un écart d'arrondi. + "settled_amount": settled_amount, + "paid_amount": bank_transaction.amount, + "rounding_difference": bank_transaction.amount - settled_amount, "amount_due_after": due_after, "before": { "paid_at": null, "version": invoice_version_pre }, "after": { diff --git a/crates/kesh-api/tests/invoice_echeancier_e2e.rs b/crates/kesh-api/tests/invoice_echeancier_e2e.rs index b568b0369..9c8e9d3cc 100644 --- a/crates/kesh-api/tests/invoice_echeancier_e2e.rs +++ b/crates/kesh-api/tests/invoice_echeancier_e2e.rs @@ -940,3 +940,412 @@ async fn due_dates_csv_has_amount_due_and_partial_status(pool: MySqlPool) { assert!(row.contains(";108.10;68.10;"), "ligne : {row}"); assert!(row.contains("Partiellement payée"), "statut : {row}"); } + +// --- Story 25-4-c3-b (#476) — le règlement manuel au centime ------------------ + +/// `(company_id, admin_id)` relus en base — `seed_base` rend ses ids dans un +/// ordre que les appelants historiques échangent sans conséquence (ids égaux). +async fn ids(pool: &MySqlPool) -> (i64, i64) { + sqlx::query_as("SELECT company_id, id FROM users WHERE username = 'admin'") + .fetch_one(pool) + .await + .unwrap() +} + +/// Désigne un compte de différences d'arrondi (6940, charge). Rend son id. +async fn designate_rounding(pool: &MySqlPool, company_id: i64) -> i64 { + let id = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type) \ + VALUES (?, '6940', 'Différences d''arrondi', 'Expense')", + ) + .bind(company_id) + .execute(pool) + .await + .unwrap() + .last_insert_id() as i64; + sqlx::query( + "UPDATE company_invoice_settings SET default_rounding_account_id = ? WHERE company_id = ?", + ) + .bind(id) + .bind(company_id) + .execute(pool) + .await + .unwrap(); + id +} + +/// Une facture validée dont le reste brut porte quatre décimales : +/// `unit_price` 9.2550 à 8.1 % → **10.0050** ; 9.2540 → **10.004** (la TVA seule +/// est arrondie à deux décimales, `line_ttc_sql`). +async fn raw_due_invoice(pool: &MySqlPool, unit_price: rust_decimal::Decimal) -> i64 { + let (company_id, admin_id) = ids(pool).await; + let contact_id = seed_contact(pool, company_id, admin_id).await; + let (id, _v) = create_validated_invoice( + pool, + company_id, + contact_id, + admin_id, + NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(), + unit_price, + ) + .await; + id +} + +async fn post_settle( + app: &TestApp, + token: &str, + id: i64, + caisse: i64, + amount: &str, +) -> reqwest::Response { + app.client + .post(app.url(&format!("/api/v1/invoices/{id}/settlements"))) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ + "settlementType": "internal_account", + "accountId": caisse, + "amount": amount, + "settledOn": "2026-04-15" + })) + .send() + .await + .unwrap() +} + +/// `(montant réglé, lignes (compte, débit, crédit))` des règlements d'une facture. +async fn settlements_with_lines( + pool: &MySqlPool, + id: i64, +) -> Vec<( + rust_decimal::Decimal, + Vec<(i64, rust_decimal::Decimal, rust_decimal::Decimal)>, +)> { + let rows: Vec<(rust_decimal::Decimal, i64)> = sqlx::query_as( + "SELECT amount, journal_entry_id FROM invoice_settlements WHERE invoice_id = ? ORDER BY id", + ) + .bind(id) + .fetch_all(pool) + .await + .unwrap(); + let mut out = Vec::new(); + for (amount, entry) in rows { + let lines = sqlx::query_as( + "SELECT account_id, debit, credit FROM journal_entry_lines WHERE entry_id = ? \ + ORDER BY line_order, id", + ) + .bind(entry) + .fetch_all(pool) + .await + .unwrap(); + out.push((amount, lines)); + } + out +} + +async fn receivable_id(pool: &MySqlPool, company_id: i64) -> i64 { + sqlx::query_scalar("SELECT id FROM accounts WHERE company_id = ? AND number = '1100'") + .bind(company_id) + .fetch_one(pool) + .await + .unwrap() +} + +async fn paid(pool: &MySqlPool, id: i64) -> bool { + sqlx::query_scalar::<_, Option>( + "SELECT paid_at FROM invoices WHERE id = ?", + ) + .bind(id) + .fetch_one(pool) + .await + .unwrap() + .is_some() +} + +/// ⛔ **10.01 sur un reste brut de 10.0050 solde la facture** : réglé au brut, +/// écart de 0.0050 au crédit du compte d'arrondi. Avant : trop-perçu refusé. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn settle_a_centime_amount_on_a_half_centime_invoice(pool: MySqlPool) { + seed_base(&pool).await; + let (company_id, _) = ids(&pool).await; + let rounding = designate_rounding(&pool, company_id).await; + let id = raw_due_invoice(&pool, dec!(9.2550)).await; + let caisse = caisse_id(&pool, company_id).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + + let resp = post_settle(&app, &token, id, caisse, "10.01").await; + assert_eq!(resp.status(), 200); + let v: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(v["fullySettled"], true, "got {v:?}"); + let receivable = receivable_id(&pool, company_id).await; + assert_eq!( + settlements_with_lines(&pool, id).await, + vec![( + dec!(10.0050), + vec![ + (caisse, dec!(10.01), dec!(0)), + (receivable, dec!(0), dec!(10.0050)), + (rounding, dec!(0), dec!(0.0050)), + ] + )] + ); + assert!(paid(&pool, id).await); +} + +/// « 10.000 » sur un reste brut de 10.004 : un montant au centime (normalisé, +/// pas refusé pour ses zéros), qui solde avec un écart de 0.004 au **débit**. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn settle_below_the_raw_due_debits_the_rounding_account(pool: MySqlPool) { + seed_base(&pool).await; + let (company_id, _) = ids(&pool).await; + let rounding = designate_rounding(&pool, company_id).await; + let id = raw_due_invoice(&pool, dec!(9.2540)).await; + let caisse = caisse_id(&pool, company_id).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + + let resp = post_settle(&app, &token, id, caisse, "10.000").await; + assert_eq!(resp.status(), 200, "« 10.000 » est au centime"); + let receivable = receivable_id(&pool, company_id).await; + assert_eq!( + settlements_with_lines(&pool, id).await, + vec![( + dec!(10.004), + vec![ + (caisse, dec!(10.00), dec!(0)), + (receivable, dec!(0), dec!(10.004)), + (rounding, dec!(0.004), dec!(0)), + ] + )] + ); + assert!(paid(&pool, id).await); +} + +/// ⛔ **10.008 est refusé pour ses décimales**, avec le code d'échelle : la garde +/// d'avant le refusait déjà — en trop-perçu (`INVALID_INPUT`) — si bien qu'un +/// test sur le seul statut 400 serait vert sans le patch. Et au niveau du dépôt, +/// la double borne le refuse aussi (garde de mutation : borne `p > b` retirée). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn a_three_decimal_amount_is_refused_at_both_layers(pool: MySqlPool) { + seed_base(&pool).await; + let (company_id, admin_id) = ids(&pool).await; + designate_rounding(&pool, company_id).await; + let id = raw_due_invoice(&pool, dec!(9.2550)).await; + let caisse = caisse_id(&pool, company_id).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + + let resp = post_settle(&app, &token, id, caisse, "10.008").await; + assert_eq!(resp.status(), 400); + let v: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(v["error"]["code"], "VALIDATION_ERROR", "got {v:?}"); + assert!(settlements_with_lines(&pool, id).await.is_empty()); + + let err = kesh_db::repositories::invoice_settlements_write::settle_invoice( + &pool, + admin_id, + company_id, + id, + kesh_db::entities::SettlementChoice::InternalAccount { account_id: caisse }, + dec!(10.008), + NaiveDate::from_ymd_opt(2026, 4, 15).unwrap(), + ) + .await + .expect_err("entre le brut et l'arrondi : trop-perçu, jamais un partiel"); + assert!( + matches!(&err, kesh_db::errors::DbError::InvalidInput(m) if m.starts_with("overpayment")), + "got {err:?}" + ); + assert!(settlements_with_lines(&pool, id).await.is_empty()); + assert!(!paid(&pool, id).await); +} + +/// Deux paiements, 5.00 puis 5.01, sur 10.0050 : le premier est un partiel à +/// deux lignes, le second solde avec l'écart. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn the_second_of_two_payments_carries_the_rounding_line(pool: MySqlPool) { + seed_base(&pool).await; + let (company_id, _) = ids(&pool).await; + let rounding = designate_rounding(&pool, company_id).await; + let id = raw_due_invoice(&pool, dec!(9.2550)).await; + let caisse = caisse_id(&pool, company_id).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + + assert_eq!( + post_settle(&app, &token, id, caisse, "5.00").await.status(), + 200 + ); + assert!(!paid(&pool, id).await, "partiel"); + assert_eq!( + post_settle(&app, &token, id, caisse, "5.01").await.status(), + 200 + ); + let receivable = receivable_id(&pool, company_id).await; + let s = settlements_with_lines(&pool, id).await; + assert_eq!(s.len(), 2); + assert_eq!(s[0].0, dec!(5.00)); + assert_eq!(s[0].1.len(), 2, "le partiel n'a pas d'écart"); + assert_eq!( + s[1], + ( + dec!(5.0050), + vec![ + (caisse, dec!(5.01), dec!(0)), + (receivable, dec!(0), dec!(5.0050)), + (rounding, dec!(0), dec!(0.0050)), + ] + ) + ); + assert!(paid(&pool, id).await); +} + +/// ⛔ **Sans compte d'arrondi utilisable, 400 dédié et rien d'écrit** — réglage +/// absent, puis compte archivé depuis sa désignation (#486). Un paiement SANS +/// écart, lui, n'exige rien. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn a_rounding_gap_without_a_usable_account_is_a_dedicated_400(pool: MySqlPool) { + seed_base(&pool).await; + let (company_id, _) = ids(&pool).await; + let id = raw_due_invoice(&pool, dec!(9.2550)).await; + let caisse = caisse_id(&pool, company_id).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + + let resp = post_settle(&app, &token, id, caisse, "10.01").await; + assert_eq!(resp.status(), 400); + let v: serde_json::Value = resp.json().await.unwrap(); + assert_eq!( + v["error"]["code"], "ROUNDING_ACCOUNT_NOT_CONFIGURED", + "got {v:?}" + ); + assert!( + v["error"]["message"] + .as_str() + .unwrap() + .contains("Paramètres"), + "le message dit où agir, got {v:?}" + ); + assert!(settlements_with_lines(&pool, id).await.is_empty()); + + let rounding = designate_rounding(&pool, company_id).await; + sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?") + .bind(rounding) + .execute(&pool) + .await + .unwrap(); + let resp = post_settle(&app, &token, id, caisse, "10.01").await; + assert_eq!(resp.status(), 400); + let v: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(v["error"]["code"], "ROUNDING_ACCOUNT_NOT_CONFIGURED"); + assert!(settlements_with_lines(&pool, id).await.is_empty()); + + // Sans écart, rien n'est exigé : un partiel passe. + assert_eq!( + post_settle(&app, &token, id, caisse, "5.00").await.status(), + 200 + ); +} + +/// ⛔ **Annuler un règlement à trois lignes les contre-passe toutes** et rend le +/// reste brut d'avant ; si le compte d'arrondi a été archivé entre-temps, le +/// refus NOMME le compte à réactiver (`archived_accounts_in_tx`). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn cancelling_a_three_line_settlement_reverses_the_rounding_line(pool: MySqlPool) { + seed_base(&pool).await; + let (company_id, _) = ids(&pool).await; + let rounding = designate_rounding(&pool, company_id).await; + let id = raw_due_invoice(&pool, dec!(9.2550)).await; + let caisse = caisse_id(&pool, company_id).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + assert_eq!( + post_settle(&app, &token, id, caisse, "10.01") + .await + .status(), + 200 + ); + let sid: i64 = sqlx::query_scalar("SELECT id FROM invoice_settlements WHERE invoice_id = ?") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + + sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?") + .bind(rounding) + .execute(&pool) + .await + .unwrap(); + let resp = post_cancel(&app, &token, id, sid).await; + assert_eq!(resp.status(), 400); + let v: serde_json::Value = resp.json().await.unwrap(); + assert!( + v.to_string().contains("6940"), + "le refus nomme le compte d'arrondi à réactiver, got {v:?}" + ); + + sqlx::query("UPDATE accounts SET active = TRUE WHERE id = ?") + .bind(rounding) + .execute(&pool) + .await + .unwrap(); + let resp = post_cancel(&app, &token, id, sid).await; + assert_eq!(resp.status(), 200); + let v: serde_json::Value = resp.json().await.unwrap(); + let reversal = v["reversalJournalEntryId"].as_i64().unwrap(); + let receivable = receivable_id(&pool, company_id).await; + let lines: Vec<(i64, rust_decimal::Decimal, rust_decimal::Decimal)> = sqlx::query_as( + "SELECT account_id, debit, credit FROM journal_entry_lines WHERE entry_id = ? \ + ORDER BY line_order, id", + ) + .bind(reversal) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + lines, + vec![ + (caisse, dec!(0), dec!(10.01)), + (receivable, dec!(10.0050), dec!(0)), + (rounding, dec!(0.0050), dec!(0)), + ] + ); + assert_eq!( + v["invoice"]["amountDue"] + .as_str() + .map(|s| s.parse::().unwrap()), + Some(dec!(10.0050)), + "le reste brut d'avant, got {v:?}" + ); + assert!(v["invoice"]["paidAt"].is_null()); +} + +/// Le compte d'arrondi peut être un **produit** (AC 4 : « charge ou produit ») — +/// la revérification au moment d'écrire l'accepte, et l'écart s'y écrit. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn a_revenue_rounding_account_receives_the_gap(pool: MySqlPool) { + seed_base(&pool).await; + let (company_id, _) = ids(&pool).await; + let rounding = designate_rounding(&pool, company_id).await; + // Retypage seul : atteignable par l'API (`PUT /accounts/{id}`), le numéro ne l'est pas. + sqlx::query("UPDATE accounts SET account_type = 'Revenue' WHERE id = ?") + .bind(rounding) + .execute(&pool) + .await + .unwrap(); + let id = raw_due_invoice(&pool, dec!(9.2550)).await; + let caisse = caisse_id(&pool, company_id).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + + assert_eq!( + post_settle(&app, &token, id, caisse, "10.01") + .await + .status(), + 200 + ); + let s = settlements_with_lines(&pool, id).await; + assert_eq!(s[0].1[2], (rounding, dec!(0), dec!(0.0050))); +} diff --git a/crates/kesh-api/tests/reconciliation_e2e.rs b/crates/kesh-api/tests/reconciliation_e2e.rs index 685913dbc..5cd122e49 100644 --- a/crates/kesh-api/tests/reconciliation_e2e.rs +++ b/crates/kesh-api/tests/reconciliation_e2e.rs @@ -4671,3 +4671,289 @@ async fn two_acceptances_of_the_same_balance_settle_it_once(pool: MySqlPool) { .unwrap(); assert_eq!(settled, vec![dec!(700.0000)], "un seul règlement"); } + +// ============================================================ +// Story 25-4-c3-b (#476) — le reste dû au centime, et l'écart en écriture +// ============================================================ + +/// Désigne un compte de différences d'arrondi (6940, charge) dans les réglages +/// de facturation — `setup_company` n'en crée aucun. Rend son id. +async fn designate_rounding_account(pool: &MySqlPool, company_id: i64) -> i64 { + let id = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type, active, postable) \ + VALUES (?, '6940', 'Différences d''arrondi', 'Expense', 1, 1)", + ) + .bind(company_id) + .execute(pool) + .await + .expect("compte 6940") + .last_insert_id() as i64; + sqlx::query( + "INSERT INTO company_invoice_settings (company_id, default_rounding_account_id) VALUES (?, ?) \ + ON DUPLICATE KEY UPDATE default_rounding_account_id = VALUES(default_rounding_account_id)", + ) + .bind(company_id) + .bind(id) + .execute(pool) + .await + .expect("réglage du compte d'arrondi"); + id +} + +/// Une facture au reste brut de **10.0050** et une transaction de `tx_amount`, +/// référencée ou non. Rend `(invoice_id, tx_id)`. +async fn half_centime_invoice_and_tx( + pool: &MySqlPool, + ctx: &CompanyCtx, + number: &str, + tx_amount: Decimal, + reference: &str, +) -> (i64, i64) { + let day = NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(); + let (inv_id, _je) = seed_validated_invoice( + pool, + ctx.company_id, + ctx.contact_id, + number, + NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(), + dec!(10.0050), + ) + .await; + let tx_ids = seed_bank_transactions( + pool, + ctx.company_id, + ctx.bank_account_id, + ctx.user_id, + &unique_hash(number), + day, + day, + vec![make_new_tx( + ctx.company_id, + ctx.bank_account_id, + day, + Some(day), + tx_amount, + "CHF", + reference, + None, + )], + ) + .await; + (inv_id, tx_ids[0]) +} + +/// Lignes `(compte, débit, crédit)` d'une écriture, dans l'ordre. +async fn entry_lines(pool: &MySqlPool, entry_id: i64) -> Vec<(i64, Decimal, Decimal)> { + sqlx::query_as( + "SELECT account_id, debit, credit FROM journal_entry_lines WHERE entry_id = ? \ + ORDER BY line_order, id", + ) + .bind(entry_id) + .fetch_all(pool) + .await + .unwrap() +} + +/// Ce qu'une facture n'a pas encore reçu, pour les assertions « rien d'écrit ». +async fn settlements_and_paid_at(pool: &MySqlPool, inv_id: i64) -> (i64, Option) { + sqlx::query_as( + "SELECT (SELECT COUNT(*) FROM invoice_settlements WHERE invoice_id = i.id), i.paid_at \ + FROM invoices i WHERE i.id = ?", + ) + .bind(inv_id) + .fetch_one(pool) + .await + .unwrap() +} + +/// ⛔ **Un virement de 10.01 solde une facture de 10.0050**, sur le seul score de +/// montant : candidat affiché au centime, accepté, règlement enregistré au reste +/// BRUT, écart de 0.0050 au crédit du compte d'arrondi, créance soldée à zéro. +/// +/// Avant la story : score de montant 0 (10.01 ≠ 10.005), donc +/// `RECONCILIATION_SCORE_TOO_LOW` — et même accepté, trop-perçu refusé. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn a_centime_payment_settles_a_half_centime_invoice_with_a_rounding_line(pool: MySqlPool) { + let ctx = setup_company(&pool, "Centime", "CH4431999123000889012", Role::Comptable).await; + let rounding = designate_rounding_account(&pool, ctx.company_id).await; + let (inv_id, tx_id) = + half_centime_invoice_and_tx(&pool, &ctx, "INV-CT-1", dec!(10.01), "sans rapport").await; + let app = spawn_app(pool.clone()).await; + + let resp = app + .client + .get(app.url(&format!( + "/api/v1/reconciliation/proposals?bankAccountId={}", + ctx.bank_account_id + ))) + .bearer_auth(&ctx.jwt) + .send() + .await + .unwrap(); + let body: Value = resp.json().await.unwrap(); + let cand = body["proposals"] + .as_array() + .unwrap() + .iter() + .find(|p| p["bankTransactionId"] == tx_id) + .and_then(|p| { + p["candidates"] + .as_array() + .unwrap() + .iter() + .find(|c| c["invoiceId"] == inv_id) + .cloned() + }) + .expect("la facture est candidate"); + assert_eq!(cand["score"]["amountScore"], 1.0, "comparé au centime"); + assert_eq!( + cand["invoiceAmount"], "10.01", + "affiché au centime, pas « 10.005 »" + ); + assert!( + cand["invoiceTotalTtc"].is_null(), + "reste et TTC égaux au centime : pas de mention, got {cand:?}" + ); + + let body = post_accept_one(&app, &ctx, tx_id, inv_id).await; + assert_eq!( + body["accepted"].as_array().map(Vec::len), + Some(1), + "failed = {:?}", + body["failed"] + ); + + let (settled, entry_id): (Decimal, i64) = sqlx::query_as( + "SELECT amount, journal_entry_id FROM invoice_settlements WHERE invoice_id = ?", + ) + .bind(inv_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(settled, dec!(10.0050), "⛔ réglé au reste BRUT"); + assert_eq!( + entry_lines(&pool, entry_id).await, + vec![ + (ctx.bank_ledger_account_id, dec!(10.01), dec!(0)), + (ctx.receivable_account_id, dec!(0), dec!(10.0050)), + (rounding, dec!(0), dec!(0.0050)), + ], + "trois lignes : banque du payé, créance du brut, écart au crédit" + ); + assert_eq!( + receivable_balance(&pool, ctx.receivable_account_id).await, + Decimal::ZERO, + "⛔ la créance se ferme exactement" + ); + let (_, paid_at) = settlements_and_paid_at(&pool, inv_id).await; + assert!(paid_at.is_some(), "la facture est soldée"); + let details: Value = sqlx::query_scalar( + "SELECT details_json FROM audit_log WHERE action = 'invoice.paid' AND entity_id = ?", + ) + .bind(inv_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + details["rounding_difference"] + .as_str() + .map(|s| s.parse::().unwrap()), + Some(dec!(0.0050)), + "l'audit nomme l'écart, got {details:?}" + ); +} + +/// 10.02 sur 10.0050 reste un trop-perçu : la référence donne un score, c'est la +/// garde à double borne qui refuse. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn two_centimes_over_a_half_centime_invoice_is_an_overpayment(pool: MySqlPool) { + let ctx = setup_company(&pool, "Trop2", "CH4431999123000889012", Role::Comptable).await; + designate_rounding_account(&pool, ctx.company_id).await; + let (inv_id, tx_id) = + half_centime_invoice_and_tx(&pool, &ctx, "INV-CT-2", dec!(10.02), "INV-CT-2").await; + let app = spawn_app(pool.clone()).await; + + let body = post_accept_one(&app, &ctx, tx_id, inv_id).await; + let failed = body["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "got {body:?}"); + assert_eq!(failed[0]["errorCode"], "RECONCILIATION_OVERPAYMENT"); + assert_eq!(settlements_and_paid_at(&pool, inv_id).await, (0, None)); +} + +/// ⛔ **Sans compte d'arrondi utilisable, rien ne s'écrit** — refus +/// per-proposal (pattern batch), réglage absent puis compte archivé. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn a_rounding_gap_without_a_usable_account_is_refused_per_proposal(pool: MySqlPool) { + let ctx = setup_company(&pool, "SansArr", "CH4431999123000889012", Role::Comptable).await; + let (inv_id, tx_id) = + half_centime_invoice_and_tx(&pool, &ctx, "INV-CT-3", dec!(10.01), "INV-CT-3").await; + let app = spawn_app(pool.clone()).await; + + let body = post_accept_one(&app, &ctx, tx_id, inv_id).await; + let failed = body["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "got {body:?}"); + assert_eq!(failed[0]["errorCode"], "ROUNDING_ACCOUNT_NOT_CONFIGURED"); + assert_eq!(settlements_and_paid_at(&pool, inv_id).await, (0, None)); + + let rounding = designate_rounding_account(&pool, ctx.company_id).await; + sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?") + .bind(rounding) + .execute(&pool) + .await + .unwrap(); + let body = post_accept_one(&app, &ctx, tx_id, inv_id).await; + let failed = body["failed"].as_array().unwrap(); + assert_eq!( + failed[0]["errorCode"], "ROUNDING_ACCOUNT_NOT_CONFIGURED", + "archivé depuis sa désignation (#486) : revérifié au moment d'écrire" + ); + assert_eq!(settlements_and_paid_at(&pool, inv_id).await, (0, None)); + let status: String = sqlx::query_scalar("SELECT status FROM bank_transactions WHERE id = ?") + .bind(tx_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, "pending", "la transaction reste à rapprocher"); +} + +/// ⛔ **Dé-rapprocher un règlement à trois lignes contre-passe les trois** : le +/// reste dû redevient le brut d'avant, créance et compte d'arrondi reviennent. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn cancelling_a_three_line_reconciliation_reverses_the_rounding_line(pool: MySqlPool) { + let ctx = setup_company(&pool, "Annul3", "CH4431999123000889012", Role::Comptable).await; + ensure_fiscal_year_today(&pool, ctx.company_id).await; + let rounding = designate_rounding_account(&pool, ctx.company_id).await; + let (inv_id, tx_id) = + half_centime_invoice_and_tx(&pool, &ctx, "INV-CT-4", dec!(10.01), "INV-CT-4").await; + let app = spawn_app(pool.clone()).await; + let body = post_accept_one(&app, &ctx, tx_id, inv_id).await; + assert_eq!( + body["accepted"].as_array().map(Vec::len), + Some(1), + "got {body:?}" + ); + + let (st, done) = cancel_reco(&app, &ctx.jwt, tx_id).await; + assert_eq!(st, 200, "got {done:?}"); + let reversal_id = done["reversalJournalEntryId"].as_i64().unwrap(); + assert_eq!( + entry_lines(&pool, reversal_id).await, + vec![ + (ctx.bank_ledger_account_id, dec!(0), dec!(10.01)), + (ctx.receivable_account_id, dec!(10.0050), dec!(0)), + (rounding, dec!(0.0050), dec!(0)), + ], + "les trois lignes, retournées" + ); + assert_eq!( + receivable_balance(&pool, ctx.receivable_account_id).await, + dec!(10.0050), + "la créance revient au reste brut" + ); + assert_eq!(receivable_balance(&pool, rounding).await, Decimal::ZERO); + let due = kesh_db::repositories::invoice_settlements::amount_due(&pool, inv_id) + .await + .unwrap(); + assert_eq!(due, dec!(10.0050)); + assert_eq!(settlements_and_paid_at(&pool, inv_id).await, (0, None)); +} diff --git a/crates/kesh-db/src/errors.rs b/crates/kesh-db/src/errors.rs index c50f64a64..212840e62 100644 --- a/crates/kesh-db/src/errors.rs +++ b/crates/kesh-db/src/errors.rs @@ -340,6 +340,17 @@ pub enum DbError { #[error("Un ou plusieurs comptes sont archivés ou invalides")] InactiveOrInvalidAccounts, + /// Un paiement solde une facture au centime et produit un écart d'arrondi, + /// mais aucun compte de différences d'arrondi utilisable n'est désigné dans + /// les paramètres de facturation (Story 25-4-c3-b, #476) : réglage vide, ou + /// compte devenu archivé, non imputable ou d'un autre type depuis (#486). + /// + /// ⛔ **Variante dédiée, et non `ConfigurationRequired`** : le mapping de + /// celle-ci jette le champ et rend un code générique, alors que le refus doit + /// dire QUOI configurer — *Paramètres → Facturation*. + #[error("Aucun compte de différences d'arrondi utilisable n'est désigné")] + RoundingAccountNotConfigured, + /// La date fournie ne tombe pas dans l'exercice courant de l'entité /// modifiée. Story 3.3 : empêche le déplacement d'une écriture vers /// un autre exercice via un simple changement de date. @@ -678,6 +689,7 @@ impl DbError { Self::IllegalStateTransition(_) => "ILLEGAL_STATE_TRANSITION", Self::FiscalYearClosed => "FISCAL_YEAR_CLOSED", Self::InactiveOrInvalidAccounts => "INACTIVE_OR_INVALID_ACCOUNTS", + Self::RoundingAccountNotConfigured => "ROUNDING_ACCOUNT_NOT_CONFIGURED", Self::DateOutsideFiscalYear => "DATE_OUTSIDE_FISCAL_YEAR", Self::AccountHasEntries { .. } => "ACCOUNT_HAS_ENTRIES", Self::AccountRoleAlreadyAssigned { .. } => "ACCOUNT_ROLE_ALREADY_ASSIGNED", diff --git a/crates/kesh-db/src/repositories/company_invoice_settings.rs b/crates/kesh-db/src/repositories/company_invoice_settings.rs index 061d25b80..78381924d 100644 --- a/crates/kesh-db/src/repositories/company_invoice_settings.rs +++ b/crates/kesh-db/src/repositories/company_invoice_settings.rs @@ -297,6 +297,48 @@ async fn rounding_account_from_chart( .map_err(map_db_error) } +/// Le compte de différences d'arrondi, **au moment d'écrire** un écart +/// (Story 25-4-c3-b, AC 4). +/// +/// Lit le réglage (`default_rounding_account_id`, c3-a1) et revérifie le compte : +/// **de la société, actif, imputable, charge ou produit** — les exigences de la +/// validation du réglage (`validate_account_of`, `kesh-api`). Le compte a pu être +/// archivé depuis sa désignation (#486) : c'est ici, et non à l'enregistrement du +/// réglage, que l'écriture est protégée. +/// +/// `FOR UPDATE` sur le compte, comme le compte interne du règlement manuel : une +/// désactivation concurrente attend la fin de l'écriture au lieu de la précéder. +/// +/// Absent ou invalide → [`DbError::RoundingAccountNotConfigured`]. ⛔ Jamais de +/// repli sur un compte déduit (produit par défaut, charges diverses). +pub async fn rounding_account_for_write( + conn: &mut sqlx::MySqlConnection, + company_id: i64, +) -> Result { + let designated: Option> = sqlx::query_scalar( + "SELECT default_rounding_account_id FROM company_invoice_settings WHERE company_id = ?", + ) + .bind(company_id) + .fetch_optional(&mut *conn) + .await + .map_err(map_db_error)?; + let Some(account_id) = designated.flatten() else { + return Err(DbError::RoundingAccountNotConfigured); + }; + sqlx::query_scalar::<_, i64>( + "SELECT id FROM accounts WHERE id = ? AND company_id = ? AND active = TRUE \ + AND postable = TRUE AND account_type IN (?, ?) FOR UPDATE", + ) + .bind(account_id) + .bind(company_id) + .bind(AccountType::Expense) + .bind(AccountType::Revenue) + .fetch_optional(&mut *conn) + .await + .map_err(map_db_error)? + .ok_or(DbError::RoundingAccountNotConfigured) +} + /// Creates company_invoice_settings with auto-prefill of default accounts resolved /// by role (`Receivable`, `DefaultRevenue`, `Payable` — Story 14-3b : no longer by /// hardcoded account number). Called during onboarding finalization (after chart of diff --git a/crates/kesh-db/src/repositories/invoice_settlements.rs b/crates/kesh-db/src/repositories/invoice_settlements.rs index 8dd1a2481..b87368c36 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements.rs @@ -18,7 +18,7 @@ use sqlx::MySqlPool; const COLUMNS: &str = "id, company_id, invoice_id, journal_entry_id, amount, settled_on, \ settlement_type, settlement_bank_account_id, settlement_account_id, created_at"; -use crate::entities::{InvoiceSettlement, NewInvoiceSettlement}; +use crate::entities::{InvoiceSettlement, NewInvoiceSettlement, NewJournalEntryLine}; use crate::errors::{DbError, map_db_error}; use crate::repositories::invoices::line_ttc_sql; @@ -108,6 +108,123 @@ pub const INVOICE_AMOUNT_DUE_DERIVED_SQL: &str = /// Le **total réglé** d'une ligne, sur les tables de [`amount_due_derived_joins`]. pub const INVOICE_AMOUNT_SETTLED_DERIVED_SQL: &str = "COALESCE(st.settled, 0)"; +// --------------------------------------------------------------------------- +// Story 25-4-c3-b (#476) — le reste dû au centime, et l'écart d'arrondi +// --------------------------------------------------------------------------- + +/// Le reste dû **au centime** — la seule définition (AC 1). +/// +/// `line_total` porte quatre décimales, la TVA seule est arrondie à deux : le +/// reste dû calculé ([`amount_due`], [`INVOICE_AMOUNT_DUE_DERIVED_SQL`]) peut +/// valoir 10.0050, alors que la QR, les rappels et le dialogue réclament 10.01. +/// Toute comparaison d'un paiement au reste dû, et tout affichage de ce reste, +/// passe par ici. +/// +/// ⛔ Le reste dû **calculé** n'est jamais arrondi : seules les comparaisons et +/// l'affichage le sont. Et la stratégie n'est pas recopiée : c'est celle de +/// [`kesh_core::types::Money::round_to_centimes`] (`MidpointAwayFromZero`). +pub fn amount_due_to_centime(raw: Decimal) -> Decimal { + kesh_core::types::Money::new(raw) + .round_to_centimes() + .amount() +} + +/// Ce que devient un paiement face au reste dû **brut** (AC 3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentAgainstDue { + /// Règlement au montant payé, deux lignes : partiel, ou solde exact quand le + /// paiement égale le reste brut. + Ordinary, + /// Le paiement égale le reste **arrondi**, qui diffère du brut : le règlement + /// s'enregistre au **brut** — le reste dû tombe à zéro exactement — et + /// `paid − raw_due` passe en troisième ligne, sur le compte de différences + /// d'arrondi. + SettlesWithRounding { raw_due: Decimal }, + /// Trop-perçu : refusé, jamais écrit. + Overpayment, +} + +/// Classe un paiement contre le reste dû brut (AC 3), dans cet ordre : +/// +/// 1. `paid == arrondi` et `arrondi != brut` → [`PaymentAgainstDue::SettlesWithRounding`], +/// y compris quand l'arrondi est **inférieur** au brut (10.00 sur 10.004) ; +/// 2. `paid > brut` → [`PaymentAgainstDue::Overpayment`] ; +/// 3. sinon → [`PaymentAgainstDue::Ordinary`]. +/// +/// ⛔ **Les deux bornes.** Comparer à l'arrondi seul laissait un paiement +/// **strictement entre** le brut et l'arrondi (10.008 sur 10.0050) passer en +/// partiel : le reste dû devenait négatif, `paid_at` se posait sans écart, et la +/// créance restait créditrice (validation P1 de la story, F1). +/// +/// Un reste brut nul ou négatif, ou inférieur au demi-centime (arrondi nul), +/// refuse tout paiement positif en trop-perçu — limite connue, #490. +pub fn classify_payment(paid: Decimal, raw_due: Decimal) -> PaymentAgainstDue { + let rounded = amount_due_to_centime(raw_due); + if paid == rounded && rounded != raw_due { + PaymentAgainstDue::SettlesWithRounding { raw_due } + } else if paid > raw_due { + PaymentAgainstDue::Overpayment + } else { + PaymentAgainstDue::Ordinary + } +} + +/// Les lignes de l'écriture d'un règlement (AC 3) : débit de la contrepartie du +/// montant **payé**, crédit de la créance du montant **réglé**, et — s'ils +/// diffèrent — l'écart sur `rounding_account_id` : au **crédit** si le paiement +/// dépasse le réglé, au **débit** sinon. Équilibrée par construction, sans ligne +/// à zéro (`chk_jel_debit_credit_exclusive`). +/// +/// Un écart sans compte d'arrondi est une erreur de l'appelant +/// ([`DbError::Invariant`]) : c'est à lui d'avoir obtenu le compte, ou refusé. +/// +/// Si le compte d'arrondi est aussi la contrepartie (compte interne choisi au +/// règlement), l'écriture porte deux lignes sur le même compte : équilibrée, +/// comptablement exacte, laissée telle quelle (revue P1 de la 25-4-c3-b). +pub fn settlement_journal_lines( + counterparty_account_id: i64, + receivable_account_id: i64, + paid: Decimal, + settled: Decimal, + rounding_account_id: Option, +) -> Result, DbError> { + let mut lines = vec![ + NewJournalEntryLine { + account_id: counterparty_account_id, + debit: paid, + credit: Decimal::ZERO, + project_id: None, + }, + NewJournalEntryLine { + account_id: receivable_account_id, + debit: Decimal::ZERO, + credit: settled, + project_id: None, + }, + ]; + let gap = paid - settled; + if !gap.is_zero() { + let account_id = rounding_account_id.ok_or_else(|| { + DbError::Invariant("écart d'arrondi sans compte de différences d'arrondi".into()) + })?; + lines.push(NewJournalEntryLine { + account_id, + debit: if gap < Decimal::ZERO { + -gap + } else { + Decimal::ZERO + }, + credit: if gap > Decimal::ZERO { + gap + } else { + Decimal::ZERO + }, + project_id: None, + }); + } + Ok(lines) +} + /// Enregistre un règlement dans la transaction courante. /// /// ⚠️ **Ne pose PAS `paid_at`** : c'est à l'appelant de le faire, et seulement @@ -239,3 +356,79 @@ pub async fn list_all_by_company( .await .map_err(map_db_error) } + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn le_reste_au_centime_arrondit_le_demi_centime_loin_de_zero() { + assert_eq!(amount_due_to_centime(dec!(10.0050)), dec!(10.01)); + assert_eq!(amount_due_to_centime(dec!(10.004)), dec!(10.00)); + assert_eq!(amount_due_to_centime(dec!(-10.0050)), dec!(-10.01)); + assert_eq!(amount_due_to_centime(dec!(68.1000)), dec!(68.10)); + } + + #[test] + fn classification_a_double_borne() { + use PaymentAgainstDue::*; + // Solde avec écart, arrondi au-dessus puis au-dessous du brut. + assert_eq!( + classify_payment(dec!(10.01), dec!(10.0050)), + SettlesWithRounding { + raw_due: dec!(10.0050) + } + ); + assert_eq!( + classify_payment(dec!(10.00), dec!(10.004)), + SettlesWithRounding { + raw_due: dec!(10.004) + } + ); + // ⛔ Entre le brut et l'arrondi : trop-perçu, jamais un partiel. + assert_eq!(classify_payment(dec!(10.008), dec!(10.0050)), Overpayment); + assert_eq!(classify_payment(dec!(10.02), dec!(10.0050)), Overpayment); + // Ordinaire : partiel, solde exact, reste déjà au centime. + assert_eq!(classify_payment(dec!(5.00), dec!(10.0050)), Ordinary); + assert_eq!(classify_payment(dec!(10.00), dec!(10.0000)), Ordinary); + assert_eq!(classify_payment(dec!(10.01), dec!(10.00)), Overpayment); + // Reste nul, négatif ou sous le demi-centime (#490) : tout est trop-perçu. + assert_eq!(classify_payment(dec!(0.01), dec!(0)), Overpayment); + assert_eq!(classify_payment(dec!(0.01), dec!(-0.004)), Overpayment); + assert_eq!(classify_payment(dec!(0.01), dec!(0.004)), Overpayment); + } + + #[test] + fn lignes_ecart_au_credit_ou_au_debit() { + let l = settlement_journal_lines(1, 2, dec!(10.01), dec!(10.0050), Some(3)).unwrap(); + assert_eq!(l.len(), 3); + assert_eq!((l[0].account_id, l[0].debit), (1, dec!(10.01))); + assert_eq!((l[1].account_id, l[1].credit), (2, dec!(10.0050))); + assert_eq!( + (l[2].account_id, l[2].debit, l[2].credit), + (3, dec!(0), dec!(0.0050)) + ); + + let l = settlement_journal_lines(1, 2, dec!(10.00), dec!(10.004), Some(3)).unwrap(); + assert_eq!( + (l[2].account_id, l[2].debit, l[2].credit), + (3, dec!(0.004), dec!(0)) + ); + let debit: Decimal = l.iter().map(|x| x.debit).sum(); + let credit: Decimal = l.iter().map(|x| x.credit).sum(); + assert_eq!(debit, credit, "écriture équilibrée"); + + let l = settlement_journal_lines(1, 2, dec!(5), dec!(5), None).unwrap(); + assert_eq!( + l.len(), + 2, + "sans écart, deux lignes et aucun compte d'arrondi requis" + ); + + assert!(matches!( + settlement_journal_lines(1, 2, dec!(10.01), dec!(10.0050), None), + Err(DbError::Invariant(_)) + )); + } +} diff --git a/crates/kesh-db/src/repositories/invoice_settlements_write.rs b/crates/kesh-db/src/repositories/invoice_settlements_write.rs index 46fd6645e..96d867d2d 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements_write.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements_write.rs @@ -17,15 +17,16 @@ use rust_decimal::Decimal; use sqlx::MySqlPool; use crate::entities::NewAuditLogEntry; -use crate::entities::{ - Journal, NewInvoiceSettlement, NewJournalEntry, NewJournalEntryLine, SettlementChoice, -}; +use crate::entities::{Journal, NewInvoiceSettlement, NewJournalEntry, SettlementChoice}; use crate::errors::{DbError, SettlementCancelBlocker, map_db_error}; +use crate::repositories::invoice_settlements::PaymentAgainstDue; use crate::repositories::journal_entries::ReversalAuthority; use crate::repositories::settlement_cancellation::{ SettlementCancelHit, settlement_entry_cancel_blocker, }; -use crate::repositories::{audit_log, fiscal_years, invoice_settlements, journal_entries}; +use crate::repositories::{ + audit_log, company_invoice_settings, fiscal_years, invoice_settlements, journal_entries, +}; /// Ce que rend un règlement enregistré : l'écriture créée, et le résiduel après. #[derive(Debug, Clone)] @@ -162,20 +163,37 @@ pub async fn settle_invoice( } }; - // (4) ⛔ Le trop-perçu est refusé AVANT toute écriture. + // (4) ⛔ Le trop-perçu est refusé AVANT toute écriture — à DOUBLE BORNE + // (Story 25-4-c3-b, AC 3) : un paiement égal au reste arrondi au centime + // solde la facture, l'écart passant en écriture ; au-delà du brut, tout le + // reste est un trop-perçu, y compris entre le brut et l'arrondi. let due_before = invoice_settlements::amount_due(&mut *tx, invoice_id).await?; - if amount > due_before { - return Err(DbError::InvalidInput(format!( - "overpayment: amountDue={due_before}, amount={amount}" - ))); - } + let (settled_amount, rounding_account_id) = + match invoice_settlements::classify_payment(amount, due_before) { + PaymentAgainstDue::Overpayment => { + return Err(DbError::InvalidInput(format!( + "overpayment: amountDue={due_before}, amount={amount}" + ))); + } + PaymentAgainstDue::Ordinary => (amount, None), + // (4bis) Le compte d'arrondi n'est exigé QUE s'il y a un écart, et il + // est revérifié ici, au moment d'écrire (AC 4). + PaymentAgainstDue::SettlesWithRounding { raw_due } => ( + raw_due, + Some( + company_invoice_settings::rounding_account_for_write(&mut tx, company_id) + .await?, + ), + ), + }; // (5) Exercice OUVERT couvrant la date de règlement. let fy = fiscal_years::find_open_covering_date(&mut tx, company_id, settled_on) .await? .ok_or(DbError::FiscalYearInvalid)?; - // (6) `D contrepartie / C créance`. + // (6) `D contrepartie (payé) / C créance (réglé)`, plus l'écart d'arrondi + // s'il y en a un. let label = invoice_number.unwrap_or_else(|| invoice_id.to_string()); let journal = match choice { SettlementChoice::BankTransfer { .. } => Journal::Banque, @@ -194,34 +212,28 @@ pub async fn settle_invoice( journal, description: format!("Règlement facture {label}"), project_id, - lines: vec![ - NewJournalEntryLine { - account_id: counterparty_account_id, - debit: amount, - credit: Decimal::ZERO, - project_id: None, - }, - NewJournalEntryLine { - account_id: receivable_account_id, - debit: Decimal::ZERO, - credit: amount, - project_id: None, - }, - ], + lines: invoice_settlements::settlement_journal_lines( + counterparty_account_id, + receivable_account_id, + amount, + settled_amount, + rounding_account_id, + )?, }, // Flux automatique : garde de postabilité désactivée (14-3b, D-A0). false, ) .await?; - // (7) La liaison. + // (7) La liaison — au montant RÉGLÉ : le reste brut quand le paiement solde + // avec un écart, sans quoi le reste dû resterait à ±0.0050. invoice_settlements::create_in_tx( &mut tx, NewInvoiceSettlement { company_id, invoice_id, journal_entry_id: je.entry.id, - amount, + amount: settled_amount, settled_on, choice, }, @@ -285,7 +297,9 @@ pub async fn settle_invoice( "paid_via": "manual_settlement", "settlement_type": choice.type_str(), "settlement_journal_entry_id": je.entry.id, - "settled_amount": amount, + "settled_amount": settled_amount, + "paid_amount": amount, + "rounding_difference": amount - settled_amount, "settled_on": settled_on, "amount_due_after": due_after, "fully_settled": fully_settled, @@ -480,8 +494,14 @@ pub async fn cancel_settlement_in_tx( // le résiduel redevient positif, et seulement alors. Un résiduel resté // ≤ 0 le laisse intact — branche défensive, que l'application ne // produit pas (trop-perçu refusé ; facture créditée arrêtée au rang 1). - // ⚠️ `version` et `updated_at` bougent **toujours** : l'encaissement ne - // les bumpe qu'au solde, mais une annulation change toujours l'état. + // ⚠️ `version` et `updated_at` bougent **toujours** : une annulation + // change toujours le reste dû (invariant de la Story 25-4-c2). + // + // La comparaison reste au **brut**, et elle est juste (Story 25-4-c3-b, + // AC 6) : un règlement soldé avec écart a été enregistré au reste brut, + // son retrait rend donc exactement le reste brut d'avant — et la + // contre-passation de l'étape (4) retourne ses trois lignes, écart + // compris. let due_after = invoice_settlements::amount_due(&mut **tx, invoice_id).await?; let sql = if due_after > Decimal::ZERO { "UPDATE invoices SET paid_at = NULL, version = version + 1, updated_at = NOW(3) \ diff --git a/crates/kesh-db/src/repositories/reconciliation.rs b/crates/kesh-db/src/repositories/reconciliation.rs index 856a8ca20..fd8a36dcc 100644 --- a/crates/kesh-db/src/repositories/reconciliation.rs +++ b/crates/kesh-db/src/repositories/reconciliation.rs @@ -93,7 +93,9 @@ const BANK_TX_COLUMNS: &str = "id, company_id, import_id, bank_account_id, booki /// (#420, Story 25-4-c) — après le HT (#246 : une facture avec TVA ne /// matchait jamais) puis le TTC (une facture réglée en partie ne matchait /// jamais son solde). Le reste dû est pris **brut** : la tolérance couvre -/// l'écart à son arrondi au centime (au plus 0.005). +/// l'écart à son arrondi au centime (au plus 0.005). ⛔ Pas de `ROUND` ici : +/// l'arrondi a UNE définition, `invoice_settlements::amount_due_to_centime`, +/// appliquée par l'appelant au score et à l'affichage (Story 25-4-c3-b). /// /// ⛔ **Forme jointe** ([`amount_due_derived_joins`] + /// [`INVOICE_AMOUNT_DUE_DERIVED_SQL`]) : c'est une liste, la forme corrélée y diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index ae1590a07..9abdaf7bf 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -272,6 +272,7 @@ journal-od = Diverse Operationen journal-entry-saved = Buchung gespeichert error-fiscal-year-closed-generic = Das Geschäftsjahr ist abgeschlossen — keine Buchungen können hinzugefügt oder geändert werden (OR Art. 957-964). error-inactive-accounts = Ein oder mehrere Konten sind archiviert oder ungültig. +error-rounding-account-not-configured = Diese Zahlung begleicht die Rechnung auf den Rappen genau, aber es ist kein verwendbares Konto für Rundungsdifferenzen festgelegt: Wählen Sie eines unter Einstellungen → Fakturierung. # Ertragskonto pro Rechnungsposition (Story 16-1a, #152) invoice-line-account-subject-line = Position { $line } @@ -729,6 +730,7 @@ invoice-error-settled-on-required = Zahlungsdatum erforderlich invoice-error-settled-on-before-invoice-date = Das Zahlungsdatum darf nicht vor dem Rechnungsdatum liegen invoice-error-amount-positive = Der Betrag muss grösser als null sein invoice-error-amount-over-due = Der Betrag übersteigt den offenen Saldo dieser Rechnung +invoice-error-amount-scale = Der Betrag darf höchstens zwei Dezimalstellen haben invoice-error-bank-account-required = Wählen Sie ein Bankkonto invoice-error-account-required = Wählen Sie ein Konto diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 4c94a254f..5865335cd 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -272,6 +272,7 @@ journal-od = Miscellaneous journal-entry-saved = Entry saved error-fiscal-year-closed-generic = The fiscal year is closed — no entries can be added or modified (Swiss CO art. 957-964). error-inactive-accounts = One or more accounts are archived or invalid. +error-rounding-account-not-configured = This payment settles the invoice to the centime, but no usable rounding-difference account is designated: choose one in Settings → Invoicing. # Per-line revenue account (Story 16-1a, #152) invoice-line-account-subject-line = Line { $line } @@ -729,6 +730,7 @@ invoice-error-settled-on-required = Settlement date required invoice-error-settled-on-before-invoice-date = The settlement date cannot precede the invoice date invoice-error-amount-positive = The amount must be greater than zero invoice-error-amount-over-due = The amount exceeds what remains due on this invoice +invoice-error-amount-scale = The amount cannot have more than two decimal places invoice-error-bank-account-required = Choose a bank account invoice-error-account-required = Choose an account diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index f6db72f29..306697e34 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -272,6 +272,7 @@ journal-od = OD journal-entry-saved = Écriture enregistrée error-fiscal-year-closed-generic = L'exercice comptable est clôturé — aucune écriture ne peut y être ajoutée ou modifiée (CO art. 957-964). error-inactive-accounts = Un ou plusieurs comptes sont archivés ou invalides. +error-rounding-account-not-configured = Ce paiement solde la facture au centime, mais aucun compte de différences d'arrondi utilisable n'est désigné : choisissez-en un dans Paramètres → Facturation. # Compte de produit par ligne de facture (Story 16-1a, #152) invoice-line-account-subject-line = Ligne { $line } @@ -766,6 +767,7 @@ invoice-error-settled-on-required = Date de règlement obligatoire invoice-error-settled-on-before-invoice-date = La date de règlement ne peut être antérieure à la date de facture invoice-error-amount-positive = Le montant doit être supérieur à zéro invoice-error-amount-over-due = Le montant dépasse ce qui reste dû sur cette facture +invoice-error-amount-scale = Le montant ne peut avoir plus de deux décimales invoice-error-bank-account-required = Choisissez un compte bancaire invoice-error-account-required = Choisissez un compte diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index a531bfc29..2bba94a5b 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -272,6 +272,7 @@ journal-od = Operazioni diverse journal-entry-saved = Scrittura salvata error-fiscal-year-closed-generic = L'esercizio contabile è chiuso — nessuna scrittura può essere aggiunta o modificata (CO art. 957-964). error-inactive-accounts = Uno o più conti sono archiviati o non validi. +error-rounding-account-not-configured = Questo pagamento salda la fattura al centesimo, ma non è designato alcun conto utilizzabile per le differenze di arrotondamento: sceglierne uno in Impostazioni → Fatturazione. # Conto di ricavo per riga di fattura (Story 16-1a, #152) invoice-line-account-subject-line = Riga { $line } @@ -729,6 +730,7 @@ invoice-error-settled-on-required = Data del pagamento obbligatoria invoice-error-settled-on-before-invoice-date = La data del pagamento non può precedere la data della fattura invoice-error-amount-positive = L'importo deve essere superiore a zero invoice-error-amount-over-due = L'importo supera il saldo dovuto su questa fattura +invoice-error-amount-scale = L'importo non può avere più di due decimali invoice-error-bank-account-required = Scegliete un conto bancario invoice-error-account-required = Scegliete un conto diff --git a/docs/manual/fr/admin-manual.pdf b/docs/manual/fr/admin-manual.pdf index af9b095d5..f690037d3 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 079c84cd8..80860c1e1 100644 --- a/docs/manual/fr/admin-manual.tex +++ b/docs/manual/fr/admin-manual.tex @@ -2024,7 +2024,9 @@ \subsection{Loi sur la TVA (LTVA)} Ces comptes sont ajoutés automatiquement aux plans comptables (PME, indépendant, association) ; les installations existantes sont complétées sans modifier les comptes déjà utilisés. Tant que le compte \emph{TVA due} n'est pas configuré, la validation d'une facture portant de la TVA est refusée (message « configuration requise »). L'usage côté comptable (décompte, réconciliation) est décrit dans le \emph{manuel utilisateur}, section \emph{Décompte TVA}. -\paragraph{Compte de différences d'arrondi.} Une facture dont le total porte plus de deux décimales --- le cas d'une quantité ou d'un prix unitaire fractionnaire --- laisse, une fois payée au centime, un écart d'un demi-centime au plus. Cet écart est destiné au \textbf{compte de différences d'arrondi}, désigné depuis \emph{Paramètres} → \emph{Facturation}, section \emph{Différences d'arrondi}. Ce doit être un compte de \textbf{charge ou de produit}, actif et \textbf{imputable} (pas un compte de regroupement). Les plans livrés (PME, indépendant, association) le proposent : \texttt{6940} \emph{Différences d'arrondi}, une charge, \textbf{désignée d'office} à la création d'une société. Pour une société créée auparavant, ou si vous préférez un autre compte : créez-le au besoin dans le plan comptable, puis choisissez-le dans les paramètres. Le réglage est facultatif tant qu'aucun écart ne se présente. \textbf{Dans cette version, il n'est encore lu par aucune écriture} : le passage des écarts en écriture arrive avec une version prochaine, qui le dira ici. +\paragraph{Compte de différences d'arrondi.} Une facture dont le total porte plus de deux décimales --- le cas d'une quantité ou d'un prix unitaire fractionnaire --- laisse, une fois payée au centime, un écart d'un demi-centime au plus. Cet écart est destiné au \textbf{compte de différences d'arrondi}, désigné depuis \emph{Paramètres} → \emph{Facturation}, section \emph{Différences d'arrondi}. Ce doit être un compte de \textbf{charge ou de produit}, actif et \textbf{imputable} (pas un compte de regroupement). Les plans livrés (PME, indépendant, association) le proposent : \texttt{6940} \emph{Différences d'arrondi}, une charge, \textbf{désignée d'office} à la création d'une société. Pour une société créée auparavant, ou si vous préférez un autre compte : créez-le au besoin dans le plan comptable, puis choisissez-le dans les paramètres. Le réglage est facultatif tant qu'aucun écart ne se présente. + +Quand un paiement --- règlement manuel ou rapprochement bancaire --- \textbf{égale le reste dû arrondi au centime} alors que le reste exact en diffère (10.01 pour 10.0050), il \textbf{solde} la facture : le règlement est enregistré pour le reste exact, et l'écart passe en \textbf{troisième ligne} d'écriture sur ce compte --- au crédit si le paiement dépasse le reste exact, au débit sinon. La créance se ferme ainsi à zéro. Si aucun compte utilisable n'est désigné --- réglage vide, ou compte archivé, rendu non imputable ou changé de type depuis ---, un tel paiement est \textbf{refusé sans rien écrire}, avec un message qui renvoie à \emph{Paramètres} → \emph{Facturation}. Un paiement sans écart n'exige aucun réglage. Un règlement manuel se saisit au centime : un montant à plus de deux décimales est refusé. Le format de décompte officiel AFC (e-décompte ESTV) et la conformité LTVA Art.~70 (factures électroniques signées) restent hors périmètre à ce stade. diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index 45feb4f94..518f2ab7b 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 5c9435ce8..5b5cc0a7f 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -1027,7 +1027,14 @@ \subsection{Enregistrer et annuler un règlement}\label{sec:reglement-client} Kesh passe l'écriture \emph{Débit contrepartie / Crédit débiteurs} et calcule ce qui reste dû. Un règlement \textbf{partiel} est permis~: la facture reste validée, avec son reste dû, et redevient «~payée~» quand le solde atteint zéro. Un montant supérieur au reste dû est -\textbf{refusé}. Le rapprochement bancaire enregistre, lui, ses règlements tout seul. +\textbf{refusé}, de même qu'un montant à plus de deux décimales. + +\textbf{Au centime.} Une facture dont le total porte plus de deux décimales (quantité ou prix +fractionnaire) se règle au \textbf{reste dû arrondi au centime}~: c'est le montant que le +dialogue propose. Payer 10.01 une facture de 10.0050 la \textbf{solde}~: l'écriture porte alors +une \textbf{troisième ligne}, l'écart d'un demi-centime au plus, sur le compte de différences +d'arrondi désigné dans \emph{Paramètres} → \emph{Facturation}. Sans ce compte, un tel paiement est +refusé --- un acompte, lui, passe toujours. Le rapprochement bancaire enregistre, lui, ses règlements tout seul. \textbf{La liste des règlements.} La fiche liste chaque règlement --- date, montant, mode --- avec un lien vers son écriture. @@ -1455,12 +1462,12 @@ \subsubsection{Algorithme de matching} Chaque candidate reçoit un score, somme de trois critères : \begin{itemize} - \item \textbf{Montant} (50\,\%) : le montant de la transaction égale exactement le reste dû. Il n'y a pas de score intermédiaire : un écart d'un centime donne zéro sur ce critère. + \item \textbf{Montant} (50\,\%) : le montant de la transaction égale exactement le reste dû \textbf{arrondi au centime}. Il n'y a pas de score intermédiaire : un écart d'un centime donne zéro sur ce critère. \item \textbf{Référence} (40\,\%) : la référence de la transaction contient le numéro de la facture, ou l'inverse (score plein) ; ou les deux commencent par les mêmes quatre caractères au moins (demi-score). \item \textbf{Contact} (10\,\%) : le nom de la contrepartie contient celui du client de la facture, ou l'inverse. \end{itemize} -La meilleure candidate est affichée avec son score et le \textbf{montant qui reste à payer}. Si la facture est déjà réglée en partie, ce montant est suivi du total d'origine --- par exemple « 600, reste dû sur 1000 » --- pour que vous la reconnaissiez. Une candidate dont le score vaut zéro n'est pas proposée. Rien n'est jamais rapproché sans votre acceptation : il n'y a ni seuil de confiance réglable, ni acceptation automatique. +La meilleure candidate est affichée avec son score et le \textbf{montant qui reste à payer}, au centime. Si la facture est déjà réglée en partie, ce montant est suivi du total d'origine --- par exemple « 600, reste dû sur 1000 » --- pour que vous la reconnaissiez. Une candidate dont le score vaut zéro n'est pas proposée. Rien n'est jamais rapproché sans votre acceptation : il n'y a ni seuil de confiance réglable, ni acceptation automatique. \subsubsection{Acceptation des propositions} @@ -1474,7 +1481,7 @@ \subsubsection{Acceptation des propositions} Pour chaque transaction : \begin{itemize} - \item \emph{Accepter} : valide le rapprochement. L'écriture comptable est créée automatiquement (Débit 1020 Banque / Crédit 1100 Débiteurs). + \item \emph{Accepter} : valide le rapprochement. L'écriture comptable est créée automatiquement (Débit 1020 Banque / Crédit 1100 Débiteurs). Si la transaction solde une facture dont le reste porte plus de deux décimales, l'écart d'arrondi passe en troisième ligne, comme pour un règlement manuel (\S\ref{sec:reglement-client}) ; sans compte de différences d'arrondi, la proposition échoue avec le code \texttt{ROUNDING\_ACCOUNT\_NOT\_CONFIGURED}. \item \emph{Rejeter} : la proposition est ignorée. La transaction reste à rapprocher manuellement. \item \emph{Modifier} : sélectionner une autre facture ou écriture. \end{itemize} diff --git a/frontend/scripts/lint-i18n-ownership.js b/frontend/scripts/lint-i18n-ownership.js index 6948de068..a476166b9 100644 --- a/frontend/scripts/lint-i18n-ownership.js +++ b/frontend/scripts/lint-i18n-ownership.js @@ -30,6 +30,8 @@ const KNOWN_VIOLATIONS = new Set([ 'src/lib/features/invoices/SettleInvoiceDialog.svelte:invoice-error-settled-on-before-invoice-date', 'src/lib/features/invoices/SettleInvoiceDialog.svelte:invoice-error-amount-positive', 'src/lib/features/invoices/SettleInvoiceDialog.svelte:invoice-error-amount-over-due', + // Story 25-4-c3-b (#476) — refus d'un montant à plus de deux décimales. + 'src/lib/features/invoices/SettleInvoiceDialog.svelte:invoice-error-amount-scale', 'src/lib/features/invoices/SettleInvoiceDialog.svelte:invoice-error-bank-account-required', 'src/lib/features/invoices/SettleInvoiceDialog.svelte:invoice-error-account-required', 'src/lib/features/invoices/SettleInvoiceDialog.svelte:invoice-settle-dialog-title', diff --git a/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte b/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte index 218ed501b..7181116fb 100644 --- a/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte +++ b/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte @@ -55,6 +55,27 @@ onConfirm, }: Props = $props(); + /** + * Le reste dû au centime (Story 25-4-c3-b). `toFixed(2)` arrondit en + * `roundHalfUp` (mode par défaut de big.js, `Big.RM = 1`), qui porte + * l'équidistant LOIN DE ZÉRO — la stratégie du serveur + * (`Money::round_to_centimes`, `MidpointAwayFromZero`). + */ + function dueToCentime(raw: string): Big { + return new Big(new Big(raw).toFixed(2)); + } + + /** Nombre de décimales significatives d'un montant saisi ; `Infinity` si illisible. */ + function centimesDigits(raw: string): number { + try { + const canon = new Big(raw.trim()).toFixed(); + const dot = canon.indexOf('.'); + return dot < 0 ? 0 : canon.length - dot - 1; + } catch { + return Number.POSITIVE_INFINITY; + } + } + function todayIso(): string { return new Date().toISOString().slice(0, 10); } @@ -72,7 +93,7 @@ settledOn = todayIso(); // Story 25-4-b1 : le reste dû arrive à l'échelle du calcul SQL // (« 68.1000 ») ; le champ montre des centimes. - amount = amountDue !== null ? new Big(amountDue).toFixed(2) : ''; + amount = amountDue !== null ? dueToCentime(amountDue).toFixed(2) : ''; settlementType = 'bank_transfer'; bankAccountId = bankAccounts.find((b) => b.isPrimary)?.id ?? bankAccounts[0]?.id ?? null; accountId = null; @@ -108,7 +129,21 @@ if (!amount || !Number.isFinite(n) || n <= 0) { return i18nMsg('invoice-error-amount-positive', 'Le montant doit être supérieur à zéro'); } - if (amountDue !== null && n > Number(amountDue)) { + // Story 25-4-c3-b (#476) : un règlement se fait au centime. Les décimales + // se comptent sur la forme CANONIQUE de big.js — jamais sur la saisie : le + // champ est du texte libre, et « 1e-3 » n'a pas de point pour trois + // décimales réelles ; « 10.000 », lui, en a trois pour une valeur au centime. + if (centimesDigits(amount) > 2) { + return i18nMsg( + 'invoice-error-amount-scale', + 'Le montant ne peut avoir plus de deux décimales', + ); + } + // Comparé au reste dû AU CENTIME, celui que le champ pré-remplit : le + // comparer au brut (10.0050) refusait le 10.01 proposé. Avec deux + // décimales au plus, « saisie > reste arrondi » est exactement la règle à + // double borne du serveur (`classify_payment`). + if (amountDue !== null && new Big(amount.trim()).gt(dueToCentime(amountDue))) { return i18nMsg( 'invoice-error-amount-over-due', 'Le montant dépasse ce qui reste dû sur cette facture', @@ -129,7 +164,7 @@ settlementType, bankAccountId: settlementType === 'bank_transfer' ? (bankAccountId ?? undefined) : undefined, accountId: settlementType === 'internal_account' ? (accountId ?? undefined) : undefined, - amount, + amount: amount.trim(), settledOn, }); } diff --git a/frontend/src/lib/features/invoices/SettleInvoiceDialog.test.ts b/frontend/src/lib/features/invoices/SettleInvoiceDialog.test.ts new file mode 100644 index 000000000..45aeb609d --- /dev/null +++ b/frontend/src/lib/features/invoices/SettleInvoiceDialog.test.ts @@ -0,0 +1,93 @@ +/** + * Le dialogue de règlement, au centime — Story 25-4-c3-b (#476). + * + * ⚠️ Chaque test nomme la MUTATION qu'il attrape. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, fireEvent, cleanup, waitFor } from '@testing-library/svelte'; + +vi.mock('$lib/shared/utils/i18n.svelte', () => ({ + i18nMsg: (_k: string, fallback: string) => fallback, +})); + +import SettleInvoiceDialog from './SettleInvoiceDialog.svelte'; +import type { BankAccountSummary } from '$lib/features/bank-accounts/bank-accounts.api'; + +const banque = { + id: 1, + bankName: 'Banque', + iban: 'CH00', + qrIban: null, + isPrimary: true, + journalAccountId: 10, + version: 1, + archived: false, +} as unknown as BankAccountSummary; + +const SCALE = 'Le montant ne peut avoir plus de deux décimales'; +const OVER = 'Le montant dépasse ce qui reste dû sur cette facture'; + +afterEach(() => cleanup()); + +function ouvrir(amountDue: string) { + const onConfirm = vi.fn(); + const r = render(SettleInvoiceDialog, { + open: true, + onOpenChange: vi.fn(), + invoiceDate: '2026-01-01', + amountDue, + accounts: [], + bankAccounts: [banque], + onConfirm, + }); + const input = () => document.getElementById('settle-amount') as HTMLInputElement; + return { ...r, onConfirm, input }; +} + +async function saisir(input: HTMLInputElement, v: string) { + await fireEvent.input(input, { target: { value: v } }); +} + +describe('SettleInvoiceDialog — le reste dû au centime', () => { + it('le 10.01 pré-rempli sur un reste brut de 10.0050 est accepté (mutation : comparaison au brut)', async () => { + const { input, getByTestId, onConfirm, queryByText } = ouvrir('10.0050'); + await waitFor(() => expect(input().value).toBe('10.01')); + expect(queryByText(OVER)).toBeNull(); + await fireEvent.click(getByTestId('settle-confirm')); + expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ amount: '10.01' })); + }); + + it('10.02 reste un trop-perçu', async () => { + const { input, findByText } = ouvrir('10.0050'); + await saisir(input(), '10.02'); + expect(await findByText(OVER)).toBeTruthy(); + }); + + it('10.008 est refusé pour ses décimales, pas accepté comme partiel (mutation : contrôle d’échelle retiré)', async () => { + const { input, findByText, queryByText } = ouvrir('10.0050'); + await saisir(input(), '10.008'); + expect(await findByText(SCALE)).toBeTruthy(); + expect(queryByText(OVER)).toBeNull(); + }); + + it('« 1e-3 » a trois décimales réelles (mutation : décimales comptées sur la saisie brute)', async () => { + const { input, findByText } = ouvrir('10.0050'); + await saisir(input(), '1e-3'); + expect(await findByText(SCALE)).toBeTruthy(); + }); + + it('« 10.000 » est un montant au centime (mutation : zéros de fin comptés)', async () => { + const { input, getByTestId, queryByText, onConfirm } = ouvrir('10.0050'); + await saisir(input(), '10.000'); + expect(queryByText(SCALE)).toBeNull(); + await fireEvent.click(getByTestId('settle-confirm')); + expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ amount: '10.000' })); + }); + + it('10.00 sur un reste brut de 10.004 est accepté (arrondi au-dessous du brut)', async () => { + const { input, queryByText } = ouvrir('10.004'); + await waitFor(() => expect(input().value).toBe('10.00')); + expect(queryByText(OVER)).toBeNull(); + expect(queryByText(SCALE)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts index ab79cc126..8b6eeaa06 100644 --- a/frontend/src/lib/shared/i18n-keys.test.ts +++ b/frontend/src/lib/shared/i18n-keys.test.ts @@ -391,7 +391,15 @@ const ATTENDU = { // Fusion de `main` (25-6-a) dans la 25-6-b, 2026-10-05 : les deux branches partaient de // 1758 ; 25-6-a +9, 25-6-b +8 (+9 −2 +1) ⇒ **1775**. `sitesNonResolus` : 32 (25-6-a) // − 1 (25-6-b, deux sites des fiches devenus un dans `pdf-error.ts`) ⇒ **31**. - sitesTotal: 1775, + // Story 25-4-c3-b (#476) : **1756 → 1757**, recompté par ce test et par + // `grep -o "i18nMsg("` aux deux bornes de `SettleInvoiceDialog.svelte` + // (18 → 19) — le refus d'un montant à plus de deux décimales, + // `invoice-error-amount-scale`, clé neuve dans les quatre catalogues. + // + // Fusion de `main` dans la 25-4-c3-b, 2026-10-05 : base commune 1756 ; `main` +19 + // (25-5-b, 25-6-a, 25-6-b), 25-4-c3-b +1 ⇒ **1776**. `sitesNonResolus` et `relais` + // suivent `main` (31, 6) — la c3-b n'y touche pas. + sitesTotal: 1776, sitesNonResolus: 31, relais: 6, sitesGabarit: 10,