diff --git a/CHANGELOG.md b/CHANGELOG.md index e0deed4b..529eb62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,13 +24,15 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **Un compte non imputable est refusé sous son vrai nom ([#429](https://github.com/guycorbaz/kesh/issues/429), [#427](https://github.com/guycorbaz/kesh/issues/427)).** Saisir une écriture sur un compte de regroupement, sur le compte de résultat de l'exercice ou sur un compte de clôture était refusé avec le message « Un ou plusieurs comptes sont archivés ou invalides » — faux pour ce motif : on cherchait un compte archivé qui ne l'était pas. Le refus le dit désormais, et **nomme le ou les comptes** : « Le compte 9000 n’est pas imputable (compte de regroupement, de résultat ou de clôture) : choisissez un compte imputable. » Sont concernés la saisie manuelle d'une écriture, l'écriture d'ouverture (soldes de départ), le règlement d'une facture client sur un compte interne, la saisie et la complétion d'une facture fournisseur importée (compte de charge), et le règlement d'une facture fournisseur sur un compte interne. Un compte inconnu, archivé ou d'une autre société garde l'ancien refus. ⚠️ **Changement visible d'une intégration par clé d'API** : ces routes répondent désormais `400 ACCOUNT_NOT_POSTABLE`, avec `details.rejected[{accountId, accountNumber}]`, au lieu de `400 INACTIVE_OR_INVALID_ACCOUNTS`. Sur une facture fournisseur, une ligne de forme invalide (quantité, prix ou taux de TVA) est désormais signalée avant un défaut de compte d'une ligne précédente. +- **Un avoir refusé pour un compte archivé nomme le compte ([#473](https://github.com/guycorbaz/kesh/issues/473), [#523](https://github.com/guycorbaz/kesh/issues/523)).** Quand le compte débiteurs ou le compte d'arrondi que la facture a mouvementés, ou le compte de TVA due des réglages, a été archivé, l'avoir est refusé avec le message « Impossible d'émettre l'avoir — compte(s) archivé(s) : 1100. Réactivez le ou les comptes concernés. ». Il était refusé par « Un ou plusieurs comptes sont archivés ou invalides », sans nom, ou — pour l'arrondi — par un renvoi aux réglages qui ne réglait rien. Ce contrôle se fait **avant** celui de l'exercice. ⚠️ **Changement visible d'une intégration par clé d'API** (`POST /api/v1/credit-notes`) : ce refus est désormais `400 ACCOUNT_ARCHIVED`, avec `details.rejected[{accountId, accountNumber}]` — le code de la contre-passation —, au lieu de `400 INACTIVE_OR_INVALID_ACCOUNTS` ou de `ROUNDING_ACCOUNT_NOT_CONFIGURED` ; un compte de produit archivé garde `CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED`, qui nomme la ligne — deux codes possibles, un même libellé « Impossible d'émettre l'avoir ». Un réglage débiteurs vide ne rend plus `CONFIGURATION_REQUIRED`. + - **Les opérations rejouées après un interblocage sont journalisées en avertissement.** Chaque opération rejouée est désormais journalisée par le serveur au niveau *avertissement*, avec le nom de l'opération (`operation`) et le numéro de la tentative ; un rejeu épuisé (interblocage encore à la dernière tentative) l'est au niveau *erreur*, avec le nom de l'opération et le nombre de tentatives. ### Corrigé - **Un compte non imputable est refusé partout où un client le désigne pour recevoir des écritures ([#427](https://github.com/guycorbaz/kesh/issues/427), [#429](https://github.com/guycorbaz/kesh/issues/429)).** Les écrans filtraient ces comptes, mais le serveur ne les contrôlait pas : un appel direct — par clé d'API ou par une intégration — pouvait rapprocher une transaction bancaire sur **9000 Bilan d'ouverture**, et fausser le résultat de l'exercice et le report à nouveau. Sont désormais refusés en `400 ACCOUNT_NOT_POSTABLE`, qui nomme le compte : le **rapprochement** manuel et ventilé, l'acceptation d'une proposition ventilée ou par règle (dans `failed[]`, sans bloquer le reste du lot) ; les **règles d'affectation** — création, changement de compte, et **réactivation** d'une règle dont le compte n'est plus imputable ; le **compte comptable d'un compte bancaire**, à la création, ou quand on le change. Une règle dont le compte est devenu non imputable — par exemple scindé en sous-comptes — **n'est plus proposée** ; la règle suivante qui correspond l'est à sa place. Les **réglages de facturation** refusent un compte non imputable **au moment où on le désigne** (créance, produit, TVA due, TVA récupérable, décompte TVA, créanciers), avec un message qui nomme le champ. ⚠️ **Limites** : un compte déjà désigné ou déjà lié, devenu non imputable après coup, reste accepté — on peut enregistrer les réglages, modifier le compte bancaire ou renommer la règle sans le changer. La validation d'une facture et la saisie (ou la complétion) d'une facture fournisseur refusent désormais un compte de réglage devenu non imputable (entrée ci-dessous) ; **restent** hors de cette garde l'avoir, le compte de produit par défaut et le compte comptable d'un compte bancaire déjà lié. L'écran des règles affichait « [object Object] » au lieu du message d'un refus : il affiche le message. Sur l'écran des comptes bancaires, un compte lié devenu non imputable s'affichait vide dans les formulaires de modification et de lien : il reste affiché. -- **Un compte de réglage devenu non imputable n'est plus utilisé par les écritures automatiques ([#429](https://github.com/guycorbaz/kesh/issues/429)).** La créance et la TVA due, désignées dans *Paramètres → Facturation*, recevaient l'écriture de chaque facture validée même devenues non imputables — après la création de sous-comptes, la case *imputable* décochée ou le rôle « résultat de l'exercice » attribué —, comme les créanciers et la TVA récupérable pour chaque facture fournisseur. La **validation d'une facture** et la **saisie (ou la complétion) d'une facture fournisseur** les contrôlent désormais au moment d'écrire, et refusent avec un message qui nomme le compte, renvoie à *Paramètres → Facturation* et dit qu'un **administrateur** doit y désigner un compte imputable — la page des réglages lui est réservée. La TVA due (ou récupérable) n'est contrôlée que si la pièce en porte. L'**avoir** n'est pas soumis à ce contrôle, délibérément : il relit encore la créance et la TVA due dans les réglages du moment, ce que corrigeront [#473](https://github.com/guycorbaz/kesh/issues/473) et [#525](https://github.com/guycorbaz/kesh/issues/525) — une facture émise reste annulable. ⚠️ **Après la mise à jour, vérifiez dans *Paramètres → Facturation* que les comptes désignés sont imputables** : un compte devenu non imputable (sous-comptes créés, case *imputable* décochée…) doit y être remplacé, faute de quoi la validation des factures (ou la saisie des factures fournisseurs) est refusée. *(API : `400 ACCOUNT_NOT_POSTABLE`, `details.rejected[{accountId, accountNumber}]`, sur `POST /invoices/{id}/validate`, `POST /supplier-invoices` et `POST /imported-supplier-invoices/{id}/complete` — après tous les autres refus, exercice compris.)* +- **Un compte de réglage devenu non imputable n'est plus utilisé par les écritures automatiques ([#429](https://github.com/guycorbaz/kesh/issues/429)).** La créance et la TVA due, désignées dans *Paramètres → Facturation*, recevaient l'écriture de chaque facture validée même devenues non imputables — après la création de sous-comptes, la case *imputable* décochée ou le rôle « résultat de l'exercice » attribué —, comme les créanciers et la TVA récupérable pour chaque facture fournisseur. La **validation d'une facture** et la **saisie (ou la complétion) d'une facture fournisseur** les contrôlent désormais au moment d'écrire, et refusent avec un message qui nomme le compte, renvoie à *Paramètres → Facturation* et dit qu'un **administrateur** doit y désigner un compte imputable — la page des réglages lui est réservée. La TVA due (ou récupérable) n'est contrôlée que si la pièce en porte. L'**avoir** n'est pas soumis à ce contrôle, délibérément — une facture émise reste annulable : il crédite la créance que la facture a débitée (entrée ci-dessous, [#473](https://github.com/guycorbaz/kesh/issues/473)), et relit encore la TVA due dans les réglages du moment, ce que corrigera [#525](https://github.com/guycorbaz/kesh/issues/525). ⚠️ **Après la mise à jour, vérifiez dans *Paramètres → Facturation* que les comptes désignés sont imputables** : un compte devenu non imputable (sous-comptes créés, case *imputable* décochée…) doit y être remplacé, faute de quoi la validation des factures (ou la saisie des factures fournisseurs) est refusée. *(API : `400 ACCOUNT_NOT_POSTABLE`, `details.rejected[{accountId, accountNumber}]`, sur `POST /invoices/{id}/validate`, `POST /supplier-invoices` et `POST /imported-supplier-invoices/{id}/complete` — après tous les autres refus, exercice compris.)* - **Enregistrer les réglages de facturation n'efface plus le compte créanciers ([#521](https://github.com/guycorbaz/kesh/issues/521)).** L'écran *Paramètres → Facturation* n'envoyait pas ce compte (il l'affiche et l'envoie désormais, voir *Ajouté*), et chaque enregistrement le vidait : la saisie d'une facture fournisseur échouait ensuite en « configuration requise ». Absent du corps, il est désormais **préservé** ; à `null`, effacé. *(API : `defaultPayableAccountId` dans `PUT /company/invoice-settings`.)* @@ -38,6 +40,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **Le manuel décrit le rapprochement tel que l'écran le fait ([#481](https://github.com/guycorbaz/kesh/issues/481), [#519](https://github.com/guycorbaz/kesh/issues/519)).** Il annonçait un bouton *Modifier*, des boutons *Accepter* et *Rejeter* par ligne, une acceptation par lot « atomique », un rapprochement manuel et un éclatement qui régleraient des factures, et des règles d'affectation qui créeraient des écritures en brouillon dès l'import : rien de cela n'existe. Il dit maintenant ce que fait l'écran — une candidate par transaction, l'acceptation qui traite chaque ligne pour elle-même, l'affectation manuelle et l'éclatement vers des comptes de charges et de produits, en une écriture, des règles qui proposent sans jamais écrire —, et ce qu'il ne fait pas : une transaction rejetée ne se rapproche plus depuis l'interface ([#526](https://github.com/guycorbaz/kesh/issues/526)), une facture de score faible passe devant une règle ([#527](https://github.com/guycorbaz/kesh/issues/527)), et un paiement reçu plus de 30 jours après la date de la facture n'est pas proposé ; il se règle depuis la fiche de la facture ([#529](https://github.com/guycorbaz/kesh/issues/529)). +- **L'avoir crédite le compte débiteurs que la facture a débité, et contre-passe l'arrondi sur le compte qu'elle a mouvementé ([#473](https://github.com/guycorbaz/kesh/issues/473), [#523](https://github.com/guycorbaz/kesh/issues/523)).** L'avoir relisait le compte débiteurs et le compte de différences d'arrondi **dans les réglages du moment**. Si l'un d'eux avait été changé dans *Paramètres → Facturation* entre la validation de la facture et l'avoir, l'avoir créditait le nouveau compte : l'ancien gardait le débit de la facture, qui passait « annulée » et sortait de la balance âgée — la créance restait au grand livre sans plus apparaître nulle part ; de même, au centime, pour l'arrondi. Les deux comptes se lisent désormais **sur l'écriture de la facture**, comme le faisaient déjà le règlement, le solde du reste et l'encaissement par rapprochement : ils reviennent à zéro, comme le promet le manuel. Un réglage débiteurs ou d'arrondi vidé depuis n'empêche plus d'émettre l'avoir. **Limite connue** : la TVA de l'avoir est encore contre-passée sur le compte de TVA due désigné au moment de l'avoir ([#525](https://github.com/guycorbaz/kesh/issues/525)). + - **Une opération qui croise un rapprochement bancaire ne finit plus en erreur interne ([#463](https://github.com/guycorbaz/kesh/issues/463), [#491](https://github.com/guycorbaz/kesh/issues/491), [#536](https://github.com/guycorbaz/kesh/issues/536)).** Un règlement client, l'annulation d'un règlement client, la validation d'une facture, la saisie d'une facture fournisseur et l'enregistrement des réglages de facturation qui croisent une autre opération (un rapprochement bancaire, notamment) ne finissent plus en erreur interne : le serveur rejoue l'opération, comme il le faisait déjà pour l'acceptation d'un rapprochement. Il en va de même de toutes les opérations courantes qui écrivent au journal — hors la restauration d'une sauvegarde et l'effacement des données de démonstration. Seul un interblocage répété trois fois de suite rend encore une erreur ; rien n'est alors écrit et l'opération peut être relancée telle quelle. - **Les manuels ne prétendent plus que la numérotation des factures se fait dans une transaction `SERIALIZABLE` ([#484](https://github.com/guycorbaz/kesh/issues/484)).** Ils décrivent le verrou du compteur, la contrainte d'unicité et le rejeu. diff --git a/_bmad-output/implementation-artifacts/15-6-creance-juste-avoir-reglement.md b/_bmad-output/implementation-artifacts/15-6-creance-juste-avoir-reglement.md new file mode 100644 index 00000000..d06106fd --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6-creance-juste-avoir-reglement.md @@ -0,0 +1,161 @@ +# Story 15.6 : La créance juste — l'avoir crédite le compte de la vente ; un règlement ne vise pas le compte qu'il solde + +## Status + +split + +⛔ **Fiche d'index — elle ne porte ni critères ni tâches.** La story a été découpée **avant** sa +première rédaction complète, parce qu'elle franchissait d'emblée le critère de périmètre de la +§ *Règle de splitting préventif* du `CLAUDE.md` (choix **C-15-6-1** de +`epic-15-choix-autonomes.md`) ; la validation P1 en a détaché une quatrième (choix **C-15-6-9**). Les +quatre fiches filles sont `ready-for-dev`. + +## Les quatre sous-stories + +| | fiche | ce qu'elle porte | issues | +|---|---|---|---| +| **15-6a** | `15-6a-avoir-credite-la-creance-de-la-vente.md` | L'avoir contre-passe la créance **et l'arrondi** sur les comptes que l'écriture de vente a mouvementés, non ceux des réglages du moment. Lecteur partagé du compte de créance (trois copies de la même requête remplacées) et lecteur recoupé de la ligne d'arrondi ; **tous** les comptes que l'avoir écrit (créance, arrondi, TVA due, produits) verrouillés **en partage, avant l'exercice**, pour la course de lecture (un seul helper de verrou de liste, partagé avec la 15-5d), un compte de créance, d'arrondi ou de TVA due archivé refusé en le nommant (`ACCOUNT_ARCHIVED`, code de la contre-passation, libellé « Impossible d'émettre l'avoir ») ; la route est rejouée sur interblocage **par la 15-5e** (C54). Inventaire des sites qui relisent un compte après la pièce ; cycles connus nommés, sans prétention d'absence (#536) ; TVA due de l'avoir en angle mort tracé (#525). Manuel (§ *Avoirs*), CHANGELOG `[0.13.0]`. | `closes #473, closes #523` | +| **15-6b** | `15-6b-contrepartie-distincte-de-la-creance.md` | Un règlement — client ou fournisseur, par virement ou par compte interne, manuel, par rapprochement ou **par lot de paiement** — refuse une contrepartie égale au compte qu'il solde — comptes d'arrondi et de solde du reste compris, comparés par identifiant : code `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, `details.role` distinguant le compte choisi du compte désigné dans les réglages (deux remèdes, un message par rôle — six clés —, 4 locales ; le compte d'arrondi porte `rounding` quel que soit le geste) ; validation et avoir en angles morts écrits (#537, #525) ; helper commun dans `invoice_settlements` dont la 15-6d emprunte la comparaison ; trois clés plates pour les comptes désignés ; les lots refusent la facture **avant** de produire le pain.001, et un lot bloqué à la confirmation dit ses deux issues. Les deux écrans de règlement ne le proposent plus. Manuel, `docs/api-external.md`. | `refs #474` (angles morts : #537, #525) | +| **15-6c** | `15-6c-configuration-sans-ecriture-nulle.md` | La configuration ne prépare plus l'écriture nulle : le compte comptable d'un compte bancaire ne peut être le compte débiteurs ou créanciers des réglages, et réciproquement ; exemption « inchangé » ; contrôles dans la transaction, les deux gestes sérialisés (`LOCK IN SHARE MODE`). Écrans des comptes bancaires et des réglages (débiteurs, et créanciers depuis la 15-5d). Manuels, `docs/api-external.md`. | `closes #474` | +| **15-6d** | `15-6d-contrepartie-distincte-de-la-banque.md` | Le rapprochement manuel et l'acceptation par règle refusent une contrepartie égale au compte de la banque, avec le refus **et à la place** du flux ventilé existant ; une règle sur ce compte n'est plus proposée ; l'écran du rapprochement manuel ne la propose plus (filtre local) ; le chemin par règle contrôle aussi le compte de banque actif (même réponse sur les quatre chemins). Manuel, `docs/api-external.md`. | `closes #524` | + +⚠️ **Ordre** : 15-6a → 15-6b (mêmes fichiers : `invoice_settlements_write.rs`, `reconciliation.rs`). +La **15-6c ne commence qu'après le merge de la 15-5b**, qui modifie les mêmes fonctions de +`crates/kesh-api/src/routes/bank_accounts.rs` (`validate_journal_account_id`, création, remplacement, +lien), le dépôt `crates/kesh-db/src/repositories/bank_accounts.rs` et le PUT des réglages +(`company_invoice_settings.rs`, AC10, AC11 et AC19 de la 15-5b), et après la 15-6b (fonctions d'écran +partagées, `ClaimSide`) et après la **15-5d** (compte créanciers exposé à l'écran des réglages). La +**15-6d** se rebase après la 15-5b (mêmes fonctions ; plus de dépendance d'ordre des refus depuis +C-15-6-23), **passe après la 15-6b**, dont elle emprunte la comparaison du helper commun de +`invoice_settlements` (C-15-6-28), et gagne à passer après la **15-5c**, qui réécrit le manuel du +rapprochement. Les 15-6a et +15-6b se rebasent aussi après les **15-5a à 15-5e** (mêmes fonctions : `accept_one_invoice`, +`settle_invoice`, `pay_in_tx`, `validate_invoice` ; la **15-5e**, réécrite par C54, rejoue sur +interblocage toutes les routes d'écriture — avoir compris — et réécrit le doc-comment canonique de +l'ordre des verrous, le « 5 bis » et le Pattern 5 : **une seule règle d'ordre des verrous pour +l'epic**, la sienne, que les 15-6 suivent sans la réécrire) — chacune a sa tâche T0. La 15-6b gagne à passer après la **15-5c** +aussi : le libellé de son code dans `failed[]` passe par le module des refus par lot que la 15-5c pose +(#492, choix C-15-6-12). + +⚠️ **CHANGELOG** : la première story de la version mergée crée `## [0.13.0] — Non publié` en tête si +elle est absente ; les suivantes y ajoutent leur entrée (règle écrite dans chaque fiche). + +## Pourquoi le découpage + +Réunies, #473 et #474 touchent `kesh-db` (avoir, règlement client, règlement fournisseur, lots de +paiement, comptes bancaires, erreurs), `kesh-api` (rapprochement, comptes bancaires, réglages de +facturation, erreurs), `kesh-i18n`, plusieurs écrans (`SettleInvoiceDialog`, fiche de facture +fournisseur, lots, comptes bancaires, réglages), le manuel et le CHANGELOG — bien plus de cinq +modules. La seule dérogation codifiée (cycle de dépendances Cargo) ne s'applique pas. Chaque fille +reste à cinq modules métier au plus, la plomberie d'erreur et d'i18n suivant le geste qu'elle sert +(patron de la 15-5a). La validation P1 a ajouté à la 15-6b les lots de paiement et #524 ; avec les +deux, elle repassait au-dessus de cinq modules — d'où la 15-6d (finding F4). + +## Décisions prises pour la story et où elles vivent + +| choix | objet | fiche | +|---|---|---| +| C-15-6-1 | découpage en trois, ordre (ordre révisé par C-15-6-12) | toutes | +| C-15-6-2 | l'avoir lit la créance sur la vente (arrondi révisé par C-15-6-7, TVA par C-15-6-8) | 15-6a | +| C-15-6-3 | refus dédié, deux modes, après les contrôles existants ; arrondi et natures résolus par le type (révisé par C-15-6-18, C-15-6-26) | 15-6b | +| C-15-6-4 | filtre d'écran par rôle singleton, sans champ d'API nouveau (précisé par C-15-6-14) | 15-6b | +| C-15-6-5 | configuration : refus du couple dans les deux sens, exemption « inchangé » (course : révisé par C-15-6-13) | 15-6c | +| C-15-6-6 | ~~voisin hors périmètre~~ — **retiré par C-15-6-9** | — | +| C-15-6-7 | #523 absorbée : arrondi lu sur la vente, compte archivé refusé par `create_in_tx` (voie b) | 15-6a | +| C-15-6-8 | TVA due de l'avoir : angle mort tracé par #525, limite dite au manuel | 15-6a | +| C-15-6-9 | #524 → 15-6d, même refus que le flux ventilé | 15-6d | +| C-15-6-10 | lots de paiement : refus à la création, garde à la confirmation | 15-6b | +| C-15-6-11 | gardes sur les lecteurs ; lecteur sœur fournisseur | 15-6b | +| C-15-6-12 | #492 relève de la 15-5c | 15-6b, index | +| C-15-6-13 | contrôles dans la transaction, deux gestes sérialisés, ordre des verrous | 15-6c | +| C-15-6-14 | fonctions d'écran par ensemble d'ids, partagées 15-6b/15-6c (la 15-6d filtre localement, C-15-6-23) | 15-6b, 15-6c | +| C-15-6-15 | spec E2E de lien bancaire : 1100 → 1000 (références de fixture corrigées par C-15-6-22) | 15-6c | +| C-15-6-16 | comptes de la vente verrouillés, refus nommé `ACCOUNT_ARCHIVED` (révise la voie b de C-15-6-7) | 15-6a | +| C-15-6-17 | écrans sans archivés ; clés conformes au lint | 15-6b | +| C-15-6-18 | comptes d'écart comparés à la créance par identifiant (révise C-15-6-3) | 15-6b | +| C-15-6-19 | lots : refus contextualisé à la confirmation, achat malformé en `failed[]`, écran de création non filtré | 15-6b | +| C-15-6-20 | décompte des modules, signal D5 déclaré, pas de découpage | 15-6b | +| C-15-6-21 | `LOCK IN SHARE MODE` ; réglages : 409 d'abord ; code des réglages hors guide (révise C-15-6-13) | 15-6c | +| C-15-6-22 | appels directs au dépôt, tests étendus de la 15-5b, code mort supprimé, signal D5 | 15-6c | +| C-15-6-23 | égalité avec le compte de banque testée d'abord ; règle plus proposée ; filtre local (révise C-15-6-9) | 15-6d | +| C-15-6-24 | comptes de la vente verrouillés en partage, avant l'exercice (rectifie C-15-6-16) | 15-6a | +| C-15-6-25 | un seul vocabulaire de refus pour l'avoir : variante propre, code `ACCOUNT_ARCHIVED` (révise C-15-6-16) | 15-6a | +| C-15-6-26 | un code, un rôle (`details.role`), deux remèdes ; tables de refus du guide ; comparaison séparée de la construction du refus (révise C-15-6-18) | 15-6b, 15-6d | +| C-15-6-27 | `FOR UPDATE` de `before` testé, attente prouvée, pas de verrou pour une dé-liaison, manuel, guide | 15-6c | +| C-15-6-28 | compte de banque actif sur le chemin par règle, gardes ventilées figées, comparaison empruntée à la 15-6b (révise C-15-6-23 sur deux points, cf. C-15-6-31) | 15-6d | +| C-15-6-29 | verrou partagé étendu à tous les comptes écrits par l'avoir, rejeu de la route, ligne absente → `Invariant` (rectifie C-15-6-24) | 15-6a | +| C-15-6-30 | rôle `rounding` du compte d'arrondi quel que soit le geste ; validation et avoir en angles morts (#537, #525) ; liste vide conditionnée | 15-6b | +| C-15-6-31 | clôture de la validation ; C-15-6-28 révise C-15-6-23 ; texte du refus selon le champ | 15-6d | +| C-15-6-32 | alignement sur la 15-5e de C54 : rejeu de la route par la 15-5e, verrou pour la course de lecture seule, aucun réordonnancement, cycles nommés ; helper de verrou partagé avec la 15-5d ; TVA à source unique (révise C-15-6-29) | 15-6a | +| C-15-6-33 | alignement sur la 15-5e de C54 ; chaque comparaison suit sa lecture ; trois clés plates ; critère de T2 par nom sur liste fermée ; sujet de refus typé | 15-6b | +| C-15-6-34 | 15-6b close à la P6 ; produit de repli et validation d'achat tracés (#525, #537) ; comparaison après son compte et la créance ; manuel d'administration ; dispersion déclarée sans découpage | 15-6b, 15-6c | +| C-15-6-35 | 15-6a : un seul contrat pour le helper de verrou de liste ; critère DRY à exception écrite ; partenaires réels du cycle (iv) ; dépendance ferme à la 15-5d | 15-6a | + +## Change Log + +- 2026-10-08 — Spécification initiale (bmad-create-story, en autonomie), découpée d'emblée en + 15-6a / 15-6b / 15-6c. Choix C-15-6-1 à C-15-6-6 consignés. Validation : à lancer, fiche par fiche. +- 2026-10-08 — **Validation P1** des trois fiches (Sonnet 4.6, lentilles F et R). Bilan : 0 CRITICAL ; + HIGH — 15-6a ×1 (#523), 15-6b ×4 (#524, `confirm_batch`, tests 5 et 7 irréalisables), 15-6c ×3 + (transaction, course, AC6 périmé). Tous appliqués ; 15-6d créée pour #524 ; choix C-15-6-7 à + C-15-6-15 consignés. Détail dans le Change Log de chaque fiche. +- 2026-10-08 — **Validation P2** des 15-6a, 15-6b, 15-6c et **P1** de la 15-6d (Opus 5.5, lentilles + R et F). Bilan : 0 CRITICAL ; HIGH — 15-6c ×1 (`FOR SHARE`, né de la remédiation P1, relevé par les + deux lentilles) ; MEDIUM — 15-6a 3 + 2, 15-6b 3 + 5, 15-6c 3 + 3, 15-6d 3 + 3 (R + F, avant + convergence). Tous appliqués ; choix C-15-6-16 à C-15-6-23 consignés. **Signaux de découpage + déclarés au Project Lead** : 15-6b (huit modules au barème des fichiers) et 15-6c (HIGH né de la + remédiation) — ni l'une ni l'autre découpée, arbitrage de Guy attendu. Détail dans le Change Log de + chaque fiche. +- 2026-10-08 — **Validation P3** des 15-6a, 15-6b, 15-6c (Sonnet, lentilles R et F) et **P2** de la + 15-6d (Opus, lentilles R et F) ; remédiation Opus 5.5. Bilan (R + F, avant dédoublonnage) : + 0 CRITICAL ; 15-6a 1 HIGH, 6 MEDIUM, 10 LOW ; 15-6b 5 MEDIUM, 9 LOW ; 15-6c 3 MEDIUM, 11 LOW ; + 15-6d 3 MEDIUM, 12 LOW. Tous appliqués ; choix C-15-6-24 à C-15-6-28 consignés. Points saillants : + 15-6a — verrou des comptes de la vente en `LOCK IN SHARE MODE` **avant** l'exercice (le HIGH, né de + la remédiation P2, inversait l'ordre canonique de `validate_invoice`), refus « Impossible d'émettre + l'avoir » sous le code `ACCOUNT_ARCHIVED` ; 15-6b — `details.role` et clé distincte pour un compte + désigné dans les réglages, helper commun ; 15-6c — `FOR UPDATE` de `before` épinglé par un test, + attentes prouvées ; 15-6d — compte de banque actif contrôlé sur le chemin par règle, **la 15-6d passe + désormais après la 15-6b**. **Signaux D5 déclarés au Project Lead** : 15-6a (sévérité remontée, + HIGH né de la remédiation P2) et 15-6b (MEDIUM → MEDIUM, deux défauts nés de la remédiation P2) — + aucune découpée ; 15-6c et 15-6d : non levés à cette passe (le signal P2 de la 15-6c reste + déclaré). Toutes les fiches gardent des MEDIUM : **P4 à lancer** (complète pour la 15-6b ; ciblée + possible pour la 15-6a). Décomptes : 15-6a 9 AC, 7 tâches, 14 tests ; 15-6b 13 AC, 10 tâches, + 21 tests ; 15-6c 11 AC, 9 tâches, 16 tests ; 15-6d 10 AC, 6 tâches, 12 tests. +- 2026-10-08 — **Validation P4** des 15-6a et 15-6b (Opus 5.5, lentilles R et F), **P4 ciblée** de la + 15-6c (Haiku 4.5) et **P3** de la 15-6d (Sonnet, lentilles R et F) ; remédiation Opus 5.5. Bilan (R + + F, avant dédoublonnage) : 0 CRITICAL, 0 HIGH ; 15-6a 4 MEDIUM, 12 LOW ; 15-6b 5 MEDIUM, 10 LOW ; + 15-6c 0 ; 15-6d 10 LOW. Tous appliqués ; choix C-15-6-29 à C-15-6-31 consignés. Points saillants : + 15-6a — le verrou partagé couvre **tous** les comptes que l'avoir écrit (la TVA due et les produits + demandaient leur S après l'exercice), la route `POST /api/v1/credit-notes` rejoue un 1213, cycle + avoir ↔ rapprochement de la même facture tracé par **#536** ; 15-6b — rôle `rounding` pour le compte + d'arrondi du solde du reste, « résolu par le type » remplacé par des angles morts (**#537**, #525), + message de liste vide conditionné. **Signal D5 déclaré au Project Lead** pour la 15-6b (F1 recycle le + discriminant de la P3 ; contenu, pas de découpage — un troisième recyclage en P5 sortirait l'AC3 bis + en story propre) ; la 15-6a voit sa sévérité baisser (P3 1 HIGH → P4 0 HIGH). **Validations closes : + 15-6c et 15-6d** (0 au-dessus de LOW ; « 0 » de la passe ciblée de la 15-6c vérifié par + l'orchestrateur). **P5 à lancer** pour les 15-6a et 15-6b (MEDIUM à cette passe). Décomptes : 15-6a + 9 AC, 7 tâches, 16 tests ; 15-6b 13 AC, 10 tâches, 22 tests ; 15-6c 11 AC, 9 tâches, 16 tests ; + 15-6d 10 AC, 6 tâches, 12 tests. +- 2026-10-08 — **Validation P5** des 15-6a et 15-6b (Sonnet, lentilles R et F ; remédiation Opus 5.5). + Bilan (R + F, avant dédoublonnage) : 0 CRITICAL, 0 HIGH ; 15-6a 3 MEDIUM, 13 LOW ; 15-6b 6 MEDIUM, + 10 LOW. Tous traités ; choix C-15-6-32 et C-15-6-33 consignés. Fil conducteur : **la 15-5e**, que + les deux fiches ignoraient, et **sa réécriture par C54** survenue pendant la remédiation (la défense + contre l'interblocage est le rejeu) : dépendances « après 15-5a à 15-5e » ; la 15-6a s'appuie sur le + rejeu de sa route par la 15-5e, garde son verrou pour la seule course de lecture, ne réordonne rien + et ne prétend plus à l'absence de cycle ; la 15-6b garde l'ordre des lectures du code (chaque + comparaison suit sa lecture), passe à trois clés plates, relève `sitesTotal`, et remplace le critère + de T2 par un relevé par nom sur liste fermée. **Signal D5** : levé par la sévérité pour la 15-6b + (MEDIUM → MEDIUM), non déclenché — le MEDIUM qui touche l'AC3 bis vient d'un changement extérieur + (la 15-5e), pas d'un patch ; idem pour R5-1/R5-2 de la 15-6a. **P6 complète (Opus) à lancer** pour + les deux. Décomptes : 15-6a 9 AC, 7 tâches, 18 tests ; 15-6b 13 AC, 10 tâches, 23 tests. +- 2026-10-08 — **Validation P6** des 15-6a et 15-6b (Opus 5.5, lentilles R et F ; prompts + `15-6a-validate-prompt-p6.md`, `15-6b-validate-prompt-p6.md`). **15-6b** : 0 CRITICAL, 0 HIGH, + 0 MEDIUM, 18 LOW (R 11, F 7) — **validation close** (C-15-6-34) ; produit de repli et validation + d'achat désormais tracés (#525, #537) ; manuel d'administration ajouté à l'AC11 (frontière de la + 15-6c alignée) ; dispersion (neuf modules au barème de la règle) déclarée au Project Lead, non + découpée. **15-6a** : 0 CRITICAL, 0 HIGH, 5 MEDIUM (4 distincts), 12 LOW — remédiée (C-15-6-35) : + un seul contrat pour le helper de verrou, critère DRY à exception écrite, doctrine résiduelle + retirée, partenaires réels du cycle (iv), dépendance ferme à la 15-5d ; **signal D5 levé** + (recyclage de la P5, contenu au texte de l'AC6) et dispersion déclarés, pas de découpage ; **P7 + complète (Sonnet) due**. Décomptes inchangés : 15-6a 9 AC, 7 tâches, 18 tests ; 15-6b 13 AC, + 10 tâches, 23 tests. À faire par l'orchestrateur : commentaire sur #536 (F6-6 de la 15-6a). diff --git a/_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md b/_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md new file mode 100644 index 00000000..797848b2 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md @@ -0,0 +1,1510 @@ +# Story 15.6a : L'avoir contre-passe la créance et l'arrondi sur les comptes que la vente a mouvementés + +## Status + +done + + + +**Issues** : `closes #473, closes #523` (P1 toutes deux) — les mots-clés vont **dans la PR** (merge en +squash). **Mère** : `15-6-creance-juste-avoir-reglement.md`. +**Suivie de** : 15-6b (mêmes fichiers `invoice_settlements_write.rs` et `reconciliation.rs` — la 15-6b +emploie le lecteur que celle-ci pose). +**Après** les 15-5a à 15-5e (ordre de l'epic : 15-5 puis 15-6 ; la **15-5e** passe avant la 15-5d) — +dépendance **ferme** pour chacune, la **15-5d** comprise (son helper de verrou, son test de C35) : si +l'une n'est pas mergée au moment de T0, la story **attend** (finding R6-10 de la P6). +Ces fiches touchent les **mêmes fonctions** : `accept_one_invoice` (15-5b AC6 y pose un commentaire de +classement sur le bloc même que l'AC2 remplace), `settle_invoice` (15-5a AC4, 15-5b AC18), +`write_off_invoice` (**15-5e AC5** : commentaire « 5 bis » réécrit en place), `validate_invoice` +(15-5b, 15-5d, et **15-5e AC5**, qui réécrit son doc-comment « # Ordre des locks » — l'ordre réel, +**convention qui réduit la fréquence** des interblocages, et **la défense, qui est le rejeu** : la +règle de l'epic, choix C54, que cette fiche **suit** et ne réécrit pas, AC6, AC7), la **route +`POST /api/v1/credit-notes`** (**15-5e AC3** : rejouée sur interblocage par l'enveloppe `DbError`, +`kesh_db::retry::retry_on_deadlock` — la 15-6a ne pose donc **pas** de second rejeu, AC6), le +Pattern 5 de `docs/MULTI-TENANT-SCOPING-PATTERNS.md` (15-5e AC5), `invoices_validate_vat.rs` (15-5b, 15-5d), le +passage du manuel sur l'avoir (15-5d AC8), le **helper de verrou d'une liste de comptes** que la 15-5d +pose (`lock_designated_accounts_in_tx`, AC6) et le **fichier de tests** `credit_notes_repository.rs`, +où la 15-5d ajoute un test (C35) que cette fiche ré-ancre (§ *Tests*, 13). Les numéros +de ligne de cette fiche sont ceux du commit `1920381e` : **T0 les refait tous** après ces merges. +**Angle mort tracé, hors story** : #525 (TVA due de l'avoir lue dans les réglages du moment — jalon de +la TVA, report assumé ; choix C-15-6-8). + +## Story + +En tant que **comptable d'une PME**, +je veux que **l'avoir contre-passe la créance et l'écart d'arrondi sur les comptes mêmes que la facture +a mouvementés**, +afin que **le compte débiteurs de la facture et le compte de différences d'arrondi reviennent à zéro**, +comme le promet le manuel, même si les comptes par défaut ont été changés entre la validation et +l'avoir. + +## Le défaut, établi au code + +- La validation d'une facture débite la créance sur `settings.default_receivable_account_id` + (`crates/kesh-db/src/repositories/invoices.rs:2005-2010`) — c'est le geste qui **crée** la créance, + il lit légitimement les réglages. Elle pousse l'écart d'arrondi en **dernière** ligne + (`generate_invoice_journal_lines_rounded`, `invoices.rs:1892-1912`) : au **crédit** du compte + d'arrondi si l'arrondi est positif, au **débit** s'il est négatif. +- L'avoir relit **les réglages du moment** : + - pour la créance (`crates/kesh-db/src/repositories/credit_notes.rs:360-364`), créditée en ligne 0 + (`generate_credit_note_journal_lines`, `:187`, ligne poussée `:219`, appel `:509`) — #473 ; + - pour le compte d'arrondi (`credit_notes.rs:525`, `rounding_account_for_write(.., Issuance)`, + `company_invoice_settings.rs:400-412`) — #523 ; + - pour la TVA due (`credit_notes.rs:511`, `settings.default_vat_payable_account_id`) — #525, hors + story (choix C-15-6-8). +- Le règlement, le solde du reste et l'encaissement par rapprochement lisent, eux, la créance **sur + l'écriture de vente** — première ligne au débit, `ORDER BY jel.id LIMIT 1` — par **trois copies** + de la même requête : `invoice_settlements_write.rs:104-115` (`settle_invoice`), + `invoice_settlements_write.rs:434-445` (`write_off_invoice`), `crates/kesh-api/src/routes/reconciliation.rs:1425-1452` + (`accept_one_invoice`). La génération garantit que la créance est cette ligne-là + (`invoices.rs:1870-1878`, doc de `generate_invoice_journal_lines_rounded`). +- Rien n'interdit de changer le compte débiteurs ou le compte d'arrondi par défaut entre-temps + (`crates/kesh-api/src/routes/company_invoice_settings.rs:274-281`, seuls type et état actif). + +**Conséquence** : l'avoir crédite le nouveau compte ; l'ancien garde le débit TTC ; la facture passe +`cancelled` et sort de la balance âgée — la créance reste au grand livre sans plus apparaître nulle +part. Même chose, au centime, pour le compte d'arrondi (écart ≤ 2,5 centimes par facture, mais une +écriture fausse). Le commentaire `credit_notes.rs:609-611` — « c'est ce qui rend la contre-passation +l'« inverse exact » même si le défaut société change ensuite » (cité `:574-576` par l'issue, sur une +version antérieure) — est vrai du compte de produit, recopié par ligne (Story 16-1a, D5), pas de la +créance ni de l'arrondi. + +## Acceptance Criteria + +1. **AC1 — Un lecteur partagé du compte de créance.** `crates/kesh-db/src/repositories/invoice_settlements.rs` + expose `pub async fn sale_receivable_account(conn: &mut sqlx::MySqlConnection, company_id: i64, sale_entry_id: i64) -> Result, DbError>` + (signature indicative ; l'exécuteur suit le patron de `rounding_account_for_write`), qui exécute + **exactement** la requête des trois copies actuelles (première ligne `debit > 0` de l'écriture, + `ORDER BY jel.id LIMIT 1`, portée par `je.company_id`). Son doc-comment porte la règle — « la + créance se lit sur l'écriture de vente, jamais sur les réglages » — et la raison de l'ordre (arrondi + négatif au débit **après** la créance, 25-4-c4-a). `None` = écriture sans ligne de débit : chaque + appelant garde **son** refus actuel (`DbError::Invariant` pour le règlement et le solde ; + `failed[]` `INVOICE_SALE_ENTRY_MALFORMED` pour le rapprochement, `details` inchangés — + `{ "reason": "no_debit_line_on_sale_entry", "saleEntryId" }`). **Erreur SQL** du lecteur dans le + rapprochement : l'appelant la convertit, comme aujourd'hui, en `FailedProposal` `DATABASE_ERROR` + avec `details = { "message": e.to_string() }` — **forme inchangée** ; le **texte** du message, lui, + dérive désormais d'un `DbError` (`map_db_error`, `crates/kesh-db/src/errors.rs:796`) et non plus + d'un `sqlx::Error` : son libellé peut différer. Le rejeu sur interblocage n'en dépend pas (il + repose sur la perte du point de sauvegarde, `reconciliation.rs:1057-1068`). +2. **AC2 — Les trois copies disparaissent.** `settle_invoice`, `write_off_invoice` et + `accept_one_invoice` appellent le lecteur ; `grep -rn --include='*.rs' "jel.debit > 0" crates/` ne + rend plus que le lecteur et la doc de `generate_invoice_journal_lines_rounded` + (`invoices.rs:1877`). Les `.sql` que le même jeton atteint — + `crates/kesh-db/migrations/20260729000001_invoice_lines_revenue_account_backfill.sql:293`, + `crates/kesh-db/migrations/20260828000001_invoice_settlements_type.sql:51` et le rejeu + `crates/kesh-db/src/post_restore/20260828000001_invoice_settlements_type.sql:25` — sont des + backfills, **hors classe** et légitimes (une migration appliquée ne se modifie plus, règle P8). + Comportement inchangé : les tests existants (`crates/kesh-db/tests/invoice_settlement.rs`, + `invoice_write_off.rs`, `crates/kesh-api/tests/reconciliation_e2e.rs`) passent sans modification + d'assertion. ⚠️ **Aucun test ne fige aujourd'hui `INVOICE_SALE_ENTRY_MALFORMED`** (vérifié à la + remédiation P1 : `grep -rn "INVOICE_SALE_ENTRY_MALFORMED" crates/ frontend/src docs` ne rend que le + site de `reconciliation.rs`) : un test l'ajoute avant le remplacement (§ *Tests*, 10), pour que la + copie remplacée garde un témoin. +3. **AC3 — L'avoir crédite la créance de la vente.** `create_credit_note` lit la créance par le + lecteur de l'AC1, sur `invoice.journal_entry_id` de la facture verrouillée ; `None` sur + `journal_entry_id` ou sur le lecteur → `DbError::Invariant` (« facture validée sans écriture de + vente » / « écriture de vente sans ligne de débit »), comme `settle_invoice`. La lecture se fait + **après** le verrou de la facture (étape (1)) et la lecture des réglages (3), **avant** l'exercice + (4) — elle ne dépend que de `invoice.journal_entry_id`, connu dès (1), et le verrou de l'AC6 en a + besoin —, dans la même transaction. L'avoir **ne lit plus** + `settings.default_receivable_account_id` : un réglage débiteurs vide n'empêche plus d'émettre un + avoir sur une facture validée. +4. **AC4 — L'avoir contre-passe l'arrondi sur le compte de la vente (#523, choix C-15-6-7).** Un + second lecteur, à côté du premier : + `pub async fn sale_rounding_account(conn: &mut sqlx::MySqlConnection, company_id: i64, sale_entry_id: i64, rounding_amount: Decimal) -> Result` + (signature indicative). Il lit la **dernière** ligne de l'écriture de vente + (`ORDER BY jel.id DESC LIMIT 1`, portée par `je.company_id`) et la **recoupe** avec l'arrondi figé + sur la facture : arrondi > 0 ⇒ `credit = rounding_amount` et `debit = 0` ; arrondi < 0 ⇒ + `debit = −rounding_amount` et `credit = 0`. Toute autre forme (pas de ligne, sens ou montant + différent) → `DbError::Invariant` (« écriture de vente : la dernière ligne n'est pas l'arrondi de la + facture »). Il n'est **appelé que si** `invoice.rounding_amount ≠ 0` — une facture émise sans + arrondi, antérieure comprise, n'exige toujours aucun compte (validation P3 de la 25-4-c4-a). Il est + appelé **à côté du lecteur de créance**, avant l'exercice (4) (AC3, AC6) ; l'étape (7 bis) de + `create_credit_note` ne lit plus `rounding_account_for_write(.., Issuance)` et emploie l'id déjà lu ; + la ligne produite (sens, montant, ajustement de la ligne 0) ne change pas. Son doc-comment dit + pourquoi le recoupement (un lecteur positionnel qui ne vérifie rien rendrait n'importe quelle ligne + en silence) et pourquoi **pas** `usable_designated_account` (doctrine « mêmes comptes que + l'origine, seule l'inactivité bloque », cf. 6 ter de la 16-1a). +5. **AC5 — Le reste de l'avoir ne bouge pas.** Compte de produit par ligne (D5) et garde des comptes + de produit archivés (6 ter), compte de TVA due (réglage courant — angle mort #525, AC8), libellé, + numéro, journal, projet, snapshot des lignes : inchangés. L'ordre des lignes de l'écriture aussi + (créance en ligne 0, arrondi en dernière ligne). L'avoir **exige toujours** + `default_revenue_account_id` (`credit_notes.rs:365-367`, repli D-B2) : seule la créance cesse + d'être lue dans les réglages. +6. **AC6 — Tous les comptes que l'avoir écrit sont verrouillés en partage avant l'exercice, et un + compte archivé depuis est refusé en le nommant** (choix C-15-6-16, qui révise la voie (b) de + C-15-6-7 ; mode, place et refus révisés par C-15-6-24 et C-15-6-25 ; périmètre étendu à tous les + comptes écrits et ligne absente tranchés par C-15-6-29 ; ordre, cycles et rejeu — porté par la + 15-5e — révisés par C-15-6-32). + - **Verrou.** Juste après la lecture des réglages (étape (3)) et les deux lecteurs (AC3, AC4), + **avant** l'exercice (étape (4), `fiscal_years::find_open_covering_date`, qui pose un + `FOR UPDATE`, `fiscal_years.rs:548`) et la séquence (étape (5), + `credit_note_number_sequences::next_number_for`, `FOR UPDATE`), `create_credit_note` verrouille + les lignes `accounts` de **tous les comptes que l'écriture de l'avoir visera** — tous connus à ce + point, le snapshot des lignes (étape (2)) précédant les réglages : + - la **créance** de la vente (AC3) ; + - le compte d'**arrondi de la vente**, si l'arrondi ≠ 0 (AC4) ; + - le compte de **TVA due** des réglages, si l'avoir émet au moins une ligne de TVA et si le réglage + est posé — vide, il n'est pas verrouillé et le refus existant de la génération reste à sa place + (ci-dessous) ; + - les **comptes de produit effectifs** : ceux des lignes (`revenue_account_id`) et, si une ligne + n'en porte pas, le produit de repli (`default_revenue_account_id`, D-B2) — exactement les ids + que la 6 ter calcule aujourd'hui (`credit_notes.rs:435-446`). + + En **une** instruction, ids dédoublonnés, `ORDER BY id` — **aucun réordonnancement** pour + fermer un cycle (choix C-15-6-32, sur C54 : la défense contre l'interblocage est le rejeu de la + 15-5e, pas l'ordre des verrous) : + `SELECT id, number, active, postable FROM accounts WHERE company_id = ? AND id IN (…) ORDER BY id LOCK IN SHARE MODE` + — **sans** filtre `active` : la ligne doit être prise même archivée, pour être nommée. + `FOR SHARE` est une erreur de syntaxe sur MariaDB 10.11 ; **syntaxe** (pas patron : aucune de ces + requêtes ne vise une liste de comptes) relevée à `crates/kesh-db/src/repositories/opening_complement.rs:502` + et `:540` (mise en garde `FOR SHARE` à `:38-39`) — la 15-6c, qui pose `claim_accounts_in_share_mode`, + passe **après** cette fiche (fiche d'index). + - **Un seul helper de verrou d'une liste de comptes — un seul contrat** (DRY, finding F5-5 de la + P5 ; contrat unifié par F6-1 = R6-1 de la P6 ; C-15-6-32, C-15-6-35). La 15-5d pose, pour la même + forme en exclusif, `lock_designated_accounts_in_tx` (`WHERE company_id = ? AND id IN (…) ORDER BY + id FOR UPDATE`, rend `id, number, active, postable`, sans refuser — 15-5d AC1, accesseur de + `company_invoice_settings.rs`). Il n'y a **qu'une** fonction de verrou d'une liste de comptes, et + **ce paragraphe est son seul contrat** dans cette fiche : + - **paramétrée par le mode** : p. ex. `lock_accounts_in_tx(conn, company_id, ids: &[i64], mode: + AccountLockMode)`, `AccountLockMode::{Exclusive, Share}` → `FOR UPDATE` / `LOCK IN SHARE MODE` + (signature indicative) ; + - **emplacement** : celui que la 15-5d lui a donné (`company_invoice_settings.rs`, T0 le + constate) ; la 15-6a ne la déplace pas ; + - **colonnes rendues** : `id, number, active, postable`, **toutes** les lignes trouvées, archivées + comprises, ordre `ORDER BY id` ; + - **elle ne refuse rien** : ni compte archivé, ni ligne absente. Le contrôle « ligne absente » + (nombre de lignes rendues ≠ nombre d'ids dédoublonnés → `Invariant`) et le partage des lignes + (créance, arrondi, TVA due → la variante ; produits → la 6 ter) vivent **chez l'appelant**, + `create_credit_note` ; + - **visibilité `pub`** : le test 17 (`crates/kesh-db/tests/credit_notes_repository.rs`, test + d'**intégration**, hors de la crate) l'appelle directement — `pub(crate)` ne compilerait pas ; + - **type de ligne** : un type **nommé, public**, distinct de `opening_complement::LockedAccount` + (`opening_complement.rs:383`, **privé**, colonnes `account_type` et `role` en plus) — p. ex. + `LockedAccountState { id, number, active, postable }` ; `LockedAccount` n'est **ni réutilisé** + (forme différente, privé à un flux que l'epic ne touche pas) **ni repris comme nom** (deux types + homonymes dans la même crate tromperaient la lecture). **Un seul type, jamais deux** : si la 15-5d a + déjà nommé **et exposé (`pub`)** le type de ligne de son helper, la 15-6a l'emploie ; si elle l'a + laissé privé ou anonyme, la 15-6a le rend public sous le nom qu'il porte (ou `LockedAccountState` + s'il n'en a pas) et ne crée pas de second type (finding R7-1 de la P7 ; C-15-6-36). + **Qui la crée** : la **15-5d**, mergée avant (dépendance **ferme**, en-tête ; finding R6-10 de la + P6). Si T0 la trouve sans paramètre de mode, la 15-6a l'y ajoute (l'appel de la 15-5d passe + `Exclusive`, son comportement ne change pas) au lieu d'écrire une jumelle. + **Critère DRY, relevé au Dev Agent Record** (finding R6-2 de la P6) : `grep -rnE "LOCK IN SHARE + MODE|FOR UPDATE" crates/kesh-db/src` lu site par site (requêtes multi-lignes et `QueryBuilder` + compris) ne montre aucune autre requête qui verrouille une **liste** d'ids de comptes, **hors une + exception écrite** : `opening_complement.rs:522-532` (`create_opening_complement`, `SELECT id, + number, account_type, active, postable, role … WHERE id IN (…) AND company_id = ? ORDER BY id FOR + UPDATE`), dont la forme diffère (deux colonnes de plus, pré-filtre `owned_account_ids` **hors** + transaction) et qui sort du périmètre de l'epic — la refondre sur le helper serait un changement + sans motif d'un flux livré. Toute **autre** occurrence est un site non résolu. + - **Plan et identifiant étranger** (finding F5-4 de la P5, sur le patron de la 15-5d, choix C51) : + sous REPEATABLE READ, un balayage ou un `filesort` qui parcourt d'autres lignes les **verrouille** + (piège mesuré à `opening_complement.rs:274-288`), et un verrou par clé primaire peut prendre une + ligne **avant** que le filtre `company_id` ne l'écarte (`opening_complement.rs:431-440`) — en + partage comme en exclusif, un S bloque l'`UPDATE` d'un archivage de ce compte. L'`EXPLAIN` de la + requête en mode `Share` (accès par clé primaire sur `id IN (…)`, aucun `filesort`) est **relevé au + Dev Agent Record** ; un test d'identifiant d'une **autre société** est ajouté (§ *Tests*, 17) ; + s'il montre la ligne étrangère verrouillée, le helper prend le patron `owned_account_ids` + d'`opening_complement.rs` — pour les deux modes, donc aussi pour la 15-5d — et le Dev Agent + Record le dit. + - **« L'avoir émet de la TVA » : une seule source** (findings R5-4 et F5-2 de la P5 ; C-15-6-32). + La condition vit aujourd'hui **dans** `generate_credit_note_journal_lines` (`total_vat > 0`, somme + des TVA agrégées par taux, `credit_notes.rs:201-214`, `:240`), appelée à l'étape (7), **après** + le verrou. Un `any(vat_rate > 0)` naïf en divergerait (ligne à taux positif dont la TVA s'arrondit + à zéro). T3 **extrait** l'agrégation en une fonction pure (p. ex. + `credit_note_vat_by_rate(lines) -> BTreeMap`, à côté du générateur, sans I/O), + appelée **par le générateur** et **par le calcul des ids à verrouiller** — aucune copie du calcul. + **Place du refus `ConfigurationRequired("default_vat_payable_account_id")` : inchangée** — il reste + rendu par le générateur, à l'étape (7), donc **après** `FISCAL_YEAR_INVALID` et après + `ACCOUNT_ARCHIVED` (un réglage vide n'est pas verrouillé, rien ne le rend plus tôt). Avancer + l'appel du générateur avant le verrou l'aurait fait passer devant l'exercice : changement de + priorité sans motif, écarté (C-15-6-32). + - **Pourquoi un verrou partagé (S), pas exclusif (X).** Le verrou a un but premier : qu'un + archivage concurrent (`accounts::archive`, `accounts.rs:604-634`, un `UPDATE … SET active = FALSE` + qui demande X et ne regarde pas les écritures) **attende la fin de l'avoir** au lieu de le + précéder. Un S y suffit, et il est compatible avec les S que tout insert de ligne d'écriture pose + déjà sur le compte parent (contrôle de clé étrangère des `INSERT INTO journal_entry_lines`). Un S + **n'ajoute aucun cycle** par la créance : `accept_one_invoice` y tient un S (clé étrangère des + lignes du règlement, jusqu'au commit du lot, `tx_outer`, `reconciliation.rs:887`), et S contre S + ne bloque pas — un X y aurait ajouté un cycle. Mais un S **n'en retire aucun** non plus : face au + **X** qu'un autre flux prend sur le même compte (compte d'arrondi du rapprochement, du règlement + ou du solde du reste ; compte de nature et TVA due du solde du reste), il **attend**. Les cycles + qui subsistent sont écrits plus bas. Corollaire : deux avoirs simultanés **ne se sérialisent pas** + sur la ligne d'un compte (S + S) — ils se sérialisent déjà, et seulement, sur la ligne des réglages + (étape (3), `get_or_create_default_in_tx`, `FOR UPDATE`, `company_invoice_settings.rs:109`). La + lecture sous S est une lecture **verrouillante** : elle rend l'état **courant** de `active`, comme + le ferait un X. + - **Ordre des verrous** : facture (1) → règlements (garde 25-4-a) → avoir existant → réglages (3) + → **comptes de l'avoir (S, `ORDER BY id`)** → exercice (4) → séquence (5) → écriture, dont l'insertion des lignes **reprend** un S sur chaque + compte écrit (clé étrangère `fk_jel_account`). La règle de l'epic est celle que la **15-5e** écrit + au doc-comment canonique de `validate_invoice` (son AC5, choix **C54**) : l'ordre des verrous est + une **convention qui réduit la fréquence** des interblocages — il ne peut pas les exclure, puisque + l'insertion des lignes reprend `accounts` **après** l'exercice — et **la défense est le rejeu des + routes**. Cette fiche **suit** cette règle et ne la réécrit pas ; elle ne prétend à l'absence + d'**aucun** cycle (C54 : « elles cessent d'affirmer l'absence de cycle et renvoient au rejeu de la + 15-5e »). + **Pourquoi ce verrou, et pourquoi avant l'exercice** : il ferme une **course de lecture** — un + archivage concurrent attend la fin de l'avoir au lieu de le précéder, et l'état `active` de + chaque compte écrit (créance, arrondi, TVA due, produits — la 6 ter comprise) est lu **frais**, + sous verrou, non dans l'instantané (C-15-6-29). C'est son seul rôle revendiqué. Sa place avant + l'exercice est celle que la fiche avait depuis la P3 (C-15-6-24) et la convention que décrit le + doc-comment canonique (comptes → exercice) ; elle n'est **pas** un instrument de fermeture de + cycle, et la fiche n'en tire aucune garantie. La ligne des réglages `FOR UPDATE` + (`company_invoice_settings.rs:109`), prise d'abord par l'avoir, la validation et la saisie + fournisseur (15-5e AC5), les sérialise entre eux — fait relevé, non promesse. Le doc-comment de + `create_credit_note` porte cet ordre et **renvoie** au doc-comment canonique pour la règle et au + rejeu de la route (AC7, DRY). + - **Cycles connus, nommés — non exhaustifs, couverts par le rejeu.** Les passes de validation en ont + trouvé quatre ; d'autres peuvent exister, et la fiche ne prétend pas le contraire (C54). Tous sont + détectés par InnoDB (1213) ; aucun ne naît de la créance : + (i) **avoir ↔ lot de rapprochement** (**#536**, fermée par le rejeu de la 15-5e, C57) : + `accept_one_invoice`, qui ne verrouille pas la facture en amont (`reconciliation.rs:1688-1691`), + prend X sur le compte d'arrondi du moment si le paiement solde avec écart (`:1487`, + `rounding_account_for_write` → `usable_designated_account`, `company_invoice_settings.rs:499`) et + X sur l'exercice (`:1520`, `fiscal_years.rs:548`), **avant** son `UPDATE invoices` (`:1703`), et le + lot garde l'exercice d'une proposition à la suivante (savepoints d'une seule transaction, + `tx_outer`, `:887`) ; l'avoir tient X sur la facture et S sur l'arrondi de la vente, puis demande + l'exercice. Préexistant (l'avoir prenait déjà l'exercice) ; le lot rejoue (`post_accept`), l'avoir + aussi (15-5e). + (ii) **avoir ↔ solde du reste d'une autre facture**, quand le compte de nature de celui-ci est + aussi un compte de produit de l'avoir (p. ex. une association qui a désigné elle-même son 3800 + *Autres produits* comme compte d'escompte — le plan des associations n'en désigne aucun d'office, + `admin-manual.tex:2029` —, ou un 3800 *Déductions sur ventes* de PME imputé sur une ligne de + facture ; finding F6-3 de la P6) : le solde prend X sur la nature, puis l'arrondi, puis la TVA due ; + l'avoir prend ses S par `id` (2200 avant 3800) — ordres opposés entre la nature et la TVA due. + Rare. **Variante fréquente** (finding R5-2 de la P5) : sans nature commune, le solde d'un reste + hors centime prend X sur l'arrondi puis sur la TVA due ; l'avoir d'une facture arrondie, par `id`, + la TVA due (2200) avant l'arrondi (6940, plus loin dans le plan livré, ou créé après lui ; finding + R6-8 de la P6) — ordres opposés entre deux comptes + courants. **Non réordonné** (C-15-6-32, C54) : couvert par le rejeu des deux routes. + (iii) **avoir ↔ règlement client par compte interne** (finding F5-3 de la P5), quand le compte + interne choisi est un compte que l'avoir écrit (sa TVA due, un de ses produits) et que le paiement + a un écart : le règlement prend X sur le compte interne puis sur l'arrondi ; l'avoir les prend + par `id`. Configuration sans sens comptable, rare. + (iv) **cycle à trois par un X en attente** : l'avoir tient S sur un compte et attend l'exercice, + tenu par un flux qui prend l'exercice **sans passer par la ligne des réglages** — saisie manuelle + (`journal_entries::create_in_tx`, exercice `journal_entries.rs:279-280`, puis l'insertion des + lignes), règlement client (`invoice_settlements_write.rs:200`), solde du reste (`:493`), + rapprochement (`reconciliation.rs:1520`, manuel et ventilé `:2083`, `:2432`) ; un `UPDATE + accounts` sur ce compte (archivage, modification, création d'un sous-compte qui rend le parent non + imputable) attend derrière le S ; ce flux insère une ligne sur ce compte, et sa demande S attend le + X en attente (file InnoDB) → 1213. **Neuf** (sans le S, l'`UPDATE` passait), rare. La + **validation** et la **saisie fournisseur** n'en sont **pas** : elles prennent la ligne des réglages + `FOR UPDATE` **avant** l'exercice — établi pour la **validation** (`invoices.rs:2006`, puis + `:2172`) ; pour la saisie fournisseur (`supplier_invoices.rs:359` pour les réglages), T0 **relève** + l'ordre par rapport à son exercice —, et l'avoir la prend avant ses + S (étape (3)) — une validation qui tient l'exercice tient donc les réglages, et l'avoir l'attend + là, sans encore tenir de S (finding F6-2 = R6-4 de la P6). + **Le rejeu** : la route `POST /api/v1/credit-notes` (`crates/kesh-api/src/routes/credit_notes.rs:183`) + est **rejouée par la 15-5e** (son AC3 : enveloppe `DbError`, `kesh_db::retry::retry_on_deadlock`, + doc-comment d'une ligne ; registre des routes, son test 5). La 15-6a **n'en pose pas** un second + (C-15-6-32, qui retire ce point de C-15-6-29) ; T0 le **constate** sur `HEAD` — si la 15-5e n'est + pas mergée, la story attend (ordre de l'epic). Le rejeu est sûr : la transaction est atomique et le + numéro d'avoir se rembobine au rollback ; un 1213 ressort en `DbError::Sqlx` (`map_db_error`, + `kesh-db/src/errors.rs:796`, ne le convertit pas). Le test 15 fige que ce rejeu couvre l'attente + **neuve** que l'AC6 crée (le S sur les comptes de l'avoir). + - **Ordre des refus qui en découle** : un compte de créance, d'arrondi ou de TVA due archivé (ce + refus) l'emporte sur `FISCAL_YEAR_INVALID` (4), sur `creditNoteTotalZero` (6 bis) et sur + `CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED` (6 ter) ; il vient **après** les refus de l'étape (1) + (facture non validée, réglée, déjà annulée), après `ConfigurationRequired("default_revenue_account_id")` + de l'étape (3) (`credit_notes.rs:365-367`, AC5) et après les `Invariant` des deux lecteurs (AC3, + AC4). Le test 14 fige la précédence sur l'exercice ; les refus antérieurs ne demandent pas de test + neuf (ils précèdent déjà toute lecture de compte). + - ⚠️ **L'état `active` se lit DANS cette lecture verrouillante**, jamais dans une lecture simple + ultérieure : sous REPEATABLE READ, une lecture non verrouillante rend l'instantané de la + transaction — établi dès la première lecture simple, le snapshot des lignes (étape (2)), donc + **avant** le verrou — et ne voit pas un archivage validé entre-temps. C'est le trou de la garde + `active` de `create_in_tx` (`journal_entries.rs:96-98`, sans verrou), qui ne suffit donc pas + seule, et c'est pourquoi la **6 ter** (comptes de produit) ne relit plus `active` par sa lecture + simple (`credit_notes.rs:450-451`) : elle prend l'état actif dans le résultat du verrou, et garde + son code (`CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED`), son message par ligne et **sa place** (après + l'exercice et la 6 bis — l'ordre des refus du produit ne change pas). + - **Ligne absente du résultat du verrou.** La requête porte `company_id = ?` : un id que le verrou ne + rend pas (compte d'une autre société sur une écriture ou une ligne corrompue — **inatteignable par + l'application** : les validations à la création d'une ligne de facture, anti-IDOR, + `invoices_line_revenue_account.rs:365-367` `create_rejects_cross_company_account`, et à la + désignation d'un réglage l'excluent, et les lectures sont portées par `je.company_id` ; **aucune** + clé étrangère ne porte la société — `fk_jel_account`, `fk_cis_receivable` et celle de + `revenue_account_id` ne référencent que `accounts(id)` —, si bien qu'un `UPDATE` direct y parvient, + comme le fait le test 17 ; finding F6-4 de la P6) → `DbError::Invariant` (« compte de l'avoir + introuvable dans la société »), dès que le nombre de lignes rendues diffère du nombre d'ids + dédoublonnés. Tranché ainsi (C-15-6-29) plutôt que de laisser le filet anonyme de `create_in_tx` + (`InactiveOrInvalidAccounts`) répondre. Pour un **compte de produit** d'une autre société, cela + remplace aussi le `CreditNoteRevenueAccountsArchived` à `account_number: None` que la 6 ter rend + aujourd'hui (`credit_notes.rs:471-495`) : changement de comportement **inatteignable par + l'application** (atteignable par SQL direct seulement), écrit pour mémoire — T4 n'écrit **pas** de test dessus (finding F5-7 de la P5). + - **Refus nommé, propre à l'avoir** (choix C-15-6-25, qui révise le « aucune variante » de + C-15-6-16 ; étendu à la TVA due par C-15-6-29). Une ligne `active = FALSE` parmi la **créance**, + l'**arrondi** de la vente ou la **TVA due** → variante neuve + `DbError::CreditNoteAccountsArchived(Vec)` (`ArchivedAccount`, + `crates/kesh-db/src/errors.rs:291`), à côté de `ReversalAccountsArchived` (`:604-614`), dont elle + reprend la forme : **400**, **même code `ACCOUNT_ARCHIVED`** (`error_code()`, `errors.rs:736`, + rend la même chaîne pour les deux, `:760`), `details.rejected[]` = `{ accountId, accountNumber }` + par compte, ordre `ORDER BY id` (celui du verrou ; sa sœur `archived_accounts_in_tx` trie par + `number` — sans effet pour un seul compte, dit pour qu'aucun test ne fige l'un pour l'autre). Un + **compte de produit** archivé n'alimente **pas** cette variante : il reste nommé par ligne par la + 6 ter (ci-dessus). Seul + le **message** diffère : clé neuve `credit-note-account-archived`, dans les **quatre** locales — + fr : « Impossible d'émettre l'avoir — compte(s) archivé(s) : 1100. Réactivez le ou les comptes + concernés. » —, sur le patron du bras `ReversalAccountsArchived` + (`crates/kesh-api/src/errors.rs:2945-2973`). **Raison** : sur la route + `POST /api/v1/credit-notes`, le compte de produit archivé rend déjà « Impossible d'émettre + l'avoir — … » (`CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED`, clé `credit-note-revenue-account-archived`, + `fr-CH/messages.ftl:291`) : un seul vocabulaire pour un seul geste, face au bouton « Créer un + avoir ». Le **code**, lui, reste celui de la contre-passation : l'avoir en **est** une, et un + intégrateur qui gère `ACCOUNT_ARCHIVED` (lire `details.rejected[]`, réactiver) le gère ici sans + rien apprendre. Deux codes restent donc possibles sur la route — l'un par **ligne** de facture + (produit, `details.rejected[].lineNumber`), l'autre par **compte** (créance ou arrondi de la + vente, TVA due) — et le manuel comme le CHANGELOG le disent (AC8, AC9). **La lecture est celle du + helper partagé** (puce « Un seul helper », ci-dessus — son seul contrat), appelé en mode `Share` : + `create_credit_note` reçoit **toutes** les lignes verrouillées (`id`, `number`, `active` ; `postable`, rendue par le helper, est ignorée), pas + seulement les archivées, fait lui-même le contrôle « ligne absente », puis les partage — créance, + arrondi et TVA due archivés → la variante ; produits archivés → la 6 ter, à sa place. + `archived_accounts_in_tx` (`journal_entries.rs:1723-1754`, non verrouillante, filtre + `active = FALSE`) n'est **pas** le modèle : elle ne sert ici qu'à la comparaison de l'ordre de + `details.rejected[]` (ci-dessus). + La garde `active` de `create_in_tx` reste en place, filet de défense ; elle n'est plus le refus + atteint pour aucun de ces comptes, **hors réactivation concurrente** (finding R5-7 de la P5) : un + compte archivé à l'instantané (étape (2)) puis réactivé et validé avant le verrou est vu actif par + le verrou, archivé par la lecture simple de `create_in_tx` (`journal_entries.rs:96-98`) — le refus + anonyme `InactiveOrInvalidAccounts` reste alors possible, fenêtre de quelques millisecondes, sans + conséquence sur les données. Dit pour qu'aucun test ne prétende l'exclure. + - ⚠️ **Changement délibéré de deux refus existants.** (i) Un avoir sur une facture arrondie dont le + compte d'arrondi a été archivé rendait `ROUNDING_ACCOUNT_NOT_CONFIGURED` (contexte `Issuance`, + #486), dont le message renvoie à *Paramètres → Facturation* — remède **faux**, puisque l'avoir ne + lit plus ce réglage ; il rend désormais `ACCOUNT_ARCHIVED`, qui **nomme** le compte à réactiver + (le refus reste nommé : l'esprit de la contrainte de #523, « sans casser le refus nommé de #486 », + est tenu ; sa lettre — le code — ne l'est pas, et la fiche le dit). (ii) Un avoir dont le compte + débiteurs (des réglages, aujourd'hui) est archivé rendait `INACTIVE_OR_INVALID_ACCOUNTS` + anonyme ; il rend `ACCOUNT_ARCHIVED` nommant le compte **de la vente**. (iii) Un avoir dont le + compte de TVA due des réglages est archivé rendait aussi `INACTIVE_OR_INVALID_ACCOUNTS` anonyme + (garde de `create_in_tx`) ; il rend `ACCOUNT_ARCHIVED` nommant ce compte (C-15-6-29). Le test + `invoices_validate_vat.rs:846` (`a_credit_note_is_refused_when_the_rounding_account_was_archived`) + **change d'assertion** en conséquence, et le Change Log de la story le dit comme tel (ce n'est pas + une régression masquée). La **validation** d'une facture garde + `RoundingAccountNotConfigured { Issuance }` (`invoices.rs:2068`) : elle crée l'écart, elle lit le + réglage. +7. **AC7 — Les commentaires disent vrai.** Le commentaire `credit_notes.rs:609-611` dit désormais ce + qui est recopié (produit par ligne) et ce qui est relu sur la vente (créance, arrondi) ; l'étape (3) + de `create_credit_note` dit pourquoi la créance ne vient plus des réglages (#473) et quels comptes en + viennent encore (TVA due #525, produit de repli D-B2) ; l'étape (7 bis) dit pourquoi l'arrondi se lit + sur la vente (#523) ; l'étape du verrou (AC6) dit pourquoi `active` se lit sous verrou, pourquoi + en partage et non en exclusif (course de lecture, pas de verrou exclusif de plus), pourquoi + **tous** les comptes écrits (la course de lecture), et que la + 6 ter lit `active` dans son résultat. Le doc-comment de `create_credit_note` reçoit une section + **« Ordre des locks »** : facture → règlements → avoir existant → réglages → comptes de l'avoir + (S, par `id`) → exercice → séquence → écriture — il + **renvoie** au doc-comment canonique de `validate_invoice` pour la règle (convention de fréquence, + défense par le rejeu — 15-5e AC5, C54) au lieu de la recopier, ne prétend à l'absence d'aucun + cycle, et ne porte que ce qui est propre à l'avoir : la ligne des réglages qui le sérialise avec la + validation et la saisie fournisseur, et le renvoi au rejeu de la route (AC6). Le doc-comment de la + route `POST /api/v1/credit-notes` (« rejouée sur interblocage ») est celui que pose la **15-5e** + (son AC3) : pas réécrit ici. + **Ce que la 15-5e a déjà réécrit, et que cette story ne réécrit pas** (findings R5-1 = F5-1 de la + P5) : le doc-comment canonique de `validate_invoice` (dont la phrase « Aucun chemin ne verrouille + `accounts` avant `invoices` ou `fiscal_years` », `invoices.rs:1921-1922` au commit `1920381e`, + retirée par la 15-5e AC5), le commentaire « 5 bis » de `write_off_invoice` + (`invoice_settlements_write.rs:466-468` au même commit, réécrit en place par la 15-5e AC5, que la + 15-5d complète) et le Pattern 5 de `docs/MULTI-TENANT-SCOPING-PATTERNS.md` (15-5e AC5). T0 les + **lit** tels que la 15-5e et la 15-5d les laissent. La 15-6a n'y fait qu'**un** geste, et seulement + s'il y a lieu : si l'un de ces textes décrit l'**ordre de l'avoir** tel qu'il était (l'exercice + avant l'arrondi, ou des comptes lus sans verrou), cette phrase est corrigée — l'avoir prend + désormais tous ses comptes, en partage, avant l'exercice ; le Dev Agent Record dit ce qui a + été trouvé (y compris « rien »). Le doc-comment de `RoundingContext::Issuance` (`crates/kesh-db/src/errors.rs:729`, « Une pièce émise — + facture validée, avoir — ») ne cite plus l'avoir ; le message + `error-rounding-account-not-configured-issuance` (« Le total de cette pièce… ») reste exact pour la + facture et n'est pas modifié. Le doc-comment de `generate_invoice_journal_lines_rounded` + (`invoices.rs:1870-1878`), qui ne protège aujourd'hui que « la créance reste la PREMIÈRE ligne au + débit », dit aussi que **l'arrondi reste la DERNIÈRE ligne** — l'avoir l'y relit + (`sale_rounding_account`) ; une ligne ajoutée après lui bloquerait tous les avoirs de factures + arrondies (`Invariant`). Le commentaire de classement que la 15-5b (son AC6) pose dans + `accept_one_invoice` sur la lecture de la créance est **conservé** et réécrit pour désigner le + lecteur `sale_receivable_account` au lieu d'une requête et de numéros de ligne qui n'existent + plus. Les doc-comments et commentaires que la 15-5b et la 15-5d laissent sur l'**exemption de + l'avoir** à la garde à l'usage (C35 : « la créance sera lue sur l'écriture de vente par la + 15-6a ») sont réécrits au présent : l'avoir lit la créance sur la vente ; seule la TVA due reste + relue dans les réglages (#525). +8. **AC8 — Manuel.** + - `docs/manual/fr/user-manual.tex` § *Avoirs et notes de crédit* (`\label{sec:avoirs}`, puce + `:1155`) : la promesse « le solde du client revient à zéro » devient **vraie** et le texte dit + pourquoi — l'avoir crédite le compte débiteurs que la facture a débité, et contre-passe l'arrondi + sur le compte que la facture a mouvementé, même si les comptes par défaut ont changé depuis. Il dit + aussi, **sans la présenter comme juste**, la limite connue (choix C-15-6-8) : la TVA de l'avoir est + contre-passée sur le compte de TVA due **actuellement désigné** dans les réglages ; changer ce + réglage entre une facture et son avoir est une limite connue de cette version. + - Même section, un `keshwarning` neuf à côté de « Avoir refusé : compte de produit archivé » + (`:1171`) : **« Avoir refusé : compte débiteurs, d'arrondi ou de TVA archivé »** — il nomme + les trois comptes (celui que la facture a débité, celui qui a reçu son arrondi, le compte de TVA + due désigné dans les réglages — C-15-6-29), cite le message tel + quel (« Impossible d'émettre l'avoir — compte(s) archivé(s) : … », AC6) et renvoie à la **même + procédure** en trois étapes : réactiver le compte (en retirant d'abord le rôle *Créances clients* du nouveau compte si nécessaire : + la réactivation est refusée tant qu'un autre compte le porte, `user-manual.tex:358-359`), émettre + l'avoir, ré-archiver. Pour le compte + de **TVA due**, qui est celui **des réglages** et non celui de la vente (#525), il dit l'**autre + issue** (finding F5-7 de la P5) : rétablir le réglage — un administrateur désigne dans + *Paramètres → Facturation* un compte de TVA due actif, de préférence celui que la facture a + crédité —, sans quoi la procédure ferait réactiver un compte que la facture n'a jamais mouvementé. + Il dit que ce + refus est distinct de celui du compte de produit (qui nomme une **ligne**) : le premier nomme un + **compte**, et les deux peuvent se suivre (le compte débiteurs, d'arrondi ou de TVA est contrôlé + d'abord, AC6). + - `user-manual.tex:363` (avertissement « Avant d'archiver un compte de produit, pensez aux + avoirs », `\label{sec:plan-comptable-archivage}`) : étendu au **compte débiteurs**, au **compte + de différences d'arrondi** et au **compte de TVA due** — un compte que des factures validées ont + mouvementé reste nécessaire tant qu'un avoir peut les viser. + - `docs/manual/fr/admin-manual.tex:2027` (*Compte de différences d'arrondi*, « un avoir le reprend + et l'annule ») : une phrase — changer de compte d'arrondi ne déplace pas les factures déjà + émises : leur avoir contre-passe l'arrondi sur le compte qu'elles ont mouvementé ; gardez l'ancien + actif tant qu'un avoir peut les viser (sinon : réactiver, émettre, ré-archiver). + - Les phrases « un avoir le reprend et l'annule » (`user-manual.tex:847`, `admin-manual.tex:2027`) + restent vraies — les relire, ne pas les réécrire ; de même `user-manual.tex:1754` (« changement du + compte clients dans les réglages », balance âgée). + - **Frontière 15-5b / 15-5d — lire, pas greper une formulation.** La 15-5b (son AC17 (iv)) et la + 15-5d (son AC8) écrivent au manuel que l'avoir **relit la créance et la TVA due dans les + réglages** (« l'avoir fait exception … »), aux encadrés de `user-manual.tex` ~`:380` (§ *Rôles des + comptes*) et ~`:390` (§ comptes de clôture), et la 15-5b touche `admin-manual.tex:2016-2029` + (comptes par défaut, arrondi). Ce texte n'est **pas encore mergé** : sa formulation exacte est + inconnue, et un grep de la formulation des **fiches** (« relit la créance ») peut rendre zéro et + laisser la phrase fausse. **T0 lit donc ces encadrés et cette section en entier, après le merge + des 15-5**, aux numéros de `HEAD` ; puis grepe la **valeur** — `avoir` à moins de 200 caractères + de `créance`, `réglages`, `relu` — sur le `.tex` et sur les PDF aplatis + (`pdftotext … - | tr '\n' ' ' | tr -s ' '`). Toute phrase qui dit que l'avoir relit la + **créance** dans les réglages devient fausse : la corriger ici ; la TVA due reste vraie (#525). + - PDF régénérés (`make fr` dans `docs/manual/`) et contrôlés **aplatis**, sur des fragments + **absents du PDF actuel** (« revient à zéro » y est déjà : il ne prouve pas la régénération), et + avec `.` à la place de chaque apostrophe dans le motif (le PDF emploie l'apostrophe typographique) : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' ' | grep -o "[^.]*"`, + puis le même `grep -o` sur un fragment de la phrase de la TVA, du `keshwarning` neuf, de + l'avertissement étendu, et sur `admin-manual.pdf` pour la phrase neuve de l'arrondi. +9. **AC9 — CHANGELOG.** Sous `## [0.13.0] — Non publié`, section `### Corrigé` : une entrée qui dit + l'effet pour l'utilisateur et renvoie à [#473](https://github.com/guycorbaz/kesh/issues/473) et + [#523](https://github.com/guycorbaz/kesh/issues/523) ; section `### Modifié` : le refus d'un avoir dont + le compte débiteurs ou d'arrondi de la facture, ou le compte de TVA due, a été archivé devient + `ACCOUNT_ARCHIVED` (il était `INACTIVE_OR_INVALID_ACCOUNTS`, ou `ROUNDING_ACCOUNT_NOT_CONFIGURED` + pour l'arrondi), nomme le + compte dans `details.rejected[]` et dit « Impossible d'émettre l'avoir » ; la route garde + `CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED` pour un compte de produit de ligne (deux codes, un même + libellé de geste — AC6) ; un réglage débiteurs vide ne refuse plus l'avoir (`CONFIGURATION_REQUIRED`) — changements + visibles d'une intégration par clé d'API (`POST /api/v1/credit-notes` est ouverte aux clés, + `docs/api-external.md` § 7), rubrique choisie comme la 15-5a. `docs/api-external.md` ne cite ni ces + codes pour l'avoir ni la route (`grep -n "ROUNDING_ACCOUNT\|credit-notes" docs/api-external.md` vide + au 2026-10-08) : **sans objet**, à écrire « vérifié » au Dev Agent Record. + **Propriétaire de la section** : la **première + story de la version mergée** la crée en tête des versions si elle est absente ; les suivantes y + ajoutent leur entrée (règle commune aux 15-5a/b/c et 15-6a/b/c/d ; conflit de rebase attendu et + trivial). + **`docs/MULTI-TENANT-SCOPING-PATTERNS.md`, Pattern 5** (finding F5-6 de la P5) : le tableau + *Where This Applies* (`:307-319` au commit `1920381e`), dans la forme que la **15-5e** lui donne + (son AC5 : « Global Lock Order » devenu convention de fréquence, deux lignes de flux comptables + corrigées, « Deny list » retirée), gagne **une** ligne pour `POST /api/v1/credit-notes` + (`repositories/credit_notes.rs::create_credit_note`) — invoices → invoice_settlements → + credit_notes → company_invoice_settings → accounts (S, par `id`) → fiscal_years → credit_note_number_sequences → journal_entries (lignes : S sur les comptes + par la clé étrangère) ; rejouée sur interblocage (15-5e). T0 vérifie que la 15-5e ne l'a pas déjà + ajoutée ; si oui, la ligne est seulement mise à jour (comptes verrouillés avant l'exercice). + +## Tasks / Subtasks + +- [x] **T0 — Rebase et recompte** *(fait le 2026-10-08 sur `9cb5083b` — Change Log « Alignement sur le livré (T0) » ; la story **attend le merge de la 15-5d**, C-15-6a-1)* avant de coder : rebase sur `main` après le merge des 15-5a à 15-5e ; + **refaire tous les numéros de ligne** cités par cette fiche sur `HEAD` ; **lire** le doc-comment + canonique de `validate_invoice`, le commentaire « 5 bis » et le Pattern 5 tels que la 15-5e (et la + 15-5d) les laissent (AC7, AC9) ; **constater** que la route `POST /api/v1/credit-notes` est rejouée + par la 15-5e (AC6) ; relever au Dev Agent Record les ordres de verrous de la validation, du règlement, + du solde du reste et de la saisie fournisseur (AC6) ; constater la forme du helper de verrou de + liste de la 15-5d (mode paramétré ou non, AC6) ; relire le + commentaire de classement posé par la 15-5b dans `accept_one_invoice` (AC7) ; **lire en entier** les + encadrés `user-manual.tex` ~`:380` et ~`:390` et `admin-manual.tex:2016-2029` tels que les 15-5b/5d + les laissent (AC8 — pas de grep d'une formulation de fiche) ; **recenser les tests que la 15-5d + ajoute à `credit_notes_repository.rs`** (dont celui de C35, § *Tests*, 13) et le nombre total de + tests du fichier ; **relever** au Dev Agent Record l'ordre des verrous que la 15-5d pose dans + `validate_invoice` (comptes de réglage avant l'exercice) — un relevé, sans exigence de conformité : + sous C54 l'ordre est une convention de fréquence, non une règle à laquelle l'AC6 se conformerait + (finding R6-3 de la P6) ; recompter l'inventaire (§ *Inventaire*) par ses commandes — tout site + neuf est résolu ici ou ajouté aux angles morts avec sa raison. +- [x] **T1 — Lecteurs** (AC1, AC4) dans `invoice_settlements.rs`, doc-comments compris. +- [x] **T2 — Remplacer les trois copies** (AC2) ; vérifier par le `grep --include='*.rs'` de l'AC2. +- [x] **T3 — Avoir** (AC3, AC4, AC5, AC6, AC7) dans `credit_notes.rs` : verrou en une instruction + `ORDER BY id`, par le helper de verrou de liste partagé avec la 15-5d en mode + `Share` (AC6 : tous les comptes écrits, 6 ter lue dans son résultat, ligne absente → `Invariant` ; + `EXPLAIN` relevé) ; agrégation de la TVA extraite en fonction pure partagée par le générateur et le + calcul des ids (AC6) ; **pas** de rejeu à poser (la 15-5e le porte, AC6) ; phrase corrigée dans un + texte de la 15-5e seulement s'il décrit l'ancien ordre de l'avoir (AC7) ; variante + `DbError::CreditNoteAccountsArchived`, son `error_code()` (`ACCOUNT_ARCHIVED`), son bras dans + `crates/kesh-api/src/errors.rs` et la clé `credit-note-account-archived` dans les **quatre** + `messages.ftl` (AC6) ; doc-comments de `RoundingContext::Issuance`, de + `generate_invoice_journal_lines_rounded` et de `create_credit_note` (ordre des locks). +- [x] **T4 — Tests** (§ *Tests*) — chaque test neuf **rougit d'abord** sur le code non corrigé, sauf + ceux des lecteurs (4, 5, 8 : fonctions neuves), le témoin 10, écrit en premier, et les tests 17 + (identifiant étranger : une mesure, dont l'issue attendue est le rouge puis `owned_account_ids`, + sauf si la 15-5d l'a déjà adopté — § *Tests*, 17) et 18 (bras d'une variante neuve). Aucun test de + « non-interblocage » (C54, 15-5e AC4). +- [x] **T5 — Manuel + PDF** (AC8), **CHANGELOG** (AC9) ; `docs/api-external.md` vérifié sans objet + (AC9) ; ligne de l'avoir au Pattern 5 de `docs/MULTI-TENANT-SCOPING-PATTERNS.md` (AC9). +- [x] **T6 — Gates** : gate complet backend (règle `kesh-db` : un dépôt est touché, le ciblage est + interdit **même en boucle de revue**), base remise à zéro avant ; frontend non touché ; E2E complet + au dernier commit de code (décision D7 — la spec `frontend/tests/e2e/credit-notes.spec.ts` passe par + cet avoir). + +## Inventaire — les sites qui lisent un compte de créance, de dette ou d'arrondi **après** la pièce + +Règle « Inventorier les sites NON RÉSOLUS » : l'ensemble clos est celui des **écrivains d'écritures**, +pas une liste de formes. Commandes de recompte (à rejouer en T0) : + +```sh +grep -rn "journal_entries::create_in_tx\|reverse_in_tx\|reverse_owned_in_tx" crates/kesh-db/src crates/kesh-api/src +grep -rn "INSERT INTO journal_entry_lines" crates/*/src +grep -rn "default_receivable_account_id\|default_payable_account_id\|rounding_account_for_write" crates/*/src +grep -n "create_in_tx(" crates/kesh-db/src/repositories/journal_entries.rs # appels intra-module (create_opening_entry) +grep -rn "delete_in_tx" crates/kesh-db/src crates/kesh-api/src # dévalidation +``` + +Relevé au 2026-10-08 (commit `1920381e`) : + +| écrivain | geste | compte relu | verdict | +|---|---|---|---| +| `invoices.rs:2005-2010` `validate_invoice` | **crée** la créance et l'arrondi | réglages | légitime — c'est la vente | +| `credit_notes.rs:360-364` `create_credit_note` (créance) | après la vente | réglages | **résolu ici (AC3)** — #473 | +| `credit_notes.rs:525` `create_credit_note` (arrondi) | après la vente | réglages | **résolu ici (AC4)** — #523 | +| `invoice_settlements_write.rs:104` `settle_invoice` | après | écriture de vente | déjà juste ; copie → lecteur (AC2) | +| `invoice_settlements_write.rs:434` `write_off_invoice` | après | écriture de vente | déjà juste ; copie → lecteur (AC2) | +| `reconciliation.rs:1425` `accept_one_invoice` | après | écriture de vente | déjà juste ; copie → lecteur (AC2) | +| `reconciliation.rs:1487` `accept_one_invoice` (arrondi `Payment`) | après | réglages | légitime — écart **nouveau**, né au rapprochement (cf. ci-dessous) | +| `supplier_invoices.rs:358-362` `create_in_tx` | **crée** la dette | réglages | légitime — c'est l'achat (import automatique compris, même chemin) | +| `supplier_invoices.rs:582-593` `pay_in_tx` | après | écriture d'achat (ligne de crédit) | déjà juste | +| `journal_entries.rs` `reverse_in_tx` (annulation de facture fournisseur, annulation de règlement, contre-passation) | après | **inversion ligne à ligne** de l'écriture d'origine | juste par construction | +| `invoices.rs` `unvalidate` (dévalidation) — `journal_entries::delete_in_tx` | supprime l'écriture de vente | aucun compte relu | hors classe | +| `journal_entries.rs` `create_opening_entry` (appel intra-module à `create_in_tx`) | — | soldes saisis | hors classe | +| `reconciliation.rs` `accept_one_split`, `accept_one_rule`, `post_manual` | — | aucun compte de créance lu | hors classe | +| `opening_complement.rs:641`, saisie manuelle | — | lignes saisies | hors classe | + +Le compte d'arrondi du **règlement** (`settle_invoice`, contexte `Payment`), celui du **rapprochement** +(`accept_one_invoice`, `reconciliation.rs:1487`) et les comptes des natures de solde +(`write_off_invoice`) sont des écarts **nouveaux**, nés au règlement : ils lisent légitimement les +réglages du moment (ils ne contre-passent rien). + +**Angles morts assumés** (même classe — « un compte relu dans les réglages après la pièce ») : + +- **TVA due de l'avoir** (`credit_notes.rs:511`, `settings.default_vat_payable_account_id`) : compte + **courant**, convention partagée avec le solde du reste (`vat_payable_account_for_write`). Écriture + fausse si le réglage a changé entre la facture et l'avoir : **tracé par #525** (P1, jalon de la TVA, + report assumé — choix C-15-6-8). Le manuel dit la limite sans la présenter comme juste (AC8). +- **Produit de repli** des lignes sans compte (D-B2) : inchangé, documenté depuis la 16-1a-bis. + **Tracé par #525**, depuis le commentaire de l'orchestrateur du 2026-10-08 (« Même famille … compte + de produit de repli … À traiter avec la TVA due de cette issue ») — la P5 l'avait écrit « non + tracé », vrai à l'écriture, faux trente-huit secondes après (finding R6-2 de la P6 de la 15-6b). + +**Classe « un compte lu sans verrou pendant l'avoir »** — fermée par l'AC6 (C-15-6-29) : tous les +comptes que l'avoir écrit (créance, arrondi, TVA due, produits) sont verrouillés en partage avant +l'exercice, et leur état actif est lu dans ce verrou (la 6 ter comprise). L'angle mort que la P3 +inscrivait ici (produits et TVA due en lecture simple) **disparaît** ; la dette « LOW acceptée » de la +validation (`invoices.rs` ~`:2096`) n'est pas touchée. + +**Sites non résolus d'une autre classe — cycles d'interblocage de l'avoir** (AC6 ; **nommés, non +exhaustifs** — l'absence d'autres cycles n'est pas affirmée, choix C54 ; tous détectés par InnoDB et +couverts par le **rejeu de la route**, posé par la 15-5e et figé pour l'avoir par le test 15) : + +| cycle | avec | né de | verdict | +|---|---|---|---| +| (i) facture X / exercice X ↔ arrondi | `accept_one_invoice` de la **même** facture (`reconciliation.rs:1487`, `:1520`, avant `:1703`), ou lot dont une proposition précédente tient l'exercice | préexistant (l'exercice) | **#536**, fermée par le rejeu des deux côtés (15-5e, C57) | +| (ii) S par `id` ↔ X nature / arrondi puis TVA due | `write_off_invoice` d'une autre facture : nature = produit de l'avoir (rare), ou reste hors centime et facture de l'avoir arrondie (fréquent, P5 R5-2) | préexistant (ordre après l'exercice), déplacé avant l'exercice | non résolu, non réordonné (C54) ; rejeu des deux côtés | +| (iii) S par `id` ↔ X compte interne puis arrondi | `settle_invoice` par compte interne = un compte de l'avoir, avec écart (P5, F5-3) | le S de l'AC6 avant l'exercice | non résolu, configuration sans sens ; rejeu des deux côtés | +| (iv) S tenu ↔ X en attente ↔ S de clé étrangère | `UPDATE accounts` + un flux qui tient l'exercice **sans** la ligne des réglages (saisie manuelle, règlement, solde du reste, rapprochement) — **pas** la validation ni la saisie fournisseur, sérialisées avec l'avoir par les réglages | **neuf** (le S de l'AC6) | non résolu, rare ; rejeu côté avoir et côté de ce flux (15-5e) | + +## Tests + +Tous en `#[sqlx::test(migrations = …)]` sur le squash — `"./test-schema"` dans `crates/kesh-db/tests`, +`"../kesh-db/test-schema"` dans `crates/kesh-api/tests` (tests 10, 12, 15 ; finding R6-6 de la P6) —, +y compris ceux des lecteurs, qui lisent une écriture réelle, **sauf le 18**, test unitaire du bras +d'erreur HTTP (sans base). + +**`crates/kesh-db/tests/credit_notes_repository.rs`** (neuf tests au commit `1920381e`, lignes +d'attribut `#[sqlx::test]` : `:101`, `:183`, `:227`, `:268`, `:309`, `:352`, `:513`, `:555`, `:593` ; +**dix** après le merge de la 15-5d, qui y ajoute le test de C35 — T0 recompte), patron `credit_note_single_rate_reverses_invoice` +(`:101`) : + +1. **`credit_note_credits_the_sale_receivable_after_settings_change`** — valider une facture (créance + sur 1100), créer un compte d'actif imputable neuf (ex. `1101`), poser + `default_receivable_account_id` sur lui, émettre l'avoir : la ligne de crédit vise **1100** pour le + TTC ; solde de 1100 sur la société = 0 ; **aucune** ligne sur 1101. *(Ce test rougit sur le code + actuel — le vérifier avant le correctif, pour qu'il ne soit pas muet.)* +2. **`credit_note_needs_no_receivable_setting`** — même montage, réglage **débiteurs** mis à `NULL` + après la validation, **par SQL direct** : l'avoir est émis (il échouait jusqu'ici en `ConfigurationRequired`). Borné à + la créance : le réglage de produit par défaut reste posé (AC5). +3. **`credit_note_refused_when_sale_receivable_archived`** — 1100 archivé après la validation par + `UPDATE accounts SET active = FALSE` (comme le test `:846` cité plus bas ; `accounts::archive`, + `accounts.rs:604-634`, ne contrôle que les sous-comptes actifs et permettrait aussi d'archiver un + compte mouvementé — le SQL direct est retenu pour ne pas dépendre de la version optimiste) : + `DbError::CreditNoteAccountsArchived` dont l'unique `ArchivedAccount` nomme **1100** + (`account_number = Some("1100")`), **rien** d'écrit (ni avoir, ni écriture, ni numéro consommé, + facture toujours `validated`). *(Rougit sur le code actuel : `InactiveOrInvalidAccounts`.)* +4. **`sale_receivable_account_is_scoped_to_the_company`** — le lecteur de l'AC1, appelé avec l'écriture + de vente d'une société et l'id d'une **seconde** société → `None`. Seconde société créée par + `companies::create` (patron `crates/kesh-db/tests/bank_accounts_repository.rs:401`). + +**`crates/kesh-db/tests/invoices_validate_vat.rs`, module `arrondi_5_centimes`** (`:592`) — ses helpers +`setup` (`:608`, désigne 6940 par `designate_rounding_account`) et `create_and_validate` (`:57`) sont +privés à ce fichier, d'où ce placement : + +5. **`sale_receivable_reader_skips_a_negative_rounding_line`** — facture à arrondi **négatif** + (234.52 → −0.02, une seconde ligne de débit après la créance) : le lecteur de l'AC1 rend 1100, pas + le compte d'arrondi. +6. **`a_credit_note_reverses_the_sale_rounding_after_redesignation`** (#523) — facture à arrondi positif + (123.44 → +0.01) sur 6940 ; puis un second compte de charge inséré à la main (`6941` — + `designate_rounding_account` insère toujours `6940` et ne peut pas servir deux fois) et désigné dans + `default_rounding_account_id` ; l'avoir : la ligne d'arrondi vise **6940**, solde de 6940 = 0, + **aucune** ligne sur 6941. *(Rougit sur le code actuel.)* +7. **`a_credit_note_needs_no_rounding_setting`** — même montage, `default_rounding_account_id` mis à + `NULL` après la validation, **par SQL direct** : l'avoir est émis, ligne d'arrondi sur 6940. *(Rougit sur le code + actuel : `RoundingAccountNotConfigured`.)* +8. **`sale_rounding_reader_refuses_a_mismatched_line`** — le lecteur de l'AC4 appelé avec l'arrondi de + **signe opposé** à celui de la facture → `DbError::Invariant`. +9. **Modifié, délibérément** (AC6) : `a_credit_note_is_refused_when_the_rounding_account_was_archived` + (`:846`) — l'assertion `DbError::RoundingAccountNotConfigured { context: Issuance }` devient + `DbError::CreditNoteAccountsArchived` nommant **6940** ; les assertions suivantes (rien d'écrit, + avoir accepté après réactivation avec le **premier** numéro) restent. Le doc-comment du test dit + pourquoi (AC6) et **réécrit sa phrase sur le compteur** (`invoices_validate_vat.rs:842-844`, « la + transaction emporte le compteur tiré plus tôt ») : le refus précède désormais la séquence (étape + (5)), aucun numéro n'est tiré ; l'assertion « premier numéro après réactivation » fige donc + l'**ordre** (refus avant la séquence), plus le rollback — le doc-comment le dit, pour que le test ne + prétende pas prouver ce qu'il ne prouve plus. + +**`crates/kesh-api/tests/reconciliation_e2e.rs`** : + +10. **`invoice_proposal_with_a_sale_entry_without_debit_line_is_malformed`** — non-régression de l'AC1/AC2, écrit + **avant** le remplacement de la copie : l'écriture de vente d'une facture repointée en SQL + (`UPDATE invoices SET journal_entry_id = …`) sur une écriture d'une **autre société** — le lecteur, + porté par `je.company_id`, ne trouve aucune ligne : `failed[]` porte + `INVOICE_SALE_ENTRY_MALFORMED` avec `details = { "reason": "no_debit_line_on_sale_entry", + "saleEntryId" }`, HTTP 200, transaction bancaire toujours en attente. Il passe avant **et** après + le correctif — c'est un témoin, pas un test rouge d'abord. *(Renommé à la revue de code P1, + findings B-1/A-4 : le nom d'origine, `…_with_a_foreign_sale_entry_is_malformed`, annonçait une + écriture d'une autre société, que le montage retenu ci-dessous ne monte pas ; la portée société + du lecteur est prouvée par le test 4.)* **Montage plus léger, retenu** : le + fichier ne crée qu'une société (`reconciliation_e2e.rs:145`) ; au lieu d'en construire une seconde + avec exercice et écriture, un **en-tête d'écriture sans ligne** dans la même société, inséré en SQL + et désigné par `UPDATE invoices SET journal_entry_id = …`, rend le même `None`. + +**`crates/kesh-db/tests/credit_notes_repository.rs`** (suite) : + +11. **`credit_note_waits_for_a_concurrent_archive_of_the_sale_receivable`** — la course de l'AC6, + sur la forme exacte du patron `credit_note_waits_for_a_concurrent_settlement` (attribut `:593`, + attente `:631`) : facture **sans arrondi** (pour que seul le verrou de l'AC6 lise `accounts` sous + verrou) ; une transaction tenue à la main archive 1100 (`UPDATE accounts SET active = FALSE`) sans + valider ; une tâche (`tokio::spawn`) émet l'avoir ; + `kesh_db::test_fixtures::attendre_une_requete_en_cours(&pool, &["FROM accounts", "LOCK IN SHARE MODE"], || avoir.is_finished())` + (`crates/kesh-db/src/test_fixtures.rs:560`) doit rendre `true` — l'avoir est **vu** en attente du + verrou ; s'il rend `false` (l'avoir a fini sans attendre), `panic!` avec son issue. **Aucun délai + fixe** : un « pas fini au bout de 300 ms » passerait à vide sur une machine chargée. La transaction + concurrente n'est validée qu'**après** l'attente vue ; l'avoir rend alors + `CreditNoteAccountsArchived` nommant 1100, rien d'écrit. Le doc-comment du test dit que ses motifs + sont couplés à la forme du verrou (`LOCK IN SHARE MODE`) et pourquoi la facture est sans arrondi. + *(Rougit sur le code actuel — mais pas par le `panic!` « l'avoir a fini » : sur ce code, l'avoir + n'aboutit pas, son `INSERT INTO journal_entry_lines` (clé étrangère vers `accounts`) pose un S sur + 1100 et attend le X de la transaction de test ; aucune requête `FROM accounts … LOCK IN SHARE MODE` + n'apparaît, et c'est l'`assert!` de délai d'`attendre_une_requete_en_cours` + (`test_fixtures.rs:585-588`, dix secondes) qui rougit — symptôme à attendre, finding R5-3 de la + P5.)* + +**`crates/kesh-api/tests/invoice_echeancier_e2e.rs`** — seul fichier de `kesh-api/tests` qui émet un +avoir **par la route** (`POST /api/v1/credit-notes`, `:742` ; les autres appellent le dépôt) : + +12. **`credit_note_route_names_the_archived_sale_receivable`** — la frontière HTTP du changement de + contrat annoncé au CHANGELOG (AC9) : facture validée, 1100 archivé en SQL, `POST + /api/v1/credit-notes` → **400**, `error.code == "ACCOUNT_ARCHIVED"`, + `error.details.rejected[0].accountNumber == "1100"` (et `accountId`), `error.message` contient + « Impossible d'émettre l'avoir » et « 1100 ». *(Rougit sur le code actuel : + `INACTIVE_OR_INVALID_ACCOUNTS`.)* Aucun Vitest n'est en cause : l'écran affiche `err.message` sans + lire de code (`frontend/src/routes/(app)/invoices/[id]/+page.svelte:99-112`, seul appelant de + `createCreditNote`, `credit-notes.api.ts:33`). Preuve à rejouer en T4, **code neuf compris** : + `grep -rn "ROUNDING_ACCOUNT_NOT_CONFIGURED\|INACTIVE_OR_INVALID_ACCOUNTS\|ACCOUNT_ARCHIVED" frontend/src` + — `ACCOUNT_ARCHIVED` y est lu par plusieurs sites (`journal-entries/[id]/+page.svelte:166`, + `reconciliation-cancel.ts:67`, `settlement-cancel-blocked.ts:46`, `invoice-cancel.ts:50`…), + **aucun** sur le chemin de création d'avoir ; les deux autres codes ne rendent que + `JournalEntryForm.svelte:178`, hors avoir. + +**`crates/kesh-db/tests/credit_notes_repository.rs`** (suite) : + +13. *(Rangement corrigé à la revue de code P1, finding A-5 (b) : ce test vit en réalité dans + `crates/kesh-db/tests/invoices_validate_vat.rs`, sous le nom + `credit_note_credits_a_non_postable_sale_receivable` — le T0 l'a établi ; il n'est pas dans + `credit_notes_repository.rs`, qui compte 9 tests avant la story et 17 après.)* + **Modifié, délibérément — le test de C35 que la 15-5d ajoute** (15-5d, § *Tests* : « avoir sur une + facture dont la créance des réglages est devenue non imputable → émis »). Après la 15-6a, l'avoir + ne lit plus ce réglage : le test resterait vert **pour une autre raison** et ne figerait plus rien + (passe à vide, forme exacte de la 16-1a). Il est **ré-ancré** : la créance **de la vente** (1100) + rendue non imputable (`postable = FALSE`) après la validation → avoir émis, crédit sur 1100 (la + doctrine « seule l'inactivité bloque », AC4/AC6) ; son nom et son doc-comment citent C-15-6-2 et + C-15-6-7 au lieu de l'exemption de la garde à l'usage. Le Change Log de la story dit qu'il change de + sens. La 15-5d est une dépendance **ferme** (en-tête) : si elle n'est pas mergée au moment de T0, + la story **attend**, comme pour la 15-5e (finding R6-10 de la P6). +14. **`credit_note_archived_sale_receivable_is_refused_before_the_fiscal_year`** — l'ordre des refus + de l'AC6 : 1100 archivé **et** date d'avoir hors de tout exercice ouvert → + `CreditNoteAccountsArchived` (et non `FiscalYearInvalid`) : le verrou des comptes de l'avoir + précède bien l'exercice. *(Rougit sur le code actuel : `FiscalYearInvalid`, l'exercice étant lu + avant tout compte.)* + +**`crates/kesh-api/tests/invoice_echeancier_e2e.rs`** (suite) : + +15. **`credit_note_route_replays_when_it_is_the_deadlock_victim`** — le rejeu de la route, **posé par + la 15-5e** (son AC3), étendu à l'attente **neuve** que l'AC6 crée (le S sur les comptes de + l'avoir) — les tests de la 15-5e font de la validation, du règlement et de son annulation la + victime, pas l'avoir. Placé dans ce fichier ou dans `crates/kesh-api/tests/rejeu_interblocage_e2e.rs` + que la 15-5e crée (choix écrit au Dev Agent Record) ; sur le patron + `accept_replays_the_batch_when_it_is_the_deadlock_victim` (`reconciliation_e2e.rs:4426`) : une + transaction de test **s'alourdit** — InnoDB choisit pour victime la transaction la plus légère — + **exactement comme le patron** (`reconciliation_e2e.rs:4482-4496`) : la table `lest_interblocage` + créée par DDL **hors** de la transaction, puis `seq_1_to_50` inséré dix fois dans la transaction ; + **jamais** un lest dans une table applicative, dont les clés étrangères poseraient des verrous sur + `accounts` et changeraient le cycle (finding R5-8 de la P5) —, puis prend `SELECT … FROM accounts WHERE id = <1100> FOR + UPDATE` ; une tâche émet l'avoir **par la route** ; `attendre_une_requete_en_cours(&pool, &["FROM + accounts", "LOCK IN SHARE MODE"], …)` doit rendre `true` (l'avoir tient la facture et les réglages + et attend 1100) ; le test demande alors `SELECT id FROM invoices WHERE id = ? FOR UPDATE` et + **doit l'obtenir** (`.expect` — sinon c'est lui la victime et le test ne prouve rien, patron de la + 15-5e) → cycle, InnoDB annule l'avoir ; le test annule sa transaction ; le rejeu repart à neuf : + **201**, un seul avoir, facture `cancelled`, premier numéro d'avoir. *(Rougit sur le code actuel — + aucune attente `LOCK IN SHARE MODE` n'est vue — et sur la mutation qui retire l'enveloppe de la + route : 500, consignée au Dev Agent Record.)* Il exerce le **prédicat** (`is_deadlock_error` sur le + `DbError` que rend le dépôt), pas seulement la présence de l'enveloppe ; son doc-comment dit + pourquoi le montage est déterministe. + +**`crates/kesh-db/tests/credit_notes_repository.rs`** (suite) : + +16. **`credit_note_refused_when_vat_payable_archived`** — le compte de TVA due des réglages archivé en + SQL après la validation d'une facture avec TVA → `DbError::CreditNoteAccountsArchived` nommant ce + compte (C-15-6-29), rien d'écrit. *(Rougit sur le code actuel : `InactiveOrInvalidAccounts`.)* + +**`crates/kesh-db/tests/credit_notes_repository.rs`** (suite) — P5 (C-15-6-32) : + +17. **`credit_note_lock_leaves_a_foreign_account_unlocked`** — l'identifiant étranger (AC6, patron du + test de la 15-5d, choix C51) : un compte d'une **seconde** société, **sans** `UPDATE` des réglages (l'appel + direct reçoit ses ids : le réglage n'y jouerait aucun rôle) ; une connexion ouvre une transaction et appelle **directement** le + helper de verrou de liste en mode `Share` avec des ids explicites (la créance de la vente et + l'identifiant étranger), **sans conclure** ; une seconde connexion tente `SELECT id FROM accounts WHERE id = ? FOR UPDATE NOWAIT` + sur la ligne étrangère → **réussit**. S'il échoue, le helper prend le patron `owned_account_ids` + (AC6) et le test reste. *(C'est une mesure, pas un test rouge d'abord. **Issue attendue, d'après la + mesure du dépôt : rouge** — `opening_complement.rs:434-436` a mesuré à deux sessions qu'un verrou + par clé primaire prend la ligne d'une autre société avant que le filtre `company_id` ne l'écarte, + et sous REPEATABLE READ ce verrou n'est pas relâché ; un S suffit à faire échouer le `NOWAIT` — + d'où, vraisemblablement, `owned_account_ids`. **Sauf** si la 15-5d l'a déjà adopté par son propre + test de C51 : T0 le constate, et le test 17 est alors vert d'emblée. Finding R6-5 de la P6.)* +**`crates/kesh-api/src/errors.rs`, module de tests** (patron du test unitaire du bras +`CreditNoteRevenueAccountsArchived`, `:3715`) : + +18. **Bras de `CreditNoteAccountsArchived`** (finding R5-9 de la P5) — la variante rend **400**, + `code = "ACCOUNT_ARCHIVED"`, `details.rejected[0]` = `{ accountId, accountNumber }`, et un message + construit sur la clé `credit-note-account-archived` (repli fr compris si l'i18n n'est pas + initialisée). Les quatre locales restent contrôlées par la parité de clés seule ; la traduction se + relit en revue. + +Soit **16 tests neufs et 2 tests modifiés** (9, 13) — périmètre : du commit de spécification à la fin +du développement, avant revue ; le 13 n'existe qu'après le merge de la 15-5d. + +Non-régression : les tests de `credit_notes_repository.rs` (neuf, dix après la 15-5d), `invoices_validate_vat.rs` (les deux +autres tests d'avoir arrondi, `:783` et `:820`), `invoice_settlement.rs`, `invoice_write_off.rs`, +`reconciliation_e2e.rs`, et **`crates/kesh-db/tests/invoices_line_revenue_account.rs`** — c'est lui +qui fige que la 6 ter, dont l'AC6 change la source de `active`, garde son code, son message par +ligne et sa place (finding F6-5 de la P6) : `credit_note_uses_materialized_account_not_current_default` +(`:279`), `credit_note_fails_when_snapshot_account_archived` (`:569`, `line_number == Some(1)`, +`account_number == Some("3200")`), `credit_note_ignores_postable_and_type_changes` (`:615`), +`null_and_explicit_default_merge_into_one_credit_line` (`:789`). + +## Dev Notes + +### Ce qui doit être préservé + +- **Verrous et ordre** de `create_credit_note` : facture `FOR UPDATE` (1), règlements `FOR UPDATE` + (garde 25-4-a), avoir existant `FOR UPDATE`, réglages (3), puis l'exercice (4) et la séquence (5). + Les deux **lecteurs** s'insèrent après (3) et ne prennent aucun verrou : ils lisent des **lignes + d'écriture**, gelées (une écriture enregistrée ne se modifie plus). L'**état des comptes**, lui, ne + l'est pas : il est verrouillé à part, **en partage, juste après les lecteurs et avant l'exercice**, + par l'AC6 — le gel de l'écriture ne dit rien de l'état actif du compte qu'elle vise. Ordre canonique + de `validate_invoice` (`invoices.rs:1916-1925` au commit `1920381e`) ; sa phrase, citée exactement + (`invoices.rs:1921-1922`) : « Aucun chemin ne verrouille `accounts` **avant** `invoices` ou + `fiscal_years`. » — en tension avec son propre « 1 bis » (`accounts` avant `fiscal_years`), et + retirée par la **15-5e** (son AC5), non par cette story (AC7). L'invariant juste, que suit l'AC6 : aucun verrou **explicite** + (`FOR UPDATE` / `LOCK IN SHARE MODE`) sur `accounts` **après** `fiscal_years` dans ces flux ; les S + de clé étrangère des insertions de lignes (`INSERT INTO journal_entry_lines`) viennent après + l'exercice — la validation, l'avoir lui-même (étape (7)), `accept_one_invoice` (`:1581` après + `:1520`) — et ne gênent pas un S ; ils attendent un X. L'AC6 place son verrou avant l'exercice pour + la **course de lecture** (`active` lu frais, archivage concurrent qui attend), selon la convention + du doc-comment canonique (comptes → exercice) ; elle n'en tire **aucune** règle de fermeture de + cycle — la défense contre l'interblocage est le rejeu de la route (15-5e, C54 ; finding R6-3 de la + P6). +- **`version` de la facture** : l'avoir l'incrémente déjà (invariant de `settle_invoice`, étape (8)) ; + ne pas y toucher. +- **Garde de postabilité désactivée** (`create_in_tx(..., false)`, 14-3b D-A0) : inchangée. La + créance et le compte d'arrondi de la vente ont été imputables au moment de la vente ; ne pas + re-vérifier `postable` (même raisonnement que la 6 ter de la 16-1a : viser les **mêmes** comptes que + l'écriture d'origine). + +### Pièges + +- **Ne pas changer `ORDER BY jel.id` en `line_order`** dans les lecteurs, même si la colonne existe + (`journal_entries.rs:417`) : trois chemins éprouvés reposent sur `jel.id`, et le lecteur de créance + doit être la même requête, pas une nouvelle ; le lecteur d'arrondi suit le même ordre. +- **Ne pas réutiliser `usable_designated_account`** pour l'arrondi de l'avoir : elle exige « charge ou + produit, actif, imputable » d'un compte **désigné** ; la doctrine de l'avoir est « mêmes comptes que + l'origine, seule l'inactivité bloque » (AC4, AC6). +- **Greper la valeur, pas la formulation** (propagation post-patch) : après T3, greper + `default_receivable_account_id`, `rounding_account_for_write` et `Issuance` sur tout le dépôt (code, + doc-comments, `docs/`, manuels) pour trouver les phrases qui disent encore que l'avoir lit les + réglages. +- **Migration** : aucune. Pas de P5/P6/P7. +- **Frontière 15-5a à 15-5e** : deux fichiers `credit_notes.rs` sont à distinguer (finding R6-7 de la + P6). Le **dépôt** `crates/kesh-db/src/repositories/credit_notes.rs`, que cette story réécrit, est + touché par la **15-5d** (son test des générateurs, `:933`, choix C50 — « `credit_notes.rs` aussi pour + les 19 tests des générateurs ») ; la **route** `crates/kesh-api/src/routes/credit_notes.rs` est + enveloppée dans le rejeu par la **15-5e** (son AC3). Les autres ne touchent ni l'un ni l'autre, mais + partagent des **fonctions** avec cette fiche — + `accept_one_invoice` (15-5b AC6 : commentaire de classement sur le bloc même que l'AC2 remplace, à + conserver et réécrire, AC7), `settle_invoice` (15-5a AC4, 15-5b AC18 ; route rejouée par la 15-5e), + `write_off_invoice` (15-5e AC5, commentaire « 5 bis »), `validate_invoice` (15-5b, 15-5d, 15-5e AC5 : + doc-comment canonique) et `invoices_validate_vat.rs` (15-5b, 15-5d), le passage du manuel sur + l'avoir (15-5b AC17, 15-5d AC8), le helper de verrou de liste de la 15-5d (AC6), le Pattern 5 + (15-5e AC5) — et la 15-5d **touche** `credit_notes_repository.rs` (test de C35, ré-ancré ici, + § *Tests* 13) et y laisse des doc-comments sur l'exemption de l'avoir (AC7). **Une seule règle + d'ordre des verrous pour l'epic, celle de la 15-5e (C54)** : l'ordre est une convention de + fréquence, la défense est le rejeu ; l'AC6 garde son verrou pour la course de lecture (réglages, + comptes par `id`, exercice), ne réordonne rien pour fermer un cycle et ne prétend à l'absence + d'aucun cycle. Les + textes que la 15-5e réécrit ne sont **pas** réécrits ici (AC7). Rebase attendu avec conflits **de + forme** ; T0 refait les numéros de ligne. + +### Références + +- [Source: crates/kesh-db/src/repositories/credit_notes.rs:187-258, :261-620 (dont :238-239, :365-367, :418-451, :507-545)] +- [Source: crates/kesh-db/src/repositories/opening_complement.rs:38-39, :502, :540 (patron `LOCK IN SHARE MODE`) ; + crates/kesh-db/src/repositories/invoice_settlements_write.rs:431-495, :466-468 ; + crates/kesh-api/src/routes/credit_notes.rs:183 ; crates/kesh-db/src/retry.rs:83 (`is_deadlock_error`), :105 (`retry_on_deadlock`, rejeu de la route par la 15-5e) ; + crates/kesh-api/src/routes/reconciliation.rs:1487, :1520, :1581, :1703 ; crates/kesh-api/tests/reconciliation_e2e.rs:4426] +- [Source: issue #536 (cycle avoir ↔ rapprochement de la même facture) ; `epic-15-choix-autonomes.md` C-15-6-29, C-15-6-32] +- [Source: `15-5e-ordre-des-verrous-reglements.md` (dépôt principal, réécrite selon C54 « Rejeu sur interblocage + des flux d'écriture » ; AC3 route de l'avoir rejouée, AC5 doc-comment canonique, « 5 bis » et Pattern 5) ; + registre C54, C55, C57 ; `15-5d-garde-usage-comptes-reglage.md` + (AC1 `lock_designated_accounts_in_tx`, C51 `EXPLAIN` et identifiant étranger) ; + `docs/MULTI-TENANT-SCOPING-PATTERNS.md:285-330` (Pattern 5) ; crates/kesh-db/src/repositories/opening_complement.rs:274-288, :431-440] +- [Source: crates/kesh-db/src/errors.rs:291, :604-614, :796 (`map_db_error`) ; crates/kesh-db/src/repositories/journal_entries.rs:96-98, :1622-1627, :1723-1754 ; + crates/kesh-api/src/errors.rs:2945-2973, :3155-3185 ; crates/kesh-db/src/repositories/accounts.rs:604-634] +- [Source: crates/kesh-db/src/repositories/invoices.rs:1916-1925 (ordre canonique des locks) ; + crates/kesh-db/src/repositories/fiscal_years.rs:548 ; crates/kesh-api/src/routes/reconciliation.rs:887, :1688-1706 ; + crates/kesh-db/src/test_fixtures.rs:560 ; crates/kesh-api/tests/invoice_echeancier_e2e.rs:742 ; + crates/kesh-i18n/locales/fr-CH/messages.ftl:291, :354] +- [Source: crates/kesh-db/src/repositories/invoice_settlements_write.rs:1-13 (doctrine), :97-115, :428-445] +- [Source: crates/kesh-api/src/routes/reconciliation.rs:1414-1452] +- [Source: crates/kesh-db/src/repositories/invoices.rs:1870-1912, :2005-2010, :2068] +- [Source: crates/kesh-db/src/repositories/company_invoice_settings.rs:400-412, :484] +- [Source: crates/kesh-db/tests/invoices_validate_vat.rs:592-930 ; crates/kesh-db/src/test_fixtures.rs:212] +- [Source: docs/manual/fr/user-manual.tex:363, :847, :1148-1180 ; docs/manual/fr/admin-manual.tex:2027] +- [Source: issues #473, #523, #525 ; `epic-15-choix-autonomes.md` C-15-6-1, C-15-6-2, C-15-6-7, C-15-6-8, C-15-6-16, C-15-6-24, C-15-6-25] + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5 (développement en autonomie, 2026-10-09, worktree `kesh-15-6a`). + +### Debug Log References + +### Completion Notes List + +**T0 — relevés (2026-10-08, sur `origin/main` `9cb5083b` ; branche de la 15-5d lue à `5624ca78`, non mergée).** + +- **Rejeu de la route — constaté.** `crates/kesh-api/src/routes/credit_notes.rs:187` (`create_credit_note`) + appelle `kesh_db::retry::retry_on_deadlock("credit_notes::create", …)` (`:193`), doc-comment « Rejouée sur + interblocage (Story 15-5e2, #536) » (`:184-186`) ; registre `crates/kesh-api/tests/audit_route_registry.rs:220` + : `("post", "credit_notes::create_credit_note", Traced, Rejouee)`. Le rejeu vient de la **15-5e2** (la fiche + écrit « 15-5e » : la 15-5e a été découpée en 15-5e1/15-5e2, C61). `is_deadlock_error` : `crates/kesh-db/src/retry.rs:115` ; + `retry_on_deadlock` : `:150`. Fichier des tests de rejeu : `crates/kesh-api/tests/rejeu_interblocage_e2e.rs` (existe). +- **Helper de verrou de liste — forme sur la branche de la 15-5d** (`company_invoice_settings.rs`, ~`:595-740` à + `5624ca78`) : `lock_designated_accounts_in_tx(conn, company_id, ids) -> Result`, + **`pub(in crate::repositories)`** ; **déjà en partagé** (`LOCK IN SHARE MODE`, choix C87 de la 15-5d — il n'y a + plus d'appelant `FOR UPDATE`) ; **déjà** le patron `owned_account_ids` (C88 : lecture non verrouillante des ids + de la société, puis verrou sur ces seuls ids, `company_id` gardé en défense) ; plan épinglé + `FORCE INDEX (PRIMARY)` ; ids triés et dédoublonnés, `ORDER BY id` ; ne refuse rien. Type de ligne : + `LockedDesignatedAccount { id, number, active, postable }`, **privé**, enveloppé dans le newtype + `DesignatedAccountsSnapshot(Vec<…>)`. Son doc-comment liste « l'avoir, qui relit la créance et la TVA due dans + les réglages du moment sans ce contrôle (C35) » parmi « ce qui n'est pas contrôlé ici » (AC7 : à réécrire). + **Absent de `HEAD`** (`grep -rn "lock_designated_accounts_in_tx" crates/` vide). +- **Ordres de verrous relevés** (relevé, sans exigence de conformité, R6-3) : + validation `invoices.rs:1921-1957` (doc-comment canonique réécrit par la 15-5e1 : facture → réglages + `FOR UPDATE` → arrondi `FOR UPDATE` → produit en partagé par clé étrangère → exercice `:2205` → séquence → + écriture) ; la 15-5d y insère, sur sa branche, le S de la créance et de la TVA due **après** l'arrondi et + **avant** l'exercice (`invoice_settlements_write.rs` « 5 bis » de sa branche) ; + règlement client `invoice_settlements_write.rs:48` : facture, compte de contrepartie `FOR UPDATE` + (`:122`, `:159`), arrondi (`:200`), exercice (`:211`) ; + solde du reste `:367` : nature (étape 4), arrondi (`:474-499`), TVA due, exercice (`:509`) — commentaire + « 5 bis » `:474-486` ; + saisie fournisseur `supplier_invoices.rs` : réglages `:376` **avant** l'exercice `:429` (le cycle (iv) l'exclut à + juste titre — R7-5 tranché). +- **Commentaire de classement de la 15-5b** dans `accept_one_invoice` : c'est le **doc-comment** de la fonction + (`reconciliation.rs:1216-1222`, « la créance est lue **sur l'écriture de vente** de la facture »), sans numéro + de ligne ni requête citée ; il reste vrai après l'AC2. Le bloc remplacé est `(b)` `:1464-1501`. +- **Textes de la 15-5e lus** : doc-comment canonique de `validate_invoice` (ne mentionne pas l'avoir) ; « 5 bis » + (ne mentionne pas l'avoir) ; Pattern 5 `docs/MULTI-TENANT-SCOPING-PATTERNS.md:285-335` — **aucune ligne pour + l'avoir**, et les lignes des flux comptables y sont désormais **par renvoi au doc-comment canonique** (« the + order is written there only », C68). → aucune phrase décrivant l'ancien ordre de l'avoir (AC7 : « rien »). +- **Manuel** (lu en entier, `HEAD` et branche 15-5d) : sur `HEAD`, `user-manual.tex:382` (4) dit « un compte + désigné … devenu non imputable … reste utilisé par les écritures automatiques (validation d'une facture, + **avoir**, facture fournisseur) ». Sur la branche 15-5d, `:380` (« Deux exceptions, voulues : l'**avoir** relit + la créance et la TVA due dans les réglages au moment de l'avoir, sans les contrôler ») et `:928` (§ « Un compte + des réglages devenu non imputable » : « L'avoir, lui, n'est pas soumis à ce contrôle … même si sa créance ou sa + TVA due est devenue non imputable ») — la phrase sur la **créance** deviendra fausse (AC8). `admin-manual.tex` + : rien sur l'avoir et les réglages hors `:2035` (« un avoir le reprend et l'annule »), qui reste vrai. + Numéros actuels : `sec:avoirs` `:1226`, puce « revient à zéro » `:1232`, `keshwarning` produit archivé `:1249`, + `sec:plan-comptable-archivage` `:361` (avertissement `:364`), arrondi `:924`, balance âgée `:1836` ; + `admin-manual.tex:2035`. +- **Tests** : `credit_notes_repository.rs` compte **9** `#[sqlx::test]` sur `HEAD` (`:101` … `:593`, inchangé) ; + **la 15-5d n'y ajoute rien** : son test de C35 est `credit_note_is_exempt_from_the_guard`, dans + `crates/kesh-db/tests/invoices_validate_vat.rs` (`~:1395-1416` sur sa branche, module des tests de la 15-5d) ; + le test 13 se ré-ancre là. Son test `foreign_account_is_never_locked_and_is_refused` (même fichier) mesure déjà + l'identifiant étranger : le test 17 sera **vert d'emblée** (cas prévu par la fiche). +- **Inventaire recompté** (commandes de la fiche) : deux écrivains neufs depuis `1920381e` — + `journal_entries::update` (15-8a, `INSERT INTO journal_entry_lines` `journal_entries.rs:1480`) et + `journal_entries::delete_in_tx` (15-8b, `:1648`). **Hors classe** (lignes saisies ; suppression) ; tous deux + refusent une écriture **possédée par une pièce** (`journal_entries.rs:45`, `:1163`, motifs `:1955-1965`), donc + l'écriture de vente d'une facture validée reste **gelée** — la prémisse des deux lecteurs tient. Ils + s'ajoutent aux partenaires possibles du cycle (iv) (ils tiennent l'exercice sans la ligne des réglages) — liste + déjà déclarée non exhaustive. Aucun autre site neuf ne relit créance, dette ou arrondi. + +**Développement (2026-10-09, sur `origin/main` `5e4bec50` ; commits `cc640ffb` T0 repris, `55143ec0` code et tests, +`ef1e7b47` documentation).** + +- **T1** — `crates/kesh-db/src/repositories/invoice_settlements.rs` : `sale_receivable_account` (la requête exacte + des trois copies, `Option`) et `sale_rounding_account` (dernière ligne `ORDER BY jel.id DESC LIMIT 1`, + recoupée avec l'arrondi figé — toute autre forme → `Invariant`), doc-comments portant la règle, l'ordre des lignes, + la raison du recoupement et celle du refus de `usable_designated_account`. +- **T2** — les trois copies remplacées (`settle_invoice`, `write_off_invoice`, `accept_one_invoice` — erreur SQL du + lecteur toujours en `DATABASE_ERROR`, `details.message` désormais dérivé d'un `DbError`). Critère de l'AC2 : + `grep -rn --include='*.rs' "jel.debit > 0" crates/` rend **deux** lignes, le lecteur + (`invoice_settlements.rs:548`) et la doc de `generate_invoice_journal_lines_rounded` (`invoices.rs:1892`). + Doc-comment de classement d'`accept_one_invoice` (15-5b) conservé, complété du nom du lecteur. +- **T3** — `create_credit_note` : (3) ne lit plus `default_receivable_account_id` ; (3 bis) créance et arrondi lus + sur l'écriture de vente (`Invariant` sur `journal_entry_id` absent ou sans ligne de débit) ; (3 ter) **un** appel au + helper de la 15-5d (`lock_designated_accounts_in_tx`, partagé, `ORDER BY id`) sur créance, arrondi si ≠ 0, TVA due + si l'avoir en émet **et** si le réglage est posé, produits effectifs ; nombre de lignes ≠ ids dédoublonnés → + `Invariant` (« compte de l'avoir introuvable dans la société ») ; créance / arrondi / TVA due archivés → + `DbError::CreditNoteAccountsArchived` (ordre des ids) ; la 6 ter lit `active` et le numéro dans le résultat du + verrou (sa lecture simple et sa seconde requête de numéros supprimées ; code, message par ligne et place + inchangés) ; (7 bis) emploie l'id lu en (3 bis). Agrégation de TVA extraite (`credit_note_vat_by_rate`, + `credit_note_emits_vat`), employée par le générateur et par le calcul des ids — refus + `ConfigurationRequired("default_vat_payable_account_id")` resté au générateur. Variante, `error_code()` + (`ACCOUNT_ARCHIVED`), bras HTTP (factorisé avec `ReversalAccountsArchived` dans `archived_accounts_response`, + C-15-6a-3), clé `credit-note-account-archived` dans les quatre `messages.ftl`. Doc-comments : `create_credit_note` + (comptes de l'avoir, « Ordre des locks » renvoyant au doc-comment canonique de `validate_invoice` et au rejeu de la + route), `RoundingContext::Issuance` (ne cite plus l'avoir), `generate_invoice_journal_lines_rounded` (l'arrondi + reste la DERNIÈRE ligne), commentaire D5 « inverse exact », helper de la 15-5d (rendu `pub` avec + `LockedDesignatedAccount`, `DesignatedAccountsSnapshot` et l'accesseur `accounts()` — C-15-6a-2 —, l'avoir retiré + de « ce qui n'est pas contrôlé ici » au profit de ce qu'il contrôle). **Textes de la 15-5e** (doc-comment canonique, + « 5 bis », Pattern 5) : **rien** n'y décrivait l'ancien ordre de l'avoir ; Pattern 5 gagne une ligne pour + `POST /credit-notes`, par renvoi (C-15-6a-1). +- **`EXPLAIN`** (base `kesh_156a`, MariaDB 10.11) de `SELECT id, number, active, postable FROM accounts FORCE INDEX + (PRIMARY) WHERE company_id = 1 AND id IN (2, 4, 5) ORDER BY id LOCK IN SHARE MODE` : `range` sur `PRIMARY`, + *Using where*, 3 lignes, **aucun `filesort`**. Identifiant étranger : test 17 **vert d'emblée** (patron + `owned_account_ids` déjà adopté par la 15-5d, C88). +- **Critère DRY** (`grep -rnE "LOCK IN SHARE MODE|FOR UPDATE" crates/kesh-db/src`, lu sur les requêtes qui visent + `accounts`) : seule autre requête verrouillant une **liste** d'ids de comptes, l'exception écrite + `opening_complement.rs` (`create_opening_complement`) ; les autres verrouillent un compte unique + (`usable_designated_account`, contreparties de règlement, comptes bancaires) ou d'autres tables. +- **T4 — tests** (périmètre : `5e4bec50` → `55143ec0`, recompté par `grep -c '#\[sqlx::test'` / + `#\[tokio::test\]` aux deux bornes) : **16 neufs** — `credit_notes_repository.rs` 9 → 17 (tests 1, 2, 3, 4, 11, 14, + 16, 17), `invoices_validate_vat.rs` 41 → 45 (5, 6, 7, 8), `reconciliation_e2e.rs` 54 → 55 (10), + `invoice_echeancier_e2e.rs` 30 → 31 (12), `rejeu_interblocage_e2e.rs` 7 → 8 (15, placé là — C-15-6a-3), + `kesh-api/src/errors.rs` 22 → 23 (18) — et **2 modifiés** : 9 (`a_credit_note_is_refused_when_the_rounding_account_was_archived`, + assertion `CreditNoteAccountsArchived` nommant 6940, doc-comment réécrit sur le compteur) et 13 + (`credit_note_is_exempt_from_the_guard` → `credit_note_credits_a_non_postable_sale_receivable`, change de sens). + - **Témoin 10 écrit et vert AVANT le remplacement** de la copie d'`accept_one_invoice`, vert après. + - **Rouges d'abord, constatés** sur l'avoir d'avant (le seul `credit_notes.rs` du dépôt restauré depuis `cc640ffb`, + le reste du correctif en place) : **11 rouges** — 1, 2, 3, 6, 7, 9, 11 (au délai de dix secondes + d'`attendre_une_requete_en_cours`, comme annoncé par R5-3), 12, 14, 15, 16. Verts sur les deux : 13 (ré-ancré, + non rouge-d'abord par construction) ; non concernés : 4, 5, 8 (lecteurs neufs), 10 (témoin), 17 (mesure, vert + d'emblée), 18 (bras neuf). Correctif restauré, fichier `touch`é. + - **Mutations jouées, constatées rouges, restaurées (`touch`)** : M1 enveloppe de rejeu retirée de la route → + test 15 rouge en **500 `INTERNAL_ERROR`** (au lieu de 201) ; M2 la TVA due retirée du refus nommé → test 16 + rouge ; M4 lecteur d'arrondi sans recoupement → test 8 rouge ; M5 lecteur de créance en `ORDER BY jel.id DESC` + → tests 5 et `a_credit_note_mirrors_the_rounding` rouges. Non jouée : la suppression du contrôle « ligne absente » + (inatteignable par l'application, aucun test par décision de la fiche, F5-7). +- **T5** — `user-manual.tex` : `:380` (l'avoir crédite la créance lue sur l'écriture ; seule la TVA due relue dans + les réglages), puce de `sec:avoirs` (pourquoi le solde revient à zéro ; limite connue de la TVA), `keshwarning` + neuf « Avoir refusé : compte débiteurs, d'arrondi ou de TVA archivé » (trois comptes, message cité, même + procédure, rôle *Créances clients* à retirer avant la réactivation, autre issue pour la TVA due, distinct du refus + par ligne et contrôlé d'abord), avertissement d'archivage `:364` étendu ; `admin-manual.tex` (*Compte de + différences d'arrondi*) : changer de compte ne déplace pas les factures émises. `:928`, `:847`/`:924` (« un avoir + le reprend et l'annule ») et la balance âgée relus, vrais, non réécrits (C-15-6a-3). PDF `admin` et `user` + régénérés (`make admin user`), contrôlés **aplatis** sur six fragments neufs du manuel utilisateur et un de + l'administrateur — tous trouvés ; « relit la créance » : 0 occurrence. CHANGELOG `[0.13.0]` : *Corrigé* (#473, + #523) et *Modifié* (contrat `ACCOUNT_ARCHIVED`) ; l'entrée de la 15-5d sur l'exemption de l'avoir réécrite. + `docs/api-external.md` : **vérifié sans objet** (`grep -n "ROUNDING_ACCOUNT\|credit-notes"` ne rend que la liste + des routes rejouées, `:488`, inchangée et vraie). Frontend : `grep -rn + "ROUNDING_ACCOUNT_NOT_CONFIGURED\|INACTIVE_OR_INVALID_ACCOUNTS\|ACCOUNT_ARCHIVED" frontend/src` — aucun site sur + le chemin de création d'avoir, comme relevé par la fiche. +- **Propagation** : `default_receivable_account_id`, `rounding_account_for_write`, `Issuance`, `C35` et « avoir » + grepés sur `crates/` et `docs/*.md` : seuls résidus, légitimes — la validation (`invoices.rs:2153`, `Issuance`), + `vat_payable_account_for_write` (« le même refus que l'avoir pour un réglage absent », vrai de la TVA due). +- **T6 — gates au dernier commit de code** (`55143ec0` ; `ef1e7b47` ne porte que documentation et PDF), bases + `kesh_156a` / `kesh_e2e_156a` remises à zéro (DROP/CREATE, migrations, seed) avant, après `wait-kesh.sh` : + - backend `scripts/test-fast.sh` (fmt + clippy `-D warnings` + nextest) : **2895 / 2895**, 4 ignorés (`main` : + 2879 ; +16) ; + - frontend : `check` vert, `lint-i18n-ownership` PASS, `test:unit` **1091 / 1091** (112 fichiers), `build` vert — + frontend non touché ; + - E2E complet (backend `:3011`, secrets `openssl rand`, `smtpConfigured: true`, inbox/documents dans le + scratchpad) : **247 passés, 7 échoués, 19 ignorés** (10,1 min) ; les 7, fichier par fichier, sont les KF-029 de + `docs/testing.md` (`mode-expert.spec.ts:26`, `:41`, `onboarding-path-b.spec.ts:65`, `:92`, `onboarding.spec.ts:57`, + `:77`, `:150`) ; `credit-notes.spec.ts` vert. Backend arrêté par son PID. + +- **Revue de code P1 et clôture** (2026-10-09) : Sonnet ×3 (B, E, A) — 0 au-dessus de LOW, 14 LOW ; remédiation au + commit `3a7bddcc` (sur la base rebasée), **sans aucune ligne de production exécutable** (`git diff -U0` sur + `crates/*/src` : doc-comments `///` et `//!` seuls). Test 13 discriminant et test 19 neuf, tous deux **rouges** sous + la mutation « créance lue sur les réglages » (rejouée puis annulée, fichier `touch`é, verts ensuite) ; test 10 + renommé. B-3 en dette (C-15-6a-4, issue à ouvrir). +- **Rebase sur `origin/main` `ec745d0c`** (15-7a1) : registre par union (entrées 15-7 / 15-7a1 de `main`, 15-6 / + 15-6a de la branche, disjointes), `sprint-status.yaml` (lignes 15-6* de la branche, 15-7* de `main` ; `last_updated` + (24) et (25) sans collision, `main` à (23)) ; aucun conflit de code — `company_invoice_settings.rs`, touché des deux + côtés, fusionné sans conflit et compilé. Manuels non touchés par `main` : PDF inchangés par le rebase. +- **Gates sur l'état rebasé, au dernier commit de code `3a7bddcc`** (D7), bases `kesh_156a` / `kesh_e2e_156a` + remises à zéro (DROP/CREATE, migrations, seed) après `wait-kesh.sh` : + - backend `scripts/test-fast.sh` (fmt + clippy `-D warnings` + nextest) : **2906 / 2906**, 4 ignorés ; + - frontend : `check` 0 erreur, `lint-i18n-ownership` PASS, `test:unit` **1091 / 1091** (112 fichiers), `build` vert ; + - E2E complet (backend `:3011`, secrets `openssl rand`, `smtpConfigured: true`, inbox/documents dans le + scratchpad) : **245 passés, 9 échoués, 19 ignorés** (9,9 min). Fichier par fichier : les 7 KF-029 de + `docs/testing.md` (`mode-expert.spec.ts:26`, `:41`, `onboarding-path-b.spec.ts:65`, `:92`, `onboarding.spec.ts:57`, + `:77`, `:150`) ; plus **`invoices.spec.ts:415` et `:439`** (21-6c), rouges aussi rejoués seuls (2 échoués / 18 + passés) — **cause établie, étrangère à la branche** : `recordManualReminderViaApi` pose `sentAt = T12:00:00`, que `record_manual_reminder` refuse (`ReminderDateInFuture`, 422, + `dunning_reminders.rs:261`) tant que l'heure UTC est avant midi — le run a tourné à 01 h UTC. Défaut du test, lié + à l'heure : issue à ouvrir. `credit-notes.spec.ts` vert. Backend arrêté par son PID. + +- **Intégration sur `de285ea8`** (2026-10-09, 15-11b mergée, #570) : branche rebasée sur `origin/main` `de285ea8`. + Conflits : registre par union (aucun identifiant `## C-…` en double, contrôlé par `sort | uniq -d`) ; + `sprint-status.yaml` — `main` porte désormais les `last_updated` (24) à (26) de la 15-11b, les trois lignes de la + branche sont **renumérotées (27), (28), (29)** ; aucune clé de story en double ; `admin-manual.pdf` en conflit + binaire, le `.tex` fusionné sans conflit → PDF **régénéré** et contrôlé aplati (fragment de la 15-6a « Changer de + compte d'arrondi ne déplace pas … » et deux fragments de la 15-11b présents). CHANGELOG `[0.13.0]` : une rubrique + de chaque. Aucun conflit de code ; la story n'ajoute aucune lecture d'environnement (`config::env_nonempty` + inchangé, test lexical (L) vert dans le gate). Gates sur l'état rebasé, bases `kesh_156a` / `kesh_e2e_156a` + remises à zéro après `wait-kesh.sh` : + - backend `scripts/test-fast.sh` : **2929 / 2929**, 4 ignorés ; + - frontend : `check` 0 erreur, `lint-i18n-ownership` PASS, `test:unit` **1091 / 1091**, `build` vert ; + - E2E complet (`:3011`, secrets `openssl rand`, `smtpConfigured: true`, inbox/documents dans le scratchpad) : + **245 passés, 9 échoués, 19 ignorés** (12,2 min) — les 7 KF-029 et les deux `invoices.spec.ts:415`, `:439` + (`manual reminder failed: 422`, run vers 02 h UTC : même cause d'horloge que ci-dessus, hors branche). Backend + arrêté par son PID. + +### File List + +- `crates/kesh-db/src/repositories/invoice_settlements.rs` — lecteurs `sale_receivable_account`, `sale_rounding_account` +- `crates/kesh-db/src/repositories/invoice_settlements_write.rs` — deux copies remplacées +- `crates/kesh-api/src/routes/reconciliation.rs` — copie remplacée, doc-comment +- `crates/kesh-db/src/repositories/credit_notes.rs` — avoir (3), (3 bis), (3 ter), 6 ter, (7 bis), agrégation de TVA +- `crates/kesh-db/src/repositories/company_invoice_settings.rs` — helper de verrou public, accesseur +- `crates/kesh-db/src/repositories/invoices.rs` — doc de `generate_invoice_journal_lines_rounded` +- `crates/kesh-db/src/errors.rs` — `CreditNoteAccountsArchived`, `Issuance` +- `crates/kesh-api/src/errors.rs` — bras HTTP factorisé, test 18 +- `crates/kesh-i18n/locales/{fr-CH,de-CH,it-CH,en-CH}/messages.ftl` — `credit-note-account-archived` +- `crates/kesh-db/tests/credit_notes_repository.rs`, `crates/kesh-db/tests/invoices_validate_vat.rs`, + `crates/kesh-api/tests/reconciliation_e2e.rs`, `crates/kesh-api/tests/invoice_echeancier_e2e.rs`, + `crates/kesh-api/tests/rejeu_interblocage_e2e.rs` — tests +- `docs/manual/fr/user-manual.tex` / `.pdf`, `docs/manual/fr/admin-manual.tex` / `.pdf`, `CHANGELOG.md`, + `docs/MULTI-TENANT-SCOPING-PATTERNS.md` + +## Change Log + +- 2026-10-08 — Spécification initiale (bmad-create-story, en autonomie). Statut `ready-for-dev`. +- 2026-10-08 — **Validation P1** (Sonnet 4.6, lentilles F et R ; prompt + `15-6a-validate-prompt-p1.md`). F : 1 HIGH, 3 MEDIUM, 4 LOW ; R : 1 HIGH, 3 MEDIUM, 4 LOW (les deux + HIGH convergent, deux MEDIUM aussi). Appliqués : + - **HIGH F1 = R1** — #523 absorbée : lecteur de la dernière ligne avec recoupement sens + montant + (AC4), refus du compte de la vente archivé par `create_in_tx` (AC6, **voie (b)** : le test `:846` + change d'assertion, délibérément — la voie (a) aurait gardé un message renvoyant à tort aux + paramètres, la voie (c) créé un refus neuf), deux tests neufs (réglage redésigné, réglage vidé), un + test neuf du recoupement et le test `:846` modifié (compte archivé). Choix C-15-6-7. *(Phrase + corrigée en P2, finding R-5 : elle comptait le test modifié parmi les neufs ; le total, lui, était + juste.)* + - **MEDIUM F3** — TVA due de l'avoir : angle mort tracé par #525 (ouverte par l'orchestrateur) ; le + manuel dit la limite sans présenter l'écriture comme juste (AC8). Choix C-15-6-8. + - **MEDIUM F2 = R2** — le `grep` de l'AC2 se limite aux `.rs` ; les backfills `.sql` sont nommés + comme légitimes. + - **MEDIUM F4 = R5** — l'erreur SQL du lecteur reste `DATABASE_ERROR` dans le rapprochement ; + `INVOICE_SALE_ENTRY_MALFORMED`, qu'aucun test ne figeait, l'est par un test neuf (AC2, test 10). + - **MEDIUM R3** — « dix tests » → neuf, lignes citées. + - **MEDIUM R4 = F5** — tests des lecteurs en `#[sqlx::test]`, placés dans le fichier dont ils + emploient les helpers privés ; seconde société désignée. + - **LOW** — citation exacte du commentaire `:609-611` (R6) ; `accounts::archive` et choix du SQL + direct (R7) ; `user-manual.tex:1754` à ne pas réécrire (R8) ; contrôle aplati de la phrase neuve + (F6) ; test 2 borné à la créance, l'avoir exigeant toujours le produit par défaut (F8). Les trois + LOW restants ne figurent pas dans le rapport condensé transmis à la remédiation : rien n'en a été + appliqué, et ce n'est pas affirmé autrement. + - Décompte après passe : **9 AC, 7 tâches (T0–T6), 10 tests** (9 neufs, 1 modifié). +- 2026-10-08 — **Validation P2** (Opus 5.5, lentilles R et F ; prompt `15-6a-validate-prompt-p2.md`). + R : 0 CRITICAL, 0 HIGH, 3 MEDIUM, 6 LOW ; F : 0 CRITICAL, 0 HIGH, 2 MEDIUM, 6 LOW (R-2 = F-1, + R-3 = F-2, R-1 ⊃ F-5, R-7 = F-6, R-8 ⊂ F-8). Tous appliqués, décisions de l'orchestrateur comprises : + - **MEDIUM R-2 = F-1** — le verrou `FOR UPDATE` du compte d'arrondi (#486) n'est plus perdu : les + comptes lus sur la vente (créance, arrondi) sont verrouillés **sans** filtre `active`, l'état actif + lu sous verrou ; la course est fermée, testée (test 11, neuf). AC6, Dev Notes. Choix C-15-6-16. + - **MEDIUM R-3 = F-2** — refus anonyme : le compte archivé est **nommé** par le refus existant de la + contre-passation (`ReversalAccountsArchived`, 400 `ACCOUNT_ARCHIVED`, clé existante) — aucune + variante ni clé neuve ; tests 3 et 9 suivent. Manuel : `keshwarning` neuf au § *Avoirs*, + avertissement `:363` étendu, phrase `admin-manual.tex:2027` (AC8). Choix C-15-6-16. L'écart à la + contrainte de #523 (le code change, le refus reste nommé) est écrit en AC6 ; **commentaire à + poser sur #523** par l'orchestrateur. + - **MEDIUM R-1 (⊃ F-5 LOW)** — frontière réelle avec les 15-5a/b/c/d écrite (en-tête, Pièges) ; T0 + rebase et refait tous les numéros de ligne ; commentaire de la 15-5b dans `accept_one_invoice` + conservé et réécrit (AC7) ; phrase de la 15-5d sur l'avoir au manuel corrigée si elle est mergée + (AC8). + - **LOW** — R-4 : site `reconciliation.rs:1487` ajouté à l'inventaire ; F-4 : dévalidation + (`delete_in_tx`) et `create_opening_entry` classés, deux commandes de recompte ajoutées ; R-5 : + phrase du Change Log P1 corrigée ; R-6 : `:100` → `:101` ; R-7 = F-6 : invariant « l'arrondi est + la DERNIÈRE ligne » écrit à la source (AC7) ; R-8 = F-8 : texte de `DATABASE_ERROR` dérivé de + `DbError` (AC1), montage léger du test 10 proposé ; F-3 : contrôle PDF sur des fragments absents + du PDF actuel, apostrophes en `.` (AC8) ; F-7 : `### Modifié` au CHANGELOG, `docs/api-external.md` + sans objet (AC9). **R-9 non appliqué tel quel** : la consigne du registre interdit de réécrire une + entrée existante ; la révision de C-15-6-2 reste portée par les titres de C-15-6-7, C-15-6-8, + C-15-6-16 et par la fiche d'index. + - Signal de découpage : sans objet (lentille F : quatre modules de code ; aucun défaut recyclé). + - Décompte après passe : **9 AC, 7 tâches (T0–T6), 11 tests** (10 neufs, 1 modifié). +- 2026-10-08 — **Validation P3** (Sonnet, lentilles R et F ; prompt `15-6a-validate-prompt-p3.md` ; + remédiation Opus 5.5). R : 0 CRITICAL, 1 HIGH, 2 MEDIUM, 4 LOW ; F : 0 CRITICAL, 0 HIGH, 4 MEDIUM, + 6 LOW (R3-1 ⊃ F-5, R3-2 = F-4, R3-3 = F-10, R3-4 = F-7, R3-5 = F-6, R3-6 = F-2). Tous appliqués, + décisions de l'orchestrateur comprises : + - **HIGH R3-1 (+ MEDIUM F-1, LOW F-5)** — le verrou des comptes de la vente était placé **après** + l'exercice et la séquence, à l'inverse de l'ordre canonique de `validate_invoice` + (`invoices.rs:1916-1925`) et de celui de la 15-5d : cycle avec chaque validation sur la ligne de + la créance. Il est désormais pris **juste après les réglages (3), avant l'exercice (4)**, et + **en partage** (`LOCK IN SHARE MODE`) : un X sur 1100 ouvrait un cycle neuf avec + `accept_one_invoice`, qui tient S sur la créance avant de demander X sur la facture. Ordre des + refus fixé (compte de la vente archivé avant `FISCAL_YEAR_INVALID`), test 14 neuf ; section + « Ordre des locks » au doc-comment de `create_credit_note` (AC7). La phrase « c'est celui de + `validate_invoice` » et la « conséquence assumée » de C-15-6-16 étaient fausses : rectifiées par + C-15-6-24 (le registre n'est pas réécrit). + - **MEDIUM F-2 = R3-6** — test 11 sur `attendre_une_requete_en_cours` (motifs `FROM accounts`, + `LOCK IN SHARE MODE`), facture sans arrondi, aucun délai fixe. + - **MEDIUM F-3** — test 12 neuf, frontière HTTP de `POST /api/v1/credit-notes` (code, `details`, + message), dans `invoice_echeancier_e2e.rs`, seul fichier qui émet un avoir par la route. + - **MEDIUM F-4 = R3-2** — le test de C35 de la 15-5d est recensé en T0 et ré-ancré (test 13, + modifié) ; doc-comments de l'exemption réécrits (AC7) ; « neuf tests » daté, dix après la 15-5d. + - **MEDIUM R3-3 = F-10** — T0 **lit** les encadrés `user-manual.tex` ~`:380`/~`:390` et + `admin-manual.tex:2016-2029` après le merge des 15-5, au lieu de greper une formulation de fiche ; + grep de la valeur ensuite, PDF aplatis compris (AC8). + - **LOW F-9** — un seul vocabulaire de refus sur la route : variante + `DbError::CreditNoteAccountsArchived`, **même code** `ACCOUNT_ARCHIVED`, clé neuve + `credit-note-account-archived` (« Impossible d'émettre l'avoir — compte(s) archivé(s) : … ») + dans les quatre locales ; manuel et CHANGELOG disent les deux codes de la route (AC6, AC8, AC9). + Choix C-15-6-25, qui révise le « aucune variante, aucune clé neuve » de C-15-6-16. + - **LOW** — R3-4 = F-7 : `map_db_error` est dans `kesh-db/src/errors.rs:796` ; R3-5 = F-6 : ce que + le verrou ne couvre pas (comptes de produit, TVA due) écrit à l'AC6 et à l'inventaire, angle mort + assumé ; R3-7 : lignes d'attribut harmonisées, ordre `ORDER BY id` vs `number` de + `details.rejected[]` dit ; F-8 : phrase « deux avoirs se sérialisent sur la ligne du compte » + retirée (S + S ne sérialise pas ; les réglages sérialisent déjà). + - **Signal D5 — levé et déclaré.** La sévérité remonte (P2 : 0 HIGH, 5 MEDIUM ; P3 : 1 HIGH, + 6 MEDIUM) et le HIGH **naît de la remédiation P2** (C-15-6-16) : c'est la forme « recyclage » de + l'amendement D5, déclarée au Project Lead. Pas de découpage : trois modules de code (`kesh-db`, + `kesh-api`, `kesh-i18n`) et la correction est un déplacement de verrou, tranché par + l'orchestrateur. Une P4 est nécessaire (MEDIUM restants à cette passe) ; une passe **ciblée** sur + l'AC6 et les tests 11-14 est la forme indiquée. + - Trend : P1 (F+R) 2 HIGH, 6 MEDIUM → P2 0 HIGH, 5 MEDIUM → P3 1 HIGH, 6 MEDIUM. + - Décompte après passe : **9 AC, 7 tâches (T0–T6), 14 tests** (12 neufs, 2 modifiés : 9 et 13). +- 2026-10-08 — **Validation P4** (Opus 5.5, lentilles R et F ; prompt `15-6a-validate-prompt-p4.md` ; + remédiation Opus 5.5). R : 0 CRITICAL, 0 HIGH, 2 MEDIUM, 8 LOW ; F : 0 CRITICAL, 0 HIGH, 2 MEDIUM, + 4 LOW (R4-1 ≈ F-2, R4-2 = F-4, R4-6 = F-6, R4-9 = F-5). Tous appliqués, décisions de l'orchestrateur + comprises (choix C-15-6-29) : + - **MEDIUM F-1** — la TVA due et les comptes de produit demandaient leur S **après** l'exercice, face + au X que le solde du reste y prend avant le sien : cycle préexistant que la 15-5d décrit et ferme + pour la validation. Le verrou de l'AC6 couvre désormais **tous** les comptes que l'avoir écrit + (créance, arrondi de la vente, TVA due si l'avoir porte de la TVA, produits effectifs, repli D-B2 + compris), en une requête `ORDER BY id`, avant l'exercice ; la 6 ter lit `active` dans ce verrou + (son code, son message et sa place ne changent pas) ; un compte de TVA due archivé rend + `ACCOUNT_ARCHIVED` nommé (il rendait `INACTIVE_OR_INVALID_ACCOUNTS`) — AC6, AC8, AC9, test 16 neuf. + L'angle mort « autre classe » de l'inventaire **disparaît**. + - **MEDIUM R4-1 ≈ F-2 (+ LOW F-3)** — l'analyse « S + S ne bloque pas » était fausse pour l'arrondi : + réécrite (un S n'ajoute aucun cycle par la créance, il n'en retire aucun) ; trois cycles résiduels + inscrits à l'AC6 et à l'inventaire — avoir ↔ rapprochement de la même facture, **préexistant, tracé + par #536** (ouverte par l'orchestrateur) ; avoir ↔ solde du reste par ordres opposés entre deux + comptes ; cycle à trois par un X en attente (neuf). La route `POST /api/v1/credit-notes` reçoit + `retry_with` sur interblocage, figé par le test 15 neuf, qui exerce le prédicat. Le registre + C-15-6-24 est rectifié par C-15-6-29 (non réécrit). + - **MEDIUM R4-2 = F-4** — Dev Notes : la phrase de `invoices.rs:1921-1922` est citée exactement + (« avant ») ; l'invariant juste est écrit (verrous **explicites** sur `accounts` avant l'exercice, + S de clé étrangère après) ; le commentaire source et celui de `invoice_settlements_write.rs:466-468` + sont à corriger (AC7). + - **LOW** — R4-3 : `code()` → `error_code()` (`errors.rs:736`) ; R4-4 : patron existant + `opening_complement.rs:502` ; R4-5 : doc-comment du test 9 réécrit (le refus précède la séquence) ; + R4-6 = F-6 : ordre des refus complet (`ConfigurationRequired` du produit, `Invariant` des + lecteurs) ; R4-7 : sérialisation par la ligne des réglages nommée ; R4-8 : grep du test 12 avec + `ACCOUNT_ARCHIVED` ; R4-9 = F-5 : ligne absente du verrou → `Invariant` ; R4-10 : lecture de la 6 ter + citée `:450-451` (et non `:445-447`, numéros relus au code). + - Signal D5 : sévérité en baisse (P3 : 1 HIGH, 6 MEDIUM ; P4 : 0 HIGH, 4 MEDIUM dont deux convergents) ; + F-1 est un défaut préexistant, R4-1/F-2 corrige un raisonnement écrit en P3 (recyclage partiel), sans + nouveau module de code (`kesh-db`, `kesh-api`, `kesh-i18n`). Pas de découpage. Une P5 est + nécessaire (MEDIUM à cette passe). + - Trend : P1 (F+R) 2 HIGH, 6 MEDIUM → P2 0 HIGH, 5 MEDIUM → P3 1 HIGH, 6 MEDIUM → P4 0 HIGH, 4 MEDIUM. + - Décompte après passe : **9 AC, 7 tâches (T0–T6), 16 tests** (14 neufs, 2 modifiés : 9 et 13). +- 2026-10-08 — **Validation P5** (Sonnet, lentilles R et F ; prompt `15-6a-validate-prompt-p5.md` ; + remédiation Opus 5.5). R : 0 CRITICAL, 0 HIGH, 2 MEDIUM, 7 LOW ; F : 0 CRITICAL, 0 HIGH, 1 MEDIUM, + 6 LOW (R5-1 = F5-1, R5-4 = F5-2). Tous traités (choix **C-15-6-32**), selon la décision de + l'orchestrateur — **une seule règle d'ordre des verrous pour l'epic, celle de la 15-5e** —, telle + que la 15-5e est **réécrite par C54** pendant cette remédiation (la défense contre l'interblocage + est le **rejeu** ; l'ordre, une convention de fréquence ; correction de consigne de l'orchestrateur + reçue en cours de remédiation, appliquée) : + - **MEDIUM R5-1 = F5-1** — la fiche ignorait la **15-5e** : dépendances « après 15-5a à 15-5e » + (en-tête, T0, Pièges) ; l'AC7 **ne réécrit plus** le doc-comment canonique (`invoices.rs:1921-1922`) + ni le commentaire « 5 bis » (`invoice_settlements_write.rs:466-468`), que la 15-5e réécrit (son + AC5) ; elle ne corrige qu'une phrase qui décrirait l'ancien ordre de l'avoir, s'il y en a une ; la + route `POST /api/v1/credit-notes` est **rejouée par la 15-5e** (son AC3) : la 15-6a ne spécifie + plus son propre `retry_with` (révise C-15-6-29 sur ce point), son test 15 fige que ce rejeu + couvre l'attente neuve de l'avoir. + - **MEDIUM R5-2 (+ LOW F5-3)** — cycles avoir ↔ solde du reste (TVA due ↔ arrondi) et avoir ↔ + règlement par compte interne : **nommés**, non fermés par réordonnancement (C54 : ne pas + réordonner l'avoir pour fermer des cycles) ; le verrou reste une instruction `ORDER BY id`, gardé + pour la seule **course de lecture** ; toute affirmation d'absence de cycle est retirée (« ensemble + clos », « pas un de plus ») ; les cycles connus sont listés comme non exhaustifs et renvoyés au + rejeu. + - **LOW F5-5 (DRY)** — un seul helper de verrou d'une liste de comptes, paramétré par le mode, + partagé avec la 15-5d (`lock_designated_accounts_in_tx`), créé par la première story mergée (a + priori la 15-5d). **LOW F5-4** — « patron existant » → « syntaxe » ; `EXPLAIN` relevé et test + d'identifiant étranger comme la 15-5d (C51), test 17 neuf. + - **LOW R5-4 = F5-2** — « l'avoir émet de la TVA » : agrégation extraite en fonction pure partagée + par le générateur et le calcul des ids ; refus `ConfigurationRequired(TVA)` laissé à sa place + (générateur, après l'exercice), écrit. + - **LOW F5-6** — une ligne pour l'avoir au Pattern 5 de `docs/MULTI-TENANT-SCOPING-PATTERNS.md`, + dans la forme que la 15-5e lui donne (AC9, T5). + - **LOW** — R5-3 : symptôme exact du test 11 sur le code actuel ; R5-5 : « 15-5b AC12 » → AC18 ; + R5-6 : test 14 « comptes de l'avoir » ; R5-7 : garde `active` de `create_in_tx` « hors réactivation + concurrente » ; R5-8 : lest du test 15 repris tel quel du patron ; R5-9 : test unitaire du bras de la + variante neuve (test 18) ; F5-7 : le manuel dit que rétablir le réglage de TVA due est l'autre + issue, et le remplacement inatteignable du refus de la 6 ter par `Invariant` est écrit (sans test). + - Propagation : jetons `15-5a, 15-5b, 15-5c et 15-5d`, `15-5a/b/c/d`, `15-5b AC12`, `comptes de la + vente`, `arrondi d'abord`, `(S, seul)`, `retry_with`, `ensemble clos`, `formes A`, `patron + **existant**`, `1921-1922`, `466-468` grepés sur la fiche — résidus restants légitimes (Change Logs + P2-P4, références au commit `1920381e`, AC7 qui nomme ce que la 15-5e réécrit, références de code). + - **Signal D5** : non levé par la sévérité (P4 0 HIGH, 4 MEDIUM → P5 0 HIGH, 3 MEDIUM, dont deux + convergents). ⚠️ **R5-1 et R5-2 ne viennent pas d'un patch** : ils naissent d'un **changement + extérieur** — la création de la 15-5e (C52) puis sa réécriture (C54), postérieures à la P4 —, non + d'une remédiation de cette fiche ; ce n'est pas un recyclage. Aucun module de code ajouté. Pas de + découpage. + - Trend : P1 (F+R) 2 HIGH, 6 MEDIUM → P2 0 HIGH, 5 MEDIUM → P3 1 HIGH, 6 MEDIUM → P4 0 HIGH, + 4 MEDIUM → P5 0 HIGH, 3 MEDIUM. La remédiation P5 retire le rejeu propre à la route et réécrit la + doctrine des verrous de l'AC6 : une **P6 complète** (Opus, rotation D6) est indiquée. + - Décompte après passe : **9 AC, 7 tâches (T0–T6), 18 tests** (16 neufs, 2 modifiés : 9 et 13). +- 2026-10-08 — **Validation P6** (Opus 5.5, lentilles R et F ; prompt `15-6a-validate-prompt-p6.md` ; + rapports `target/gate-logs/15-6a-p6-R.md`, `15-6a-p6-F.md` ; remédiation Opus 5.5). R : 0 CRITICAL, + 0 HIGH, 3 MEDIUM, 7 LOW ; F : 0 CRITICAL, 0 HIGH, 2 MEDIUM, 5 LOW (F6-1 = R6-1 ; F6-2 = R6-4, MEDIUM + côté F, LOW côté R — retenu MEDIUM ; F6-7 observation). **4 MEDIUM distincts**, tous traités (choix + **C-15-6-35**) : + - **MEDIUM F6-1 = R6-1** — deux contrats pour la fonction de verrou : le paragraphe « Refus nommé » + (`lock_accounts_in_share_mode`, `pub(crate)`, « à côté de » `archived_accounts_in_tx`, « le + contrôle « ligne absente » y vit ») contredisait la puce P5. Retiré ; la puce « Un seul helper » + devient le **seul contrat** : paramétré par le mode, à l'emplacement de la 15-5d, colonnes `id, + number, active, postable`, **ne refuse rien** (« ligne absente » et partage chez l'appelant), + **`pub`** (le test 17 est un test d'intégration), type de ligne **nommé et distinct** de + `opening_complement::LockedAccount` (privé, autres colonnes : ni réutilisé ni homonyme). + - **MEDIUM R6-2** — le critère DRY était faux dès aujourd'hui : `opening_complement.rs:518-537` + verrouille une liste d'ids de comptes. Nommé comme **exception écrite** (forme distincte, hors + périmètre de l'epic) ; toute autre occurrence est un site non résolu. + - **MEDIUM R6-3** — doctrine retirée en P5 restée aux Dev Notes (« d'où la règle de l'AC6 … se + verrouille avant l'exercice ») et en T0 (« vérifier que l'AC6 lui reste conforme ») : réécrits — + verrou avant l'exercice pour la course de lecture, aucune règle de cycle ; T0 **relève** l'ordre de + la 15-5d sans exigence de conformité. + - **MEDIUM F6-2 (= R6-4)** — cycle (iv) : la validation, sérialisée avec l'avoir par la ligne des + réglages (la fiche le dit elle-même), ne peut pas en être le partenaire. Partenaires réels nommés — + saisie manuelle, règlement, solde du reste, rapprochement (flux qui prennent l'exercice sans les + réglages) ; validation et saisie fournisseur explicitement exclues ; table de l'inventaire alignée. + - **LOW** — R6-5 : test 17, issue attendue **rouge** d'après `opening_complement.rs:434-436` (puis + `owned_account_ids`), sauf si la 15-5d l'a adopté (T0), T4 aligné ; R6-6 : `"../kesh-db/test-schema"` + pour les tests de `kesh-api` ; R6-7 : frontière des deux `credit_notes.rs` (dépôt : 15-5d ; route : + 15-5e) ; R6-8 : 6940 « plus loin dans le plan livré, ou créé après lui » ; R6-9 : `retry_with` + retiré des Références, remplacé par `retry.rs:83, :105` ; R6-10 : la 15-5d est une dépendance + **ferme** (en-tête, test 13 : la story attend) ; F6-3 : exemple du 3800 corrigé (le plan des + associations ne désigne aucun escompte d'office) ; F6-4 : « inatteignable par l'application » au + lieu de « par les clés étrangères » (aucune clé étrangère ne porte la société) ; F6-5 : + `invoices_line_revenue_account.rs` (quatre tests de la 6 ter) à la non-régression ; F6-6 : relève + de l'orchestrateur (commentaire sur #536 : l'avoir est couvert par le rejeu de la 15-5e, la 15-6a + ne ferme aucun cycle) — **signalé**, rien dans la fiche. + - **F6-7 — décompte au barème de la règle**, écrit ici et déclaré : modules métier de premier niveau + touchés — `kesh-db/repositories/invoice_settlements` (lecteurs), `…/invoice_settlements_write` + (règlement, solde), `…/credit_notes` (avoir), `…/invoices` (doc-comment seul), + `…/company_invoice_settings` (helper, seulement si la 15-5d l'a posé sans mode), + `kesh-api/routes/reconciliation` — **six**, plus la plomberie `kesh-db/errors`, `kesh-api/errors`, + `kesh-i18n` : **plus de cinq** au barème de la règle (le décompte « trois ou quatre modules de code » + des P2-P5 était par crate). Le critère 1 ne vaut qu'avant la validation ; il est **déclaré**, non + rétroactif. + - Propagation : jetons `lock_accounts_in_share_mode`, `LockedAccount`, `à côté d'elle`, `y vit`, + `règle de l'AC6`, `reste conforme`, `se verrouille avant l'exercice`, `tenu par une validation`, + `validate_invoice` (table des cycles), `vert attendu`, `clés étrangères`, `créé après le plan`, + `Autres produits`, `./test-schema`, `retry_with`, `pas mergée` grepés sur les fiches 15-6 et le + registre — résidus restants légitimes (Change Logs P2-P5 ; « clés étrangères » du lest du test 15, + autre sens ; `LockedAccount` cité pour être écarté). + - **Signal D5 — levé, déclaré au Project Lead, pas de découpage** (C-15-6-35). Sévérité P5 → P6 + MEDIUM → MEDIUM (2 → 4 distincts), et **trois des quatre MEDIUM recyclent la remédiation P5** + (R6-1 = F6-1, R6-2, R6-3 : la refonte doctrinale de l'AC6 laissée incomplète — un paragraphe, un + critère et une note non grepés) ; le quatrième (F6-2) date de la P4. Recyclage **contenu** : tous + portent sur le **texte** de l'AC6 et de ses Dev Notes, aucun sur une règle métier ni un module ; la + remédiation P6 retire des phrases et en unifie d'autres, elle n'ajoute aucun mécanisme. Découper + l'AC6 n'en retirerait pas le défaut (une propagation incomplète) ; le geste qui le traite est le + grep du symptôme, fait ici. Avec le critère de dispersion (F6-7), le signal est déclaré deux fois ; + l'arbitrage revient à Guy. + - Trend : P1 (F+R) 2 HIGH, 6 MEDIUM → P2 0 HIGH, 5 MEDIUM → P3 1 HIGH, 6 MEDIUM → P4 0 HIGH, + 4 MEDIUM → P5 0 HIGH, 3 MEDIUM → **P6 0 HIGH, 5 MEDIUM** (4 distincts). Modèles : P1 Sonnet 4.6, + P2 Opus, P3 Sonnet, P4 Opus, P5 Sonnet, P6 Opus ; remédiations Opus 5.5. La boucle **ne s'arrête + pas** : une **P7** est due (rotation D6 : Sonnet, passe **complète** — la remédiation réécrit le + contrat d'un helper et un cycle, et trois MEDIUM de la P6 venaient d'une remédiation). + - Décompte après passe : **9 AC, 7 tâches (T0–T6), 18 tests** (16 neufs, 2 modifiés : 9 et 13) — + inchangé. + +- **Validation P7** (Sonnet, lentilles R et F, prompt `15-6a-validate-prompt-p7.md`) : 0 CRITICAL, 0 HIGH, + **1 MEDIUM**, 10 LOW (R : 1 MEDIUM + 4 LOW ; F : 6 LOW ; R7-2 = F7-1 : la même phrase). + - **MEDIUM R7-1** — le type de ligne du helper : la fiche (« la 15-6a prend le sien ») et le registre + (C-15-6-35 (1) : « ou celui de la 15-5d ») s'excluaient. **Un seul type, jamais deux** : emploi de celui de + la 15-5d s'il est nommé et `pub`, sinon rendu public (AC6) ; C-15-6-36 révise C-15-6-35 (1). + - **LOW** — R7-2 = F7-1 : phrase d'en-tête réparée ; R7-3 : quatre colonnes (`postable` incluse, ignorée) ; + R7-4 : C-15-6-32 (6) et C-15-6-29 marqués révisés (C-15-6-36) ; R7-5 : cycle (iv), `invoices.rs:2006/:2172` + cités pour la validation seule, T0 relève la saisie fournisseur ; F7-2 : test 17 en appel direct, sans + `UPDATE` des réglages ; F7-3 : lignes de `credit_notes.rs` et `opening_complement.rs` corrigées ; + F7-4 : `keshwarning` du manuel, rôle *Créances clients* à retirer du nouveau compte avant la réactivation ; + F7-5 : « par SQL direct » aux tests 2 et 7 ; F7-6 : note de PR (`closes #523` renvoie au commentaire du + 2026-10-08), consignée en C-15-6-36. + - Propagation : jetons `prend le sien`, `s'il existe`, `première story mergée`, `chaque validation`, + `LockedAccountState`, `518-537`, `523-545` grepés sur les fiches 15-6 et le registre ; résidus restants + légitimes (entrées historiques du registre, Change Logs P2-P6). + - Trend : P1 (F+R) 2 HIGH, 6 MEDIUM → P2 0 HIGH, 5 MEDIUM → P3 1 HIGH, 6 MEDIUM → P4 0 HIGH, 4 MEDIUM → + P5 0 HIGH, 3 MEDIUM → P6 0 HIGH, 5 MEDIUM (4 distincts) → **P7 0 HIGH, 1 MEDIUM**. Modèles : P1 Sonnet 4.6, + P2 Opus, P3 Sonnet, P4 Opus, P5 Sonnet, P6 Opus, P7 Sonnet ; remédiations Opus 5.5. Le MEDIUM de la P7 + recycle le texte de l'AC6 (remédiation P6), sans règle métier ni module. + - **Suite** : la **P8** (dernière passe du plafond de 8) sera une passe **ciblée** sur ce commit, la + remédiation P7 ne touchant que la fiche et le registre (aucun code de production). + - Décompte après passe : **9 AC, 7 tâches (T0–T6), 18 tests** (16 neufs, 2 modifiés : 9 et 13) — inchangé. + +- 2026-10-08 — **Alignement sur le livré (T0)**, sur `origin/main` `9cb5083b` (15-5a, 15-5b, 15-5c, 15-5e1, + 15-5e2, 15-8a, 15-8b mergées ; **15-5d non mergée**, branche lue à `5624ca78`). Relevés au Dev Agent Record. + Écarts (C-15-6a-1) : + 1. **15-5d non mergée — dépendance ferme : la story attend.** Le helper de verrou de liste + (`lock_designated_accounts_in_tx`) et le test de C35 n'existent que sur sa branche. La fiche dit « si l'une + n'est pas mergée au moment de T0, la story attend » (finding R6-10) et C-15-6-35 a écarté la branche « si la + 15-5d n'est pas mergée » : **développement suspendu après le T0**. + 2. **Helper déjà en partagé, sans mode** (C87 de la 15-5d) : le paramètre `AccountLockMode` de l'AC6 n'a plus + d'objet — aucun appelant exclusif. L'AC6 prévoyait d'ajouter le mode « si T0 la trouve sans paramètre » pour + que la 15-5d passe `Exclusive` ; elle passerait `Share` : la 15-6a **emploie le helper tel quel**, sans mode. + Forme, non règle. + 3. **Type de ligne privé** (`LockedDesignatedAccount`, dans le newtype `DesignatedAccountsSnapshot`), helper + `pub(in crate::repositories)` : cas prévu (AC6, C-15-6-36) — la 15-6a les rend **`pub`** sous ces noms (le + test 17 l'appelle de `tests/`), sans second type. + 4. **`owned_account_ids` déjà adopté** (C88 de la 15-5d) : test 17 vert d'emblée (cas prévu par la fiche) ; + plan épinglé `FORCE INDEX (PRIMARY)` — l'`EXPLAIN` de l'AC6 se relève sur ce plan. + 5. **Test 13 ailleurs** : le test de C35 est dans `invoices_validate_vat.rs`, pas dans + `credit_notes_repository.rs` (qui reste à 9 tests) ; il se ré-ancre là. + 6. **Rejeu de la route par la 15-5e2** (et non « la 15-5e », découpée — C61) : constaté, rien à poser. + 7. **Pattern 5 par renvoi** (C68 de la 15-5e2) : la ligne de l'avoir (AC9) prendra la forme des autres flux + comptables — renvoi au doc-comment « Ordre des locks » de `create_credit_note` —, non la séquence recopiée. + 8. **Deux écrivains neufs** (15-8a `update`, 15-8b `delete_in_tx`) : hors classe, et gardés par la pièce — les + lignes de l'écriture de vente restent gelées. + 9. **Numéros de ligne** : ceux de `credit_notes.rs` ont peu bougé (`:362-364` créance, `:365-367` produit, + `:429-440` ids de la 6 ter, `:442-446` filtre `active = TRUE`, `:449-498` refus, `:507-512` appel du + générateur, `:525` arrondi, `:609-611` commentaire « inverse exact ») ; ailleurs : `invoices.rs` doc de + `generate_invoice_journal_lines_rounded` `:1870-1884`, validation `:1971`, créance `:2041-2042`, arrondi + `Issuance` `:2101`, exercice `:2205` ; copies de la requête de créance `invoice_settlements_write.rs:104-115` + et `:445-456`, `reconciliation.rs:1475-1501` ; rapprochement : arrondi `:1537`, exercice `:1570`, + `UPDATE invoices` `:1753` ; `fiscal_years.rs:550-555` ; `errors.rs` (kesh-db) `ArchivedAccount` `:403`, + `ReversalAccountsArchived` `:846-856`, `RoundingContext::Issuance` `:972`, code `:1010`, `map_db_error` + `:1048` ; `kesh-api/src/errors.rs` bras `ReversalAccountsArchived` `:3018-3050`, bras + `CreditNoteRevenueAccountsArchived` `:3254`, son test `:3814` ; `accounts::archive` `:646` ; + `archived_accounts_in_tx` `journal_entries.rs:2380` ; `test_fixtures.rs:560` (délai `:586`) ; + `fr-CH/messages.ftl:304` (`credit-note-revenue-account-archived`), `:289` (`…-issuance`). + **Aucun écart ne change une règle métier ni un AC sur le fond** (2, 3, 4, 5, 7 sont des cas que la fiche + prévoyait ou des formes) ; le seul bloquant est le 1. + +- 2026-10-09 — **T0 repris sur le livré, après le merge de la 15-5d** (`origin/main` `5e4bec50` ; branche + rebasée, registre et `sprint-status.yaml` par union — C-15-6a-2). Le relevé du 2026-10-08, fait sur la + branche de la 15-5d à `5624ca78`, est **confirmé au code mergé** : + `crates/kesh-db/src/repositories/company_invoice_settings.rs:698` `lock_designated_accounts_in_tx(conn, + company_id, ids) -> Result`, `pub(in crate::repositories)`, **partagé + seul** (`LOCK IN SHARE MODE`, aucun appelant exclusif : `invoices.rs:2158`, `supplier_invoices.rs:468`), + patron `owned_account_ids` (lecture non verrouillante `:711-727`), `FORCE INDEX (PRIMARY)` (`:740`), + ids triés et dédoublonnés, ne refuse rien ; type de ligne `LockedDesignatedAccount { id, number, active, + postable }` **privé** (`:595`), dans le newtype `DesignatedAccountsSnapshot` à champ privé (`:606`). Son + doc-comment cite l'avoir parmi « ce qui n'est pas contrôlé ici » (`:684-686`, AC7 : à réécrire). + - **Écart 1 levé** : la 15-5d est mergée, le développement reprend. + - **Écarts 2 à 5, 7, 8 inchangés** : helper employé tel quel, **sans paramètre de mode** (`AccountLockMode` + n'a plus d'objet — un mode à une seule valeur serait du code mort) ; le contrat de l'AC6 (« paramétrée + par le mode », `Share` / `Exclusive`) se lit donc **sans** son premier tiret, le reste du contrat tient + (une requête, `ORDER BY id`, quatre colonnes, ne refuse rien, `pub`, un seul type). Rendus `pub` : la + fonction, `LockedDesignatedAccount` (champs compris) et `DesignatedAccountsSnapshot`, avec un accesseur + `accounts() -> &[LockedDesignatedAccount]` (le newtype garde son champ privé — `check_written` reste la + seule voie de la garde à l'usage). Test 13 : `credit_note_is_exempt_from_the_guard`, + `invoices_validate_vat.rs:1399` ; test 17 attendu vert d'emblée (`owned_account_ids`, cf. + `foreign_account_is_never_locked_and_is_refused`, `:1434`). `credit_notes_repository.rs` : **9** + `#[sqlx::test]`. + - **Manuel tel que la 15-5d le laisse** (lu en entier) : `user-manual.tex:380` (« Deux exceptions, + voulues~: l'avoir relit la créance et la TVA due dans les réglages … sans les contrôler ») et `:928` + (« L'avoir, lui, n'est pas soumis à ce contrôle … même si sa créance ou sa TVA due est devenue non + imputable ») — la moitié « créance » devient fausse (AC8, à corriger). `sec:avoirs` `:1228`, puce + « revient à zéro » `:1234`, `keshwarning` produit archivé `:1251`, archivage `:361` / `:364`, arrondi + `:924`, balance âgée `:1846-1851`. + - **Copies de la requête de créance** : `invoice_settlements_write.rs:104-115` et `:445-456`, + `reconciliation.rs:1475-1501` ; doc `invoices.rs:1892`. La route de l'avoir et le Pattern 5 : inchangés + depuis le 2026-10-08. + **Aucun écart ne change une règle ou un AC sur le fond.** Statut `in-progress`. + +- 2026-10-09 — **Développement** (bmad-dev-story, en autonomie ; Dev Agent Record). T1–T6 faits. **Changements + délibérés d'assertion** : test 9 (`a_credit_note_is_refused_when_the_rounding_account_was_archived` : + `RoundingAccountNotConfigured { Issuance }` → `CreditNoteAccountsArchived` nommant 6940 ; le refus précède la + séquence, l'assertion du premier numéro fige l'ordre) ; test 13 (`credit_note_is_exempt_from_the_guard` → + `credit_note_credits_a_non_postable_sale_receivable`, **change de sens** : il fige que l'avoir crédite la créance de + la vente devenue non imputable, non plus l'exemption C35). 16 tests neufs, 2 modifiés ; 11 rouges d'abord constatés, + 4 mutations rouges. Gates au commit de code `55143ec0` : backend 2895/2895, Vitest 1091/1091, E2E 247 / 7 KF-029. + Choix C-15-6a-2, C-15-6a-3. Statut `review`. + +- 2026-10-09 — **Revue de code P1** (`bmad-code-review`, Sonnet ×3 : lentilles B, E, A — rapports + `target/gate-logs/15-6a-review-p1-{B,E,A}.md`, prompt `15-6a-review-prompt-p1.md`) : **0 CRITICAL, 0 HIGH, + 0 MEDIUM, 14 LOW** (B 4, E 4, A 6 ; B-1 = A-4 et B-4 = E3 convergent). Remédiation **sans aucune ligne de + production exécutable** — tests, doc-comments, manuel, `docs/api-external.md`, fiche (choix C-15-6a-4) : + - **B-1 = A-4** : test 10 renommé `invoice_proposal_with_a_sale_entry_without_debit_line_is_malformed` — il + monte un en-tête **sans ligne de la même société**, non une écriture étrangère ; § *Tests* de la fiche + réaligné (la portée société reste prouvée par le test 4). + - **B-2** : doc-comment de `DbError::ConfigurationRequired` — l'avoir ne lit plus la créance des réglages. + - **A-5** : (a) en-tête du module `credit_notes.rs` — les refus d'archivage (`CreditNoteAccountsArchived`, + `CreditNoteRevenueAccountsArchived`) et la source des comptes de la vente ; (b) § *Tests*, item 13 : + le test vit dans `invoices_validate_vat.rs`. + - **A-6** : doc-comment de `generate_credit_note_journal_lines` — `[0]` crédite le compte que la vente a + débité. + - **E1** : `docs/api-external.md`, section neuve « Émettre un avoir — refus de compte » : `400 + ACCOUNT_ARCHIVED` (créance, arrondi, TVA due des réglages), `CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED`, + `CONFIGURATION_REQUIRED`, et le changement de la v0.13.0. + - **A-2** : `user-manual.tex:364` — la TVA due est **celle des réglages**, et seulement si la facture porte de la + TVA ; propagé à `:1271` (« jusqu'à trois comptes », arrondi « s'il y en a un », TVA « si la facture porte + de la TVA »). PDF régénéré, contrôlé aplati (`pdftotext | tr '\n' ' ' | tr -s ' '`) : les trois fragments + neufs présents. + - **A-1** : test 13 rendu discriminant sur la **source** — les réglages désignent, après la validation, un + autre compte débiteurs (1101, actif et imputable) ; assertion « aucune ligne sur 1101 » ajoutée. **Constaté + rouge** sous la mutation « créance lue sur les réglages » (`left: (6, 108.1000)`, `right: (2, 108.10)`), + vert après restauration (fichier `touch`é). + - **A-3** : test 19 `credit_note_refused_when_the_sale_entry_has_no_debit_line` (`credit_notes_repository.rs`) + — facture repointée en SQL sur un en-tête d'écriture vide : `DbError::Invariant`, rien d'écrit ; **rouge** + sous la même mutation (l'avoir est émis sur le réglage). Le second `Invariant` de l'AC3 (« facture validée + sans écriture de vente ») **ne se monte pas** : `chk_invoices_validated_has_je` l'interdit même en SQL + direct — angle mort écrit au doc-comment du test. + - **Acceptés sans changement** : **B-3** (double dérivation de l'ensemble des comptes de produit, + `credit_notes.rs` `revenue_ids` / `sites`) — un garde serait du code de production : **dette**, écrite au + registre C-15-6a-4 ; **B-4 = E3** (le `details.message` de `DATABASE_ERROR` au rapprochement porte + désormais « Erreur SQLx : … », texte du `DbError`, au lieu du texte brut de `sqlx::Error` ; code inchangé, + aucun test ni client ne lit ce libellé, le rejeu ne le lit pas) ; **E2** (un compte de produit d'une autre + société sur une ligne rend `Invariant` 500 au lieu d'un refus nommé — inatteignable par l'application, + donnée corrompue seulement) ; **E4** (cycle archivage en attente / écriture manuelle / avoir : rejoué par + la route, assumé par la doc « réduit la fréquence, ne les exclut pas »). + Décompte (de `ec089830` à ce commit) : 1 test neuf (19), 1 modifié (13), 1 renommé (10) — 2895 → 2896. + Pas de passe ciblée : la remédiation ne touche aucune ligne de production exécutable (critère de clôture de + la § *La passe ciblée*). +- 2026-10-09 — **Revue de code P1 CLOSE** (Sonnet ×3, 0 au-dessus de LOW, 14 LOW ; remédiation sans code de + production, donc pas de passe ciblée). Rebasée sur `origin/main` `ec745d0c` ; gates sur l'état rebasé au commit de + code `3a7bddcc` : backend 2906/2906, Vitest 1091/1091, E2E 245 / 7 KF-029 + 2 `invoices.spec.ts` (heure UTC, cause + établie, hors branche). Statut `done`. diff --git a/_bmad-output/implementation-artifacts/15-6a-review-prompt-p1.md b/_bmad-output/implementation-artifacts/15-6a-review-prompt-p1.md new file mode 100644 index 00000000..762795a6 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6a-review-prompt-p1.md @@ -0,0 +1,37 @@ +# Prompt — revue de code P1, Story 15-6a + +*Versionné le 2026-10-09. Trois lentilles (Sonnet), contexte frais chacune, en lecture seule (bmad-code-review : +Blind Hunter, Edge Case Hunter, Acceptance Auditor). Rotation D6 : passes complètes Sonnet ↔ Opus ; Haiku en passe ciblée.* + +Worktree `/home/gcorbaz/devel/kesh-15-6a`, branche `story/15-6a-avoir-credite-la-creance`. **Le diff à revoir** : `git -C /home/gcorbaz/devel/kesh-15-6a diff 5e4bec50dc96d69287cdf29224bda5997446f41e..5b2a046a` (un seul diff aplati ; le code, +les tests, les manuels, la doc — les fiches `_bmad-output/` sont le contexte, pas l'objet). **Fiche** : +`_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md` (AC, tâches, Dev Agent Record, Change Log). Issues (par +`gh api repos/guycorbaz/kesh/issues/N`) : #473, #523. Registre : `epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Choix C-15-6-*, C-15-6a-1 à 3. Code touché : crates/kesh-db/src/repositories/{credit_notes.rs,invoice_settlements.rs,...}, lock_designated_accounts_in_tx rendu pub, variante CreditNoteAccountsArchived (ACCOUNT_ARCHIVED, clé i18n 4 locales), rejeu de la route, manuels et PDF, CHANGELOG. Axes : l'avoir lit la créance et l'arrondi sur l'écriture de vente (et non recalcule) ; ordre des verrous (comptes en partagé avant l'exercice) contre les autres écrivains ; les deux assertions de test qui changent de sens (9 et 13) ; le texte d'erreur du rapprochement dérivé d'un DbError ; la fenêtre de réactivation concurrente ; traductions DE/IT/EN ; manuel et PDF aplati. + +## Lentilles + +- **B — Blind Hunter** : le diff seul, sans la fiche. Défauts de correction, régressions, erreurs de concurrence + (verrous, REPEATABLE READ, ordre d'acquisition), erreurs rendues au client, tests qui passeraient à vide (un test qui + ne mord pas sur la mutation qu'il prétend couvrir), code mort, duplication (DRY), doc-comments devenus faux. +- **E — Edge Case Hunter** : chaque branche et chaque borne du code modifié — entrées vides, nulles, multiples, en + doublon, d'une autre société, archivées ; chemins par clé d'API ; lots partiellement en échec ; locales ; et les + **chemins NON modifiés qui devraient l'être** (inventorier les sites non résolus de la même famille par `grep`). +- **A — Acceptance Auditor** : chaque AC de la fiche contre le code ET les tests (un AC sans test qui le prouve est + un finding) ; le Dev Agent Record ne déclare-t-il que ce qui a tourné (chiffres recomptés : `grep -c '#\[sqlx::test\]\|#\[test\]\|#\[tokio::test\]'` aux deux bornes) ; + le **manuel** (`docs/manual/fr/*.tex` **et PDF aplatis** : `pdftotext -nopgbrk f.pdf - | tr '\n' ' ' | tr -s ' '` vers + `target/gate-logs/`, ligatures ff/fi/fl normalisées), `docs/api-external.md`, CHANGELOG, i18n 4 locales. + +## Ce que tu rends + +Rapport complet dans `target/gate-logs/15-6a-review-p1-.md` : findings numérotés, sévérité +(CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (sortie de `grep -nF` pour toute affirmation de présence ou +d'absence ; code cité relu), correction proposée ; ⛔ **axes exercés ET non exercés**. Dernier message : le chemin, le +bilan par sévérité, une ligne par MEDIUM+. + +## Interdits + +⛔ N'écris aucun fichier hors `target/gate-logs/`. Aucune commande qui écrit dans le dépôt ou dans une base, ni +aucune commande qui compile ou exécute des tests : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, +`latexmk`, `git commit`/`add`/`checkout`/`stash`/`apply`, `sqlx`, `cargo`, `npm`, `npx`, +`gh issue create`/`comment`/`edit`, SQL d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, +`gh api` en lecture, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p1.md new file mode 100644 index 00000000..e9529ab5 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p1.md @@ -0,0 +1,34 @@ +# Prompt — validation P1 de la spec, Story 15-6a + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md`. C'est la première passe : la fiche n'a pas encore été remédiée. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 473, 523. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6, sœurs 15-6b et 15-6c. ⚠️ L'issue #523 (l'avoir relit aussi le compte d'arrondi dans les réglages) a été ouverte APRÈS l'écriture de la fiche, qui l'avait laissée en angle mort (C-15-6-2) : l'orchestrateur décide qu'elle entre dans la 15-6a — juge comment la traiter sans casser #486, et signale ce qu'il faut changer. Le dépôt principal (autre branche) contient des stories sœurs 15-5a/b/c qui touchent des fichiers voisins : `/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5*.md` (lecture seulement). +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Auditeur d'acceptation** : applique `.claude/skills/bmad-create-story/checklist.md`. Chaque phrase des issues couverte par un AC testable, chaque AC par une tâche, chaque tâche par un test ; implémentable sans deviner (fonctions, fichiers, codes d'erreur, clés i18n 4 locales, replis Svelte) ; faits cités revérifiés au code (`grep -nF`) ; recompte des AC et tâches. +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +Rapport compact. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p2.md new file mode 100644 index 00000000..a6aa3524 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p2.md @@ -0,0 +1,35 @@ +# Prompt — validation P2 de la spec, Story 15-6a + +*Versionné le 2026-10-08. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md`. La dernière remédiation est le commit `cb54ff72` (`git show cb54ff72`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 473, 523, 525. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d. Les fiches 15-5a/b/c du dépôt principal (`/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5*.md`, lecture seule) touchent des fichiers voisins et seront mergées AVANT 15-6b/c/d : vérifie la frontière. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show cb54ff72`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6a-p2-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p3.md new file mode 100644 index 00000000..253df8d1 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p3.md @@ -0,0 +1,35 @@ +# Prompt — validation P3 de la spec, Story 15-6a + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md`. La dernière remédiation est le commit `4964eeb4` (`git show 4964eeb4`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 473, 523, 525. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d. Les fiches 15-5a/b/c/d du dépôt principal (`/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5*.md`, lecture seule) seront mergées AVANT : vérifie la frontière. Axe : verrou FOR UPDATE sur créance et arrondi, refus ReversalAccountsArchived, test de course. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 4964eeb4`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6a-p3-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p4.md new file mode 100644 index 00000000..5dfb04b8 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p4.md @@ -0,0 +1,35 @@ +# Prompt — validation P4 de la spec, Story 15-6a + +*Versionné le 2026-10-08. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md`. La dernière remédiation est le commit `89e8987a` (`git show 89e8987a`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 473, 523, 525. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d ; les fiches 15-5a/b/c/d (dépôt principal, lecture seule) sont mergées AVANT. Axe principal : l'ordre des verrous de l'avoir (LOCK IN SHARE MODE avant l'exercice) contre TOUS les chemins (validation, règlements, rapprochement, 15-5d), l'ordre des refus, la variante CreditNoteAccountsArchived. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 89e8987a`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6a-p4-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p5.md b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p5.md new file mode 100644 index 00000000..6d2f369e --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p5.md @@ -0,0 +1,35 @@ +# Prompt — validation P5 de la spec, Story 15-6a + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md`. La dernière remédiation est le commit `bc4986d2` (`git show bc4986d2`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 473, 523, 525, 536. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; les fiches 15-5a/b/c/d/e (dépôt principal, lecture seule ; la 15-5a est MERGÉE sur main) passent avant. Axes : verrou partagé étendu à tous les comptes de l'avoir, retry_with sur la route, les trois cycles résiduels écrits, les choix C-15-6-29 (TVA due archivée → ACCOUNT_ARCHIVED nommé ; ligne absente → Invariant) que l'orchestrateur demande de relire. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show bc4986d2`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6a-p5-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p6.md b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p6.md new file mode 100644 index 00000000..16f90d32 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p6.md @@ -0,0 +1,35 @@ +# Prompt — validation P6 de la spec, Story 15-6a + +*Versionné le 2026-10-08. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md`. La dernière remédiation est le commit `e871cd58` (`git show e871cd58`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 473, 523, 525, 536. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Index 15-6 ; dépend des 15-5a à 15-5e (dépôt principal, lecture seule : la 15-5e a été RÉÉCRITE selon C54 — rejeu sur interblocage, b92f3319). 15-5a mergée sur main. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show e871cd58`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6a-p6-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p7.md b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p7.md new file mode 100644 index 00000000..ce212698 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6a-validate-prompt-p7.md @@ -0,0 +1,35 @@ +# Prompt — validation P7 de la spec, Story 15-6a + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6a-avoir-credite-la-creance-de-la-vente.md`. La dernière remédiation est le commit `32290dd3` (`git show 32290dd3`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 473, 523, 525, 536. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Index 15-6 ; dépend des 15-5a à 15-5e (dépôt principal, lecture seule ; 15-5e = rejeu sur interblocage, C54). ⚠️ Passe 7 sur un plafond de 8 : signale clairement si un défaut vient du recyclage du texte de l'AC6 (contrat du helper de verrou). +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 32290dd3`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6a-p7-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md b/_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md new file mode 100644 index 00000000..1c08e661 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md @@ -0,0 +1,1212 @@ +# Story 15.6b : Un règlement ne vise pas le compte qu'il solde + +## Status + +ready-for-dev + + + +**Issue** : `refs #474` (P1) — la fermeture vient avec la 15-6c, qui traite la configuration. +**Mère** : `15-6-creance-juste-avoir-reglement.md`. **Dépend de** : 15-6a (lecteur +`invoice_settlements::sale_receivable_account`, mêmes fichiers). **De préférence après** la 15-5c +(le code neuf apparaît dans `failed[]`, dont la **15-5c** pose les libellés traduits — #492, +`frontend/src/lib/features/reconciliation/failed-proposal-label.ts`). +**Après** les 15-5a à 15-5e (ordre de l'epic ; la **15-5e** passe avant la 15-5d), qui touchent les +mêmes fonctions : `settle_invoice` et `pay_in_tx` (15-5a AC4 : refus `ACCOUNT_NOT_POSTABLE` à +l'étape du compte interne), leurs **routes** et celle du règlement fournisseur et de la confirmation d'un lot (**15-5e** AC3 : +rejouées sur interblocage — aucun ordre de verrou ni de refus n'y change, C55), le commentaire « 5 bis » +de `write_off_invoice` (15-5e AC5, réécrit en place), `accept_one_invoice` (15-5b AC6 : commentaire de +classement), le manuel du rapprochement (15-5c) — **T0** rebase et refait les numéros de ligne (ceux de +cette fiche sont du commit `1920381e`). Cette fiche cite les étapes **par leur nom** (lecture de la +créance, compte interne, compte d'arrondi, trop-perçu…), les numéros d'étape pouvant changer aux +rebases. +**Voisin sorti** : la contrepartie égale au **compte de banque** (#524) est la **15-6d** (choix +C-15-6-9) — hors de cette fiche, **écran du rapprochement manuel compris** (`ManualMatchModal`). + +## Story + +En tant que **comptable**, +je veux que **Kesh refuse un règlement dont la contrepartie est le compte même qu'il doit solder** +(le compte débiteurs de la facture client, le compte créanciers de la facture fournisseur), +afin que **le reste dû ne baisse jamais sans mouvement au grand livre**. + +## Le défaut, établi au code + +- **Règlement client** (`crates/kesh-db/src/repositories/invoice_settlements_write.rs`, + `settle_invoice`) : la créance est lue sur l'écriture de vente (`:100-115`), la contrepartie résolue + selon le mode (`:118-170`) — virement : `bank_accounts.journal_account_id` ; compte interne : tout + compte `active && postable` (`:159-167`). **Aucune comparaison** entre les deux. Choisir 1100 produit + `D 1100 / C 1100` : l'écriture s'équilibre (`kesh-core/src/accounting/balance.rs` n'interdit pas un + même compte au débit et au crédit), le règlement s'enregistre, le reste dû baisse, la facture peut + passer « payée » — et le grand livre ne bouge pas. +- **Le virement aussi** : un compte bancaire lié au 1100 (la route l'accepte, `bank_accounts.rs:288-293` + — type actif ou passif) donne la même écriture nulle. +- **Règlement fournisseur** (`crates/kesh-db/src/repositories/supplier_invoices.rs`, `pay_in_tx`) : + symétrique — dette lue sur l'écriture d'achat (`:580-593`), contrepartie par virement (`:598-621`) + ou compte interne (`:622-656`, `Some((true, true))` `:648`) ; rien n'interdit le 2000 : + `D 2000 / C 2000`, facture « payée » sans paiement. +- **Lots de paiement** (`crates/kesh-db/src/repositories/payment_batches.rs`) : `confirm_batch` + (`:304`) appelle `pay_in_tx` en `BankTransfer` pour chaque facture du lot (appel `:360`), dans une + seule transaction (ouverte `:311`) — troisième appelant de `pay_in_tx`. `create_batch` (`:68`) ne contrôle du + compte bancaire source que « existe, non archivé, journal lié non nul » (`:84-103`) : un compte + bancaire lié au 2000 produit un lot **et son fichier pain.001**, que la seule garde de `pay_in_tx` + rendrait ensuite **inconfirmable** — alors que le fichier est peut-être déjà déposé à la banque. +- **Encaissement par rapprochement** (`crates/kesh-api/src/routes/reconciliation.rs`, + `accept_one_invoice` `:1175`) : compte de banque (`:1384-1412`) et créance (`:1425-1452`) lus, jamais + comparés. +- **Écrans** : `SettleInvoiceDialog.svelte:105` offre tout compte `active && postable`, 1100 compris ; + la fiche fournisseur (`supplier-invoices/[id]/+page.svelte:83-84`) offre de même le 2000, et les + deux offrent tout compte bancaire lié. +- **Comptes d'écart du même geste** (finding F3 de la P2) : le compte d'arrondi du règlement + (`settle_invoice`, compte d'arrondi du règlement, `invoice_settlements_write.rs:189` ; `accept_one_invoice` (c-bis), + `reconciliation.rs:1487`) et les comptes du solde du reste (`write_off_invoice` : nature `:433`, + reste d'arrondi, TVA due `:489`) sont lus dans les **réglages** — l'utilisateur ne les choisit pas + au moment du geste. Les comptes d'arrondi et des natures sont revérifiés **charge ou produit** au + moment d'écrire (`company_invoice_settings.rs:400-447`, `:484-507`, `usable_designated_account`). + ⚠️ Le compte de **TVA due**, lui, ne l'est **par aucun type** : `vat_payable_account_for_write` + (`company_invoice_settings.rs:454-477`) n'exige que « de la société, actif, imputable » + (`FOR UPDATE`) — le type n'est contrôlé qu'à la **désignation** (route des réglages). Pour lui, la + comparaison à la créance (AC3 bis) n'est donc pas un filet du changement de type : c'est la + **seule** garde au moment d'écrire (finding F4 de la P3). + Le contrôle de **type** ne suffit pas non plus pour les autres : le type d'un compte qui porte des écritures **se change** + avec confirmation (`accounts.rs:497-512`, `confirm_retype`), le rôle `Receivable` se retirant dans le + même geste. Le 1100 devenu `Expense` porte toujours la créance des ventes antérieures (lue sur la + vente, 15-6a) et passe le contrôle de type : `write_off_invoice` écrirait alors `D 1100 / C 1100`. + Ces comptes sont donc **comparés par identifiant** à la créance de la vente (AC3 bis, choix + C-15-6-18, qui révise le « résolu par le type » de C-15-6-3). + +**Ce que la story ne résout pas — angles morts écrits** (finding F2 de la P4 ; le « résolu par le +type » des rédactions précédentes était faux : l'argument du retypage confirmé, qui a motivé l'AC3 bis, +le défait aussi, et la créance lue dans les réglages n'a **aucun type contrôlé** à la validation, +`invoices.rs:2008-2010` — présence seulement, puis `active` par `create_in_tx`) : + +- **Validation d'une facture — créance retypée** : le compte débiteurs désigné, retypé avec + confirmation (`accounts.rs:497-512` ; le retypage ne touche pas les réglages) en `Revenue`, reste + `default_receivable_account_id` ; une ligne de facture qui l'impute en produit donne à la validation + `D 1100 / C 1100` (HT) ; de même le compte d'arrondi de la validation, s'il est la créance retypée en + charge. Tracé par **#537** (ouverte par l'orchestrateur). Hors de cette story : la validation + **crée** la créance, elle n'en solde aucune (classe de la story : « un règlement ne vise pas le + compte qu'il solde »), et la garder ici ajouterait un sixième module (`invoices.rs`). +- **Avoir — TVA due et produit de repli** : l'avoir écrit la TVA due et le produit de repli + (`default_revenue_account_id`, lignes sans compte, D-B2) **lus dans les réglages courants** + (`credit_notes.rs:205`, `:511`) ; un 1100 retypé `Liability` puis désigné TVA due donne + `D 1100 (TVA) / C 1100`. La **TVA due** est tracée par **#525** (TVA due de l'avoir lue dans les + réglages du moment — angle mort de la 15-6a), dont c'est un cas ; le **produit de repli** est + tracé lui aussi par **#525**, depuis le commentaire de l'orchestrateur du 2026-10-08 (« Même + famille … compte de produit de repli … À traiter avec la TVA due de cette issue » — posté juste + après la P5, qui l'avait écrit « non tracé » ; finding R6-2 de la P6). La créance de l'avoir est relue sur la + vente (15-6a), ses autres comptes ne le sont pas. Hors de cette story pour la même raison (un septième module, + `credit_notes.rs`). + +## Acceptance Criteria + +1. **AC1 — Une variante dédiée.** `crates/kesh-db/src/errors.rs` gagne + `DbError::SettlementCounterpartyIsClaimAccount { account_id: i64, account_number: Option, claim: ClaimSide, role: SettlementAccountRole, batch: Option }`, + l'énumération `ClaimSide { Receivable, Payable }` (`as_str()` → `"receivable"` / `"payable"`), + l'énumération **`SettlementAccountRole`** — d'où vient le compte en cause, donc quel est le remède + (finding R1 = F1 de la P3, choix C-15-6-26) : + - `Counterparty` (`"counterparty"`) — la contrepartie que l'utilisateur **choisit** (compte interne, + compte bancaire du virement) ou que porte le **compte bancaire** du geste (lot, rapprochement) ; + remède : un autre compte, ou relier le compte bancaire à son propre compte de banque ; + - `Rounding` (`"rounding"`) — le compte de différences d'arrondi des **réglages** + (`default_rounding_account_id`), **quel que soit le geste** : règlement (compte d'arrondi du + règlement), rapprochement (c-bis), et au solde du reste le compte du reste d'arrondi comme le + compte de la nature + `rounding` — `write_off_account_for_write(.., Rounding)` lit la **même colonne** + (`company_invoice_settings.rs:438-441`) que `rounding_account_for_write` (`:408`) (findings R1 = F1 + de la P4, choix C-15-6-30) ; + - `WriteOffNature` (`"write_off_nature"`) — le compte d'une nature de solde des **réglages**, pour + les natures `discount`, `bank_fees` et `bad_debt` seulement (les trois champs de la section + *Solde du reste* des réglages, `settings/invoicing/+page.svelte:29`) ; + - `VatPayable` (`"vat_payable"`) — le compte de TVA due des **réglages** (solde du reste) ; + pour les trois derniers, le remède est de désigner un autre compte dans *Paramètres → Facturation*. + `claim: Payable` ne se combine qu'avec `Counterparty` (le règlement fournisseur n'écrit aucun + compte d'écart) — et ce n'est pas un doc-comment qui le tient (finding F5 de la P5) : le helper de + construction ne prend **pas** la paire `(claim, role)` mais un **sujet** qui rend la combinaison + `Payable` + rôle désigné irreprésentable, p. ex. + `ClaimSubject::{ Counterparty(ClaimSide), Designated(DesignatedRole) }` avec + `DesignatedRole::{ Rounding, WriteOffNature, VatPayable }` (un compte désigné est toujours comparé à + une **créance** : `claim = Receivable` s'en déduit) — `ClaimSubject` et `DesignatedRole` vivent + **tous deux** dans `invoice_settlements.rs`, à côté du helper qui les consomme (finding R6-8 de la + P6 : `DesignatedRole` n'avait ni fichier ni place au décompte) ; `SettlementAccountRole`, dont + `DesignatedRole` est un sous-ensemble, reste dans `errors.rs` avec la variante ; la variante garde ses deux champs, remplis + par le helper. Le doc-comment dit aussi que **le rôle suit la colonne de réglage lue, pas la + route** — un même compte désigné sort sous le même `details.role` partout. Et + `SettlementBatchContext { payment_batch_id: i64, supplier_invoice_id: i64, supplier_invoice_number: Option }` + (signatures indicatives). `batch` vaut `None` partout sauf à la **confirmation d'un lot** (AC6), où + `confirm_batch` le remplit en interceptant le refus de `pay_in_tx`. `error_code()` → + `"SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT"` — **un** code pour les quatre rôles (le défaut est le + même : une écriture `D X / C X`) ; `details.role` et le message distinguent le remède. Doc-comment : + le motif (écriture nulle, reste dû qui baisse sans mouvement), #474. + **Helper commun** (finding F3 de la P3, DRY ; choix C-15-6-26) — dans + `crates/kesh-db/src/repositories/invoice_settlements.rs`, à côté du lecteur de la 15-6a, la + **comparaison** est séparée de la **construction du refus** (signatures indicatives) : + - `pub fn ensure_not_claim_account(account_id: i64, claim_account_id: i64) -> Result<(), ClaimAccountClash>` + — **pure**, sans I/O ni transaction ; `ClaimAccountClash { account_id: i64 }` à l'égalité. C'est + la seule partie que la **15-6d** emprunte (sa garde compare la contrepartie au compte de banque ; + son refus reste le `VALIDATION_ERROR` du flux ventilé, C-15-6-28) ; + - `pub async fn claim_account_refusal(conn: &mut sqlx::MySqlConnection, company_id: i64, clash: ClaimAccountClash, subject: ClaimSubject) -> DbError` + — appelée **à l'échec seulement** : lit `accounts.number` (`SELECT number FROM accounts WHERE id = ? AND company_id = ?`, + dans la transaction de l'appelant) et rend la variante, `batch: None` ; une erreur SQL de cette + lecture est rendue telle quelle (`DbError` de `map_db_error`) ; + - `pub fn claim_account_refusal_details(account_id: i64, account_number: Option<&str>, claim: ClaimSide, role: SettlementAccountRole, bank_account_id: Option) -> serde_json::Value` + (`kesh-db` dépend déjà de `serde_json`) — construit les `details` pour les **trois** + consommateurs : le mapping HTTP (AC2), le `FailedProposal` du rapprochement (AC5) et le + `PaymentBatchFailedItem` de la création de lot (AC6), pour que leurs clés ne divergent pas. Les + clés **de lot** (`paymentBatchId`, `supplierInvoiceId`, AC2 et AC6) sont **hors** de ce helper : + le **mapping HTTP** les ajoute, et lui seul, quand la variante porte `batch: Some` — le seul + consommateur qui en ait (finding F6-5 de la P6). + Tous les sites des AC3, AC3 bis, AC4, AC5 et AC6 appellent `ensure_not_claim_account`, puis, sur + `Err(clash)` seulement, `claim_account_refusal` ; la lecture du numéro **à + l'échec seulement** remplace les « lectures neuves » écrites site par site. +2. **AC2 — Sa réponse HTTP.** `crates/kesh-api/src/errors.rs` : **400**, corps + `{ "error": { "code": "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT", "message": …, "details": { "accountId": …, "accountNumber": …, "claim": "receivable" | "payable", "role": "counterparty" | "rounding" | "write_off_nature" | "vat_payable" } } }` + (`details` construits par `claim_account_refusal_details`, AC1). Le patron de + `CREDIT_NOTE_INVOICE_SETTLED` (`crates/kesh-api/src/errors.rs:2838-2845`) ne vaut **que pour la + forme de `details`** : lui est un **409** et appelle `t(` ; ce refus-ci est un **400** et emploie + `t_args` (`errors.rs:52`, privé au module, utilisable). Avec `batch`, `details` gagne + `paymentBatchId` et `supplierInvoiceId`. Message par `t_args`, variable `$account` (le numéro ; + repli `#` s'il manque), **six** clés **plates** — la clé suit le **rôle**, pas seulement le + côté : + - `error-settlement-counterparty-is-receivable` — fr : « Le compte { $account } est le compte + débiteurs de cette facture : un règlement doit faire sortir la créance vers un autre compte + (banque, caisse, compensation…). » + - `error-settlement-counterparty-is-payable` — fr : « Le compte { $account } est le compte + créanciers de cette facture : un règlement doit éteindre la dette par un autre compte (banque, + caisse, compensation…). » + - `error-settlement-counterparty-is-payable-in-batch` (avec `batch`, variable `$invoice` = numéro + de la facture fournisseur, repli `#`) — fr : « Le compte bancaire de ce lot est lié au compte + { $account }, le compte créanciers de la facture { $invoice } : le lot ne peut pas être confirmé. + Reliez le compte bancaire à son propre compte de banque puis confirmez de nouveau, ou annulez le + lot et réglez la facture depuis sa fiche. » — le remède de la confirmation n'est pas celui du + règlement unitaire : l'utilisateur n'y choisit aucun compte (finding F4 de la P2). + - **trois clés plates pour un compte désigné dans les réglages**, une par rôle — **aucun + sélecteur Fluent** (finding R5-1 de la P5, choix C-15-6-33 ; même choix que la 15-5c, son AC4) : + un sélecteur neuf rougirait `no_new_select_expression_reaches_the_frontend_dictionary` + (`crates/kesh-i18n/src/loader.rs:482`, liste `SELECTEURS_RESOLUS_COTE_SERVEUR`, `:367`), et le + dictionnaire du frontend le résoudrait sans argument, sur sa variante par défaut. Textes fr : + - `error-settlement-rounding-account-is-receivable` (rôle `rounding`) — « Le compte + { $account }, désigné dans les réglages comme compte de différences d'arrondi, est le compte + débiteurs de cette facture : un administrateur doit désigner un autre compte dans + Paramètres → Facturation. » + - `error-settlement-write-off-account-is-receivable` (rôle `write_off_nature`) — « Le compte + { $account }, désigné dans les réglages comme compte de cette nature de solde, est le compte + débiteurs de cette facture : un administrateur doit désigner un autre compte dans + Paramètres → Facturation. » + - `error-settlement-vat-payable-account-is-receivable` (rôle `vat_payable`) — « Le compte + { $account }, désigné dans les réglages comme compte de TVA due, est le compte débiteurs de + cette facture : un administrateur doit désigner un autre compte dans Paramètres → + Facturation. » + « Un administrateur doit » et non « désignez » (finding F4 de la P4) : + le solde du reste et le règlement sont ouverts au Comptable, les réglages à l'Administrateur seul + (`crates/kesh-api/src/lib.rs:207-210`) — c'est la formulation que C36 a retenue pour la 15-5d, + vraie aussi pour un client d'API. L'utilisateur n'a choisi **aucun** compte dans le dialogue de + règlement ou de solde : le renvoyer vers « un autre compte (banque, caisse…) » serait un remède + faux (findings R1 = F1 de la P3). **Repli** : chaque clé a son `default` de `t_args` + (`crates/kesh-api/src/errors.rs:52`), la phrase fr complète — plus de `match` **de sélection** + à construire en Rust (il n'existait que pour le sélecteur). ⚠️ `t_args` rend ce `default` + **tel quel**, sans les `FluentArgs` (`errors.rs:58`, `None => default.to_string()`) : il est + donc **construit par `format!`** avec le numéro (et `$invoice` pour la clé de lot), patron + `errors.rs:88-92` (`let fallback = format!("Ligne {n}"); … t_args(…, &fallback, &args)`) — un + `default` littéral « Le compte { $account } … » laisserait fuir le gabarit (finding R6-5 de la P6). + Choix de la clé côté serveur, **un rôle, une clé** : `role = Counterparty` → `-is-receivable` / + `-is-payable` (et `-is-payable-in-batch` avec `batch`) ; `Rounding` → `-rounding-account-is-receivable` ; + `WriteOffNature` → `-write-off-account-is-receivable` ; `VatPayable` → + `-vat-payable-account-is-receivable`. + Dans les **quatre** locales (`crates/kesh-i18n/locales/{fr-CH,de-CH,it-CH,en-CH}/messages.ftl`), + placées près de `error-credit-note-blocked-settled` ; `npm run lint-i18n-ownership` vert. +3. **AC3 — Règlement client, deux modes.** Dans `settle_invoice`, **aussitôt après** la résolution + de la contrepartie (compte bancaire du virement, ou compte interne) et donc **après** ses refus + existants — 404 compte bancaire, configuration absente, `INACTIVE_OR_INVALID_ACCOUNTS`, et + `ACCOUNT_NOT_POSTABLE` que la **15-5a** (son AC4, mergée avant) y insère pour le compte interne —, + et **avant** le refus du trop-perçu : + si `counterparty_account_id == receivable_account_id` → `SettlementCounterpartyIsClaimAccount { claim: Receivable, role: Counterparty, batch: None, … }`, + par `ensure_not_claim_account` puis `claim_account_refusal` (AC1). La garde compare l'identifiant que **le lecteur de la 15-6a** + a déjà rendu (choix C-15-6-11). Numéro : lu par le helper **à l'échec seulement**, dans les deux + modes (le `number` que la 15-5a ajoute au `SELECT` du compte interne n'est pas réutilisé : un seul + chemin vaut mieux que l'économie d'une lecture sur un refus). Rien n'est écrit : ni écriture, + ni ligne `invoice_settlements`, ni `paid_at`, ni `version`, ni audit (la transaction est + abandonnée). Ordre écrit dans le doc-comment de la fonction. + **AC3 bis — Les comptes d'écart ne sont pas la créance** (choix C-15-6-18). Le même refus, par la + même comparaison d'identifiants avec la créance de la vente (lecteur de la 15-6a), garde les comptes + que le geste écrit **en face** de la créance. **Règle de placement, unique** (findings F1 et F6 de + la P5, choix C-15-6-33 ; précisée par R6-4 = F6-6 de la P6) : **chaque comparaison suit la + lecture de son compte et celle de la créance** — aussitôt la plus tardive des deux —, et donc les + refus de configuration ou d'état de ce compte ; l'ordre des refus est celui des lectures du code — que la + 15-5e, réécrite par C54, **ne change pas** (C55 : ni l'arrondi avant le compte interne, ni avant la + nature) ; T0 le relève : + - `settle_invoice`, **compte d'arrondi du règlement** (rendu par + `rounding_account_for_write(.., Payment)`, `invoice_settlements_write.rs:189` au commit + `1920381e`) — `role: Rounding`. Ordre des refus, dans les deux modes : **contrepartie** (compte + bancaire ou compte interne : leurs refus, puis la comparaison de l'AC3, `role: counterparty`) → + **trop-perçu** → **compte d'arrondi** (`ROUNDING_ACCOUNT_NOT_CONFIGURED`, puis cette comparaison, + `role: rounding` ; trop-perçu et écart d'arrondi sont exclusifs) ; + - `write_off_invoice` — ordre des lectures **nature → reste d'arrondi → TVA due** (`:430-490` au + commit `1920381e`, inchangé par la 15-5e) : le compte de la **nature** — `role: WriteOffNature` + pour `discount`, `bank_fees`, `bad_debt`, `role: Rounding` pour la nature `rounding` (il **est** + alors le compte d'arrondi, un seul compte, une seule comparaison ; le reste d'arrondi n'est pas lu + séparément, `:471-472`) — ; le compte du **reste d'arrondi** (lu quand le reste n'est pas au + centime) — `role: Rounding` (même colonne que le compte d'arrondi du règlement, AC1) — ; le compte + de **TVA due** — `role: VatPayable`. La **nature** est lue **avant** la créance + (`write_off_account_for_write` `:433`, puis la créance `:434-445`) : elle est comparée aussitôt + la **créance** lue ; le reste d'arrondi et la TVA due, aussitôt leur propre lecture. Aucun refus + métier ne sépare la nature de la créance (seul l'`Invariant` d'une vente malformée) : l'ordre des + refus n'en change pas — et la lecture de la créance **ne s'avance pas** (C55 : aucun + réordonnancement). + **Ordre des refus quand plusieurs coïncident avec la créance** (finding F7 de la P4) : celui des + lectures — **nature, puis reste d'arrondi, puis TVA due** ; le premier égal est nommé (un seul + `account_id` / `role` par refus), ce que le doc-comment écrit, pour que le message et + `details.role` soient déterministes. Et vis-à-vis des refus de configuration (finding F6 de la + P5) : une **nature** égale à la créance est refusée **avant** la lecture du reste d'arrondi et de + la TVA due, donc avant leurs `WRITE_OFF_ACCOUNT_NOT_CONFIGURED` / `CONFIGURATION_REQUIRED`. Le + test 10 ter le fige. + - `accept_one_invoice`, étape (c-bis) : le compte d'arrondi (`reconciliation.rs:1487`) → + `FailedProposal` comme l'AC5, `details = { "accountId", "accountNumber", "claim": "receivable", "role": "rounding" }` + — **sans** `bankAccountId`, qui n'a pas de sens pour un compte d'arrondi (finding F1 de la P3). + `account_id` du refus = le compte d'écart fautif (qui **est** la créance) ; message : la clé plate + du rôle (AC2 — `-rounding-account-`, `-write-off-account-`, `-vat-payable-account-is-receivable`), + qui renvoie à *Paramètres → Facturation*. Ferme le chemin du changement de type confirmé (`accounts.rs:497-512`) ; « résolu par + le type » ne vaut plus pour ces comptes. Le contrôle de type existant reste (il garde ce qu'il + gardait) — et pour la TVA due, il n'y en a pas au moment d'écrire (§ *Le défaut*) : la comparaison + est sa seule garde. +4. **AC4 — Règlement fournisseur, deux modes.** Même garde dans `pay_in_tx`, après l'étape (3) et ses + refus existants (dont `ACCOUNT_NOT_POSTABLE` de la 15-5a — absent du code de `1920381e`, sa place + se relit en T0 ; le bloc du compte interne est aujourd'hui `supplier_invoices.rs:622-656`) : + `counterparty_account_id == payable_account_id` → `claim: Payable, role: Counterparty`, par + `ensure_not_claim_account` puis `claim_account_refusal` (AC1). Mêmes garanties « rien + d'écrit ». Le commentaire « jumeau exact » de `:623-641` mentionne la nouvelle garde. **DRY** + (choix C-15-6-11) : la requête de l'étape (2) (`:582-593`, ligne de **crédit** de l'écriture + d'achat, **et** le TTC) n'est pas celle du lecteur de la 15-6a (ligne de débit, sans montant) ; elle + est extraite en lecteur sœur + `supplier_invoices::purchase_payable_line(conn, company_id, purchase_entry_id) -> Result, DbError>` + (signature indicative), appelé par `pay_in_tx` et par la création de lot (AC6) — deux appelants, + une requête. +5. **AC5 — Rapprochement d'une facture.** Dans `accept_one_invoice`, après la lecture de la créance + (étape (b), par le lecteur de la 15-6a) et **avant** le contrôle du trop-perçu (étape (c)) : + `bank_ledger_account_id == receivable_account_id` → + `FailedProposal { error_code: "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT", details: { "bankAccountId", "accountId", "accountNumber", "claim": "receivable", "role": "counterparty" } }` + (refus rendu par `ensure_not_claim_account` / `claim_account_refusal`, converti par une seule fonction de + `reconciliation.rs` qui appelle `claim_account_refusal_details`, AC1, pour cette étape et (c-bis)) + — **jamais** d'`AppError` global (§ *Pattern batch* du `CLAUDE.md`) ; HTTP 200, la proposition dans + `failed[]`, les autres du lot traitées normalement. Les refus antérieurs à l'étape de la créance + gardent **tous** la priorité (`RECONCILIATION_SCORE_TOO_LOW` de l'étape 7bis compris), notamment : + `BANK_ACCOUNT_NOT_CONFIGURED` (compte bancaire sans journal), `INVOICE_SALE_ENTRY_MALFORMED` + (étape (b)) ; le trop-perçu (`RECONCILIATION_OVERPAYMENT`) vient **après**. Erreur SQL de la + lecture du numéro → `DATABASE_ERROR` comme les étapes voisines. Le commentaire de classement que la + **15-5b** (son AC6) pose dans cette fonction — « reste tel quel … ses comptes ne viennent pas du + client » — est **amendé** : le compte de banque vient de la configuration et peut être la créance ; + il est désormais gardé ici (sinon le commentaire deviendrait trompeur). +6. **AC6 — Lots de paiement : refus à la création, garde à la confirmation** (choix C-15-6-10, + précisé par C-15-6-19). + - **Création** (`create_batch` → `validate_invoice_for_batch`, `payment_batches.rs:226-300`) : pour + chaque facture, la dette lue sur l'écriture d'achat (lecteur sœur de l'AC4) est comparée au + `journal_account_id` du compte bancaire source ; égalité → la facture va dans `failed[]` du lot + (`PaymentBatchFailedItem`), `error_code = "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT"`, + `details = { "bankAccountId", "accountId", "accountNumber", "claim": "payable", "role": "counterparty" }` + (par `claim_account_refusal_details`, AC1) — patron batch, pas d'erreur globale. **Source de + `accountNumber`** (finding R7 de la P3) : `create_batch` ne lit du compte bancaire que + `journal_account_id, archived` (`payment_batches.rs:83-90`) ; le numéro est lu par + `claim_account_refusal` **à l'échec seulement** (une `SELECT number FROM accounts` dans la + transaction du lot), puis le refus est converti en item. Une **erreur SQL** de cette lecture + (finding R7 de la P4) n'est pas un refus de la facture : elle est **propagée**, échec infra de + toute la création, comme les autres `DbError` de `validate_invoice_for_batch` + (`payment_batches.rs:229`) — seul le refus métier devient un item. **Signature** : + `validate_invoice_for_batch` ne lit aujourd'hui ni `purchase_journal_entry_id` (son `SELECT`, + `:251-253`) ni le compte lié du compte bancaire (`create_batch` le lit `:84-103` sans le + transmettre, appel `:114`) : elle gagne la colonne et un paramètre `bank_ledger_account_id: i64`. **Écriture d'achat sans ligne de crédit** + (`purchase_payable_line` → `None` ; `pay_in_tx` en fait un `Invariant`, 500, + `supplier_invoices.rs:593`) : à la création, c'est une donnée de **cette** facture — item + `failed[]` `SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED`, + `details = { "reason": "no_credit_line_on_purchase_entry", "purchaseEntryId" }`, patron de + `INVOICE_SALE_ENTRY_MALFORMED` du rapprochement (§ *Pattern batch* du `CLAUDE.md` : une erreur qui + dépend de la proposition individuelle ne devient pas une erreur globale). Ces contrôles viennent + **après** les refus existants de la facture (`SUPPLIER_INVOICE_NOT_FOUND`, `…_NOT_OPEN`, + `NO_PAYMENT_COORDINATES`, `ALREADY_IN_GENERATED_BATCH`, IBAN). Si **aucune** facture n'est + retenue, aucun lot n'est créé et **aucun fichier pain.001 n'est produit** (comportement existant + `accepted.is_empty()`, `:120`). Le `fn fail` local (`:235-241`, `details: None`) reçoit une + variante avec `details`, ou le contrôle construit l'item lui-même. **Ce refus à la création est la + défense principale.** + - **Confirmation** (`confirm_batch`, `:304`, boucle `pay_in_tx` `:359-370`) : la garde de + `pay_in_tx` (AC4) **reste** — le compte bancaire a pu être relié entre la création et la + confirmation. `confirm_batch` intercepte ce refus et le rend **avec son contexte** (`batch: + Some(SettlementBatchContext { payment_batch_id, supplier_invoice_id, supplier_invoice_number })`). + **Source de `supplier_invoice_number`** (finding F5 de la P3) : `pay_in_tx` le lit + (`inv.supplier_invoice_number`) mais ne le rend pas, et l'item de lot n'a que + `supplier_invoice_id` (`ITEM_COLS`, `payment_batches.rs:47-48`). Tranché : `confirm_batch`, en + interceptant le refus, fait **une** `SELECT supplier_invoice_number FROM supplier_invoices WHERE id = ? AND company_id = ?` + dans la même transaction (permise : le refus est une erreur métier, aucune erreur SQL n'a rendu + la transaction inutilisable) — **pas** de champ de plus dans la variante, qui resterait vide + partout ailleurs. Effet : + la confirmation entière échoue en **400** `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT` + (transaction atomique), `details` portant `paymentBatchId` et `supplierInvoiceId`, message + `error-settlement-counterparty-is-payable-in-batch` (AC2) qui dit les **deux issues** : relier le + compte bancaire à son propre compte de banque puis confirmer de nouveau, ou annuler le lot + (`cancel_batch`, `:431` — les factures redeviennent réglables à l'unité) et régler la facture + depuis sa fiche. Le lot reste `generated`, aucun règlement écrit. Le doc-comment de `confirm_batch` + dit les deux contrôles, pourquoi le second subsiste, et les deux issues. + - **Écran** : `failedItemLabel` (`frontend/src/lib/features/payment-batches/payment-batch-helpers.ts:78`) + reçoit les **deux** codes neufs, clés `payment-batches-failed-counterparty-is-claim-account` et + `payment-batches-failed-purchase-entry-malformed` **écrites en toutes lettres** (règle de la 23-3 + rappelée dans le JSDoc), quatre locales. Textes **fr** de référence (finding F6 de la P3) : + `payment-batches-failed-counterparty-is-claim-account` — « Le compte bancaire du lot est lié au + compte créanciers de cette facture » ; `payment-batches-failed-purchase-entry-malformed` — + « Écriture d'achat sans ligne de crédit : facture à vérifier ». Le libellé n'affiche pas le + numéro du compte (le `failed[]` du lot ne montre que `#id — libellé`, + `payment-batches/+page.svelte:200`) : le compte en cause est celui du compte bancaire choisi à + l'écran, il n'y a rien d'autre à nommer. **Les « six » deviennent « huit » aux QUATRE sites** + (finding R4 de la P3, jeton grepé) : `payment-batch-helpers.ts:59` (« Les six libellés sont + RELEVÉS, pas inventés » — à amender : les deux neufs ne sont **pas** relevés du glossaire, ils sont + écrits par cette story, et le JSDoc le dit), `payment-batch-helpers.ts:73` (« Les six clés »), + `payment-batch-helpers.test.ts:59` (commentaire « Les six codes sont désormais assertés ») et + `:61` (`it('mappe les six codes …')`). Le refus de confirmation + s'affiche par son message traduit dans la fiche du lot + (`frontend/src/routes/(app)/payment-batches/[id]/+page.svelte:40-50`, `confirmBatch` : + `catch (err) { if (isApiError(err)) actionError = err.message; }`, `:45`) — **vérifié à la P2, + aucun changement**. + - **Écran de création d'un lot** (`frontend/src/routes/(app)/payment-batches/+page.svelte:53`, filtre + `journalAccountId !== null`) : **non filtré, choix écrit** (C-15-6-19) — il ne charge pas le plan + comptable, et le refus y est lisible par facture (`failed[]` libellé) ; voir § *Angles morts*. +7. **AC7 — Libellé dans le `failed[]` du rapprochement.** Si la **15-5c** est mergée (module + `frontend/src/lib/features/reconciliation/failed-proposal-label.ts`, #492 — vérifier par + `test -f` sur ce chemin), le code neuf y reçoit son libellé, dans les quatre locales, **à deux + cas selon `details.role`** (finding F1 de la P3) : `counterparty` — clé + `reconciliation-failed-counterparty-is-claim-account`, fr « Le compte bancaire est lié au compte + débiteurs de la facture : reliez-le à son propre compte de banque » ; `rounding` — clé + `reconciliation-failed-rounding-account-is-claim-account`, fr « Le compte d'arrondi désigné est le + compte débiteurs de la facture : un administrateur doit en désigner un autre dans Paramètres → + Facturation » (C36, finding F4 de la P4 ; préfixes à + ajuster à la convention que la 15-5c aura posée, sans changer le sens). Le test de la liste en + dur du module (`failed-proposal-label.test.ts`) inclut les deux cas, et ses deux sites neufs entrent + dans le relevé de la **borne exacte** `sitesTotal` (AC8, dernier point). Sinon : le signaler dans le + Dev Agent Record et à l'orchestrateur, pour que la 15-5c l'ajoute à sa liste — **ne pas** poser un + second mécanisme. +8. **AC8 — Les écrans ne proposent plus le compte soldé** (choix C-15-6-4, signature fixée par + C-15-6-14, archivés tranchés par C-15-6-17). Trois fonctions pures dans + `frontend/src/lib/features/accounts/account-options.ts`, partagées avec la 15-6c : + - `accountIdsWithRole(accounts, role)` → `Set` des comptes portant ce rôle ; + - `withoutAccountIds(accounts, ids)` → les comptes dont l'id n'est pas dans `ids` ; + - `bankAccountsNotLinkedTo(bankAccounts, ids)` → les comptes bancaires dont `journalAccountId` + n'est pas dans `ids`. + JSDoc : pourquoi le rôle ici (singleton par société, réglages dérivés), que la garde serveur, + exacte, reste l'autorité, et que **les ids se calculent sur la liste que l'écran reçoit, avant le + filtre `active && postable`** — sans quoi un compte bancaire lié à un 1100 devenu **non imputable** + ne serait pas écarté. ⚠️ **Les comptes archivés n'y sont pas, et c'est voulu** (C-15-6-17) : les + trois écrans chargent le plan **sans** les archivés (`invoices/[id]/+page.svelte:554` et + `invoices/due-dates/+page.svelte:102` : `fetchAccounts(false)` ; `supplier-invoices/[id]/+page.svelte:80` : + `fetchAccounts()`, défaut `false`). Un compte bancaire lié à un compte **archivé** reste donc + proposé — mais il ne peut produire **aucune** écriture : la garde `active` de `create_in_tx` + refuse tout compte archivé, et une créance archivée est refusée de même. Le serveur suffit ; le + JSDoc le dit. + - `SettleInvoiceDialog.svelte` : `selectableAccounts` (`:105`) et la liste des comptes bancaires + (`:208`) passent par ces fonctions avec les ids du rôle `Receivable` calculés sur la prop + `accounts` ; le compte bancaire **présélectionné** (`:89`, primaire ou premier) est choisi **dans + la liste filtrée**. ⚠️ Le `$effect` de réinitialisation (`:82-91`) lit aujourd'hui `open`, + `amountDue` et `bankAccounts` ; choisir la présélection dans la liste filtrée le ferait dépendre + aussi de `accounts`, et une liste `accounts` arrivée **après** l'ouverture **effacerait la saisie + en cours** (date, montant, mode). La présélection vit donc dans un **effet distinct**, qui ne + touche ni la date ni le montant ni le mode, et ne remplace `bankAccountId` que si la valeur + courante est **absente de la liste filtrée** (`null` compris) ; l'effet de réinitialisation garde + ses seules dépendances (finding F6-3 de la P6 ; test 18). Si la liste filtrée est **vide** **et** qu'au moins un compte bancaire a été + **écarté par le filtre** (`bankAccounts.length > filtered.length` — le seul compte bancaire est lié + au compte débiteurs ; finding F3 de la P4), le dialogue l'**écrit** — clé + `invoices-settle-no-eligible-bank-account`, fr « Aucun + compte bancaire utilisable : le seul compte lié est le compte débiteurs de cette facture. Reliez + un compte bancaire à son propre compte de banque, ou réglez par un compte interne. » (premier + segment `invoices` : conforme à `lint-i18n-ownership` pour un fichier de + `src/lib/features/invoices/`, sans entrée neuve dans `KNOWN_VIOLATIONS`), quatre locales — au + lieu d'un menu vide silencieux. Une société **sans aucun** compte bancaire (installation neuve) + a aussi une liste filtrée vide, mais rien n'a été écarté : le message, qui accuse le compte + débiteurs, y serait faux — le dialogue garde alors son comportement actuel (aucun état vide + n'existe aujourd'hui, `grep -n "length === 0" SettleInvoiceDialog.svelte` ne rend rien). + - `supplier-invoices/[id]/+page.svelte:83-84` : idem avec `Payable` ; les ids se calculent sur + `accts` **avant** le filtre `accts.filter((a) => a.active && a.postable)` de la ligne 84 ; liste + vide **et** au moins un compte bancaire écarté par le filtre des comptes créanciers (la page filtre + déjà `journalAccountId !== null`, `:83` : une société sans compte bancaire **lié** n'a rien + d'écarté, et garde son comportement actuel) → même traitement, clé + `supplier-invoices-pay-no-eligible-bank-account` (alignée sur ses + voisines `supplier-invoices-pay-*`, `:96`, `:100`, `:322`), fr « Aucun compte bancaire + utilisable : le seul compte lié est le compte créanciers de cette facture. Reliez un compte + bancaire à son propre compte de banque, ou réglez par un compte interne. » + - Les écrans qui alimentent le dialogue (`invoices/[id]/+page.svelte`, `invoices/due-dates/+page.svelte`) + ne changent pas : le filtre vit dans le dialogue, et ils lui passent déjà la liste des comptes + actifs non filtrée par `postable`. + - **Borne exacte `sitesTotal`** (`frontend/src/lib/shared/i18n-keys.test.ts:456`, assertée `:742` ; + **elle existe déjà** — 1868 au 2026-10-08 —, ce n'est pas la 15-5c qui la pose ; finding R5-2 de + la P5) : elle est relevée pour **tous** les sites `i18nMsg` neufs de la story, comptés — + **deux** dans `failedItemLabel` (AC6), **deux** pour les messages de liste vide (AC8 : + `SettleInvoiceDialog.svelte` et `supplier-invoices/[id]/+page.svelte`), et **deux** de plus si la + 15-5c est mergée (libellé `failed[]` du rapprochement, AC7) : **+4** attendus, **+6** avec la + 15-5c. Le chiffre se **recompte** par le relevé du test sur l'état **rebasé** (l'ordre de merge des + 15-5c, 15-5d et 15-6b change la base), aux deux bornes, et la ventilation (`sitesNonResolus`, + `relais`, `sitesGabarit`) se recompte avec lui ; le commentaire de la borne reçoit une ligne qui + nomme la story et les sites, sur le patron des lignes existantes (`:441-455`). +9. **AC9 — Les refus serveur s'affichent** dans le dialogue client, dans le **dialogue de solde du + reste** et sur la fiche fournisseur (message traduit de l'AC2, non le code). Vérifié par lecture + (P1, lentille R ; P2 ; P3) : les `catch` qui lisent `ApiError.message` sont dans les **parents** du + dialogue de règlement (`invoices/due-dates/+page.svelte:265-268`, + `invoices/[id]/+page.svelte:582-585`), qui passent la prop `errorMsg` ; le **solde du reste** + (AC3 bis, rôles `write_off_nature`, `rounding`, `vat_payable`) s'affiche par + `invoices/[id]/+page.svelte:509` (`writeOffError = err.message;`, rendu `:1577` + `errorMsg={writeOffError}`) — c'est **là** que les messages des comptes désignés (AC2, trois clés + plates : « un administrateur doit désigner un autre compte dans Paramètres → Facturation ») sont + lus, et, pour `rounding`, **aussi dans le dialogue de règlement** (compte d'arrondi du règlement, + AC3 bis : `markError = err.message`, `invoices/[id]/+page.svelte:584`, + `invoices/due-dates/+page.svelte:267`, prop `errorMsg={markError}` `:519` ; finding R6-10 de la + P6) — et c'est pourquoi ils ne + renvoient pas à un choix de compte que ce dialogue n'offre pas (finding R1 = F1 de la P3) ; ceux de la + fiche fournisseur sont dans la page — **aucun changement de code**. Le test Vitest (§ *Tests*) ne fige que + le **rendu** de la prop `errorMsg` par le dialogue, pas la lecture de `err.message` par les parents + (montage trop lourd pour ce qu'il prouverait) : c'est dit, pas surestimé. +10. **AC10 — Documentation des intégrateurs** (`docs/api-external.md`). Le § 10 *Gestion des + erreurs* (`:397-415`) **n'est pas touché** : il ne porte que des codes génériques + (`UNAUTHENTICATED`, `API_KEY_*`, `VALIDATION_ERROR`, `NOT_FOUND`), et la convention du guide est la + **table de refus par route** (`CREDIT_NOTE_INVOICE_SETTLED` n'y figure pas non plus). Les sites, + tranchés (findings R2 = F2 de la P3, choix C-15-6-26) : + - **Solde du reste** — table « Refus / Code / Statut » de `POST /api/v1/invoices/{id}/write-off` + (`:274-283`) : une ligne « Un compte désigné pour la nature, le reste d'arrondi ou la TVA due est + le compte débiteurs de la facture | `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT` | `400`, + `details.role` (`write_off_nature`, `rounding`, `vat_payable`) ». + - **Règlement client** — `POST /api/v1/invoices/{id}/settlements` n'a ni section ni table (le + guide ne le cite qu'en renvoi, `:257`, `:272`) : une phrase « Refus de `POST + /api/v1/invoices/{id}/settlements` (enregistrer un règlement) » ajoutée sous le § *Lister et + annuler les règlements d'une facture* (`:253`), **hors** de la table de ce § (`:259-268`), qui est + celle de l'**annulation** — sans quoi un intégrateur la lirait comme un refus d'annulation (finding + F6 de la P4) ; elle nomme la route, le code, `400` et les `details` (`claim`, `role` : + `counterparty` ou `rounding`). + - **Règlement fournisseur et lots** — `POST /supplier-invoices/{id}/pay` n'est cité qu'en renvoi + (`:287`) et le guide n'a **aucune** section sur les lots (`grep -n "payment-batch" + docs/api-external.md` ne rend **rien** ; la mention de `:287` dit « lot de paiement ») : une + phrase « Refus de `POST /api/v1/supplier-invoices/{id}/pay` (régler une facture fournisseur) » + sous le § *Annuler le règlement d'une facture fournisseur* (`:285`), à côté de ce renvoi, posée + **avant** la ligne « Refus : » de l'**annulation** (`:291`, `SUPPLIER_INVOICE_NOT_PAID`…) et + libellée par sa route, pour qu'un intégrateur ne la lise pas comme un refus d'annulation — même + consigne que côté client (finding F7 de la P5). Elle nomme la route du paiement (`400`), puis les deux + routes de lot (ouvertes aux clés : `comptable_routes`, `crates/kesh-api/src/lib.rs:496-505`) : + `POST /api/v1/payment-batches` (item `failed[]`, avec `SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED`) + et `POST /api/v1/payment-batches/{id}/confirm` (`400`, `details.paymentBatchId` et + `details.supplierInvoiceId`). Pas de section neuve. + - **Rapprochement** — dans la **phrase** des refus par proposition du § *Accepter des propositions + de rapprochement* (`:307`, qui cite en prose `RECONCILIATION_OVERPAYMENT` et + `RECONCILIATION_INVOICE_NOT_ELIGIBLE` ; le § n'a pas de liste), avec `details.role` + (`counterparty`, `rounding`). +11. **AC11 — Manuels** (`docs/manual/fr/user-manual.tex`, et `admin-manual.tex` en dernier tiret) : + - § *Enregistrer et annuler un règlement* (`\label{sec:reglement-client}`, `:1045`) : « ou + espèces et **tout autre compte** » (`:1048`, jumeau du « libre » fournisseur, présent au PDF + aplati — finding F5 de la P4) devient « ou espèces et un autre compte de contrepartie » ; après « Kesh + passe l'écriture *Débit contrepartie / Crédit débiteurs* » (`:1049`), une phrase : la contrepartie + ne peut pas être le compte débiteurs de la facture — l'écriture serait nulle et le reste dû + baisserait sans que rien ne bouge au grand livre ; Kesh ne le propose pas et le refuse ; de même un + compte bancaire lié au compte débiteurs. **Deux cas, deux remèdes** (finding F1 de la P3) : si le + compte en cause est celui que vous **choisissez** (compte interne, compte bancaire), choisissez-en + un autre ou reliez le compte bancaire à son propre compte de banque ; si c'est un compte + **désigné dans les réglages** — compte d'arrondi, compte d'une nature de solde, compte de TVA due + (AC3 bis) —, un administrateur doit en désigner un autre dans *Paramètres → Facturation*. La + seconde phrase est écrite + au paragraphe **Solder le reste** du même § (`:1064-1072`, qui renvoie déjà à la section + *Solde du reste* des paramètres). + - § *Régler une facture fournisseur* (`:1295-1305`) : le même énoncé pour le compte créanciers ; + et « choisissez un compte de contrepartie **libre** » (présent au PDF aplati) est **corrigé** — + « libre » devient faux. + - § *Paiements par fichier pain.001* (`\label{sec:paiements-fournisseurs}`, `:1346-1360`) : une + facture dont le compte créanciers est le compte lié au compte bancaire du lot n'entre pas dans le + lot (motif affiché) ; si le compte bancaire a été relié depuis, la confirmation est refusée — et + le manuel dit les **deux issues** : relier de nouveau le compte bancaire à son propre compte de + banque puis confirmer, ou annuler le lot et régler la facture depuis sa fiche (le fichier + pain.001 a pu être exécuté par la banque : c'est alors la première). + - § rapprochement (puce *Accepter*, `:1525`) : la proposition échoue avec son libellé si le compte + bancaire est lié au compte débiteurs (renvoi à la configuration, que la 15-6c ferme), **ou** si + le compte d'arrondi désigné est le compte débiteurs (renvoi à *Paramètres → Facturation*) — les + deux cas du libellé de l'AC7. ⚠️ La + **15-5c** réécrit cette section : écrire sur sa version si elle est mergée, sinon rebase attendu. + - § balance âgée (`:1754`, finding F3 de la P5) : la liste des écritures qui écartent le total + général du solde du compte clients cite « règlement dont la contrepartie est ce compte + lui-même » comme un cas ordinaire — après la story, ce geste est **refusé**. La phrase devient + « règlement **enregistré avant la version 0.13.0** dont la contrepartie était ce compte + lui-même », ou le cas rejoint les « données antérieures » que la même phrase cite déjà. Le reste + de la phrase (« changement du compte clients dans les réglages », que la 15-6a relit sans le + réécrire ; « paiement d'une facture fournisseur compensé sur ce compte », que la story n'interdit + pas — le compte soldé y est 2000, non 1100) **ne change pas**. + - **Manuel d'administration** (`docs/manual/fr/admin-manual.tex`, § des réglages de facturation ; + finding F6-1 de la P6 — l'AC n'y portait aucun verdict, et la 15-6c écrivait que la 15-6b n'y + touche pas) : le remède des trois clés « compte désigné » renvoie l'**administrateur** à + *Paramètres → Facturation*, et c'est ce manuel qu'il lit. Verdict, ligne par ligne : + `:2027` (*Compte de différences d'arrondi* : « charge ou produit, actif et imputable ») — + **inchangée**, elle reste vraie (l'exigence de type est celle de la désignation) ; `:2029` + (*Comptes du solde du reste*) — **une phrase ajoutée** : un solde du reste est refusé, sans rien + écrire, si le compte de la nature, le compte de différences d'arrondi ou le compte de TVA due + est le **compte débiteurs de la facture** (cas d'un compte débiteurs changé de type depuis) ; + désigner alors un autre compte ici ; `:2033` (paiement avec écart) — la liste « réglage vide, + ou compte archivé, rendu non imputable ou changé de type depuis » gagne « **ou devenu le compte + débiteurs de la facture** ». La phrase de frontière de la 15-6c (son AC9) est alignée dans la + même remédiation. + PDF régénérés (`make fr`) et contrôlés **aplatis** (`pdftotext … | tr '\n' ' ' | tr -s ' '`), une + phrase neuve au moins par section — `:1754` et les deux phrases du manuel d'administration compris. +12. **AC12 — CHANGELOG** sous `## [0.13.0] — Non publié`, `### Corrigé`, renvoi à + [#474](https://github.com/guycorbaz/kesh/issues/474). **Propriétaire de la section** : la première + story de la version mergée la crée en tête des versions si elle est absente ; les suivantes y + ajoutent leur entrée. + +## Tasks / Subtasks + +- [ ] **T0 — Rebase** sur `main` après le merge de la 15-6a et des 15-5a à 15-5e ; + **refaire tous les numéros de ligne** de la fiche sur `HEAD` ; vérifier où la 15-5a a placé + `ACCOUNT_NOT_POSTABLE` dans `settle_invoice` et `pay_in_tx` (AC3, AC4) ; **relever l'ordre des + lectures** de `settle_invoice` et de `write_off_invoice` sur `HEAD` et vérifier que l'AC3 bis le suit + — sinon l'AC3 bis suit le code et le doc-comment le dit ; constater que les routes du règlement, du + solde, du paiement fournisseur et de la confirmation d'un lot sont rejouées par la 15-5e (un refus + métier de cette story n'est pas un 1213 : il n'est jamais rejoué) ; relire + le commentaire de la 15-5b dans `accept_one_invoice` (AC5), tester l'existence de + `failed-proposal-label.ts` (AC7), relever `sitesTotal` (AC8). Les choix **C36, C52, C54, C55** + que la fiche cite vivent au registre de la branche des 15-5 (dépôt principal) : absents de cette + branche avant le rebase, ils y arrivent avec lui (finding R6-11 de la P6). +- [ ] **T1 — Variante, rôle, contexte de lot, code, mapping, i18n** (AC1, AC2). +- [ ] **T2 — Helper commun puis gardes serveur** : `ensure_not_claim_account`, + `claim_account_refusal` et `claim_account_refusal_details` dans `invoice_settlements.rs` (AC1), **avant** les sites qui les + appellent ; gardes (AC3, AC3 bis, AC4, AC5), lecteur sœur fournisseur (AC4). Aucun site ne compare + ni ne construit le refus ni ne lit le numéro par lui-même. **Critère par le nom seul, sur une liste + fermée** (findings R5-3 et F2 de la P5, choix C-15-6-33 — le critère de la P4, un `grep` mono-ligne + sur un littéral à champs, rendait vert par construction le littéral que rustfmt coupe sur plusieurs + lignes, et tout champ contenant un point) : + `grep -rnF "SettlementCounterpartyIsClaimAccount" crates/*/src | grep -vE ':[0-9]+:\s*//'` + (occurrences de **code**, commentaires et doc-comments exclus) ne rend des lignes **que** dans ces + cinq fichiers, chacune pour une raison nommée : + - `kesh-db/src/errors.rs` — la définition et le bras d'`error_code()` (et leurs tests unitaires) ; + - `kesh-db/src/repositories/invoice_settlements.rs` — `claim_account_refusal`, **seule** + construction de la variante (et ses tests) ; + - `kesh-db/src/repositories/payment_batches.rs` — l'interception de `confirm_batch`, qui reconstruit + le refus avec `batch: Some`, et la conversion en `PaymentBatchFailedItem` à la création (AC6) ; + - `kesh-api/src/errors.rs` — le mapping HTTP (AC2) et ses tests ; + - `kesh-api/src/routes/reconciliation.rs` — la conversion en `FailedProposal` (AC5, AC3 bis). + Une ligne dans un **autre** fichier est un site non résolu : il est ramené au helper ou ajouté à la + liste avec sa raison, au Dev Agent Record. Dans les cinq fichiers, chaque occurrence est **lue** + (pas seulement comptée) : une construction hors de `claim_account_refusal` et de l'interception de + `confirm_batch` est un défaut. Et `grep -rnF "SELECT number FROM accounts WHERE id = ? AND company_id = ?" crates/*/src` + ne rend, pour cette story, que `claim_account_refusal` (chaîne écrite sur une ligne — une requête + coupée par `\` y échapperait : le helper l'écrit d'un tenant, et le doc-comment le dit). +- [ ] **T3 — Lots de paiement** (AC6), serveur (création, écriture d'achat malformée, confirmation + contextualisée) et libellés. +- [ ] **T4 — Libellé `failed[]` du rapprochement** (AC7) ou signalement. +- [ ] **T5 — Fonctions d'écran et écrans** (AC8, AC9). +- [ ] **T6 — Tests** (§ *Tests*) — chaque test de garde **rougit d'abord** sur le code non corrigé, + **sauf** les tests de non-régression 3 et 9, verts avant comme après. +- [ ] **T7 — Documentation** : `docs/api-external.md` (AC10), manuel + PDF (AC11), CHANGELOG (AC12). +- [ ] **T8 — Inventaire fermé** (§ *Inventaire*) : rejouer les commandes ; tout site neuf est traité + ou écrit en angle mort avec sa raison. +- [ ] **T9 — Gates** : backend complet (dépôts `kesh-db` touchés : pas de ciblage), frontend complet + (`check`, `lint-i18n-ownership`, `test:unit` — dont la borne `sitesTotal` relevée, AC8 —, `build`), + E2E complet au dernier commit de code (D7). + Le verdict de lecture sur les E2E existants (§ *Tests*, « E2E existants ») est recopié au Dev Agent + Record, puis confronté au run : un rouge sur une des specs nommées s'examine d'abord contre ce + verdict. + +## Inventaire — les sites qui écrivent `D X / C X` avec un compte choisi par le client + +Règle « Inventorier les sites NON RÉSOLUS » : l'ensemble clos est celui des **écrivains d'une +écriture de règlement ou de rapprochement à deux comptes dont l'un est désigné par l'utilisateur ou par +la configuration**. Commandes de recompte : + +```sh +grep -rn "journal_entries::create_in_tx" crates/kesh-db/src crates/kesh-api/src +grep -rn "pay_in_tx\|settle_invoice(" crates/kesh-db/src crates/kesh-api/src +grep -rn "rounding_account_for_write\|write_off_account_for_write\|vat_payable_account_for_write" crates/kesh-db/src crates/kesh-api/src +grep -rn "active && a.postable\|a.active && a.postable" frontend/src +grep -rn "listBankAccounts(" frontend/src +``` + +| site | compte soldé | contrepartie | verdict | +|---|---|---|---| +| `settle_invoice` (règlement client, deux modes) | créance (vente) | banque / compte interne | **AC3** | +| `settle_invoice`, compte d'arrondi du règlement | créance (vente) | compte d'arrondi | **AC3 bis** | +| `write_off_invoice` (solde du reste) | créance (vente) | nature, reste d'arrondi, TVA due | **AC3 bis** (le type ne suffit pas : changement de type confirmé) | +| `pay_in_tx` (règlement fournisseur, deux modes) | dette (achat) | banque / compte interne | **AC4** | +| `create_batch` / `confirm_batch` (lots) | dette (achat) | banque du lot | **AC6** | +| `accept_one_invoice` (rapprochement d'une facture) | créance (vente) | banque ; arrondi (c-bis) | **AC5**, **AC3 bis** | +| `post_manual`, `accept_one_rule` | compte de banque | compte choisi | **15-6d** (#524) | +| `accept_one_split`, `post_split` | compte de banque | comptes choisis | déjà gardés (`:1941-1950`, `:3462-3475`) | +| `credit_notes.rs:548` `create_credit_note` | créance (vente) | produit, TVA, arrondi | **15-6a** pour la créance et l'arrondi (lus sur la vente) ; TVA due lue dans les réglages courants : **angle mort, #525** ; produit de repli : **angle mort, #525** (commentaire de l'orchestrateur du 2026-10-08) (§ *Le défaut*) | +| validation de facture (`invoices.rs:2255`) | — (**crée** la créance) | produit `Revenue`, arrondi | créance des réglages sans type contrôlé : **angle mort, #537** (§ *Le défaut*) | +| validation d'achat (`supplier_invoices.rs:372`) | — (**crée** la dette) | charge | hors classe (aucun compte soldé) ; même forme possible que #537 côté dette (2000 retypé en charge) — à confronter en T8 à la garde à l'usage de la 15-5d, tracé par **#537** (commentaire du 2026-10-08, « Même forme côté dette ») | +| `opening_complement.rs:641` (soldes de départ) | — | lignes saisies, `enforce_postable = true` | hors classe : chaque ligne est choisie par l'utilisateur, aucune créance n'est soldée par le geste | +| saisie manuelle d'écriture (non rendue par les commandes : elle passe par `journal_entries::create`, ajoutée par raisonnement) | — | lignes libres | hors classe : l'utilisateur écrit chaque ligne | + +Écrans rendus par les commandes frontend : + +| écran | verdict | +|---|---| +| `SettleInvoiceDialog.svelte:105`, `supplier-invoices/[id]/+page.svelte:84` | **AC8** | +| `payment-batches/+page.svelte:53` (création de lot, comptes bancaires) | non filtré, **choix écrit** (C-15-6-19) : refus par facture lisible ; § *Angles morts* | +| `settings/invoicing/+page.svelte:65-77` (réglages) | **15-6c** (désignation) | +| `supplier-invoices/+page.svelte:129`, `supplier-invoices/import/+page.svelte:83` (charges d'une facture fournisseur) | hors classe : aucun compte soldé n'est choisi | +| `bank-accounts/+page.svelte`, `BankAccountJournalLinkForm.svelte` (lien des comptes bancaires) | **15-6c** | +| `ReconciliationProposals.svelte` / `ManualMatchModal.svelte` | **15-6d** | +| `routes/(app)/+page.svelte:58` (tuile des comptes bancaires du tableau de bord, rendue par `listBankAccounts(`) | hors classe : affichage seul, aucun règlement (finding F6-2 de la P6) | +| `features/accounts/account-validity.ts:20` (rendu par `active && a.postable`) | hors classe : doc-comment, aucun menu (finding F6-2 de la P6) | + +## Décompte des modules — signal de découpage (D5) + +Finding F5 de la P2 : la P1 a ajouté les lots sans recompter. Recompté le 2026-10-08 (et de nouveau à +la P5, finding R5-5), **au barème de la règle** (« modules métier de premier niveau ») : `kesh-db` +règlement client (`invoice_settlements_write.rs`, solde du reste compris), `kesh-db` helper commun +(`invoice_settlements.rs` : trois fonctions publiques et les types `ClaimAccountClash`, +`ClaimSubject`, `DesignatedRole` — les deux premiers omis jusqu'à la P4, le troisième jusqu'à la P6), `kesh-db` règlement fournisseur +(`supplier_invoices.rs`), `kesh-db` lots (`payment_batches.rs`), `kesh-api` rapprochement +(`routes/reconciliation.rs`), `frontend` factures (`features/invoices`), `frontend` factures +fournisseurs (`routes/supplier-invoices`), `frontend` lots (`features/payment-batches`), `frontend` +comptes (`features/accounts/account-options.ts`) — **neuf** (et non huit : le helper commun manquait +au décompte ; les clés plates de la P5 n'ajoutent pas `kesh-i18n/src/loader.rs`), plus la plomberie +d'erreur et d'i18n +(`kesh-db/errors`, `kesh-api/errors`, `kesh-i18n`) et, si la 15-5c est mergée, une ligne dans +`features/reconciliation`. **Au barème du geste** (patron de la 15-5a : l'écran suit le geste qu'il +sert) : règlement client, règlement fournisseur, lots, rapprochement, fonctions d'écran partagées — +**cinq**. **Signal déclaré au Project Lead** ; **pas de découpage** (choix C-15-6-20) : les gestes ne +sont pas indépendants — une variante, un `ClaimSide`, un lecteur sœur partagé par `pay_in_tx` et les +lots, trois fonctions d'écran —, et les lots ne font qu'appeler la garde de `pay_in_tx` (AC4) ; les +séparer laisserait une story sans sa garde ou dupliquerait la plomberie. Aucun défaut de la P2 ne +recycle un défaut de la P1. + +**P3 (signal D5 levé de nouveau, déclaré au Project Lead)** : sévérité P2 → P3 MEDIUM → MEDIUM. Deux +des cinq MEDIUM distincts (R1 = F1 : remède faux du refus de l'AC3 bis ; R2 = F2 : table de refus du +solde oubliée) **naissent de la remédiation P2** — l'AC3 bis ajoutée sans son message ni sa +documentation : c'est le motif « recyclage » de l'amendement D5, mais **contenu** — une seule règle +(AC3 bis), un seul module de plus (aucun : le dialogue de solde est lu, pas modifié), corrigé ici par +un discriminant et une clé. Le troisième (F3, DRY) est d'origine. **Pas de découpage** (choix +C-15-6-26) ; la remédiation P3 change une règle métier (le message suit le rôle) : la passe suivante +doit être **complète**, pas ciblée. + +**P4 (signal D5 levé une troisième fois, déclaré au Project Lead)** : MEDIUM → MEDIUM ; F1 **recycle** +le discriminant posé en P3 (compte d'arrondi du solde mal classé), contenu à une ligne de classement et +un test ; F2 et F3 sont d'origine. Aucun module de plus (F2 traité en angle mort, #537 et #525). **Pas +de découpage** (C-15-6-30) ; un troisième recyclage sur l'AC3 bis en P5 déciderait de la sortir en +story propre. + +**P5 (signal D5 levé par la sévérité, déclaré au Project Lead)** : MEDIUM → MEDIUM (3 et 3, R + F ; +F2 = R5-3). **Le critère « troisième recyclage sur l'AC3 bis » n'est pas atteint** : le seul MEDIUM qui +touche l'AC3 bis (F1, son ordre des refus) **ne vient pas d'un patch** — il naît d'un **changement +extérieur**, la création de la 15-5e (C52), qui réordonnait alors `settle_invoice` et +`write_off_invoice`, après la P4 ; sa réécriture par C54 (pendant la remédiation) a retiré ce +réordonnancement, et l'AC3 bis garde l'ordre des lectures du code. Les deux autres recyclent des patches de passes antérieures +**hors** de l'AC3 bis : R5-1, le sélecteur Fluent de l'AC2 (posé en P3, réécrit en P4), remplacé par +trois clés plates ; F2 = R5-3, le critère de T2 réécrit en P4, remplacé par un critère par nom sur liste +fermée. F3 (manuel `:1754`) est d'origine. Aucun module de plus (le décompte passe de huit à neuf par +une omission corrigée, pas par un ajout). **Pas de découpage** (C-15-6-33). + +**P6 (clôture — signal de dispersion déclaré, non découpé)** : 0 au-dessus de LOW ; le signal de +**sévérité** n'est pas levé. Le finding F6-7 constate en revanche que le **critère de dispersion** de +l'amendement D5 (« la dispersion dans plus de cinq modules ») est **rempli au barème de la règle** : +**neuf** modules métier de premier niveau, plus la plomberie d'erreur et d'i18n, et `admin-manual.tex` +(documentation, hors décompte) ajouté par F6-1. Le chiffre de **cinq** ne vaut qu'au « barème du +geste », que la règle ne prévoit pas : il n'est **plus** invoqué comme justification. **Déclaré au +Project Lead, non découpé** (choix C-15-6-34), pour une raison écrite qui ne dépend pas du barème : la +fiche est **validée** (six passes, sévérité retombée à 0 au-dessus de LOW), ses gestes partagent une +variante, un sujet typé, un lecteur sœur et trois fonctions d'écran, et un découpage à ce stade +rouvrirait la validation de deux fiches pour séparer ce que la conception lie — le risque que la règle +de découpage veut prévenir (non-convergence) ne s'est pas réalisé. L'arbitrage revient à Guy en revue +de fin d'epic. + +## Tests + +Backend, `#[sqlx::test(migrations = "./test-schema")]` : + +1. `crates/kesh-db/tests/invoice_settlement.rs` — **compte interne = créance** : refus + `SettlementCounterpartyIsClaimAccount { claim: Receivable, role: Counterparty, account_number: Some("1100"), batch: None, .. }` ; + aucune écriture neuve, aucune ligne `invoice_settlements`, `version` et `paid_at` inchangés. +2. Même fichier — **virement, compte bancaire lié au 1100** (posé par `UPDATE bank_accounts SET + journal_account_id = …` : le test ne doit pas dépendre de la 15-6c) : même refus. +3. Même fichier — un règlement ordinaire (banque 1020, puis compte interne caisse) reste accepté + (non-régression de l'ordre et du montant). Le seed `seed_accounting_company` n'a pas de 1020 + (`test_fixtures.rs:129-133` : 1000, 1100, 2000, 3000, 4000) : le créer comme + `invoice_settlement.rs:182-185`. +4. Même fichier — **montant excessif sur 1100** : le refus est celui de la contrepartie, **pas** le + trop-perçu (ordre AC3 ; le test 1 n'a pas de montant excessif et ne le fige pas). +5. `crates/kesh-db/tests/supplier_invoices_repository.rs` — compte interne = 2000 : `claim: Payable`, + `role: Counterparty`, + facture toujours `open`, aucune écriture. +6. Même fichier — virement sur un compte bancaire lié au 2000 : même refus. ⚠️ La fixture existante + (`:215`) lie un compte bancaire au **1100** pour payer des factures fournisseurs : elle reste + valide (le compte soldé est 2000) ; ne pas la changer. +7. `crates/kesh-api/tests/reconciliation_e2e.rs` — **un lot de deux propositions, deux créances + différentes** : la facture A validée avec la créance sur 1100 ; puis un compte d'actif `1101` créé + et désigné comme débiteurs ; la facture B validée (créance sur 1101) ; le compte bancaire du lot lié + au 1100 (SQL). `accept_batch` (un seul `bank_account_id` pour tout le lot, `:1013`) : HTTP 200, B + acceptée, A dans `failed[]` avec le code et les **cinq** clés de `details` (`bankAccountId`, + `accountId`, `accountNumber`, `claim`, `role = "counterparty"`) ; la transaction bancaire + de A reste en attente. +8. Même fichier — proposition sur A **d'un montant supérieur au reste** : `failed[]` porte + `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, pas `RECONCILIATION_OVERPAYMENT` (ordre AC5). La + transaction bancaire porte la **référence de la facture** (patron du test existant de + `RECONCILIATION_OVERPAYMENT`, `two_centimes_over_a_half_centime_invoice_is_an_overpayment`, + `reconciliation_e2e.rs:4869`), pour que son **score reste positif** : l'étape 7bis + (`RECONCILIATION_SCORE_TOO_LOW` si `score.total <= 0.0`, `reconciliation.rs:1344-1354`) passe + **avant** les étapes (a) à (c), et un score nul rendrait ce refus-là avant comme après la story, + sans rien figer de l'ordre AC5 (finding F6-4 de la P6). +9. `crates/kesh-db/tests/invoice_settlement.rs` — **ordre** : compte interne 1100 **archivé** → + `INACTIVE_OR_INVALID_ACCOUNTS` (l'ancien refus prime). +10. `crates/kesh-db/tests/invoice_write_off.rs` — **solde du reste, nature = créance** (AC3 bis) : + après validation, 1100 change de type par SQL (`account_type = 'Expense'`, `role = NULL` — le + chemin `confirm_retype` sans la route) et est désigné compte d'escompte en SQL ; solder le reste en + escompte → `SettlementCounterpartyIsClaimAccount { claim: Receivable, role: WriteOffNature, .. }`, + rien d'écrit. +10 bis. Même fichier — **solde du reste, reste d'arrondi = créance** (findings R1 = F1 de la P4) : + même changement de type, 1100 désigné compte **d'arrondi** (et non d'escompte, qui reste un compte + de charge distinct) ; solder en **escompte** un reste à quatre décimales (montage de + `a_four_decimal_remainder_sends_its_sub_centime_to_the_rounding_account`, `:898`) → + `SettlementCounterpartyIsClaimAccount { role: Rounding, .. }` — **pas** `WriteOffNature` : le rôle + suit la colonne de réglage lue, pas la nature du geste ; rien d'écrit. +10 ter. Même fichier — **ordre des refus du solde** (AC3 bis, finding F6 de la P5) : une facture + **avec TVA** validée d'abord (la validation exige le réglage de TVA due, `test_fixtures.rs:150-151`) ; + **après validation** — finding R6-9 de la P6 —, même changement de type, 1100 désigné compte + d'**escompte**, **et** réglage de TVA due vidé (`default_vat_payable_account_id = NULL`) ; solder en + escompte → `SettlementCounterpartyIsClaimAccount { role: WriteOffNature, .. }`, **pas** + `ConfigurationRequired("default_vat_payable_account_id")` : la nature est comparée aussitôt la + créance lue, avant la lecture de la TVA due ; rien d'écrit. +11. `crates/kesh-db/tests/invoice_settlement.rs` (finding R5-7 de la P5 : le fichier du règlement, non + celui du solde) — **arrondi du règlement = créance** (AC3 bis). Ce fichier n'a pas de montage + d'arrondi : il reprend celui de `crates/kesh-db/tests/invoice_amount_due_parity.rs` + (`seeded_with_rounding`, `:72`, qui désigne le compte d'arrondi par `designate_rounding_account`) + et une facture au **reste brut hors centime** (patron `raw_due_invoice`, + `crates/kesh-api/tests/invoice_echeancier_e2e.rs:986`) — + **arrondi du règlement = créance** + (AC3 bis) : même changement de type, 1100 désigné compte d'arrondi ; un paiement au centime de + cette facture (`SettlesWithRounding`) par **compte interne** (caisse 1000) → même refus, + `role: Rounding`, rien d'écrit (le compte interne, valide, est contrôlé avant, AC3 bis). +12. `crates/kesh-api/tests/reconciliation_e2e.rs` — **arrondi (c-bis) = créance** (AC3 bis) : patron + `half_centime_invoice_and_tx` (`:4705`) et `designate_rounding_account` (`:4681`), le compte + d'arrondi désigné étant la créance retypée → `failed[]` `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, + HTTP 200, `details.role = "rounding"` et **pas** de `details.bankAccountId` (AC3 bis). +13. `crates/kesh-db/tests/payment_batches_repository.rs` — **création** : deux factures fournisseurs, + A achetée avec la dette sur 2000, B après changement du réglage créanciers vers un `2001` ; compte + bancaire source lié au 2000 : A dans `failed[]` avec le code et ses `details`, B dans le lot ; avec + A seule, **aucun** lot créé. `details` = les cinq clés de l'AC6 (`role = "counterparty"`), et + `accountNumber = "2000"` (lu à l'échec par le helper, AC6). +14. Même fichier — **création, écriture d'achat sans ligne de crédit** (en-tête d'écriture sans + ligne, désigné par `UPDATE supplier_invoices SET purchase_journal_entry_id = …`) : la facture dans + `failed[]` avec `SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED` et ses `details`, les autres du lot + retenues. +15. Même fichier — **confirmation, puis remède** : lot créé sur un compte bancaire lié au 1020 (créé + comme au test 3), puis relié au 2000 en SQL ; `confirm_batch` → `SettlementCounterpartyIsClaimAccount + { claim: Payable, role: Counterparty, batch: Some(..), .. }` portant l'id du lot, de la facture + **et son numéro** (`supplier_invoice_number`, relu par `confirm_batch`, AC6), lot toujours + `generated`, aucun règlement écrit ; puis relié de nouveau au 1020 → la confirmation **passe**. + (La fixture `:100` lie au 1100 : inchangée, même raison qu'au test 6.) +16. Test HTTP `POST /api/v1/invoices/{id}/settlements` (fichier existant qui l'appelle, + `crates/kesh-api/tests/invoice_echeancier_e2e.rs`) : 400, code, `details.claim = "receivable"`, + message **fr**. ⚠️ Kesh n'a **pas** de négociation par `Accept-Language` : la langue des messages + est **globale au processus** (`init_error_i18n`, `crates/kesh-api/src/errors.rs:23-26`), comme le + dit `docs/api-external.md` § 10. Le test de parité de `kesh-i18n` (`parity_between_locales`, + `crates/kesh-i18n/src/loader.rs:719`) ne contrôle que la **présence** des clés dans les quatre + locales, **pas leur traduction** — une copie du français passerait : la traduction se relit en + revue. +16 bis. Même fichier — test HTTP `POST /api/v1/invoices/{id}/write-off` (le fichier l'appelle déjà) : + compte de la nature `discount` = la créance retypée (montage du test 10) → **400**, + `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, `details.role = "write_off_nature"`, message **fr** + contenant « nature de solde » et « Paramètres → Facturation » et **ne** contenant **pas** « banque, + caisse » — il fige la clé plate `error-settlement-write-off-account-is-receivable` de l'AC2 + (findings R1 = F1 de la P3, R5-1 de la P5), que le test 16 ne voit pas. + +Frontend (Vitest) : + +17. `frontend/src/lib/features/accounts/account-options.test.ts` (existant) : les trois fonctions — le + rôle donné écarté, les comptes bancaires liés écartés, les autres gardés, les comptes sans rôle + intacts ; un compte bancaire lié à un compte de rôle `Receivable` **non imputable** est écarté (ids + calculés avant le filtre `active && postable`). +18. `SettleInvoiceDialog.test.ts` : 1100 (rôle `Receivable`) absent du menu « Compte interne » ; un + compte bancaire lié au 1100 absent du menu des virements, la présélection tombe sur un autre ; + `accounts` fourni **après** l'ouverture et une saisie de montant : le montant saisi est **gardé** + (AC8, effet distinct) ; seul + compte bancaire lié au 1100 → le message de liste vide ; **aucun** compte bancaire → **pas** de + message de liste vide (rien n'a été écarté, AC8 — finding F3 de la P4) ; la prop `errorMsg` + s'affiche (AC9 — rendu seulement). +19. `supplier-invoices/[id]/supplier-settlement-page.test.ts` : 2000 absent, banque liée au 2000 + absente, liste vide écrite ; aucun compte bancaire lié → pas de message de liste vide. +20. `frontend/src/lib/features/payment-batches/payment-batch-helpers.test.ts` : les libellés des deux + codes neufs (AC6), le test « mappe les six codes » devenu « huit ». + +Soit **23 tests nommés** (19 backend — 1 à 16, 10 bis, 10 ter et 16 bis —, 4 fichiers Vitest étendus) : tous neufs, +sauf le test existant « mappe les six codes » de `payment-batch-helpers.test.ts:61`, **renommé et +étendu** à huit codes (test 20 ; ses six assertions existantes restent) — à recompter aux deux bornes +avant de les déclarer. Si la 15-5c est mergée, `failed-proposal-label.test.ts` reçoit en plus les +deux cas de l'AC7 (non compté : conditionnel). + +**E2E existants — verdict de lecture** (finding F7 de la P3 ; à confronter au run de T9) : +- `frontend/tests/e2e/bank-account-journal-link.spec.ts:105-119` lie le premier compte bancaire + (TEST_IBAN) au **1100**, compte débiteurs du préréglage (`test_fixtures.rs:129-147`). La 15-6b ne + touche pas la liaison (c'est la 15-6c, C-15-6-15) : la spec reste verte ici. ⚠️ **Le filtre par + rôle de l'AC8 est inerte en E2E** (finding R4 de la P4) : le seed (`seed_accounting_company`, + appelé par `test_endpoints.rs:184`, `:197`) insère les comptes **sans rôle** + (`test_fixtures.rs:129-139`, `INSERT INTO accounts (company_id, number, name, account_type)` ; + aucun `SET role` ni `'Receivable'` dans le seed) : `accountIdsWithRole(…, 'Receivable')` y rend un + ensemble vide, rien ne disparaît des menus, rien ne se décale. Seul le refus **serveur** (qui + compare la créance lue sur la vente, sans rôle) s'appliquerait si une spec réglait une facture + client par le compte bancaire lié au 1100 — aucune ne le fait. +- Les specs qui en dépendent n'en souffrent pas, par lecture : `invoices_echeancier.spec.ts:143-146` + règle par **compte interne**, `selectOption({ index: 1 })` — le menu n'est pas filtré en E2E (seed + sans rôle), l'index 1 reste le même compte qu'avant la story ; `invoices.spec.ts:318-321` fait de + même (même menu, même index, même raison — finding R5-6 de la P5) ; `reminders.spec.ts:104-112` règle + par API sur un compte d'actif `startsWith('10')` (liquidités, jamais 1100) ; + `invoice-write-off.spec.ts:70-85` — **seule** spec qui traverse le chemin de l'AC3 bis — désigne + l'escompte sur 4000 et règle par API sur 1000 : nature 4000, TVA due 2000 du seed + (`test_fixtures.rs:164-166`), aucune n'est la créance 1100 ; `invoices_echeancier.spec.ts:227-228` et + `invoices-settlement-cancel.spec.ts:103-104` règlent par API sur `cash` (1000) ; + `reconciliation-cancel.spec.ts:60-83` relie le compte bancaire au 1000 avant d'accepter ; + `reconciliation-amount-due.spec.ts` n'accepte rien ; `reconciliation.spec.ts` ne joue pas + l'acceptation ; `payment-batches.spec.ts:51-81` et `supplier-invoices.spec.ts` paient des dettes + du **2000** avec un compte bancaire lié à un compte liquide, ou un compte interne de l'actif + (`supplier-invoices.spec.ts:77`, premier compte `Asset` actif — 1000, pas 2000). +- **Aucune rupture attendue** — par l'inertie du filtre en E2E, non par un décalage sans effet ; aucune + spec n'est modifiée par cette story, et **aucun E2E n'exerce l'AC8** (angle mort écrit, § *Angles + morts*). *(Le test « un compte d'actif désigné comme compte d'arrondi est refusé » de la P1 est +**retiré** : il figeait la garantie du « résolu par le type », que l'AC3 bis remplace.)* + +## Dev Notes + +### Ce qui doit être préservé + +- L'**ordre** des refus existants (AC3–AC6) — la 15-5e n'en change aucun (son AC7) — et le **verrou** `FOR UPDATE` + posé sur le compte bancaire ou le compte interne à la résolution de la contrepartie — la garde neuve compare des identifiants déjà lus, elle ne + relit rien sauf le numéro du compte (à l'échec) et, à la confirmation d'un lot, le numéro de la + facture fournisseur (`supplier_invoice_number`, AC6). +- Le **refus du trop-perçu** (du règlement ; étape (c) du rapprochement) vient **après** : un + montant excessif sur 1100 rend le refus de contrepartie, pas `overpayment` — choix délibéré (la + contrepartie est le défaut de fond) ; les tests 4 et 8 le figent. +- `enforce_postable = false` de ces flux : inchangé. +- Le pattern batch des lots (`PaymentBatchFailedItem`) : une facture refusée n'empêche pas les autres. + +### Pièges + +- **Ne pas** réutiliser `DbError::InvalidInput` : code générique `INVALID_INPUT`, sans `details` + (choix C-15-6-3). **Ne pas** réutiliser `ACCOUNT_NOT_POSTABLE` (15-5a) : 1100 est imputable. +- La 15-5a insère `ACCOUNT_NOT_POSTABLE` à la lecture du compte interne de `settle_invoice` et de `pay_in_tx` ; la 15-5b + pose dans `accept_one_invoice` un commentaire de classement que l'AC5 amende ; la **15-5c** pose les + libellés de `failed[]` (#492) et réécrit le manuel du rapprochement — le rebase peut demander de + fusionner la liste de codes : c'est l'AC7. T0 refait les numéros de ligne. +- `kesh-i18n` : un test vérifie la **présence** des clés dans les quatre locales (pas leur + traduction) ; un autre (`no_new_select_expression_reaches_the_frontend_dictionary`, `loader.rs:482`) + refuse tout **sélecteur** Fluent neuf — d'où les clés plates (AC2). Ajouter les clés neuves aux + quatre fichiers dans le même commit — **six** serveur (AC2 : trois par côté et contexte, trois pour + un compte désigné dans les réglages, une par rôle), **quatre** écran + (deux de lots, AC6 ; deux de liste vide, AC8), **deux** de plus si la 15-5c est mergée (libellé + `failed[]` du rapprochement à deux cas, AC7). +- **Un code, deux remèdes** (C-15-6-26) : ne pas créer un second code pour le compte désigné — le + défaut est le même (`D X / C X`), un intégrateur le traite d'un seul bras ; c'est `details.role` + qui porte la différence, et le message qui la dit à l'utilisateur. +- **Recompter** les tests ajoutés aux deux bornes avant de les déclarer (§ *Recompter ses propres + comptes rendus*). + +### Angles morts assumés + +- Le filtre d'écran est par **rôle**, la garde serveur par **compte de l'écriture de vente** : si le + réglage débiteurs a été déplacé hors du compte de rôle `Receivable`, l'écran peut proposer un compte + que le serveur refusera (avec un message clair) — jamais l'inverse (écriture fausse). Choix C-15-6-4. +- **Comptes archivés** : absents des listes que reçoivent les écrans ; un compte bancaire lié à un + compte archivé reste proposé, et le serveur refuse toute écriture sur un compte archivé (C-15-6-17). +- **Écran de création d'un lot** (`payment-batches/+page.svelte:53`) : il propose un compte bancaire + lié au compte créanciers ; le refus arrive par facture, libellé, avant tout fichier (AC6). Le + filtrer demanderait de charger le plan comptable sur cet écran (C-15-6-19). +- **Validation d'une facture et avoir** (finding F2 de la P4) : créance retypée lue à la validation + (**#537**), TVA due de l'avoir lue dans les réglages courants (**#525**), produit de repli de + l'avoir de même (**#525** aussi, commentaire de l'orchestrateur du 2026-10-08) ; validation d'achat, + même forme côté dette (**#537**, commentaire du 2026-10-08) — § *Le défaut*, inventaire. Hors classe + de la story (aucun ne solde un compte), et deux modules de plus. +- **Le filtre d'écran n'est exercé par aucun E2E** : le seed E2E n'a pas de rôle (§ *Tests*, « E2E + existants ») ; l'AC8 n'est figé que par Vitest (tests 17-19). +- **Données antérieures** : des règlements `D 1100 / C 1100` ou `D 2000 / C 2000` déjà écrits par une + instance v0.12 restent tels quels, sans détection. Acceptable : le produit ne tient pas encore de + comptabilité réelle (`CLAUDE.md` § *Project Overview*) ; à réexaminer si une instance en a écrit. + +### Références + +- [Source: crates/kesh-db/src/repositories/invoice_settlements_write.rs:43-200, :356-540] +- [Source: crates/kesh-db/src/repositories/accounts.rs:497-515 (changement de type confirmé)] +- [Source: crates/kesh-db/src/repositories/supplier_invoices.rs:554-700] +- [Source: crates/kesh-db/src/repositories/payment_batches.rs:68-140, :226-300, :304-380, :431] +- [Source: crates/kesh-api/src/routes/reconciliation.rs:1013, :1175, :1378-1500] +- [Source: crates/kesh-db/src/errors.rs:515-532, :742-790 ; crates/kesh-api/src/errors.rs:23-26, :2823-2846] +- [Source: crates/kesh-db/src/repositories/company_invoice_settings.rs:397-447, :454-477 (TVA due, sans type), :484-507] +- [Source: frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte:89, :105, :208 ; + frontend/src/routes/(app)/supplier-invoices/[id]/+page.svelte:80-84 ; + frontend/src/routes/(app)/invoices/[id]/+page.svelte:509, :554, :582-585, :1577 ; frontend/src/routes/(app)/invoices/due-dates/+page.svelte:102, :265-268 ; + frontend/src/lib/features/payment-batches/payment-batch-helpers.ts:78 ; + frontend/src/routes/(app)/payment-batches/[id]/+page.svelte:40-50 ; frontend/scripts/lint-i18n-ownership.js:164-187] +- [Source: docs/manual/fr/user-manual.tex:1045-1052, :1064-1072, :1295-1305, :1346-1360, :1525 ; + docs/api-external.md:253-283, :285-287, :307, :397-415] +- [Source: frontend/tests/e2e/bank-account-journal-link.spec.ts:105-119, invoices_echeancier.spec.ts:143-146 ; + crates/kesh-db/src/test_fixtures.rs:129-139 (seed sans rôle) ; crates/kesh-api/src/lib.rs:207-210 (réglages, Admin)] +- [Source: crates/kesh-db/src/repositories/company_invoice_settings.rs:408, :438-441 (même colonne d'arrondi) ; + crates/kesh-db/src/repositories/invoices.rs:2008-2010 ; crates/kesh-db/src/repositories/credit_notes.rs:205, :511 ; + frontend/src/routes/(app)/settings/invoicing/+page.svelte:29 ; issues #525, #537 ; C36 (15-5d), C-15-6-30] +- [Source: issue #474 ; `epic-15-choix-autonomes.md` C-15-6-1, C-15-6-3, C-15-6-4, C-15-6-9 à C-15-6-12, C-15-6-14, C-15-6-17 à C-15-6-20, C-15-6-26, C-15-6-30, C-15-6-33, C-15-6-34 ; C36, C52, C54, C55 au registre de la branche des 15-5 (arrivent au rebase, T0)] +- [Source: docs/manual/fr/admin-manual.tex:2027, :2029, :2033 (AC11, verdict) ; frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte:82-91 (effet de réinitialisation) ; crates/kesh-api/src/errors.rs:52-59, :88-92 (repli `t_args` formaté)] + +## Dev Agent Record + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List + +## Change Log + +- 2026-10-08 — Spécification initiale (bmad-create-story, en autonomie). Statut `ready-for-dev`. +- 2026-10-08 — **Validation P1** (Sonnet 4.6, lentilles F et R ; prompt + `15-6b-validate-prompt-p1.md`). F : 2 HIGH, 5 MEDIUM, 2 LOW ; R : 2 HIGH, 5 MEDIUM, 2 LOW (R3 = F1, + R4 = F2, R5 = F3). Appliqués : + - **HIGH F1 (= R3 MEDIUM)** — #524 : sorti vers une sous-story **15-6d** (le finding F4 de découpage + la rendait nécessaire), avec le même refus que le flux ventilé ; C-15-6-6 révisé par C-15-6-9 ; + l'angle mort « voisin hors périmètre » est retiré de cette fiche. + - **HIGH F2 (= R4 MEDIUM)** — `confirm_batch`, troisième appelant de `pay_in_tx` : refus **à la + création du lot**, par facture, avant tout fichier pain.001, et garde maintenue à la confirmation + (AC6, tests 11–12, manuel, api-external). Choix C-15-6-10. + - **HIGH R1** — test 5 irréalisable (un seul compte bancaire par lot) : refait avec deux factures à + créances différentes (test 7). + - **HIGH R2** — test « fr et de par `Accept-Language` » irréalisable (locale globale, + `init_error_i18n`) : test fr + parité des clés (test 13). + - **MEDIUM F3 = R5** — #492 est en **15-5c**, pas en 15-5b : dépendance, AC7, pièges corrigés ; + registre révisé (C-15-6-12). + - **MEDIUM F4** — découpage (plus de cinq modules avec #524 et les lots) : 15-6d créée. + - **MEDIUM F5** — ordre vis-à-vis de `BANK_ACCOUNT_NOT_CONFIGURED`, `INVOICE_SALE_ENTRY_MALFORMED` + et `RECONCILIATION_OVERPAYMENT` écrit (AC5) et testé (test 8). + - **MEDIUM F6** — `docs/api-external.md` (AC10). + - **MEDIUM F7** — liste de comptes bancaires vide écrite à l'écran (AC8, tests 15–16). + - **MEDIUM R6** — « le test 1 fige le trop-perçu » était faux : test 4 distinct. + - **MEDIUM R7** — DRY : la garde client compare l'id du lecteur de la 15-6a ; côté fournisseur, la + requête diffère (crédit + TTC) et devient un lecteur sœur partagé par `pay_in_tx` et la création + de lot (C-15-6-11). + - **LOW F8** — propriétaire unique de la section `[0.13.0]` (AC12) ; lignes de la fiche fournisseur + corrigées (`:82-83`). **LOW F9** — T7 « grep de formes » remplacé par un inventaire fermé des + sites (§ *Inventaire*, T8). **LOW R8** — décomptes refaits. **LOW R9** — ids calculés sur la liste + complète (AC8, test 14) ; route du règlement client nommée ; AC9 « vérifié, aucun changement » + + test ; lignes de la fiche fournisseur. + - Signature des fonctions d'écran fixée pour être partagée avec la 15-6c (C-15-6-14, finding F2 de + la 15-6c). + - Décompte après passe : **12 AC, 9 tâches, 17 tests** (13 backend, 4 fichiers Vitest). +- 2026-10-08 — **Validation P2** (Opus 5.5, lentilles R et F ; prompt `15-6b-validate-prompt-p2.md`). + R : 0 CRITICAL, 0 HIGH, 3 MEDIUM, 9 LOW ; F : 0 CRITICAL, 0 HIGH, 5 MEDIUM, 10 LOW (R-1 = F1, + R-2.1 = F9, R-6 = F7, R-7 = F10, R-9 ⊃ F12, R-10 = F13, R-11 = F8). Tous appliqués, décisions de + l'orchestrateur comprises : + - **MEDIUM R-1 = F1** — les écrans ne reçoivent pas les archivés : l'AC8 y **renonce** (un compte + archivé ne peut recevoir aucune écriture, la garde serveur suffit) ; les ids se calculent avant le + filtre `active && postable` (cas non imputable, test 17). Choix C-15-6-17. + - **MEDIUM F2** — clés renommées pour `lint-i18n-ownership` (`invoices-settle-…`), sans allonger + `KNOWN_VIOLATIONS` ; clé fournisseur alignée (`supplier-invoices-pay-…`). Choix C-15-6-17. + - **MEDIUM F3** — « résolu par le type » ne tient pas (changement de type confirmé) : les comptes + d'arrondi et de solde du reste sont **comparés par identifiant** à la créance (AC3 bis, tests + 10–12). Choix C-15-6-18, qui révise C-15-6-3. + - **MEDIUM F4** — lot bloqué à la confirmation : message propre, `details` (lot, facture) et manuel + disent les deux issues (relier de nouveau, ou annuler le lot) ; test 15 vérifie le remède. Le refus + à la création reste la défense principale. Choix C-15-6-19. + - **MEDIUM F5** — modules recomptés et écrits (§ *Décompte des modules*) : huit au barème des + fichiers, cinq au barème du geste ; **signal D5 déclaré au Project Lead**, pas de découpage + (gestes dépendants). Choix C-15-6-20. + - **MEDIUM R-2** — `ACCOUNT_NOT_POSTABLE` (15-5a) dans l'ordre des refus (AC3, AC4) ; tâche T0 de + rebase ; commentaire de la 15-5b dans `accept_one_invoice` amendé (AC5). + - **MEDIUM R-3** — le filtre de `ManualMatchModal` relève de la 15-6d (en-tête, inventaire), qui le + tranche sans conditionnel. + - **LOW** — R-4 : `:83-84` ; R-5 : `:120`, `usable_designated_account` `:484-507` ; R-6 = F7 : + fichier de la confirmation et « vérifié, aucun changement » ; R-7 = F10 : signature de + `validate_invoice_for_batch`, écriture d'achat malformée → `failed[]` (test 14) ; R-8 : six → huit + clés ; R-9 = F12 : décompte des clés, parité = présence ; R-10 = F13 : test du dialogue = rendu de + la prop, dit ; R-11 = F8 : inventaire complété par ses propres commandes (backend et écrans) ; + R-12 : 1020 créé, tests verts d'avance exclus du « rougit d'abord » ; F6 : écran de création de + lot non filtré, choix écrit ; F11 : « contrepartie libre » corrigé au manuel ; F14 : lots dans la + table du § 10 seulement, routes nommées ; F15 : données antérieures en angle mort. + - **Tests renumérotés** (10–12 neufs pour l'AC3 bis, 14 neuf, l'ancien 10 retiré) : les numéros + cités dans l'entrée P1 ci-dessus visent l'**ancienne** numérotation. + - Décompte après passe : **13 AC (AC1–AC12 et AC3 bis), 10 tâches (T0–T9), 20 tests** (16 backend, + 4 fichiers Vitest). +- 2026-10-08 — **Validation P3** (lentilles R et F en **Sonnet** — prompt `15-6b-validate-prompt-p3.md` ; + remédiation par Opus 5.5). R : 0 CRITICAL, 0 HIGH, 2 MEDIUM, 5 LOW ; F : 0 CRITICAL, 0 HIGH, + 3 MEDIUM, 4 LOW (F8 noté par la lentille, non compté comme défaut). R1 = F1, R2 = F2. Tous appliqués, + décisions de l'orchestrateur comprises (choix C-15-6-26) : + - **MEDIUM R1 = F1** — un compte venu des **réglages** (arrondi, nature, TVA due) n'appelle pas le + remède d'une contrepartie choisie : discriminant `role` (`SettlementAccountRole`) dans la variante + et en `details.role` ; quatrième clé serveur `error-settlement-designated-account-is-receivable` + qui renvoie à *Paramètres → Facturation* (sélecteur Fluent sur `$role`) ; libellé `failed[]` du + rapprochement à deux cas (AC7) ; manuel à deux cas (AC11) ; `details` de (c-bis) fixés sans + `bankAccountId` ; dialogue de solde (`invoices/[id]/+page.svelte:509`) couvert par l'AC9 ; test 16 + bis (HTTP du solde) neuf. Un seul code conservé. + - **MEDIUM R2 = F2** — `docs/api-external.md` : ligne à la table du solde (`:274-283`) ; une phrase + « Refus » sous le § des règlements client (`:253`) et sous celui du règlement fournisseur (`:285`, + lots compris) ; le § 10, générique, n'est plus visé (AC10). + - **MEDIUM F3** — helper commun dans `invoice_settlements` : **comparaison** pure + (`ensure_not_claim_account`, empruntée par la 15-6d) séparée de la **construction** du refus + (`claim_account_refusal`, numéro lu à l'échec seulement) et des `details` + (`claim_account_refusal_details`, trois consommateurs) ; T2 l'ordonne en premier. + - **LOW** — F4 : `vat_payable_account_for_write` ne contrôle aucun type (`company_invoice_settings.rs:454-480`), + la comparaison est sa seule garde (§ *Le défaut*, AC3 bis) ; F5 : `supplier_invoice_number` relu par + `confirm_batch` (une `SELECT`), pas de champ de plus ; R7 : `accountNumber` de la création de lot lu + à l'échec par le helper ; F6 : textes fr des quatre clés d'écran (et des deux libellés de l'AC7) ; + F7 : verdict écrit sur les E2E existants, `bank-account-journal-link.spec` compris (§ *Tests*, T9) ; + R3 : `:432` → `:433`, `:110` → `:114`, `:247-249` → `:251-253` ; R4 : « six » → « huit » aux quatre + sites (jeton grepé) ; R5 : patron `CREDIT_NOTE_INVOICE_SETTLED` borné à la forme de `details` + (409, `t(`) ; R6 : relève du registre — porté par C-15-6-26, qui dit C-15-6-3 révisé (pas de + réécriture d'entrée). + - Propagation : `grep` des jetons `six`, `trois** clés`, `:432`, `:247-249`, `lecture neuve`, + « patron de CREDIT_NOTE » sur la fiche — résidus restants légitimes (Change Logs P1/P2, sites + « six » nommés pour être corrigés). + - Signal D5 : **levé** (MEDIUM → MEDIUM ; deux MEDIUM nés de la remédiation P2, contenus dans + l'AC3 bis) ; déclaré au Project Lead, pas de découpage (§ *Décompte des modules*). La P4 doit être + complète (règle métier touchée). + - Trend : P1 4 HIGH / 10 MEDIUM / 4 LOW (F + R) → P2 0 HIGH / 8 MEDIUM / 19 LOW → P3 0 HIGH / + 5 MEDIUM / 9 LOW (dédoublonnés : 3 MEDIUM distincts). + - Décompte après passe : **13 AC (AC1–AC12 et AC3 bis), 10 tâches (T0–T9), 21 tests** (17 backend + dont 16 bis neuf, 4 fichiers Vitest ; 20 neufs, 1 existant renommé et étendu). +- 2026-10-08 — **Validation P4** (Opus 5.5, lentilles R et F ; prompt `15-6b-validate-prompt-p4.md` ; + remédiation Opus 5.5). R : 0 CRITICAL, 0 HIGH, 2 MEDIUM, 6 LOW ; F : 0 CRITICAL, 0 HIGH, 3 MEDIUM, + 4 LOW (R1 = F1, R5 ⊂ F6). Tous appliqués, décisions de l'orchestrateur comprises (choix C-15-6-30) : + - **MEDIUM R1 = F1** — le compte du reste d'arrondi (5 bis) et celui de la nature `rounding` lisent + `default_rounding_account_id`, la colonne du compte d'arrondi du règlement : ils portent désormais + `role: rounding` ; `write_off_nature` ne vaut que pour `discount`, `bank_fees`, `bad_debt` (les trois + champs de la section *Solde du reste*). Le rôle suit la colonne lue, pas la route (doc-comment). + AC1, AC3 bis, AC9, AC10, AC11 ; test **10 bis** neuf (escompte à reste fractionnaire, compte + d'arrondi = créance → `rounding`). + - **MEDIUM R2** — critère de T2 réécrit : il vise la **construction** de la variante (littéral avec + champs) et la **lecture du numéro** (`grep -rn`, `-r` compris) ; les déstructurations attendues + sont énumérées hors du critère. + - **MEDIUM F2** — « résolu par le type » était faux pour la validation et l'avoir : remplacé par des + angles morts écrits — créance retypée à la validation, **#537** (ouverte par l'orchestrateur) ; TVA + due et produit de repli de l'avoir lus dans les réglages courants, **#525** (§ *Le défaut*, + inventaire, § *Angles morts*). Pas de garde neuve : deux modules de plus, hors de la classe + « règlement ». La validation d'achat (même forme côté dette) est **signalée à l'orchestrateur**. + - **MEDIUM F3** — le message de liste vide n'apparaît que si la liste filtrée est vide **et** qu'au + moins un compte bancaire a été écarté par le filtre ; une société sans compte bancaire (installation + neuve) garde le comportement actuel (AC8, tests 18 et 19). + - **LOW** — R3 : sélecteur Fluent écrit sur plusieurs lignes, repli Rust par rôle (AC2) ; R4 : verdict + E2E corrigé — le filtre par rôle est **inerte** en E2E (seed sans rôle), aucun E2E n'exerce l'AC8 + (angle mort écrit) ; R5 : `grep "payment-batch"` ne rend rien ; R6 : Dev Notes — numéro de la + facture relu à la confirmation d'un lot ; R7 : erreur SQL de la lecture du numéro à la création d'un + lot propagée (échec infra) ; R8 : transaction de `confirm_batch` ouverte `:311`, montages des tests + `:352`/`:898` (des tests, pas des helpers), `ACCOUNT_NOT_POSTABLE` fournisseur renvoyé à T0, borne + `sitesTotal` de la 15-5c à recompter (AC7) ; F4 : « un administrateur doit désigner » (C36) aux + messages, au libellé `failed[]` de l'AC7 et au manuel ; F5 : « espèces et tout autre compte » + (`user-manual.tex:1047`) corrigé avec le « libre » ; F6 : sites de l'AC10 (phrase « Refus » hors de + la table d'annulation, phrase et non liste au § du rapprochement) ; F7 : ordre des refus quand + plusieurs comptes d'écart sont la créance (nature, reste d'arrondi, TVA due). + - Propagation : jetons `write_off_nature`, `WriteOffNature`, `désignez`, `résolu par le type`, + `tout autre compte`, `payment-batch` grepés sur la fiche — résidus restants légitimes (Change Logs + P2/P3, citations du finding, test 16 bis dont la nature `discount` reste `write_off_nature`). + - **Signal D5 — levé, déclaré au Project Lead, pas de découpage.** Sévérité F : P3 3 MEDIUM → P4 + 3 MEDIUM (égale). **F1 est un recyclage** : le discriminant `role` posé par la remédiation P3 pour + donner le bon remède classait mal le compte d'arrondi du solde — un défaut né du correctif + précédent, la forme que l'amendement D5 retient comme motif de découpage. Il ne découpe pas ici, + pour trois raisons écrites : (1) le recyclage est **contenu** au même discriminant — une ligne de + classement et un test, aucun module touché de plus ; (2) les deux autres MEDIUM ne recyclent rien + (F2 date de la spécification initiale, F3 de la P1) ; (3) F2 est traité en angle mort et non en + garde, précisément pour ne pas ajouter les sixième et septième modules qui déclencheraient le + critère de dispersion. Le recyclage de la P3 (deux MEDIUM nés de la remédiation P2) et celui-ci + portent sur la même règle (AC3 bis) : si la P5 en trouve un troisième, le découpage de l'AC3 bis en + story propre est la mesure indiquée. + - Trend : P1 4 HIGH / 10 MEDIUM / 4 LOW (F + R) → P2 0 HIGH / 8 MEDIUM / 19 LOW → P3 0 HIGH / + 5 MEDIUM / 9 LOW → P4 0 HIGH / 5 MEDIUM / 10 LOW (dédoublonnés : 4 MEDIUM distincts). + - Décompte après passe : **13 AC (AC1–AC12 et AC3 bis), 10 tâches (T0–T9), 22 tests** (18 backend + dont 10 bis et 16 bis, 4 fichiers Vitest ; 21 neufs, 1 existant renommé et étendu). +- 2026-10-08 — **Validation P5** (Sonnet, lentilles R et F ; prompt `15-6b-validate-prompt-p5.md` ; + remédiation Opus 5.5). R : 0 CRITICAL, 0 HIGH, 3 MEDIUM, 5 LOW ; F : 0 CRITICAL, 0 HIGH, 3 MEDIUM, + 5 LOW (R5-3 = F2). Tous appliqués, décision structurante de l'orchestrateur comprise (choix + **C-15-6-33**) — **une seule règle d'ordre des verrous pour l'epic, celle de la 15-5e** : + - **MEDIUM F1** — la fiche ignorait la **15-5e** : dépendances « après 15-5a à 15-5e » (en-tête, + T0) ; étapes citées **par leur nom**. La 15-5e, **réécrite par C54** pendant cette remédiation + (correction de consigne de l'orchestrateur), ne réordonne plus `settle_invoice` ni + `write_off_invoice` : elle **rejoue** leurs routes (et celles du paiement fournisseur et de la + confirmation d'un lot) et réécrit le commentaire « 5 bis ». L'ordre des refus de l'AC3 bis reste + celui des lectures du code — **nature, puis reste d'arrondi, puis TVA due** au solde ; + **contrepartie, puis trop-perçu, puis compte d'arrondi** au règlement —, désormais énoncé comme + règle (chaque comparaison suit sa lecture). + - **MEDIUM R5-1** — le sélecteur Fluent `$role` aurait rougi + `no_new_select_expression_reaches_the_frontend_dictionary` (`loader.rs:482`) : **trois clés + plates**, une par rôle désigné (comme la 15-5c) ; le repli Rust par `match` disparaît ; six clés + serveur au lieu de quatre. + - **MEDIUM R5-2** — la borne `sitesTotal` **existe** (1868) : relevée pour **tous** les sites neufs, + comptés (+4, +6 avec la 15-5c), ventilation recomptée sur l'état rebasé (AC8, T0, T9). + - **MEDIUM R5-3 = F2** — le critère de T2 était muet sur un littéral que rustfmt coupe : critère par + le **nom seul** (`grep -rnF`, commentaires exclus) sur une **liste fermée** de cinq fichiers, chaque + occurrence lue. + - **MEDIUM F3** — `user-manual.tex:1754` (balance âgée) citait le règlement sur le compte débiteurs + comme un cas ordinaire : ajouté à l'AC11, reformulé en données antérieures, contrôlé au PDF aplati. + - **LOW** — R5-4 : le **produit de repli** de l'avoir n'est **pas** couvert par #525 (vérifié par + `gh api` sur le corps et les commentaires) : écrit « non tracé », **signalé à l'orchestrateur** ; + R5-5 : modules recomptés, **neuf** (le helper commun `invoice_settlements.rs` manquait) ; R5-6 : + verdict E2E complété (`invoices.spec.ts:318-321`, `reminders.spec.ts:104-112`, + `invoice-write-off.spec.ts:70-85`) ; R5-7 : test 11 placé dans `invoice_settlement.rs`, montage + d'arrondi nommé, `:180-183` → `:182-185` ; R5-8 : colonne « issues » de la fiche d'index complétée + (angles morts #537, #525) ; F4 : `:1047` → `:1048` ; F5 : `Payable` + rôle désigné rendu + irreprésentable par un sujet `ClaimSubject` au helper de construction ; F6 : chaque comparaison suit + sa lecture, une nature égale à la créance précède le `CONFIGURATION_REQUIRED` de la TVA due — test + **10 ter** neuf ; F7 : phrase « Refus » du paiement fournisseur posée avant la ligne de refus de + l'annulation, libellée par sa route ; F8 : pour mémoire (registre non réécrit ; la fiche d'index dit + les révisions de C-15-6-3). + - Propagation : jetons `15-5a, 15-5b, 15-5c et 15-5d`, `designated-account-is-receivable`, + `sélecteur`, `$role`, `(4bis)`, `(5 bis)`, `étape (3)`, `étape (4)`, `huit`, `:1047`, `180-183`, + `#525` grepés sur la fiche — résidus restants légitimes (Change Logs P1-P4 ; étapes de `pay_in_tx`, + que la 15-5e ne touche pas). + - Signal D5 : § *Décompte des modules*, « P5 » — levé par la sévérité, **non** déclenché : F1 vient + d'un changement extérieur (la 15-5e), non d'un patch ; les deux recyclages (R5-1, F2) sont hors de + l'AC3 bis. + - Trend : P1 4 HIGH / 10 MEDIUM / 4 LOW (F + R) → P2 0 HIGH / 8 MEDIUM / 19 LOW → P3 0 HIGH / + 5 MEDIUM / 9 LOW → P4 0 HIGH / 5 MEDIUM / 10 LOW → P5 0 HIGH / 6 MEDIUM / 10 LOW (dédoublonnés : + 5 MEDIUM distincts). La remédiation P5 change l'ordre des refus et les clés i18n : une **P6 + complète** (Opus, rotation D6) est indiquée. + - Décompte après passe : **13 AC (AC1–AC12 et AC3 bis), 10 tâches (T0–T9), 23 tests** (19 backend + dont 10 bis, 10 ter et 16 bis, 4 fichiers Vitest ; 22 neufs, 1 existant renommé et étendu). +- 2026-10-08 — **Validation P6 — clôture** (Opus 5.5, lentilles R et F ; prompt + `15-6b-validate-prompt-p6.md` ; rapports `target/gate-logs/15-6b-p6-R.md`, `15-6b-p6-F.md` ; + remédiation Opus 5.5). R : 0 CRITICAL, 0 HIGH, 0 MEDIUM, 11 LOW ; F : 0 CRITICAL, 0 HIGH, 0 MEDIUM, + 7 LOW (R6-4 = F6-6 ; F6-7 est une observation). **0 au-dessus de LOW : la boucle s'arrête** (§ *Review + Iteration Rule*). Tous les LOW appliqués (choix C-15-6-34) : + - R6-1 : test 11 sans renvoi à la « concurrence 1 » de la 15-5e, retirée par C55 (le helper + `raw_due_invoice` existe toujours, `invoice_echeancier_e2e.rs:986`). + - R6-2, R6-3 : le **produit de repli** de l'avoir est tracé par **#525**, la **validation d'achat** + par **#537** (commentaires de l'orchestrateur du 2026-10-08) — « non tracé » / « signalé » + corrigés au § *Le défaut*, à l'inventaire et aux angles morts (le Change Log P5 reste historique). + - R6-4 = F6-6 : la règle de placement de l'AC3 bis dit « suit la lecture de son compte **et** celle + de la créance » — la nature est lue avant la créance (`:433`, puis `:434-445`) ; aucun + réordonnancement. + - R6-5 : repli de `t_args` construit par `format!` (patron `errors.rs:88-92`). + - R6-6 : fiche d'index — « un message par rôle (six clés) ». + - R6-7 : `:409` → `:408`, `:454-480` → `:454-477`, table de l'annulation `:262-271` → `:259-268`. + - R6-8 : `DesignatedRole` placé dans `invoice_settlements.rs` avec `ClaimSubject`, et cité au décompte. + - R6-9 : test 10 ter — TVA due vidée **après** validation. + - R6-10 : AC9 — le message `rounding` arrive aussi par le dialogue de **règlement**. + - R6-11 : C-15-6-30, C-15-6-33, C-15-6-34 aux Références ; C36, C52, C54, C55 arrivent au rebase (T0). + - F6-1 : **manuel d'administration** — verdict ligne par ligne (`:2027` inchangée ; une phrase à + `:2029` ; « ou devenu le compte débiteurs de la facture » à `:2033`) ; frontière de la 15-6c alignée. + - F6-2 : deux lignes « hors classe » à la table des écrans (`routes/(app)/+page.svelte:58`, + `account-validity.ts:20`). + - F6-3 : présélection dans un **effet distinct**, qui n'efface pas la saisie en cours ; test 18 étendu. + - F6-4 : test 8 — transaction à référence de facture, score positif (étape 7bis avant (a)-(c)) ; AC5 : + tous les refus antérieurs gardent la priorité. + - F6-5 : clés de lot ajoutées par le mapping HTTP seul, hors de `claim_account_refusal_details`. + - F6-7 : décompte au barème de la règle (neuf modules) écrit et déclaré (§ *Décompte des modules*, P6). + - Propagation : jetons `non tracé`, `signalé à l'orchestrateur`, `aucune issue`, `concurrence 1`, + `aussitôt`, `:409`, `454-480`, `262-271`, `DesignatedRole`, `deux messages` grepés sur les fiches + 15-6 — résidus restants légitimes (Change Logs P4/P5, citations des findings) ; `:558` de la 15-6a + corrigé dans le même commit. + - **Signal D5 — dispersion déclarée au Project Lead, non découpée** : neuf modules métier au barème + de la règle, plus de cinq ; le « barème du geste » n'est plus invoqué. Raison écrite (§ *Décompte + des modules*, P6) : fiche validée et convergée, gestes liés par une variante, un sujet typé, un + lecteur sœur et trois fonctions d'écran ; le risque visé par la règle (non-convergence) ne s'est + pas réalisé. Arbitrage de Guy en revue de fin d'epic. + - **Trend complet** : P1 (Sonnet) 4 HIGH / 10 MEDIUM / 4 LOW (F + R) → P2 (Opus) 0 HIGH / 8 MEDIUM / + 19 LOW → P3 (Sonnet) 0 HIGH / 5 MEDIUM / 9 LOW → P4 (Opus) 0 HIGH / 5 MEDIUM / 10 LOW → P5 (Sonnet) + 0 HIGH / 6 MEDIUM / 10 LOW → **P6 (Opus) 0 HIGH / 0 MEDIUM / 18 LOW** (17 distincts). Aucun + CRITICAL en six passes. Remédiations : Opus 5.5 (P2 à P6). Reclassements : aucun MEDIUM+ reclassé + en dette ; la validation et l'avoir (F2 de la P4) sont des angles morts **tracés** (#537, #525). + - Décompte après passe : **13 AC (AC1–AC12 et AC3 bis), 10 tâches (T0–T9), 23 tests** (19 backend + dont 10 bis, 10 ter et 16 bis, 4 fichiers Vitest ; 22 neufs, 1 existant renommé et étendu) — + inchangé (le test 18 gagne un cas, sans test neuf). Statut : `ready-for-dev`. diff --git a/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p1.md new file mode 100644 index 00000000..589846d6 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p1.md @@ -0,0 +1,34 @@ +# Prompt — validation P1 de la spec, Story 15-6b + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md`. C'est la première passe : la fiche n'a pas encore été remédiée. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 474, 524. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6, sœurs 15-6a et 15-6c. ⚠️ L'issue #524 (rapprochement manuel et acceptation par règle acceptent le compte de la banque comme contrepartie) a été ouverte APRÈS l'écriture de la fiche : l'orchestrateur décide qu'elle entre dans la 15-6b — signale ce qu'il faut ajouter. La 15-5b (dépôt principal, `/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5b-gardes-surfaces-neuves.md`, lecture seulement) touche les mêmes flux de rapprochement : vérifie la frontière. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Auditeur d'acceptation** : applique `.claude/skills/bmad-create-story/checklist.md`. Chaque phrase des issues couverte par un AC testable, chaque AC par une tâche, chaque tâche par un test ; implémentable sans deviner (fonctions, fichiers, codes d'erreur, clés i18n 4 locales, replis Svelte) ; faits cités revérifiés au code (`grep -nF`) ; recompte des AC et tâches. +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +Rapport compact. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p2.md new file mode 100644 index 00000000..a156b414 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p2.md @@ -0,0 +1,35 @@ +# Prompt — validation P2 de la spec, Story 15-6b + +*Versionné le 2026-10-08. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md`. La dernière remédiation est le commit `cb54ff72` (`git show cb54ff72`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 474. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d. Les fiches 15-5a/b/c du dépôt principal (`/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5*.md`, lecture seule) touchent des fichiers voisins et seront mergées AVANT 15-6b/c/d : vérifie la frontière. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show cb54ff72`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6b-p2-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p3.md new file mode 100644 index 00000000..4d1c0fcf --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p3.md @@ -0,0 +1,35 @@ +# Prompt — validation P3 de la spec, Story 15-6b + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md`. La dernière remédiation est le commit `4964eeb4` (`git show 4964eeb4`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 474. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d. Les fiches 15-5a/b/c/d du dépôt principal (`/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5*.md`, lecture seule) seront mergées AVANT : vérifie la frontière. Axes : AC3 bis (TVA due du solde du reste), contexte de lot (SettlementBatchContext), code SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 4964eeb4`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6b-p3-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p4.md new file mode 100644 index 00000000..0cd92444 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p4.md @@ -0,0 +1,35 @@ +# Prompt — validation P4 de la spec, Story 15-6b + +*Versionné le 2026-10-08. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md`. La dernière remédiation est le commit `89e8987a` (`git show 89e8987a`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 474. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d ; les fiches 15-5a/b/c/d (dépôt principal, lecture seule) sont mergées AVANT. Axes : details.role et les deux familles de refus, ensure_not_claim_account (helper commun), api-external. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 89e8987a`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6b-p4-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p5.md b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p5.md new file mode 100644 index 00000000..c580e9c1 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p5.md @@ -0,0 +1,35 @@ +# Prompt — validation P5 de la spec, Story 15-6b + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md`. La dernière remédiation est le commit `bc4986d2` (`git show bc4986d2`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 474, 537, 525. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; les fiches 15-5a/b/c/d/e (dépôt principal, lecture seule ; la 15-5a est MERGÉE sur main) passent avant. Axes : role rounding, critère de T2, message de liste vide conditionné. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show bc4986d2`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6b-p5-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p6.md b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p6.md new file mode 100644 index 00000000..9ad814e1 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6b-validate-prompt-p6.md @@ -0,0 +1,35 @@ +# Prompt — validation P6 de la spec, Story 15-6b + +*Versionné le 2026-10-08. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md`. La dernière remédiation est le commit `e871cd58` (`git show e871cd58`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 474, 537, 525. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Index 15-6 ; dépend des 15-5a à 15-5e (dépôt principal, lecture seule : la 15-5e a été RÉÉCRITE selon C54 — rejeu sur interblocage, b92f3319). 15-5a mergée sur main. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show e871cd58`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6b-p6-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6c-configuration-sans-ecriture-nulle.md b/_bmad-output/implementation-artifacts/15-6c-configuration-sans-ecriture-nulle.md new file mode 100644 index 00000000..cf687ce1 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6c-configuration-sans-ecriture-nulle.md @@ -0,0 +1,631 @@ +# Story 15.6c : La configuration ne prépare plus l'écriture nulle + +## Status + +ready-for-dev + + + +**Issue** : `closes #474` (P1, avec la 15-6b) — mot-clé **dans la PR**. **Mère** : +`15-6-creance-juste-avoir-reglement.md`. + +⛔ **Ne commence qu'après le merge de la 15-5b**, qui modifie les **mêmes fonctions** : +`validate_journal_account_id` (`crates/kesh-api/src/routes/bank_accounts.rs:260`, paramètre +`require_postable` ajouté), `create_bank_account` (`:384`), `update_bank_account` (`:498`), +`patch_bank_account_journal_link` (`:717`), le dépôt `crates/kesh-db/src/repositories/bank_accounts.rs` +(`set_journal_account_id_for_company` `:265`, `update_for_company` `:356`, contrôle « dans la +transaction » de son choix C10), le PUT des réglages (`company_invoice_settings.rs`, ses AC10, AC11 et +AC19) et l'écran des comptes bancaires (son AC13 : `withCurrentAccount` sur les deux `` n'offrent ni le compte débiteurs ni le compte créanciers désignés, et + affichent un lien existant vers 1100 sans le perdre ; `getInvoiceSettings` en échec → menus non + filtrés, page affichée. +15. `frontend/src/routes/(app)/settings/invoicing/settings-invoicing-page.test.ts` (existant) : les + menus du compte débiteurs et du compte créanciers (15-5d) n'offrent pas un compte lié à un compte + bancaire, gardent la valeur en place, et les autres menus d'actif et de passif (TVA) sont intacts ; + `listBankAccounts` en échec → pas de filtre. + +E2E : `bank-account-journal-link.spec.ts` modifiée (inventaire ci-dessus). + +Soit **16 tests nommés** (13 backend — 1 à 12 et 12 bis —, 3 fichiers Vitest) et **1 spec E2E +modifiée** ; s'y ajoutent les **adaptations** des tests Vitest existants (§ *Tests existants qui +changent de sens*, quatre rendus et un mock), qui ne sont pas des tests neufs. + +## Dev Notes + +### Angles morts assumés + +- **Rôle sans réglage** : la garde suit les **réglages** (les comptes qu'utiliseront les ventes et + achats futurs), pas le rôle ; l'écran aussi (AC7). Un compte de rôle `Receivable` qui ne serait plus + le réglage débiteurs reste liable. +- **Données antérieures** : un lien fautif existant n'est pas défait (pas de migration de données — le + produit ne tient pas encore de comptabilité réelle, `CLAUDE.md` § *Project Overview*) ; il est refusé + à l'usage (15-6b) et signalé à la modification. +- **Import d'une sauvegarde** (`.keshbackup`) : il restaure tables et liens sans repasser par ces + routes ; un couple fautif présent dans la sauvegarde revient tel quel. Filet : la garde à l'usage de + la 15-6b. +- **Création des réglages à l'onboarding** (`company_invoice_settings::insert_with_defaults_in_tx`, + désignation par rôle) : elle ne consulte pas les comptes bancaires. Un compte bancaire déjà lié au + compte de rôle `Receivable` à ce moment n'est pas détecté ; même filet. + +### Latence — ce que les verrous ajoutent, sans le présenter comme un défaut + +- Tout geste bancaire **vers un compte** (création, remplacement, lien) prend un S sur la ligne des + réglages ; il **attend** donc toute transaction qui tient cette ligne en X : PUT des réglages, mais + aussi validation de facture, avoir et facture fournisseur (`get_or_create_default_in_tx`, + `FOR UPDATE`, `invoices.rs:2006`, `credit_notes.rs:361`, `supplier_invoices.rs:359`) — le temps + d'une transaction. Une dé-liaison n'attend pas (AC2). +- Le lecteur bancaire du PUT pose des verrous d'intervalle (AC2) : une création de compte bancaire + concurrente peut attendre le PUT. Raisonné au code, **non mesuré**. + +### Pièges + +- **Respecter l'ordre et l'emplacement de la 15-5b** : sa garde de postabilité et celle-ci ne doivent + pas se court-circuiter. Les deux vivent au même endroit — dans la transaction, après le contrôle de + version et, pour le lien, après son no-op ; sous la condition « nouvelle valeur ≠ + `existing.journal_account_id` » ; la postabilité d'abord, puis le compte de créance. +- ⛔ **`FOR SHARE` n'existe pas en MariaDB 10.11** : `LOCK IN SHARE MODE` (AC2, + `opening_complement.rs:38-39`). Le défaut a été écrit en P1 et attrapé en P2 ; un test de + sérialisation (11, 12, 12 bis) le révélerait en 500 dès sa première exécution. +- **Attendre un verrou, c'est le prouver** : `attendre_une_requete_en_cours` (tests 11, 12, 12 bis), + jamais un délai fixe. +- **Respecter l'ordre des verrous** (AC2) : réglages, puis comptes bancaires. Lire les réglages + **après** le `FOR UPDATE` de la ligne bancaire réintroduirait un interblocage entre les deux gestes. +- `default_receivable_account_id` / `default_payable_account_id` peuvent être `NULL` : pas de refus + dans ce cas. + +### Références + +- [Source: crates/kesh-api/src/routes/bank_accounts.rs:258-294, :384-600, :717-770] +- [Source: crates/kesh-db/src/repositories/bank_accounts.rs:237-420, :579-598 (verrou sentinelle)] +- [Source: crates/kesh-api/src/routes/company_invoice_settings.rs:246-400 ; + crates/kesh-db/src/repositories/company_invoice_settings.rs:147-240] +- [Source: frontend/src/routes/(app)/bank-accounts/+page.svelte:77-85, :321-330, :437, :472-481 ; + frontend/src/lib/features/bank-accounts/BankAccountJournalLinkForm.svelte:44-60 ; + frontend/src/routes/(app)/settings/invoicing/+page.svelte:65, :85-111, :195-196] +- [Source: crates/kesh-db/src/test_fixtures.rs:80-172 ; frontend/tests/e2e/bank-account-journal-link.spec.ts:105-119] +- [Source: crates/kesh-db/src/repositories/opening_complement.rs:38-39, :502, :540 ; crates/kesh-api/src/lib.rs:190-343, :339-341, :664-671 ; + crates/kesh-api/tests/admin_pat_denied_e2e.rs:64 ; frontend/src/lib/features/bank-accounts/BankAccountList.svelte] +- [Source: docs/manual/fr/user-manual.tex:237-242 ; docs/manual/fr/admin-manual.tex:2013, :2017-2035 (insertion après :2033)] +- [Source: crates/kesh-db/src/test_fixtures.rs:560 (`attendre_une_requete_en_cours`) ; crates/kesh-db/tests/credit_notes_repository.rs:594-628 ; + docs/api-external.md § 7, § 10 ; frontend/src/routes/(app)/settings/invoicing/settings-invoicing-page.test.ts:27-32] +- [Source: `15-5b-gardes-surfaces-neuves.md` AC10–AC13, AC19, choix C3, C4, C5, C10, C12, C25, C26 ; + `15-5d-garde-usage-comptes-reglage.md` AC5, AC8, choix C34 (dépôt principal, branche + `story/15-5-gardes-postabilite-serveur`)] +- [Source: issue #474 ; `epic-15-choix-autonomes.md` C-15-6-1, C-15-6-5, C-15-6-13 à C-15-6-15, C-15-6-21, C-15-6-22, C-15-6-27] + +## Dev Agent Record + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List + +## Change Log + +- 2026-10-08 — Spécification initiale (bmad-create-story, en autonomie). Statut `ready-for-dev`. +- 2026-10-08 — **Validation P1** (Sonnet 4.6, lentilles F et R ; prompt + `15-6c-validate-prompt-p1.md`). F : 2 HIGH, 4 MEDIUM, 3 LOW ; R : 1 HIGH, 5 MEDIUM, 4 LOW (R3 = F5, + R7 = F4). Appliqués : + - **HIGH F1** — contrôle des réglages hors transaction : déplacé **dans** `company_invoice_settings::update`, + contre `before` lu sous verrou ; ordre des erreurs écrit, route par route (AC5, AC6). + - **HIGH F2** — course entre les deux gestes : **fermée** par `FOR SHARE` *(graphie fausse, corrigée en P2 : `LOCK IN SHARE MODE`)* / `FOR UPDATE` et un ordre + de verrous unique (AC2, tests 11–12) ; l'angle mort est supprimé (C-15-6-13, qui révise + C-15-6-5). + - **HIGH R1** — AC6 périmé par l'AC13 de la 15-5b : l'écran vise désormais le filtre + `eligibleAccounts` de `BankAccountJournalLinkForm`, avant `withCurrentAccount` (AC7). + - **MEDIUM F3** — écran filtré sur les **ids désignés dans les réglages**, non sur le rôle (AC7). + - **MEDIUM F4 (= R7 LOW)** — l'écran des réglages ne propose plus en débiteurs un compte lié (AC8) ; + la lecture de `err.message` est vérifiée, la question retirée. + - **MEDIUM F5 = R3** — destinations du manuel fixées (AC9). + - **MEDIUM F6 = R6** — variantes `DbError` et fonctions de dépôt nommées, contrôle de création placé + dans la transaction de la route, lecteurs uniques (AC1–AC3). + - **MEDIUM R2** — signature des fonctions d'écran partagée avec la 15-6b (C-15-6-14). + - **MEDIUM R4** — tests existants inventoriés au seed ; la spec E2E qui liait au 1100 lie au 1000 + (C-15-6-15). + - **MEDIUM R5** — compte créanciers comparé à sa **valeur résolue** (AC19 de la 15-5b), test dédié + (test 8). + - **LOW F7** — propriétaire unique de la section `[0.13.0]` (AC11). **LOW F8** — angles morts + « import de sauvegarde » et « création des réglages à l'onboarding » écrits. **LOW F9** — tests + « réglage NULL » et « deux comptes bancaires, premier par id » (tests 4, 7) ; seed vérifié. + **LOW R8** — une clé par `claim` nommant le bon compte (AC1). **LOW R9** — refus typé et traduit + sur une route aux refus en français en dur : dit (AC1). **LOW R10** — fichier de test du PUT des + réglages fixé (neuf) ; `docs/api-external.md` (AC10). + - Décompte après passe : **11 AC, 9 tâches (T0–T8), 15 tests** (12 backend, 3 fichiers Vitest) + + 1 spec E2E modifiée. +- 2026-10-08 — **Validation P2** (Opus 5.5, lentilles R et F ; prompt `15-6c-validate-prompt-p2.md`). + R : 0 CRITICAL, 1 HIGH, 3 MEDIUM, 6 LOW ; F : 0 CRITICAL, 1 HIGH, 3 MEDIUM, 4 LOW (R2-1 = F1, + R2-2 = F4, R2-3 = F3, R2-8 = F5, R2-10 = F7). Tous appliqués, décisions de l'orchestrateur comprises : + - **HIGH R2-1 = F1** — `FOR SHARE` (né de la remédiation P1) est une erreur de syntaxe sur MariaDB + 10.11 : **`LOCK IN SHARE MODE`** dans les deux lecteurs, lecteur renommé + `claim_accounts_in_share_mode`, piège écrit ; valeur grepée sur les quatre fiches et le registre. + Choix C-15-6-21. + - **MEDIUM F2** — route des réglages : le **409 d'abord**, puis le refus (le client voit l'état + courant avant qu'on juge son changement) ; AC6, test 9 (→ 409). Choix C-15-6-21, qui révise + l'ordre de C-15-6-13. + - **MEDIUM R2-3 = F3** — `CLAIM_ACCOUNT_LINKED_TO_BANK_ACCOUNT` n'est pas destiné aux intégrateurs + (route fermée aux clés) : AC1 (destinataires), AC10 (un seul code au guide), test 8. Choix + C-15-6-21. + - **MEDIUM R2-2 = F4** — tests 6 et 14 **étendent** les fichiers créés par la 15-5b, pas de doublon ; + T0 vérifie leur existence. + - **MEDIUM R2-4** — appels directs au dépôt inventoriés (14 sites de test) et tranchés : + `ClaimAccounts::default()`. Choix C-15-6-22. + - **15-5d** (consigne de l'orchestrateur) : le compte créanciers devient corrigeable à l'écran ; son + menu est filtré (AC8), son refus atteignable par l'écran (AC5, test 8, test 15). + - **LOW** — R2-5 : références de fixture `:80-172` ; R2-6 : place exacte du contrôle (après la + version, après le no-op du seul lien) ; R2-7 : « premier verrou après la sentinelle », ordre global + `companies → réglages → bank_accounts` ; R2-8 = F5 : interblocage préexistant de l'`INSERT IGNORE` + écrit dans la note KF-004 réécrite, non promis comme résolu ; R2-9 : frontière 15-5b/15-5d/15-6a + au manuel d'administration, paragraphe à la fin du bloc LTVA qui porte déjà tous les réglages de + facturation ; R2-10 = F7 : échec de `getInvoiceSettings()` / `listBankAccounts()` → pas de filtre ; + F6 : `BankAccountList.svelte`, code mort, supprimé, prop obligatoire (C-15-6-22) ; F8 : voir + ci-dessous. + - **Signal de découpage (F8), déclaré au Project Lead** : la sévérité ne baisse pas (HIGH en P1, + HIGH en P2) et le HIGH de la P2 **naît de la remédiation P1** — c'est le cas de recyclage que + l'amendement D5 désigne comme déclencheur ; les modules dépassent cinq (`kesh-db` comptes bancaires + et réglages, `kesh-api` deux routes, `frontend` deux écrans). **Non découpée** (choix C-15-6-22) : + le défaut est une graphie de mot-clé, non une conception qui tourne en rond, et séparer les deux + sens casserait l'ordre de verrous qui les tient ensemble. **Arbitrage de Guy attendu.** + - Décompte après passe : **11 AC, 9 tâches (T0–T8), 15 tests** (12 backend, 3 fichiers Vitest) + + 1 spec E2E modifiée ; un fichier supprimé (`BankAccountList.svelte`). +- 2026-10-08 — **Validation P3** (Sonnet, lentilles R et F ; remédiation Opus 5.5 ; prompt + `15-6c-validate-prompt-p3.md`). R : 0 CRITICAL, 0 HIGH, 1 MEDIUM, 5 LOW ; F : 0 CRITICAL, 0 HIGH, + 2 MEDIUM, 6 LOW (R3-2 = F3, R3-6 = F6). Tous appliqués, décisions de l'orchestrateur comprises + (choix C-15-6-27) : + - **MEDIUM F1** — le `FOR UPDATE` de `before`, clé de la sérialisation côté réglages, n'était épinglé + par aucun test (11 et 12 passent avec un `SELECT` simple) : **test 12 bis** neuf (transaction tenue + en S par `claim_accounts_in_share_mode`, le PUT attend au `FOR UPDATE`), qui rougit par mutation ; + le test 12 dit ce qu'il ne prouve pas. + - **MEDIUM F2** — attente des tests 11, 12, 12 bis prouvée par `attendre_une_requete_en_cours` + (`test_fixtures.rs:560`), motifs écrits ; plus de délai fixe. + - **MEDIUM R3-1** — paragraphe du manuel d'administration placé **après** `:2033` (« Quand un + paiement… »), avant `:2035` ; borne `:2017-2033` (AC9, Références). **LOW F5** — il présente + d'abord les comptes débiteurs et créanciers, absents du manuel d'administration. + - **LOW R3-2 = F3** — `$account` (numéro, repli `#`), `$bank` (`bank_name`) ; numéro lu après + décision du refus, sans verrou ; cas `None` (AC1). + - **LOW F4** — tests Vitest existants inventoriés : quatre rendus de + `BankAccountJournalLinkForm.test.ts` reçoivent `claimAccountIds: new Set()`, mock de + `bank-accounts.api` ajouté à `settings-invoicing-page.test.ts` ; aucun ne change de sens. + - **LOW R3-6 = F6** — portée réelle du verrou (next-key / intervalle, attente possible d'un `INSERT` + concurrent, non mesurée) ; latence des gestes bancaires derrière les validations de pièces écrite + (Dev Notes, *Latence*). + - **LOW F7** — dé-liaison `null` (et création/remplacement sans compte) : ni lecture des réglages ni + verrou S, `ClaimAccounts::default()` (AC2, AC4). + - **LOW F8** — `docs/api-external.md` : ligne *Comptes bancaires* au § 7, code au § 10 (AC10, T7). + - **LOW R3-3** — les sept fonctions de l'AC6 nommées. **LOW R3-4** — « 14 sites » daté, avant le + merge de la 15-5b. **LOW R3-5** — chemin du dépôt préfixé pour la sentinelle (`:588`/`:592`). + - **Trend** : P1 3 HIGH, 9 MEDIUM → P2 2 HIGH, 6 MEDIUM → P3 0 HIGH, 3 MEDIUM. **Signal de + découpage (D5)** : non levé à cette passe — la sévérité baisse (HIGH → MEDIUM) et les trois MEDIUM + sont distincts de ceux de la P2 ; aucun ne naît d'une remédiation (F1 et F2 visent des tests écrits + dès la P1, R3-1 une borne du manuel écrite en P2 mais fausse dès son écriture, non recyclée). Le + signal de la P2 (modules > 5) reste déclaré, l'arbitrage de Guy toujours attendu. + - Décompte après passe : **11 AC, 9 tâches (T0–T8), 16 tests** (13 backend dont le 12 bis neuf, + 3 fichiers Vitest) + 1 spec E2E modifiée + adaptations de deux fichiers Vitest existants ; un + fichier supprimé (`BankAccountList.svelte`). +- 2026-10-08 — **Validation P4 — passe ciblée, clôture de la validation** (Haiku 4.5, une lentille + « chasseur de régressions » braquée sur la seule remédiation P3, diff aplati + `git diff 4964eeb4 89e8987a` sur cette fiche ; prompt versionné `15-6c-validate-prompt-p4-ciblee.md`). + **0 CRITICAL, 0 HIGH, 0 MEDIUM, 0 LOW.** Axes déclarés exercés : cohérence du commentaire d'en-tête, + arguments des messages et source du numéro, portée du verrou (next-key), exemption de la dé-liaison, + les sept fonctions de l'AC6 (recomptées), place du paragraphe du manuel d'administration, ligne du + § 7 du guide, tâches T6/T7, inventaire daté et adaptations Vitest, test 12 bis (distinct du 12, motif + d'attente, mutation), recompte des tests (16). Axes déclarés **non** exercés (hors d'une passe + ciblée sur une fiche) : compilation, rougissement des tests, code des routes et du dépôt, rendu + frontend, numéros de ligne après rebase, E2E — renvoyés à T0, T6 et T8. + **« 0 » vérifié par l'orchestrateur, comme un finding** (règle « Une passe qui ne déclare pas ses axes + ne compte pas ») : le test 12 bis existe dans la fiche ; `attendre_une_requete_en_cours` existe à + `crates/kesh-db/src/test_fixtures.rs:560`. Haiku est employé ici en passe **ciblée**, conformément à + la décision D6 (rétrospective de l'Epic 25) ; la remédiation relue ne touchait aucune ligne de code de + production (fiche seule) : la boucle peut se clore. + - **Trend complet** (F + R par passe) : P1 (Sonnet 4.6, lentilles F et R) 3 HIGH, 9 MEDIUM, 7 LOW → + P2 (Opus 5.5, lentilles R et F) 2 HIGH, 6 MEDIUM, 10 LOW → P3 (Sonnet, lentilles R et F ; + remédiation Opus 5.5) 0 HIGH, 3 MEDIUM, 11 LOW → P4 ciblée (Haiku 4.5) 0. Aucun CRITICAL sur + l'ensemble. Modèles : Sonnet → Opus → Sonnet pour les passes complètes, Haiku pour la passe ciblée + de fin de boucle (D6). + - **Reclassements** : aucun finding MEDIUM+ reclassé en dette. **Signal de découpage** : levé en P2 + (modules > 5, HIGH né de la remédiation P1), non découpé (C-15-6-22), **arbitrage de Guy toujours + attendu** ; non relevé en P3 ni en P4. + - **Validation close** : 0 au-dessus de LOW. Décompte final : **11 AC, 9 tâches (T0–T8), 16 tests** + (13 backend, 3 fichiers Vitest) + 1 spec E2E modifiée + adaptations de deux fichiers Vitest + existants ; un fichier supprimé (`BankAccountList.svelte`). diff --git a/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p1.md new file mode 100644 index 00000000..867afe5c --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p1.md @@ -0,0 +1,34 @@ +# Prompt — validation P1 de la spec, Story 15-6c + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6c-configuration-sans-ecriture-nulle.md`. C'est la première passe : la fiche n'a pas encore été remédiée. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 474. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6, sœurs 15-6a et 15-6b. La 15-5b (dépôt principal, `/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5b-gardes-surfaces-neuves.md`, lecture seulement) modifie les mêmes fonctions de `bank_accounts.rs` et des réglages : vérifie la frontière et la dépendance. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Auditeur d'acceptation** : applique `.claude/skills/bmad-create-story/checklist.md`. Chaque phrase des issues couverte par un AC testable, chaque AC par une tâche, chaque tâche par un test ; implémentable sans deviner (fonctions, fichiers, codes d'erreur, clés i18n 4 locales, replis Svelte) ; faits cités revérifiés au code (`grep -nF`) ; recompte des AC et tâches. +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +Rapport compact. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p2.md new file mode 100644 index 00000000..fdb59357 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p2.md @@ -0,0 +1,35 @@ +# Prompt — validation P2 de la spec, Story 15-6c + +*Versionné le 2026-10-08. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6c-configuration-sans-ecriture-nulle.md`. La dernière remédiation est le commit `cb54ff72` (`git show cb54ff72`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 474. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d. Les fiches 15-5a/b/c du dépôt principal (`/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5*.md`, lecture seule) touchent des fichiers voisins et seront mergées AVANT 15-6b/c/d : vérifie la frontière. Axe prioritaire : la sérialisation par verrous (C-15-6-13) — ordre d'acquisition, interblocages possibles avec les autres chemins qui verrouillent les réglages ou les comptes bancaires. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show cb54ff72`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6c-p2-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p3.md new file mode 100644 index 00000000..167c3178 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p3.md @@ -0,0 +1,35 @@ +# Prompt — validation P3 de la spec, Story 15-6c + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6c-configuration-sans-ecriture-nulle.md`. La dernière remédiation est le commit `4964eeb4` (`git show 4964eeb4`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 474. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d. Les fiches 15-5a/b/c/d du dépôt principal (`/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5*.md`, lecture seule) seront mergées AVANT : vérifie la frontière. Axes : LOCK IN SHARE MODE et ordre des verrous, 409 avant le refus sur les réglages, suppression de BankAccountList.svelte. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 4964eeb4`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6c-p3-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p4-ciblee.md b/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p4-ciblee.md new file mode 100644 index 00000000..eb7520c7 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6c-validate-prompt-p4-ciblee.md @@ -0,0 +1,15 @@ +# Prompt — validation P4 CIBLÉE de la spec, Story 15-6c + +*Versionné le 2026-10-08. Une lentille (Haiku), contexte frais, lecture seule. Passe ciblée (CLAUDE.md § « La passe ciblée ») : la P3 n'a laissé que des MEDIUM de tests manquants, remédiés par le commit `89e8987a`, qui ne touche que des fiches. Haiku est réservé à ce type de passe (décision D6).* + +Worktree `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. Fiche : `_bmad-output/implementation-artifacts/15-6c-configuration-sans-ecriture-nulle.md`. + +## Lentille unique — Regression hunter sur la dernière remédiation + +Lis `git diff 4964eeb4 89e8987a -- _bmad-output/implementation-artifacts/15-6c-configuration-sans-ecriture-nulle.md` (un seul diff aplati), puis la fiche entière. Pour chaque hunk : contradiction introduite (AC, tâches, Dev Notes, registre C-15-6-*) ? fait faux (vérifie au code par `grep -nF`) ? test qui ne prouverait rien (le test 12 bis mord-il sur le `FOR UPDATE` de `before` ? `attendre_une_requete_en_cours` est-il employé avec un motif qui existe ?) ? décompte faux (recompte AC, tâches, tests) ? + +⛔ Toute affirmation de présence ou d'absence : la sortie d'un `grep -nF`, copiée. ⛔ Un « 0 » sans la liste des axes exercés ET non exercés ne compte pas. + +Rapport complet dans `target/gate-logs/15-6c-p4-ciblee.md`. Dernier message : le chemin, le bilan, une ligne par MEDIUM+. + +⛔ Interdits : n'écris rien hors `target/gate-logs/` ; aucune commande qui écrit, compile ou exécute (scripts/*, make, latexmk, git commit/add/checkout/stash, sqlx, cargo, npm, npx, gh issue create/comment/edit, SQL d'écriture). Autorisés : lecture, grep, sed -n, git log/show/diff, gh api en lecture. diff --git a/_bmad-output/implementation-artifacts/15-6d-contrepartie-distincte-de-la-banque.md b/_bmad-output/implementation-artifacts/15-6d-contrepartie-distincte-de-la-banque.md new file mode 100644 index 00000000..f25eab3f --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6d-contrepartie-distincte-de-la-banque.md @@ -0,0 +1,485 @@ +# Story 15.6d : Un rapprochement ne prend pas le compte de la banque pour contrepartie + +## Status + +ready-for-dev + + + +**Issue** : `closes #524` (P1) — mot-clé **dans la PR** (merge en squash). **Mère** : +`15-6-creance-juste-avoir-reglement.md`. +**Après la 15-5b** (rebase : elle modifie les mêmes fonctions — `ACCOUNT_NOT_POSTABLE` sur +`post_manual`, son AC1, et sur `accept_one_rule`, son AC4 ; l'ensemble passé à `first_matching_rule` +dans `get_proposals`, son AC5). **Aucune dépendance d'ordre des refus** : le refus de cette story +précède le sien (AC1, AC2, choix C-15-6-23). Son passage du manuel s'écrit sur la section du +rapprochement que la **15-5c** réécrit : après la 15-5c de préférence, sinon rebase attendu. La +**15-5c** modifie aussi les deux fichiers d'écran de l'AC4 — `ReconciliationProposals.svelte` (son +libellé de `failed[]`) et `ReconciliationProposals.test.ts` (trois tests ajoutés, sa T3) — : rebase de +forme attendu sur eux. Sa traduction des codes de `failed[]` porte l'AC6. +**Après la 15-6b** pour un seul emprunt : la **comparaison** de deux comptes de son helper commun +(`invoice_settlements`, `pub fn ensure_not_claim_account(account_id: i64, claim_account_id: i64) -> Result<(), ClaimAccountClash>`, +fonction **pure**, sans connexion ni `async`, telle que la fiche 15-6b la pose — T0 reprend le nom réel), choix +C-15-6-28 ; **pas** sa construction de refus (AC3). Autres fonctions de `reconciliation.rs` que les +15-6a/b/c ; filtre d'écran local, sans emprunt à `account-options.ts`. + +## Story + +En tant que **comptable qui rapproche ses relevés**, +je veux que **Kesh refuse un rapprochement manuel ou par règle dont la contrepartie est le compte même +de la banque**, +afin qu'**une transaction bancaire ne soit jamais marquée rapprochée par une écriture `D banque / C +banque`, nulle au grand livre**. + +## Le défaut, établi au code + +- Le rapprochement **ventilé** refuse déjà une contrepartie égale au compte comptable du compte + bancaire, sur ses deux chemins : + - `accept_one_split` (`crates/kesh-api/src/routes/reconciliation.rs:1829`, garde `:1941-1950`) : + `FailedProposal { error_code: "VALIDATION_ERROR", details: { "reason": "counterparty_equals_bank_ledger" } }` ; + - `post_split` (`:3362`, garde `:3462-3475`) : `AppError::Validation("splits[{idx}].counterpartyAccountId ne peut pas être le compte ledger banque")`, + **400**, message en français en dur. +- Le rapprochement **manuel** (`post_manual`, `:2942` ; compte de banque `:2989`, contrepartie + `:3028`) et l'acceptation **par règle** (`accept_one_rule`, `:2207` ; compte de banque `:2235`, + contrepartie `:2287`) **ne comparent jamais** les deux comptes ; + `build_journal_entry_for_counterparty` (`crates/kesh-reconciliation/src/manual.rs:67`) non plus. +- Une règle n'est attachée à aucun compte bancaire (l'entité `reconciliation_rule` ne porte pas de + `bank_account_id`), et sa création par l'API ne restreint pas le type du compte : seule + l'acceptation connaît le compte de banque en jeu. +- **Contrôle du compte de banque actif** : `post_manual` (étape 2 bis, `:3004-3017`, 412), + `accept_one_split` (« Step c », `:1912-1938`, `failed[]` `BANK_ACCOUNT_NOT_CONFIGURED`) et + `post_split` (« Step 4bis », `:3446-3460`, 412) le font **avant** leur garde d'égalité ; + `accept_one_rule` **ne le fait pas** (étapes 1, 2, 4, 5, 6 — `:2217-2318`) : un compte de banque + archivé y atteint l'écriture, dont la garde `active` (`journal_entries.rs:98`) refuse. +- **Écrans** : `ManualMatchModal.svelte:65-69` ne propose que les classes 5/6/7. Le serveur impose le + **type** du compte de banque — actif ou passif (`validate_journal_account_id`, + `crates/kesh-api/src/routes/bank_accounts.rs:260-294`) — mais **pas sa classe** : aucun des trois + plans livrés (`crates/kesh-core/assets/charts/*.json`) n'a d'actif ou de passif en classe 5 à 7, et + le commentaire de `post_split` (`:3465-3467`) le dit (« en UX normal pas de collision possible ») ; + la collision suppose un compte d'actif ou de passif créé par l'utilisateur en classe 5 à 7. + `RuleFormModal.svelte:44-50` ne propose **en création** que charges et produits ; en **édition**, le + compte en place de la règle est réintroduit (`withCurrentAccount`, `:55-57`, + `frontend/src/lib/features/accounts/account-options.ts:58`, #271), même s'il n'est pas éligible. + +## Acceptance Criteria + +1. **AC1 — Rapprochement manuel** (`post_manual`) : **juste après** l'étape 2 bis (compte de banque + résolu et actif, `:2989-3017`), **avant** l'étape 3 (contrepartie : 404 `ACCOUNT_NOT_FOUND`, puis + `ACCOUNT_NOT_POSTABLE` de la 15-5b) : `body.counterparty_account_id == bank_ledger_account_id` → + **le même refus que `post_split`** (choix C-15-6-9) : + `AppError::Validation(…)`, 400 `VALIDATION_ERROR` — même **code** ; le **texte** suit le champ en + cause : `post_split` écrit aujourd'hui `splits[{idx}].counterpartyAccountId ne peut pas être le + compte ledger banque` (`reconciliation.rs:3471`), `post_manual` écrira `counterpartyAccountId ne + peut pas être le compte ledger banque`, par la fonction unique de l'AC3, qui prend le chemin du champ + en paramètre (finding F3-2 de la P3). **Même place que le flux ventilé** (choix C-15-6-23) : `accept_one_split` et + `post_split` comparent **après** le contrôle d'activité du compte de banque (« Step c » + `:1912-1938`, « Step 4bis » `:3446-3460`) et **avant** la validation des comptes de contrepartie + (`:1941-1950` puis « Step d » ; `:3462-3475` puis « Step 5 batch validation ») — si bien qu'un + compte de banque devenu non imputable pris pour contrepartie rend partout **ce** refus, et non + `ACCOUNT_NOT_POSTABLE` (qui inviterait à agir sur la mauvaise chose). Le 404 ne pourrait de toute façon pas précéder utilement : égale au + compte de banque, la contrepartie existe et est active (étape 2 bis). **Avant l'étape 4** + (`find_strictly_pending…`) aussi : une contrepartie égale au compte de banque sur une transaction + **déjà rapprochée** rend donc **400 `VALIDATION_ERROR`**, pas 404 + `RECONCILIATION_TRANSACTION_NOT_PENDING` — choix écrit (le défaut de la requête prime l'état de la + transaction), testé (test 3). Rien n'est écrit : aucune écriture, transaction bancaire toujours + `pending`, aucun audit. **Ordre** écrit dans le doc-comment : forme (étape 0) → 404 compte bancaire + / 412 `BANK_ACCOUNT_NOT_CONFIGURED` (étapes 1 à 2 bis, existantes) → **400 `VALIDATION_ERROR`** → + 404 `ACCOUNT_NOT_FOUND` → 400 `ACCOUNT_NOT_POSTABLE` (15-5b) → 404 transaction non en attente → la + suite inchangée. +2. **AC2 — Acceptation par règle** (`accept_one_rule`). + - **Contrôle du compte de banque actif, ajouté** (choix C-15-6-28) : juste après l'étape 1, la + même étape que la « Step c » d'`accept_one_split` (`:1912-1938`) — `SELECT active FROM accounts + WHERE id = ? AND company_id = ?` dans la transaction ; erreur SQL → `DATABASE_ERROR` + (`details.message`) ; compte absent ou archivé → `FailedProposal { error_code: + "BANK_ACCOUNT_NOT_CONFIGURED", details: { "bankAccountId" } }`, forme de l'étape 1 et + d'`accept_one_split`. Sans lui, un même défaut — compte de banque archivé — rendrait sur ce seul + chemin `VALIDATION_ERROR` (règle sur ce compte) ou le refus de la garde `active` de l'écriture + (toute autre règle), quand les trois autres rendent `BANK_ACCOUNT_NOT_CONFIGURED`. **Changement + délibéré** d'un refus existant : le code rendu aujourd'hui pour une règle ordinaire sur un compte + de banque archivé est relevé en T0 et écrit au Change Log ; aucun test existant ne l'exerce + (`grep -n "BANK_ACCOUNT_NOT_CONFIGURED" crates/kesh-api/tests/reconciliation_rules_e2e.rs` → `:1360` + seul, compte bancaire **non lié**, étape 1, inchangé). **Atteignable depuis l'écran** (finding R1 de + la P3) : `get_proposals` ne contrôle que l'archivage du **compte bancaire** (`ba_check.archived`, + `reconciliation.rs:476`), jamais l'activité du compte comptable lié, et l'archivage d'un compte du + plan n'est pas refusé quand un compte bancaire y est lié ; un compte de banque archivé **avant** + l'ouverture de l'écran laisse donc proposer une règle ordinaire, dont l'acceptation tombe dans + cette étape — ce que l'utilisateur d'écran voit (libellé de la 15-5c, AC6). Aucun contrôle n'est + ajouté à `get_proposals` (périmètre) : angle mort écrit (§ *Angles morts*). + - **Garde d'égalité** : **après** l'étape 4 (concordance de la + contrepartie de la proposition avec celle de la règle, `RECONCILIATION_RULE_MISMATCH`) et **avant** + l'étape 5 (contrepartie active, puis `ACCOUNT_NOT_POSTABLE` de la 15-5b, son AC4) : égalité de la + contrepartie de la règle avec le compte de banque (lu à l'étape 1, dans la transaction) → + `FailedProposal { error_code: "VALIDATION_ERROR", details: { "reason": "counterparty_equals_bank_ledger" } }` + — exactement la forme de `accept_one_split`. HTTP 200, la proposition dans `failed[]`, les autres + du lot traitées normalement (§ *Pattern batch* du `CLAUDE.md`) ; la transaction bancaire reste en + attente, aucune écriture, aucun audit pour elle. **Ordre** : étape 1 (compte de banque : + `DATABASE_ERROR`, `BANK_ACCOUNT_NOT_CONFIGURED`, `BANK_ACCOUNT_NOT_FOUND`) → **compte de banque + actif** (`DATABASE_ERROR`, `BANK_ACCOUNT_NOT_CONFIGURED`) → étape 2 + (`RECONCILIATION_RULE_NOT_FOUND`) → étape 4 (`RECONCILIATION_RULE_MISMATCH`) → **`VALIDATION_ERROR`** + → étape 5 (`ACCOUNT_NOT_FOUND`, `ACCOUNT_NOT_POSTABLE`) → étape 6. Écrit dans le doc-comment. + **AC2 bis — Une règle sur le compte de banque n'est plus proposée.** `get_proposals` lit déjà le + compte bancaire (`ba_check`, `reconciliation.rs:469-478`, qui porte `journal_account_id`) et passe + à `first_matching_rule` (`:634-638` ; `kesh-reconciliation/src/rules.rs:85-95`, qui rend la + **première** règle concordante) l'ensemble `active_account_ids` (`:556-557`). Le compte de banque en + est **retiré** (s'il est configuré) : une règle dont la contrepartie est ce compte n'est plus + proposée — l'acceptation la refuserait toujours — et **ne masque plus** une règle suivante valable + pour la même transaction. Patron de l'AC5 de la 15-5b, qui retire du même ensemble les comptes non + imputables : les deux retraits se composent, à écrire sur l'état rebasé. Le commentaire que la 15-5b + pose au site d'appel pour dire ce que contient l'ensemble est **mis à jour** : « actifs, imputables, + hors compte de la banque ». Le doc-comment de `first_matching_rule` (`kesh-reconciliation/src/rules.rs:72-75`, + « non archivé ») reste tel quel : `kesh-reconciliation` est hors périmètre, comme pour la 15-5b — le + site d'appel fait foi. La garde de l'AC2 reste le filet d'un client d'API ou d'une règle modifiée + entre la proposition et l'acceptation. +3. **AC3 — Le choix de la forme est écrit.** Les deux refus reprennent ceux du flux ventilé + **existant** — forme **et place** — pour qu'un même défaut rende la même réponse sur les **quatre** + chemins (manuel, ventilé direct, ventilé par lot, règle) : compte de banque **actif** pris pour + contrepartie → `VALIDATION_ERROR` ; devenu **non imputable** → `VALIDATION_ERROR`, pas + `ACCOUNT_NOT_POSTABLE` ; **archivé** → `BANK_ACCOUNT_NOT_CONFIGURED` (412 sur les routes directes, + `failed[]` sur le lot — sur la règle grâce à l'étape ajoutée par l'AC2). La **comparaison** emploie + celle du helper commun de la 15-6b (en-tête) ; la **construction** du refus reste locale à + `reconciliation.rs`, **une** fonction pour les deux `FailedProposal` (`accept_one_split`, + `accept_one_rule`) et **une** pour les deux `AppError::Validation` (`post_split`, `post_manual`), + les deux sites ventilés existants y étant ramenés. Signature de la seconde (indicative) : + `fn counterparty_is_bank_ledger_error(field: &str) -> AppError`, qui rend + `AppError::Validation(format!("{field} ne peut pas être le compte ledger banque"))` — + `post_split` lui passe `splits[{idx}].counterpartyAccountId`, `post_manual` `counterpartyAccountId`. + Les tests 1 et 8 n'assertent que le **code** `VALIDATION_ERROR`, pas le message (en dur, non + traduit). **Limite assumée** (héritée + de `post_split`, non introduite ici) : le message du 400 manuel est en français en dur, non + traduit ; le code `VALIDATION_ERROR` et, pour le lot, `details.reason` sont les champs stables. Les + doc-comments des deux gardes renvoient l'une à l'autre et au flux ventilé. +4. **AC4 — L'écran du rapprochement manuel ne propose pas le compte de la banque** (choix C-15-6-23). + - **Résolution** dans `ReconciliationProposals.svelte` (qui reçoit `bankAccountId`, et dont la page + n'a pas de test) : `listBankAccounts()` (`frontend/src/lib/features/bank-accounts/bank-accounts.api.ts:96`, + comptes non archivés) **une fois, au montage**. **Pas de garde de génération** : la page remonte le + composant à chaque changement de compte bancaire (`{#key selectedId}`, + `frontend/src/routes/(app)/reconciliation/+page.svelte:70-72`), si bien qu'une réponse ne peut pas + arriver pour un autre `bankAccountId` que celui du montage (choix C-15-6-28). Le doc-comment de + l'appel le dit, et dit que le retrait du `{#key}` exigerait la garde. Le `journalAccountId` du + compte sélectionné est passé à `ManualMatchModal` (prop neuve `bankLedgerAccountId: number | null`, + `null` par défaut). + - **Repli** : pendant le chargement, en cas d'échec de l'appel (erreur ignorée, sans message — + comme le chargement des comptes du même composant, `:75-90`), si le compte bancaire est absent de + la liste (archivé) ou non lié (`journalAccountId` nul) → `null` : **aucun filtrage**, la garde + serveur (AC1) tranche. + - **Filtre** dans `ManualMatchModal` : **local, une condition** dans `filteredAccounts` (`:65-69`), + `a.id !== bankLedgerAccountId` — **sans conditionnel sur la 15-6b** et sans emprunt à + `account-options.ts` (un retrait d'un seul id ne justifie pas la fonction d'ensemble ; rien ne le + remplacera). Le filtre de classes 5/6/7 reste. + - *(La page lit déjà `/api/v1/companies/current`, dont `bankAccounts` porte `journalAccountId` + — `crates/kesh-api/src/routes/companies.rs:315-323` — mais son type local ne le déclare pas, et la + page n'a aucun test : résoudre dans le composant garde le câblage testable dans son fichier de + test existant.)* + - **Écran de ventilation** (`TransactionSplitModal.svelte:71-72`, même filtre 5/6/7) : **non + filtré**, angle mort écrit (§ *Angles morts*) — le serveur y refuse déjà. +5. **AC5 — L'écran des règles, figé.** `RuleFormModal.svelte:44-50` exclut le compte de banque par le + type (charges et produits seulement) **en création**. En **édition**, `withCurrentAccount` (`:55-57`, + #271) réintroduit le compte en place de la règle même non éligible : une règle créée par l'API sur le + compte de banque, ouverte en édition, le propose et l'enregistre tel quel. **Aucun changement de + code** : la réintroduction est voulue (#271 — sinon le champ s'afficherait vide sur une règle + complète) ; une telle règle n'est plus proposée (AC2 bis) et elle est refusée à l'acceptation (AC2). + Un commentaire dit les deux cas et renvoie à la garde serveur. +6. **AC6 — Libellé dans `failed[]`.** `VALIDATION_ERROR` fait partie des codes que la **15-5c** + traduit (`failed-proposal-label.ts`, sa liste de 26 codes) : rien à ajouter si elle est mergée. Le + libellé est **générique** — la 15-5c le relève sur un message existant ; au 2026-10-08, + `error-validation` dit « Erreur de validation » (`crates/kesh-i18n/locales/fr-CH/messages.ftl:36`) + — et `details.reason` n'est pas affiché : l'utilisateur n'apprend pas que la contrepartie est le + compte de banque. **Assumé** : depuis l'AC2 bis, l'écran ne propose plus une telle règle ; ce + libellé n'est atteint que par un client d'API ou une règle modifiée entre la proposition et + l'acceptation. Le dev relit le libellé effectif de la 15-5c et l'écrit au Dev Agent Record. Si la + 15-5c n'est pas mergée : le signaler, sans second mécanisme. +7. **AC7 — Documentation des intégrateurs** (`docs/api-external.md`) : au § *Accepter des propositions + de rapprochement* (`:303-307`), `VALIDATION_ERROR` avec `details.reason = "counterparty_equals_bank_ledger"` + pour une proposition par règle (et par ventilation, déjà vrai et non documenté), et + `BANK_ACCOUNT_NOT_CONFIGURED` (`details.bankAccountId`) pour une proposition par règle dont le compte + de banque est archivé (AC2) ; une demi-phrase dit qu'une règle visant le compte de la banque n'est + pas proposée (AC2 bis). **Rapprochement manuel et ventilation directe** : la **15-5b**, mergée + avant, ajoute au même § une phrase disant que ces deux routes refusent une contrepartie non + imputable en `400 ACCOUNT_NOT_POSTABLE`, « après le `404 ACCOUNT_NOT_FOUND` » + (`15-5b-gardes-surfaces-neuves.md:323-330`, dépôt principal). Cette phrase est **complétée** : les + deux routes refusent une contrepartie égale au compte de la banque en `400 VALIDATION_ERROR` (message + seul, sans `details.reason`), **avant** le 404 et le non-imputable. T0 refait + `grep -n "reconciliation/manual\|reconciliation/split" docs/api-external.md` sur l'état rebasé + (vide au 2026-10-08, avant la 15-5b) ; s'il est encore vide, la 15-5b n'est pas mergée : le + signaler, sans écrire sa phrase à sa place. +8. **AC8 — Manuel** (`docs/manual/fr/user-manual.tex`) : une phrase qui vaut pour les **trois** + flux — rapprochement manuel (`:1536-1545`), éclatement (`:1546-1560`) et règles d'affectation + (`:1561-1586`) — : la contrepartie d'un rapprochement ne peut pas être le compte comptable du compte + bancaire lui-même (l'écriture serait nulle) ; Kesh **le refuse** dans les trois cas — seule + affirmation sans réserve — et, **en règle générale**, ne le propose pas dans le rapprochement manuel + ni ne propose une règle qui le vise (finding F3-4 de la P3 : la fiche connaît des cas où il est + proposé — échec du chargement des comptes bancaires, compte non lié, plan atypique, écran de + ventilation jamais filtré ; le manuel ne promet pas une protection partielle comme totale). *(La section *Éclatement* ne + dit aujourd'hui rien de ce refus : ne pas écrire « comme pour un éclatement ».)* La section du + rapprochement manuel est inexacte et la **15-5c** la réécrit : écrire **sur sa version**. PDF + régénéré et contrôlé **aplati**. +9. **AC9 — CHANGELOG** sous `## [0.13.0] — Non publié`, `### Corrigé`, renvoi à + [#524](https://github.com/guycorbaz/kesh/issues/524) ; et `### Modifié` (finding F3-5 de la P3) : + l'acceptation d'une proposition par règle dont le compte de banque est archivé rend désormais + `BANK_ACCOUNT_NOT_CONFIGURED` (`details.bankAccountId`) dans `failed[]` — changement délibéré d'un + refus existant (AC2), visible d'un intégrateur ; le code d'avant, relevé en T0, y est nommé. La + section `[0.13.0]` est absente au 2026-10-08 (`grep -n "^## \[" CHANGELOG.md` → premier `0.12.1`) : + T0 le revérifie après rebase. **Propriétaire de la section** : la première + story de la version mergée la crée en tête des versions si elle est absente ; les suivantes y + ajoutent leur entrée. + +## Tasks / Subtasks + +- [ ] **T0 — Rebase après la 15-5b et la 15-6b** (et la 15-5c si mergée — elle touche + `ReconciliationProposals.svelte` et `ReconciliationProposals.test.ts`) ; relire les numéros de ligne + de `post_manual`, `accept_one_rule`, `accept_one_split`, `post_split` et `get_proposals` ; vérifier + où la 15-5b a placé `ACCOUNT_NOT_POSTABLE` (le refus de cette story le précède), comment elle a réduit + `active_account_ids` (AC2 bis s'y compose), où elle a posé le helper `set_account_not_postable` (le + réutiliser, ou le dupliquer dans chaque binaire de test qui l'emploie : tests 2, 5, 8, 9) ; reprendre + le nom réel du helper de comparaison de la 15-6b ; relever le code que rend aujourd'hui une règle + ordinaire sur un compte de banque archivé (AC2) ; refaire le grep de `docs/api-external.md` (AC7). +- [ ] **T1 — Gardes serveur** (AC1, AC2 dont l'étape du compte de banque actif, AC3 et ses deux + fonctions de refus) et proposition (AC2 bis, commentaire du site d'appel). +- [ ] **T2 — Écrans** (AC4, AC5) — dont le mock de `bank-accounts.api` dans + `ReconciliationProposals.test.ts` ; libellé (AC6) ou signalement. +- [ ] **T3 — Tests** (§ *Tests*), chaque test d'une garde neuve **rougit d'abord** ; les témoins 8 + et 9 (gardes ventilées existantes) passent avant **et** après. +- [ ] **T4 — Documentation** : `docs/api-external.md` (AC7), manuel + PDF (AC8), CHANGELOG (AC9). +- [ ] **T5 — Gates** : backend complet, frontend complet, E2E complet au dernier commit de code (D7). + +## Tests + +Backend : + +1. `crates/kesh-api/tests/reconciliation_manual_e2e.rs` — `POST /reconciliation/manual` avec + `counterpartyAccountId` = compte lié du compte bancaire → 400 `VALIDATION_ERROR` ; **aucune + écriture**, transaction bancaire toujours `pending`, **aucun audit** (patron + `post_manual_emits_audit_log_pair`, `:1217`). +2. Même fichier — **ordre** : le compte de banque rendu **non imputable** (`postable = FALSE`, toujours + actif — `set_account_not_postable` de la 15-5b, UPDATE direct, `version + 1`, sur le patron de + `products_revenue_account_e2e.rs:254-260`) et pris pour contrepartie rend **ce refus** + (`VALIDATION_ERROR`), **pas** `ACCOUNT_NOT_POSTABLE` (AC1, même place que le flux ventilé). *(Un + compte de banque **archivé** n'atteint pas cette garde : l'étape 2 bis rend 412 + `BANK_ACCOUNT_NOT_CONFIGURED` avant — de même sur les trois autres chemins, test 7 pour la règle.)* +3. Même fichier — **transaction déjà rapprochée** et contrepartie = compte de banque → 400 + `VALIDATION_ERROR`, pas 404 `RECONCILIATION_TRANSACTION_NOT_PENDING` (AC1). +4. `crates/kesh-api/tests/reconciliation_rules_e2e.rs` — une règle dont la contrepartie est le compte + lié (créée par l'API — **avant** tout passage en non imputable : après la 15-5b, AC7, la création + contrôle `postable` — ou en SQL). Depuis l'AC2 bis, `GET /reconciliation/proposals` ne rend plus + cette proposition : le test **construit** le corps (`type: "rule"`, `bankTransactionId`, `ruleId`, + `counterpartyAccountId`), patron `post_accept_rule` (`:1255`). `POST /reconciliation/accept` d'un + lot de deux propositions, l'une par cette règle, l'autre valide → HTTP 200, l'une acceptée, l'autre + dans `failed[]` avec `VALIDATION_ERROR` et `details.reason = "counterparty_equals_bank_ledger"` ; sa + transaction reste en attente, **aucune écriture ni audit** pour elle. +5. Même fichier — **ordre côté règle** : la même règle, **créée d'abord**, puis le compte de banque + rendu non imputable ; proposition construite comme au test 4 → `failed[]` `VALIDATION_ERROR` / + `counterparty_equals_bank_ledger`, **pas** `ACCOUNT_NOT_POSTABLE` (AC2). +6. Même fichier (ou celui des propositions, à repérer en T0) — **proposition** (AC2 bis) : deux règles + concordant avec la même transaction, la **première** sur le compte de banque, la seconde valable → + `GET` des propositions : la proposition porte la **seconde** ; avec la première seule, aucune + proposition par règle. +7. Même fichier — **compte de banque archivé, chemin par règle** (AC2, étape ajoutée) : le compte lié + du compte bancaire archivé en SQL (`UPDATE accounts SET active = FALSE`) **après** la création des + deux règles par l'API — la création refuse un compte archivé (`validate_counterparty_account`, + `reconciliation_rules.rs:197-221`, 404 `ACCOUNT_NOT_FOUND`) : règles d'abord, archivage ensuite + (findings R2 = F3-3 de la P3) ; deux propositions + construites, l'une par une règle ordinaire (contrepartie 6510), l'autre par la règle sur le compte + de banque → les deux dans `failed[]` en `BANK_ACCOUNT_NOT_CONFIGURED` avec + `details.bankAccountId`, ni `VALIDATION_ERROR` ni le refus de l'écriture ; rien d'écrit. *(Rougit + sur le code actuel.)* +8. `crates/kesh-api/tests/reconciliation_split_e2e.rs` — **témoin de l'ordre ventilé direct** : + `POST /reconciliation/split`, une ligne dont la contrepartie est le compte de banque rendu non + imputable → 400 `VALIDATION_ERROR`, pas `ACCOUNT_NOT_POSTABLE` ; aucune écriture. Fige l'ordre + « égalité avant postabilité » sur lequel repose C-15-6-23 (garde `:3462-3475`, aucun test + aujourd'hui : `grep -rnF "compte ledger banque" crates/kesh-api/tests` vide). +9. `crates/kesh-api/tests/reconciliation_e2e.rs` — **témoin de l'ordre ventilé par lot** : acceptation + d'une proposition `type: "split"` (patron `:2579`) dont une ligne vise le compte de banque rendu non + imputable → `failed[]` `VALIDATION_ERROR` / `details.reason = "counterparty_equals_bank_ledger"`, pas + `ACCOUNT_NOT_POSTABLE` ; aucune écriture (garde `:1941-1950`, aucun test aujourd'hui : + `grep -rnF "counterparty_equals_bank_ledger" crates/kesh-api/tests` vide). + +Frontend (Vitest) : + +10. `frontend/src/lib/features/reconciliation/ManualMatchModal.test.ts` (existant ; ses montages + passent la prop neuve) : le compte passé en `bankLedgerAccountId` n'est pas proposé, même numéroté + en classe 5/6/7 ; les autres le sont ; `null` → aucun filtrage. +11. `frontend/src/lib/features/reconciliation/ReconciliationProposals.test.ts` (existant) : **mock de + `$lib/features/bank-accounts/bank-accounts.api` ajouté** (les tests existants ne mockent que + `reconciliation.api`, `accounts.api` et l'i18n, `:20`, `:29` — sans lui, l'appel neuf partirait sur + l'`apiClient` réel) ; la modale ouverte ne propose pas le `journalAccountId` du compte bancaire + monté ; `listBankAccounts` en échec → compte non écarté, aucune erreur affichée. +12. `frontend/src/lib/features/reconciliation/rules/RuleFormModal.test.ts` (existant) : **en création**, + un compte d'actif n'est pas proposé (fige l'AC5). + +Soit **12 tests nommés** (9 backend dont 2 témoins, 3 fichiers Vitest). + +## Dev Notes + +### Ce qui doit être préservé + +- L'ordre des refus existants de `post_manual` et `accept_one_rule` ; le refus neuf s'y **insère** + avant la validation de la contrepartie, comme dans le flux ventilé — il ne déplace aucun refus + existant, **hors** le changement délibéré de l'AC2 (compte de banque archivé sur le chemin par règle, + désormais `BANK_ACCOUNT_NOT_CONFIGURED` ; finding R3 de la P3). +- La forme des refus du flux ventilé : ne pas les « améliorer » ici (un code dédié, une traduction) — + ce serait diverger des deux chemins existants ; si un code dédié devient souhaitable, il vaudra pour + les quatre chemins et sera une story à part. Le helper de la 15-6b n'est emprunté que pour la + comparaison : sa construction de refus (variante nommée d'un compte de créance ou de dette) n'est pas + celle-ci (AC3). +- **Aucun verrou à ajouter** pour l'égalité : sur les quatre chemins, la valeur comparée est **celle qui + construit l'écriture** — `post_manual` lit `bank_ledger_account_id` (`:2989`) et passe la même + variable à `build_journal_entry_for_counterparty` (`:3143-3147`) ; les chemins de lot font de même + dans la transaction. Un relien concurrent du compte bancaire peut faire imputer l'**ancien** compte + de banque (dette préexistante, hors #524), jamais produire une écriture `D x / C x`. Le + `with_account_lock` des acceptations est un `GET_LOCK` consultatif + (`crates/kesh-reconciliation/src/mutex.rs:66-90`) que les routes des comptes bancaires ne prennent + pas : ce n'est pas lui qui rend l'égalité sûre. + +### Angles morts assumés + +- La **création** d'une règle sur un compte qui est, ou deviendra, le compte de banque d'un compte + bancaire n'est pas refusée : une règle n'est pas attachée à un compte bancaire. Elle n'est plus + **proposée** (AC2 bis) et elle est refusée à l'acceptation (AC2), seul moment où le couple existe. +- **Écran de ventilation** (`TransactionSplitModal.svelte:71-72`) : il propose encore le compte de + banque s'il est numéroté en classe 5/6/7 (plan atypique) ; le serveur le refuse déjà (400 / + `failed[]`, gardes `:1941-1950`, `:3462-3475`). Filtre d'écran non étendu (choix C-15-6-23). +- **Message du repli d'écran** : quand le filtre ne s'applique pas (échec de `listBankAccounts`, + compte non lié, plan atypique), `ManualMatchModal.svelte:126-131` affiche `e.message` du 400 — en + français en dur dans les quatre langues (limite héritée de `post_split`, AC3). Pas de clé i18n ici + (§ *Ce qui doit être préservé*). +- **Édition d'une règle sur le compte de banque** (AC5) : l'écran la propose et l'enregistre ; elle + n'est ni proposée ni acceptée. +- **Compte de banque archivé avant l'ouverture de l'écran** (AC2) : `get_proposals` ne contrôle pas + l'activité du compte lié ; une règle ordinaire est proposée et son acceptation rend + `BANK_ACCOUNT_NOT_CONFIGURED`. Pas de contrôle ajouté à la proposition. +- **Câblage de l'écran vu seulement sous mock** (finding F3-6 de la P3) : le test 11 mocke + `listBankAccounts` ; `frontend/tests/e2e/reconciliation-manual.spec.ts` est un smoke sans fixtures ; + le passage `journalAccountId` API → prop → filtre n'est jamais exercé de bout en bout. Risque faible + (le parseur du champ est couvert, `bank-accounts.api.test.ts:37`, `:120`), assumé. + +### Références + +- [Source: crates/kesh-api/src/routes/reconciliation.rs:469-478, :556-557, :634-638, :1829, :1912-1950, :2207-2330, :2942-3060, :3143-3147, :3362, :3446-3475 ; + crates/kesh-reconciliation/src/rules.rs:72-95 ; crates/kesh-reconciliation/src/mutex.rs:66-90 ; + crates/kesh-api/src/routes/companies.rs:315-323 ; crates/kesh-api/src/routes/bank_accounts.rs:260-294] +- [Source: crates/kesh-reconciliation/src/manual.rs:67] +- [Source: frontend/src/lib/features/reconciliation/ManualMatchModal.svelte:65-69, :126-131, :157-163 ; + frontend/src/lib/features/reconciliation/ReconciliationProposals.svelte:26-34, :75-90, :330-348 ; + frontend/src/lib/features/reconciliation/ReconciliationProposals.test.ts:20, :29 ; + frontend/src/routes/(app)/reconciliation/+page.svelte:70-72 ; + frontend/src/lib/features/reconciliation/TransactionSplitModal.svelte:71-72 ; + frontend/src/lib/features/reconciliation/rules/RuleFormModal.svelte:44-57 ; + frontend/src/lib/features/accounts/account-options.ts:58-74] +- [Source: crates/kesh-api/tests/reconciliation_rules_e2e.rs:1255, :1360 ; crates/kesh-api/tests/reconciliation_e2e.rs:2579 ; + crates/kesh-api/tests/products_revenue_account_e2e.rs:254-260] +- [Source: docs/manual/fr/user-manual.tex:1536-1586 ; docs/api-external.md:303-307 ; crates/kesh-i18n/locales/fr-CH/messages.ftl:36] +- [Source: `15-5b-gardes-surfaces-neuves.md` AC1, AC4, AC5, AC7, `:323-330`, T6 ; `15-5c-rapprochement-libelles-et-manuel.md` + AC1, T3 (dépôt principal) ; `15-6b-contrepartie-distincte-de-la-creance.md` (helper commun)] +- [Source: issue #524 ; `epic-15-choix-autonomes.md` C-15-6-6, C-15-6-9, C-15-6-23, C-15-6-28] + +## Dev Agent Record + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List + +## Change Log + +- 2026-10-08 — Création à la validation P1 de la 15-6b (findings F1 = R3 et F4), en autonomie : + #524 sort de la 15-6b, qui dépassait cinq modules avec lui. Choix C-15-6-9. **9 AC, 6 tâches + (T0–T5), 5 tests.** Statut `ready-for-dev`. Validation : à lancer. +- 2026-10-08 — **Validation P1** (Opus 5.5, lentilles R et F ; prompt `15-6d-validate-prompt-p1.md`). + R : 0 CRITICAL, 0 HIGH, 3 MEDIUM, 3 LOW ; F : 0 CRITICAL, 0 HIGH, 3 MEDIUM, 4 LOW (R-1 = F3, + R-2 ⊃ F5, R-3 ≈ F4, R-5 = F7). Tous appliqués, décisions de l'orchestrateur comprises : + - **MEDIUM F1** — l'égalité avec le compte de banque est testée **avant** le 404 et + `ACCOUNT_NOT_POSTABLE`, comme le flux ventilé : même réponse sur tous les chemins, plus de + dépendance d'ordre envers la 15-5b ; AC1, AC2, AC3, test 2 inversé. Choix C-15-6-23, qui révise + l'ordre de C-15-6-9. + - **MEDIUM F2** — `get_proposals` ne propose plus une règle sur le compte de banque, qui masquait une + règle suivante (AC2 bis, test 6). + - **MEDIUM R-1 = F3** — câblage `ReconciliationProposals` → `ManualMatchModal` spécifié (effet + dépendant de `bankAccountId`, garde de génération) et testé (test 8), mock de `bank-accounts.api` + dans le fichier existant, repli écrit (chargement, échec, compte non lié ou archivé → aucun + filtrage). + - **MEDIUM R-3** — filtre local d'une condition, sans conditionnel sur la 15-6b (AC4). + - **MEDIUM R-2 (⊃ F5)** — « aucune écriture / aucun audit » asserté (tests 1, 4) ; ordre côté règle + testé (test 5) ; transaction déjà rapprochée → 400, écrit et testé (AC1, test 3). + - **LOW** — R-4 : phrase du manuel pour les trois flux, ligne des règles, « comme pour un + éclatement » retiré ; R-5 = F7 : guide sans section manuel/ventilation → « rien à ajouter » ; + R-6 : libellé générique dit et assumé (AC6) ; F4 : écran de ventilation non filtré, angle mort + écrit ; F6 : course résiduelle de `post_manual` écrite. + - Signal de découpage : sans objet (lentille F : `kesh-api` rapprochement, `frontend` + rapprochement, docs — sous le seuil ; aucun défaut recyclé, première passe). + - Décompte après passe : **10 AC (AC1–AC9 et AC2 bis), 6 tâches (T0–T5), 9 tests** (6 backend, + 3 fichiers Vitest). +- 2026-10-08 — **Validation P2** (Opus 5.5, lentilles R et F ; prompt `15-6d-validate-prompt-p2.md`). + R : 0 CRITICAL, 0 HIGH, 1 MEDIUM, 5 LOW ; F : 0 CRITICAL, 0 HIGH, 2 MEDIUM, 7 LOW (R-1 = F-4, + R-2 = F-3, R-4 = F-7). Tous appliqués, décisions de l'orchestrateur comprises : + - **MEDIUM R-1 (= F-4 LOW)** — `accept_one_rule` reçoit le contrôle du compte de banque actif (étape + de la « Step c » d'`accept_one_split`), **avant** la garde d'égalité : même réponse sur les quatre + chemins, y compris compte de banque archivé (AC2, AC3) ; AC1 rectifié (le flux ventilé compare + **après** ce contrôle, avant la validation des contreparties) ; test 7 neuf. La voie « angle mort » + proposée par F-4 est écartée. Choix C-15-6-28. + - **MEDIUM F-2** — les deux gardes ventilées, sur lesquelles repose l'ordre de C-15-6-23, sont figées + par deux témoins (tests 8 et 9, `post_split` et `accept_one_split`). + - **MEDIUM F-1** — AC7 et T0 : le grep de `docs/api-external.md` est refait sur l'état rebasé ; la + phrase que la 15-5b ajoute sur les routes manuelle et ventilée est complétée par le refus de cette + story, qui précède le 404. *(Corrige la ligne R-5 = F7 de la P1 : « rien à ajouter » n'était vrai + qu'avant la 15-5b.)* + - **LOW** — F-3 = R-2 : angle mort « course résiduelle » retiré ; la valeur comparée est celle qui + construit l'écriture, aucun verrou à ajouter (Dev Notes) ; F-6 : garde de génération et sous-cas + « changement de `bankAccountId` » retirés, la page remonte le composant (`{#key}`) ; R-3 : + `withCurrentAccount` en édition dit tel quel (AC5, test 12 « en création », angle mort) ; R-4 = + F-7 : helper `set_account_not_postable` décrit sans sous-compte, emplacement repéré en T0 ; R-5 : + tests 4 et 5 construisent leur proposition, règle créée avant le passage en non imputable, + parenthèse sur la création retirée ; R-6 : la 15-5c citée en en-tête et en T0 pour les deux + fichiers d'écran ; F-5 : prémisse corrigée (type imposé, classe non) ; F-8 : commentaire du site + d'appel de la 15-5b mis à jour, doc-comment de `rules.rs` laissé (choix écrit), demi-phrase du + guide sur la règle non proposée ; F-9 : message du repli d'écran en français, angle mort écrit. + - **Helper commun** (décision de l'orchestrateur, finding F3 de la P3 de la 15-6b) : la 15-6d emprunte + la **comparaison** du helper que la 15-6b pose dans `invoice_settlements` (nom indicatif + `ensure_not_claim_account`) ; la construction du refus reste celle du flux ventilé, ramenée à deux + fonctions locales (AC3). Ordre : après la 15-6b. Choix C-15-6-28. + - Signal de découpage (amendement D5) : **non levé**. Sévérité P1 → P2 : MEDIUM → MEDIUM, mais les + défauts sont **distincts** de ceux de la P1 et ne viennent pas de sa remédiation, sauf F-1, qui + prolonge une affirmation de la P1 sans la recycler ; modules : `kesh-api` (rapprochement), frontend + (rapprochement, règles), docs, tests `kesh-api` — sous le seuil. Déclaré au Project Lead par ce + Change Log. + - **Renumérotation** : les tests Vitest 7, 8, 9 de la P1 deviennent 10, 11, 12 (les numéros cités par + l'entrée P1 ci-dessus sont ceux d'alors). + - Décompte après passe : **10 AC (AC1–AC9 et AC2 bis), 6 tâches (T0–T5), 12 tests** (9 backend dont + 2 témoins, 3 fichiers Vitest). +- 2026-10-08 — **Validation P3 — clôture de la validation** (Sonnet, lentilles R et F ; prompt + `15-6d-validate-prompt-p3.md` ; remédiation Opus 5.5). R : 0 CRITICAL, 0 HIGH, 0 MEDIUM, 4 LOW ; F : + 0 CRITICAL, 0 HIGH, 0 MEDIUM, 6 LOW (R2 = F3-3). Axes déclarés exercés par les deux lentilles + (numéros de ligne revérifiés au code, inventaire des appelants du constructeur d'écriture et de + `active_account_ids`, ordre des contrôles sur les quatre chemins, verrous, tests existants, frontend, + i18n, manuel `.tex` et PDF aplati, guide, CHANGELOG, règle de découpage, registre) et non exercés + (aucune exécution ; fiches 15-5b/15-5c non rebasées). Tous les LOW appliqués (choix C-15-6-31) : + - R1 : la nouvelle étape de l'AC2 est **atteignable depuis l'écran** (compte lié archivé avant + l'ouverture ; `get_proposals` ne le contrôle pas) — phrase corrigée, angle mort écrit, aucun + contrôle ajouté ; + - R2 = F3-3 : test 7 — règles créées d'abord, compte lié archivé ensuite ; + - R3 : Dev Notes — « ne déplace aucun refus » sauf le changement délibéré de l'AC2 ; + - R4 : bandeau — C-15-6-28 parmi les choix propres ; + - F3-1 : le registre ne se réécrit pas ; C-15-6-31 dit que C-15-6-28 **révise** C-15-6-23 (garde de + génération, course résiduelle) ; + - F3-2 : texte du refus selon le champ, signature de la fonction de refus (AC1, AC3) ; tests 1 et 8 + sur le code seul ; + - F3-4 : le manuel n'affirme sans réserve que le refus (AC8) ; + - F3-5 : `### Modifié` au CHANGELOG pour le changement délibéré de l'AC2 ; section `[0.13.0]` à + revérifier en T0 (AC9) ; + - F3-6 : câblage de l'écran vu seulement sous mock, angle mort écrit. + - Propagation : jetons `Atteignable seulement`, `ne déplace aucun refus`, `Kesh ne le propose pas`, + `ne peut pas être le compte ledger banque` grepés sur la fiche — aucun résidu hors citations. + - **Trend complet** (F + R) : P1 (Opus 5.5, lentilles R et F) 0 HIGH, 6 MEDIUM, 7 LOW → P2 (Opus 5.5, + lentilles R et F) 0 HIGH, 3 MEDIUM, 12 LOW → P3 (Sonnet, lentilles R et F) 0 HIGH, 0 MEDIUM, + 10 LOW. Aucun CRITICAL ni HIGH sur l'ensemble. Reclassements : aucun. Signal de découpage : jamais + levé (sous le seuil de cinq modules, défauts distincts d'une passe à l'autre). + - **Validation close** : 0 au-dessus de LOW. Décompte final : **10 AC (AC1–AC9 et AC2 bis), + 6 tâches (T0–T5), 12 tests** (9 backend dont 2 témoins, 3 fichiers Vitest) — inchangé par la P3. diff --git a/_bmad-output/implementation-artifacts/15-6d-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/15-6d-validate-prompt-p1.md new file mode 100644 index 00000000..14ba0d2b --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6d-validate-prompt-p1.md @@ -0,0 +1,35 @@ +# Prompt — validation P1 de la spec, Story 15-6d + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6d-contrepartie-distincte-de-la-banque.md`. C'est la première passe : la fiche n'a pas encore été remédiée. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 524. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d. Les fiches 15-5a/b/c du dépôt principal (`/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5*.md`, lecture seule) touchent des fichiers voisins et seront mergées AVANT 15-6b/c/d : vérifie la frontière. Elle est née du découpage de la 15-6b en P1 (commit cb54ff72). +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Auditeur d'acceptation** : applique `.claude/skills/bmad-create-story/checklist.md`. Chaque phrase des issues couverte par un AC testable, chaque AC par une tâche, chaque tâche par un test ; implémentable sans deviner (fonctions, fichiers, codes d'erreur, clés i18n 4 locales, replis Svelte) ; faits cités revérifiés au code (`grep -nF`) ; recompte des AC et tâches. +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6d-p1-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6d-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/15-6d-validate-prompt-p2.md new file mode 100644 index 00000000..abd779fa --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6d-validate-prompt-p2.md @@ -0,0 +1,35 @@ +# Prompt — validation P2 de la spec, Story 15-6d + +*Versionné le 2026-10-08. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6d-contrepartie-distincte-de-la-banque.md`. La dernière remédiation est le commit `4964eeb4` (`git show 4964eeb4`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 524. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d. Les fiches 15-5a/b/c/d du dépôt principal (`/home/gcorbaz/devel/kesh/_bmad-output/implementation-artifacts/15-5*.md`, lecture seule) seront mergées AVANT : vérifie la frontière. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 4964eeb4`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6d-p2-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-6d-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/15-6d-validate-prompt-p3.md new file mode 100644 index 00000000..9b350133 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-6d-validate-prompt-p3.md @@ -0,0 +1,35 @@ +# Prompt — validation P3 de la spec, Story 15-6d + +*Versionné le 2026-10-08. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-6`, branche `story/15-6-creance-juste-avoir-reglement`. **Fiche** : `_bmad-output/implementation-artifacts/15-6d-contrepartie-distincte-de-la-banque.md`. La dernière remédiation est le commit `89e8987a` (`git show 89e8987a`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : 524. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche d'index 15-6 ; sœurs 15-6a/b/c/d ; les fiches 15-5a/b/c/d (dépôt principal, lecture seule) sont mergées AVANT. Elle passe désormais APRÈS la 15-6b, dont elle emprunte la comparaison. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 89e8987a`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), 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 exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-6d-p3-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md index 385c4dd4..8143ff9a 100644 --- a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md +++ b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md @@ -3614,3 +3614,831 @@ l'import (#458–#461). côtés (il aurait omis l'apport de l'autre). - **Réversible** : oui (rebase ; branche poussée). +## C-15-6-1 — 15-6 : découpée d'emblée en trois (a, b, c) + +- **Contexte** : #473 et #474 réunis touchent `kesh-db` (avoir, règlement client, règlement + fournisseur, comptes bancaires, erreurs), `kesh-api` (rapprochement, comptes bancaires, réglages de + facturation, erreurs), `kesh-i18n`, trois écrans et le manuel : bien plus de cinq modules — le + critère de périmètre de la § *Règle de splitting préventif* est franchi avant toute validation. +- **Retenu** : trois sous-stories, comptées en modules métier (la plomberie d'erreur et d'i18n + suit le geste qu'elle sert, comme pour la 15-5a) : + - **15-6a** — l'avoir crédite la créance de la vente (#473) : avoir, règlement, rapprochement + (3 modules) ; elle pose le lecteur partagé du compte de créance. + - **15-6b** — un règlement ne vise pas le compte qu'il solde (#474, cœur) : règlement client, + règlement fournisseur, rapprochement, et les deux écrans de règlement (5 modules). + - **15-6c** — la configuration ne prépare pas l'écriture nulle (#474, voisin) : compte comptable + d'un compte bancaire, réglages de facturation, écran des comptes bancaires (3 modules). +- **Ordre** : a → b (mêmes fichiers) ; c **après le merge de la 15-5b** (mêmes routes + `bank_accounts.rs` et `company_invoice_settings.rs`) ; b de préférence après la 15-5b aussi (le + libellé du nouveau code dans `failed[]` passe par l'écran de #492 que la 15-5b pose). +- **Écartées** : une story unique (règle franchie) ; deux stories « serveur / écrans » (le filtre + d'écran se séparerait de la garde qu'il reflète, et la story serveur dépasserait encore cinq + modules). +- **Réversible** : oui, tant qu'aucune n'est développée. + +## C-15-6-2 — 15-6a : l'avoir lit la créance sur l'écriture de vente ; arrondi et TVA restent aux réglages + +- **Contexte** : l'avoir lit dans les réglages du moment trois comptes que la vente a déjà + mouvementés : créance (#473), compte de différences d'arrondi (même défaut, non signalé), TVA due. + Le compte de produit est déjà recopié par ligne (16-1a, D5). +- **Retenu** : la créance se lit sur l'écriture de vente par un **lecteur partagé** + (`invoice_settlements::sale_receivable_account`) qui remplace aussi les trois copies de la même + requête (règlement, solde du reste, rapprochement). L'avoir ne dépend plus du réglage + `default_receivable_account_id`. +- **Laissés, en angles morts écrits dans la fiche** : + - la **TVA due** reste débitée sur le compte **courant** — c'est la convention écrite du solde du + reste (doc de `write_off_invoice` : « comme l'avoir ») et la changer toucherait le décompte TVA ; + - le **compte d'arrondi** reste lu dans les réglages : le lire sur la vente casserait le refus + nommé de #486 (`a_credit_note_is_refused_when_the_rounding_account_was_archived`, dont le message + renvoie aux réglages) et demanderait un refus neuf « réactivez tel compte » ; l'écart en jeu est + d'au plus 2,5 centimes par facture. **Issue à ouvrir** par l'orchestrateur ; + - le repli sur le produit par défaut des lignes sans compte (D-B2), inchangé. +- **Écartée** : construire l'avoir en miroir ligne à ligne de l'écriture de vente (exact par + construction, mais refonte de la génération, de la garde des comptes archivés de la 16-1a et de + leurs tests, hors de la mesure d'un P1). +- **Réversible** : oui. + +## C-15-6-3 — 15-6b : un refus dédié, sur les deux modes de règlement, après les contrôles existants + +- **Retenu** : nouvelle variante `DbError::SettlementCounterpartyIsClaimAccount`, code stable + `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT` (HTTP 400), `details = { accountId, accountNumber, + claim: "receivable" | "payable" }`, message 4 locales (deux clés : client, fournisseur). Elle + s'applique au **virement** autant qu'au **compte interne** (un compte bancaire lié au 1100 produit la + même écriture nulle), au règlement client, au règlement fournisseur et à l'acceptation d'un + rapprochement de facture (`failed[]`, même code). Elle vient **après** les refus existants (404, + configuration, inactif/non imputable) : leur ordre ne change pas. +- **Résolus par le type, sans garde neuve** : compte d'arrondi et comptes des natures de solde, + revérifiés « charge ou produit » au moment d'écrire — jamais 1100 ni 2000. +- **Écartées** : `InvalidInput("…")` (code générique `INVALID_INPUT`, pas de `details`, contraire à + la consigne) ; `ACCOUNT_NOT_POSTABLE` de la 15-5a (autre motif : 1100 est imputable). +- **Réversible** : oui. + +## C-15-6-4 — 15-6b : le filtre d'écran se fait par rôle, sans champ d'API nouveau + +- **Contexte** : l'écran ne connaît pas le compte de créance d'une facture ; l'échéancier ouvre le + même dialogue depuis une liste. +- **Retenu** : le dialogue de règlement client écarte les comptes de **rôle `Receivable`** et les + comptes bancaires liés à un tel compte ; la fiche fournisseur, ceux de rôle `Payable`. Les rôles + sont des singletons par société (`uq_accounts_company_singleton_role`) et les réglages en sont + dérivés. La garde serveur reste exacte (compte de l'écriture de vente) ; l'écart possible — réglage + changé depuis — se solde par un refus clair, non par une écriture fausse. +- **Écartée** : exposer `receivableAccountId` sur la fiche et sur les lignes de l'échéancier (deux + DTO, une sous-requête de plus sur une liste tenue à parité par `invoice_amount_due_parity.rs`). +- **Réversible** : oui. + +## C-15-6-5 — 15-6c : la configuration refuse le couple, dans les deux sens, avec exemption « inchangé » + +- **Retenu** : le compte comptable d'un compte bancaire ne peut être ni le compte débiteurs ni le + compte créanciers **des réglages** ; symétriquement, les réglages refusent un compte débiteurs ou + créanciers lié à un compte bancaire non archivé. Exemption quand la valeur ne change pas (patron + C4/C10 de la 15-5b) ; contrôle des routes bancaires dans la transaction, comme la 15-5b. Deux + codes : `BANK_ACCOUNT_LEDGER_IS_CLAIM_ACCOUNT`, `CLAIM_ACCOUNT_LINKED_TO_BANK_ACCOUNT`. +- **Assumé** : la course entre les deux gestes (deux administrateurs, deux tables) n'est pas + verrouillée ; la garde à l'usage de la 15-6b reste le filet. Écrit dans la fiche. +- **Écartée** : ne garder que l'usage (la configuration fautive resterait offerte par l'écran, et + chaque rapprochement échouerait ensuite un par un). +- **Réversible** : oui. + +## C-15-6-6 — Voisin relevé, hors périmètre : la contrepartie égale au compte de banque + +- **Constat (lu au code, non exécuté)** : le rapprochement **ventilé** refuse une contrepartie égale + au compte de banque (`reconciliation.rs:1941-1950`, `:3462-3474`) ; le rapprochement **manuel** + (`post_manual`, `:2942`) et l'acceptation **par règle** (`accept_one_rule`, `:2207`) ne le font pas, + et `build_journal_entry_for_counterparty` (`kesh-reconciliation/src/manual.rs:67`) non plus — même + écriture nulle `D banque / C banque`. +- **Retenu** : hors de #474 (le compte soldé n'est pas une créance) ; signalé à l'orchestrateur pour + une issue, non traité ici. +- **Réversible** : sans objet. + +## C-15-6-7 — 15-6a absorbe #523 : l'arrondi de l'avoir se lit sur la vente ; un compte archivé est refusé par `create_in_tx` (révise C-15-6-2) + +- **Contexte** : validation P1 de la 15-6a (findings F1 = R1, HIGH). C-15-6-2 laissait le compte + d'arrondi de l'avoir aux réglages pour ne pas casser le refus nommé de #486 ; l'orchestrateur a + ouvert #523 et décidé de le traiter dans la 15-6a (`closes #473, closes #523`). +- **Retenu** : un second lecteur de l'écriture de vente, sur sa **dernière** ligne + (`ORDER BY jel.id DESC LIMIT 1`), **recoupée** avec l'arrondi figé sur la facture (sens et valeur + absolue), sinon `DbError::Invariant` ; appelé seulement si l'arrondi ≠ 0. Compte de la vente archivé + depuis : **voie (b)** — `journal_entries::create_in_tx` refuse en `INACTIVE_OR_INVALID_ACCOUNTS`, + comme pour la créance. Le test `invoices_validate_vat.rs:846` change d'assertion, délibérément, et + la fiche l'écrit comme tel. Trois tests neufs : réglage redésigné, réglage vidé, recoupement. +- **Écartées** : (a) garder `RoundingAccountNotConfigured { Issuance }` en vérifiant le compte de la + vente — son message renvoie aux paramètres, remède **faux** puisque l'avoir ne les lit plus ; (c) une + variante neuve nommant le compte de la vente — un refus de plus pour un cas que le refus générique + existant couvre déjà, comme pour la créance ; réutiliser `usable_designated_account` — contraire à la + doctrine « mêmes comptes que l'origine, seule l'inactivité bloque ». +- **Réversible** : oui, tant que la 15-6a n'est pas développée. + +## C-15-6-8 — 15-6a : la TVA due de l'avoir reste un angle mort, tracé par #525 ; le manuel dit la limite + +- **Contexte** : finding F3 (MEDIUM) de la 15-6a — l'avoir débite la TVA due sur le compte **courant** + des réglages ; même classe que #473. L'orchestrateur a ouvert **#525** (P1, jalon de la TVA, report + assumé). +- **Retenu** : la fiche cite #525 comme angle mort tracé. Le manuel **ne présente pas** l'écriture + comme juste : il dit que la TVA de l'avoir suit le compte de TVA due actuellement désigné, et que + changer ce réglage entre une facture et son avoir est une limite connue de cette version. +- **Écartée** : le traiter dans la 15-6a (toucherait le décompte TVA et la convention partagée avec le + solde du reste, `vat_payable_account_for_write` — c'est l'objet du jalon de la TVA). +- **Réversible** : oui. + +## C-15-6-9 — #524 devient la sous-story 15-6d ; même refus que le flux ventilé existant (révise C-15-6-6) + +- **Contexte** : validation P1 de la 15-6b — finding F1 (HIGH) : #524 doit être traité ; finding F4 + (MEDIUM) : avec lui et les lots de paiement, la 15-6b dépasse cinq modules. C-15-6-6 le laissait + hors périmètre ; **il est retiré** par la présente entrée. +- **Retenu** : sous-story **15-6d-contrepartie-distincte-de-la-banque** (`closes #524`). + `post_manual` et `accept_one_rule` refusent une contrepartie égale au compte de la banque **avec le + même refus que le flux ventilé existant** : manuel → `AppError::Validation` comme `post_split` (400, + message français en dur — limite assumée de ce flux existant, écrite comme telle) ; règle → + `FailedProposal VALIDATION_ERROR`, `details.reason = "counterparty_equals_bank_ledger"`, HTTP 200, + comme `accept_one_split`. Ordre : 404, puis `ACCOUNT_NOT_POSTABLE` (15-5b), puis ce refus. L'écran du + rapprochement manuel ne propose pas le compte de la banque (test Vitest). Dépend de la 15-5b. +- **Écartées** : un code dédié et traduit (divergerait des deux chemins ventilés existants ; s'il + devient souhaitable, il vaudra pour les quatre chemins, story à part) ; garder #524 dans la 15-6b + (règle de découpage franchie). +- **Réversible** : oui, tant qu'elle n'est pas développée. + +## C-15-6-10 — 15-6b : les lots de paiement refusent le compte créanciers à la création, et gardent le refus à la confirmation + +- **Contexte** : findings F2 (HIGH) / R4 de la 15-6b — `confirm_batch` est le troisième appelant de + `pay_in_tx`. Avec la seule garde de `pay_in_tx`, un compte bancaire lié au compte créanciers produit + un lot **et son fichier pain.001**, puis un lot inconfirmable — alors que le fichier est peut-être + déjà à la banque. +- **Retenu** : `create_batch` compare, **par facture**, la dette de l'écriture d'achat au compte lié du + compte bancaire source **avant** de produire le fichier : égalité → la facture va dans `failed[]` du + lot (`SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, `details` avec `claim: "payable"`), patron batch ; + aucune facture retenue → aucun lot, aucun fichier. `confirm_batch` garde le refus de `pay_in_tx` + (le compte bancaire a pu être relié entre-temps) : 400, lot toujours `generated`. Libellé à l'écran + des lots, manuel (§ pain.001), `docs/api-external.md`. +- **Écartées** : refuser le lot entier en erreur globale (contraire au patron batch : une facture + fautive ne doit pas bloquer les autres) ; ne garder que la confirmation (le défaut même du finding). +- **Réversible** : oui. + +## C-15-6-11 — 15-6b : les gardes réutilisent les lecteurs ; côté fournisseur, un lecteur sœur + +- **Contexte** : finding R7 (MEDIUM) de la 15-6b — la dépendance au lecteur de la 15-6a était déclarée + mais inemployée. +- **Retenu** : côté client (`settle_invoice`, `accept_one_invoice`), la garde compare l'identifiant que + le lecteur `sale_receivable_account` de la 15-6a a **déjà** rendu — aucune requête neuve. Côté + fournisseur, la requête est **différente** (ligne de **crédit** de l'écriture d'achat **et** le TTC, + `supplier_invoices.rs:582-593`) : elle est extraite en lecteur sœur `purchase_payable_line`, partagé + par `pay_in_tx` et la création de lot (C-15-6-10). +- **Écartée** : faire lire la dette par le lecteur de la créance (autre sens, autre montant). +- **Réversible** : oui. + +## C-15-6-12 — #492 relève de la 15-5c, non de la 15-5b (révise l'ordre de C-15-6-1) + +- **Contexte** : findings F3 = R5 de la 15-6b. C-15-6-1 disait « le libellé du nouveau code dans + `failed[]` passe par l'écran de #492 que la 15-5b pose » ; depuis C15, #492 est dans la **15-5c** + (`frontend/src/lib/features/reconciliation/failed-proposal-label.ts`). +- **Retenu** : la 15-6b est « de préférence après la 15-5c » ; son développeur ajoute le code au module + si la 15-5c est mergée, sinon il le signale (pas de second mécanisme). Fiches 15-6 et 15-6b corrigées. +- **Réversible** : sans objet (correction d'une référence). + +## C-15-6-13 — 15-6c : contrôles dans la transaction, et les deux gestes se sérialisent (révise C-15-6-5) + +- **Contexte** : findings F1 et F2 (HIGH) de la 15-6c — le contrôle des réglages se faisait hors + transaction, contre une valeur lue sans verrou ; la course entre les deux gestes était déclarée en + angle mort alors qu'elle se ferme à bon marché. +- **Retenu** : deux lecteurs uniques — `company_invoice_settings::claim_accounts_for_share` (`FOR + SHARE` sur la ligne des réglages) et `bank_accounts::first_active_bank_account_linked_to` (`FOR + SHARE`, premier compte bancaire non archivé par `id`) — et un **ordre de verrous unique** : la ligne + des réglages d'abord, les comptes bancaires ensuite. Côté compte bancaire, la lecture des réglages + est la première de la transaction (après le verrou sentinelle, avant tout `FOR UPDATE` de ligne + bancaire) ; côté réglages, `company_invoice_settings::update` lit `before` en `FOR UPDATE`, compare + contre lui (exemption « inchangé »), puis interroge les comptes bancaires. **L'angle mort « course » + est supprimé.** Variantes nommées : `DbError::BankAccountLedgerIsClaimAccount`, + `DbError::ClaimAccountLinkedToBankAccount`. **Ordre des erreurs** : routes bancaires — celui de C10 + (le refus neuf en dernier, **après** le 409 de version et le non-imputable) ; route des réglages — + forme → refus → 409, tous les 400 de cette route précédant déjà le 409. +- **Écartées** : contrôle dans le handler (valeur lue hors verrou, F1) ; verrou sentinelle `companies` + sur les quatre gestes (aurait sérialisé aussi, mais le PATCH et le PUT des réglages ne le prennent + pas, et la sentinelle sérialise bien plus que ces deux gestes) ; lire les réglages **après** la + ligne bancaire (ordre inverse → interblocage entre les deux gestes). +- **Réversible** : oui, tant qu'elle n'est pas développée. + +## C-15-6-14 — 15-6b/15-6c : des fonctions d'écran par ensemble d'identifiants ; la 15-6b les nourrit par le rôle, la 15-6c par les réglages (précise C-15-6-4) + +- **Contexte** : finding F3 (MEDIUM) de la 15-6c — un écran filtré par **rôle** et un serveur qui + refuse selon les **réglages** divergent ; finding R2 (MEDIUM) — la signature du helper de la 15-6b + (un rôle) ne servait pas la 15-6c (deux comptes, et pas par rôle). +- **Retenu** : trois fonctions pures dans `account-options.ts`, posées par la 15-6b : + `accountIdsWithRole`, `withoutAccountIds`, `bankAccountsNotLinkedTo`, et des identifiants calculés + sur la liste **complète** des comptes. La 15-6b les nourrit par le rôle (C-15-6-4 maintenu : le + dialogue de règlement ne connaît pas la créance de la facture) ; la 15-6c par les identifiants + **désignés dans les réglages** (`getInvoiceSettings`, lisible par tout rôle), comme son serveur, et + l'écran des réglages par les comptes liés à un compte bancaire non archivé. +- **Écartée** : un helper par story (deux filtres voisins dans le même module, qui dériveraient). +- **Réversible** : oui. + +## C-15-6-15 — 15-6c : la spec E2E qui liait un compte bancaire au 1100 le lie au 1000 + +- **Contexte** : finding R4 (MEDIUM) de la 15-6c. Vérifié au code : le préréglage E2E `with-company` + (`seed_accounting_company`, `test_fixtures.rs:88-107`) désigne **1100 « Banque CI »** comme compte + débiteurs ; `bank-account-journal-link.spec.ts:105-119` lie un compte bancaire au 1100 par l'écran. + Après la 15-6c, le menu ne le propose plus et le serveur le refuse. Le seed de dev + (`scripts/seed-dev-db.sql`) ne crée pas de réglages : non concerné. +- **Retenu** : la spec lie au **1000 « Caisse CI »** (actif, imputable, non désigné comme créance). +- **Écartée** : changer la fixture partagée (désigner un autre compte débiteurs) — elle sert des + 204 appels dans 41 fichiers (`grep -rho "seed_accounting_company(" crates/`, relevé le 2026-10-08), + dont beaucoup supposent la créance sur 1100. +- **Réversible** : oui. + +## C-15-6-16 — 15-6a : les comptes lus sur la vente sont verrouillés ; un compte archivé est refusé par le refus nommé de la contre-passation (révise la voie (b) de C-15-6-7) + +- **Contexte** : validation P2 de la 15-6a — R-2 = F-1 (MEDIUM) : remplacer `rounding_account_for_write` + par un lecteur sans verrou retirait le `FOR UPDATE` que #486 posait sur le compte d'arrondi ; la garde + `active` de `create_in_tx` est une lecture simple, qui sous REPEATABLE READ ne voit pas un archivage + validé entre-temps. R-3 = F-2 (MEDIUM) : la voie (b) rendait un refus anonyme + (`INACTIVE_OR_INVALID_ACCOUNTS`), que la 6 ter de la même fonction avait justement écarté pour les + comptes de produit. Décisions de l'orchestrateur : fermer la course, nommer le compte par la forme + la plus simple. +- **Retenu** : après les deux lecteurs et la garde 6 ter, `create_credit_note` verrouille en une + instruction les lignes `accounts` de la créance et de l'arrondi de la vente + (`SELECT id, number, active … ORDER BY id FOR UPDATE`, **sans** filtre `active`) et lit l'état actif + **dans** cette lecture verrouillante. Un compte archivé → `DbError::ReversalAccountsArchived` + (400 `ACCOUNT_ARCHIVED`, `details.rejected[]`, clé `journal-entries-reverse-account-archived` + existante) : l'avoir est une contre-passation, c'est le refus que la contre-passation rend pour le + même cas. Aucune variante, aucun code, aucune clé neuve. La garde de `create_in_tx` reste, en filet. + Ordre des verrous : facture → règlements → avoir existant → comptes (celui de `validate_invoice`). + Test neuf de la course (attente puis refus nommé). Manuel : `keshwarning` au § *Avoirs*, + avertissement du plan comptable étendu, phrase du manuel d'administration sur l'arrondi. +- **Concilie** deux consignes : « le refus reste celui de `create_in_tx` » (pas de refus neuf — tenu : + aucun code neuf) et « nommer le compte » (tenu par le refus nommé existant). Lire `active` par la + garde de `create_in_tx` après le verrou n'aurait pas suffi : sa lecture simple rend l'instantané. +- **Écartées** : une variante neuve propre à l'avoir (un refus de plus pour un cas que la + contre-passation nomme déjà) ; garder `INACTIVE_OR_INVALID_ACCOUNTS` anonyme et le compenser au + manuel (l'utilisateur ne saurait pas quel compte réactiver) ; assumer la course en dette LOW (un site + aujourd'hui verrouillé l'aurait perdu). +- **Conséquence assumée** : deux avoirs simultanés sur des factures de même compte débiteurs se + sérialisent sur la ligne de ce compte, le temps d'une transaction. Le code du refus change pour + l'arrondi (`ROUNDING_ACCOUNT_NOT_CONFIGURED` → `ACCOUNT_ARCHIVED`) : l'esprit de la contrainte de + #523 (refus nommé) est tenu, sa lettre non — à commenter sur #523. +- **Réversible** : oui, tant que la 15-6a n'est pas développée. + +## C-15-6-17 — 15-6b : les écrans renoncent aux comptes archivés ; clés d'écran conformes au lint + +- **Contexte** : validation P2 de la 15-6b — R-1 = F1 (MEDIUM) : l'AC8 promettait d'écarter un compte + bancaire lié à un compte de rôle `Receivable` **archivé**, mais les trois écrans chargent le plan sans + les archivés ; F2 (MEDIUM) : la clé `invoice-settle-no-eligible-bank-account` faisait rougir + `lint-i18n-ownership` dans `src/lib/features/invoices/`. +- **Retenu** : l'AC8 **renonce** aux archivés — un compte archivé ne peut recevoir aucune écriture (la + garde `active` de `create_in_tx` est inconditionnelle), la garde serveur suffit ; les ids se + calculent avant le filtre `active && postable` (cas non imputable, qui lui arrive à l'écran). Clés + renommées `invoices-settle-no-eligible-bank-account` et `supplier-invoices-pay-no-eligible-bank-account` + (alignée sur ses voisines). +- **Écartées** : charger `fetchAccounts(true)` dans trois écrans pour un cas que le serveur refuse + déjà ; allonger `KNOWN_VIOLATIONS` (dette #30) au lieu de nommer la clé correctement. +- **Réversible** : oui. + +## C-15-6-18 — 15-6b : les comptes d'écart sont comparés à la créance par identifiant (révise le « résolu par le type » de C-15-6-3) + +- **Contexte** : finding F3 (MEDIUM) de la 15-6b — le « résolu par le type » reposait sur un invariant + qu'un geste ultérieur défait : le type d'un compte porteur d'écritures se change avec confirmation + (`accounts.rs:497-512`, `confirm_retype`), et le 1100 devenu `Expense` porte toujours la créance des + ventes antérieures ; `write_off_invoice` écrirait `D 1100 / C 1100`, l'arrondi du règlement de même. +- **Retenu** : le même refus `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, par comparaison + d'identifiants avec la créance lue sur la vente, garde le compte d'arrondi de `settle_invoice` (4bis) + et de `accept_one_invoice` (c-bis) et les comptes de `write_off_invoice` (nature, reste d'arrondi, TVA + due) — AC3 bis, trois tests. Le contrôle de type existant reste. Le test qui figeait le « résolu par + le type » est retiré. +- **Écartée** : écrire le chemin du changement de type en angle mort avec une issue (trois `if` sur des + identifiants déjà lus coûtent moins qu'une dette tracée). +- **Réversible** : oui. + +## C-15-6-19 — 15-6b : lots — refus contextualisé à la confirmation, écriture d'achat malformée en `failed[]`, écran de création non filtré + +- **Contexte** : findings F4 (MEDIUM), R-7 = F10 et F6 (LOW) de la 15-6b. Le refus à la confirmation + d'un lot disait « par un autre compte » alors que l'utilisateur n'en choisit aucun, et que le fichier + pain.001 a pu être exécuté par la banque ; le cas « écriture d'achat sans ligne de crédit » à la + création n'était pas dit ; l'écran de création d'un lot propose un compte bancaire lié au compte + créanciers. +- **Retenu** : la variante porte un contexte de lot optionnel (`SettlementBatchContext`), rempli par + `confirm_batch` ; clé propre `error-settlement-counterparty-is-payable-in-batch` qui dit les deux + issues (relier de nouveau le compte bancaire puis confirmer, ou annuler le lot et régler depuis la + fiche), `details.paymentBatchId` / `supplierInvoiceId`, manuel ; test du remède. Le refus à la + création reste la défense principale. À la création, une écriture d'achat sans ligne de crédit est + une donnée de **cette** facture : item `failed[]` `SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED` + (patron `INVOICE_SALE_ENTRY_MALFORMED`, § *Pattern batch*), libellé à l'écran des lots. L'écran de + création n'est **pas** filtré : il ne charge pas le plan comptable, et le refus y arrive par facture, + libellé, avant tout fichier — angle mort écrit. +- **Écartées** : le message du règlement unitaire à la confirmation (remède faux) ; une erreur globale + (500) pour l'écriture d'achat malformée (contraire au patron batch) ; charger le plan comptable sur + l'écran de création pour un filtre que le serveur double. +- **Réversible** : oui. + +## C-15-6-20 — 15-6b : décompte des modules refait, signal D5 déclaré, pas de découpage + +- **Contexte** : finding F5 (MEDIUM) de la 15-6b — la P1 avait ajouté les lots sans recompter. +- **Retenu** : décompte écrit dans la fiche — huit modules au barème des fichiers, cinq au barème du + geste (patron de la 15-5a). Signal **déclaré au Project Lead** ; pas de découpage : les gestes ne sont + pas indépendants (une variante, un `ClaimSide`, un lecteur sœur partagé par `pay_in_tx` et les lots, + trois fonctions d'écran ; les lots n'appellent que la garde de `pay_in_tx`), et aucun défaut de la P2 + ne recycle un défaut de la P1. +- **Écartée** : sortir les lots en sous-story (elle naîtrait sans sa garde, ou dupliquerait la + plomberie d'erreur et d'i18n). +- **Réversible** : oui — un découpage reste possible tant que la story n'est pas développée, sur + arbitrage de Guy. + +## C-15-6-21 — 15-6c : `LOCK IN SHARE MODE` ; route des réglages, le 409 d'abord ; son code n'est pas pour les intégrateurs (révise C-15-6-13) + +- **Contexte** : validation P2 de la 15-6c — R2-1 = F1 (HIGH) : `FOR SHARE`, écrit par C-15-6-13, est + une erreur de syntaxe (1064) sur MariaDB 10.11 ; F2 (MEDIUM) : le refus des réglages avant le 409 + jugeait un changement contre un état que le client n'avait pas vu ; R2-3 = F3 (MEDIUM) : la route des + réglages est fermée aux clés d'API. +- **Retenu** : `LOCK IN SHARE MODE` dans les deux lecteurs (graphie du dépôt, + `opening_complement.rs:38-39`), lecteur renommé `claim_accounts_in_share_mode` ; route des réglages : + forme → 409 version → 400 `CLAIM_ACCOUNT_LINKED_TO_BANK_ACCOUNT` → no-op ; `docs/api-external.md` ne + documente que `BANK_ACCOUNT_LEDGER_IS_CLAIM_ACCOUNT` (routes bancaires, ouvertes aux clés). La graphie + `FOR SHARE` de C-15-6-13 est **fausse** ; cette entrée la corrige (l'entrée d'origine n'est pas + réécrite). +- **Écartées** : garder le refus avant le 409 « parce que les autres 400 de la route le précèdent » + (ceux-là ne dépendent que du corps) ; lister le second code au guide comme « interne » (le guide + documente l'API par clé : un code qu'elle ne peut pas rendre n'y a pas sa place). +- **Réversible** : oui. + +## C-15-6-22 — 15-6c : appels directs au dépôt, fichiers de test de la 15-5b, code mort, signal D5 + +- **Contexte** : validation P2 de la 15-6c — R2-4 (MEDIUM) : 14 sites de test appellent directement les + deux fonctions du dépôt dont la signature change ; R2-2 = F4 (MEDIUM) : deux fichiers de test + doublaient ceux que crée la 15-5b ; F6 (LOW) : `BankAccountList.svelte`, second appelant du formulaire + de lien, n'est importé nulle part ; F8 : signal de découpage (le HIGH de la P2 naît de la remédiation + P1). Consigne de l'orchestrateur : tenir compte de la 15-5d, qui expose `defaultPayableAccountId` à + l'écran des réglages. +- **Retenu** : les appels directs passent `&ClaimAccounts::default()` (la garde n'est l'objet d'aucun + d'eux ; aucun test ne change de sens) ; les tests 6 et 14 étendent `company_invoice_settings_postable_e2e.rs` + et `bank-accounts/+page.test.ts` de la 15-5b ; `BankAccountList.svelte` est supprimé, la prop + `claimAccountIds` obligatoire ; le menu créanciers de la 15-5d est filtré comme celui des débiteurs. + Signal D5 **déclaré au Project Lead**, pas de découpage : le défaut est une graphie de mot-clé, non + une conception qui tourne en rond, et séparer les deux sens casserait l'ordre de verrous qui les + tient ensemble. Références de fixture de C-15-6-15 (`test_fixtures.rs:88-107`) **fausses** : lire + `:80-172`, désignation `:157-172`. +- **Écartées** : passer les réglages réels de la fixture aux appels directs (ferait rougir un test qui + lie au 1100, sans rapport avec son objet) ; prop optionnelle (laisserait le composant mort non + filtré, en silence). +- **Réversible** : oui ; la suppression de `BankAccountList.svelte` se défait par l'historique. + +## C-15-6-23 — 15-6d : l'égalité avec le compte de banque testée d'abord ; règle sur ce compte plus proposée ; filtre d'écran local (révise l'ordre de C-15-6-9) + +- **Contexte** : validation P1 de la 15-6d — F1 (MEDIUM) : la fiche plaçait le refus après le 404 et + `ACCOUNT_NOT_POSTABLE`, à l'inverse du flux ventilé qu'elle prétendait reproduire ; F2 (MEDIUM) : + `get_proposals` proposait une règle sur le compte de banque, que l'acceptation refuserait toujours, + et qui masquait une règle suivante ; R-1 = F3, R-3 (MEDIUM) : câblage de l'écran non testé, filtre + conditionné à la 15-6b. Décisions de l'orchestrateur. +- **Retenu** : `post_manual` compare juste après l'étape 2 bis, `accept_one_rule` juste après l'étape 4 + — avant 404 et non imputable, et avant la recherche de la transaction en attente (une transaction + déjà rapprochée rend 400) ; même réponse sur tous les chemins. `get_proposals` retire le compte de + banque de l'ensemble passé à `first_matching_rule` (patron 15-5b AC5). Écran : `ReconciliationProposals` + résout le compte de banque par `listBankAccounts()` (effet dépendant de `bankAccountId`, garde de + génération ; repli `null` = aucun filtrage), testé dans son fichier existant (mock de + `bank-accounts.api`) ; filtre **local** d'une condition dans `ManualMatchModal`. L'écran de + ventilation reste non filtré (angle mort écrit). Course résiduelle de `post_manual` écrite. +- **Écartées** : garder l'ordre « après la 15-5b » (réponse différente selon le chemin, message + trompeur) ; faire passer le compte de banque par la page (`/companies/current` le porte, mais la page + n'a pas de test : le câblage resterait non vérifié) ; `withoutAccountIds` de la 15-6b (dépendance pour + un seul id). +- **Réversible** : oui, tant qu'elle n'est pas développée. + +## C-15-6-24 — 15-6a : comptes de la vente verrouillés EN PARTAGE et AVANT l'exercice (rectifie C-15-6-16) + +- **Contexte** : validation P3 de la 15-6a — R3-1 (HIGH, né de la remédiation P2) : C-15-6-16 plaçait + le verrou des comptes de la vente (créance, arrondi) **après** `fiscal_years` et la séquence, et + affirmait « c'est l'ordre de `validate_invoice` » ; c'est l'inverse de l'ordre canonique + (`invoices.rs:1916-1925` : `invoices` → `accounts` (1 bis) → `fiscal_years` → séquences → + écriture), et la 15-5d verrouille la créance dans `validate_invoice` avant l'exercice : cycle sur la + ligne 1100 entre chaque validation et chaque avoir. F-1 (MEDIUM) : un `FOR UPDATE` (X) sur la + créance ouvrait un cycle neuf avec `accept_one_invoice`, qui tient S sur 1100 (clé étrangère des + lignes insérées) avant de demander X sur la facture, que l'avoir tient. F-8 : la « conséquence + assumée » (deux avoirs se sérialisent sur la ligne du compte) était fausse. Décision de + l'orchestrateur. +- **Retenu** : les deux lecteurs sont appelés juste après les réglages (étape (3)) ; les comptes de la + vente sont verrouillés en une instruction `… ORDER BY id LOCK IN SHARE MODE`, **avant** l'exercice + (4) et la séquence (5) — ordre : facture → règlements → avoir existant → réglages → comptes de la + vente (S) → exercice → séquence → écriture, conforme à la validation, au règlement et à la 15-5d. Un + S suffit à faire attendre `accounts::archive` (X) et rend l'état courant de `active` ; S + S ne + bloque ni le rapprochement ni un second avoir. Ordre des refus qui en découle : compte de la vente + archivé avant `FISCAL_YEAR_INVALID`, `creditNoteTotalZero` et `CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED` + (test 14). Test de course sur `attendre_une_requete_en_cours` (motif `LOCK IN SHARE MODE`). Ce que + le verrou ne couvre pas — comptes de produit (6 ter) et TVA due, lus sans verrou — est écrit comme + angle mort assumé, préexistant. +- **Rectification de C-15-6-16** (entrée non réécrite) : ses phrases « ordre des verrous : … comptes + de la vente. C'est celui de `validate_invoice` » et « deux avoirs simultanés … se sérialisent sur la + ligne de ce compte » sont **fausses** ; le mode `FOR UPDATE` qu'elle prescrivait est remplacé par + `LOCK IN SHARE MODE`. Le reste de C-15-6-16 (comptes verrouillés sans filtre `active`, état lu sous + verrou, refus nommé) tient, le refus étant précisé par C-15-6-25. +- **Écartées** : garder `FOR UPDATE` en le déclarant risque connu (cycle avec le rapprochement sur le + compte le plus mouvementé du grand livre) ; garder la place après l'exercice en écrivant l'inversion + comme angle mort (cycle avec chaque validation dès la 15-5d) ; joindre les comptes de produit et de + TVA due au même verrou (exige de faire lire la 6 ter sous verrou ; hors sujet de #473/#523). +- **Réversible** : oui, tant qu'elle n'est pas développée. + +## C-15-6-25 — 15-6a : un seul vocabulaire de refus pour l'avoir — variante propre, code `ACCOUNT_ARCHIVED`, libellé « Impossible d'émettre l'avoir » (révise C-15-6-16) + +- **Contexte** : validation P3 de la 15-6a — F-9 (LOW) : sur `POST /api/v1/credit-notes`, un compte + de produit archivé rend `CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED` (« Impossible d'émettre l'avoir — + … ») et un compte de la vente archivé rendait, par C-15-6-16, `ACCOUNT_ARCHIVED` avec le message de + la contre-passation (« Impossible de contre-passer — … ») : deux vocabulaires pour un même geste. + L'orchestrateur demande de trancher entre étendre `CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED` et un + libellé « Impossible d'émettre l'avoir » pour `ACCOUNT_ARCHIVED` sur cette route. +- **Retenu** : la seconde voie. Variante `DbError::CreditNoteAccountsArchived(Vec)`, + **même code** `ACCOUNT_ARCHIVED`, même `details.rejected[]` (`accountId`, `accountNumber`) que la + contre-passation ; clé neuve `credit-note-account-archived` dans les quatre locales — fr : + « Impossible d'émettre l'avoir — compte(s) archivé(s) : { $detail }. Réactivez le ou les comptes + concernés. » Le manuel et le CHANGELOG disent les deux codes de la route (par ligne / par compte de + la vente). Révise le « aucune variante, aucun code, aucune clé neuve » de C-15-6-16 : aucun **code** + neuf, une variante et une clé. +- **Écartées** : étendre `CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED` (sa structure `RejectedRevenueAccount` + désigne une **ligne de facture** ou le produit par défaut, et son nom dit « produit » : y loger la + créance mentirait au lecteur de l'API et exigerait un sujet neuf dans `format_rejected_revenue_accounts`) ; + garder le message de la contre-passation (« contre-passer » face au bouton « Créer un avoir », + incohérent avec le refus voisin de la même route). +- **Réversible** : oui, tant qu'elle n'est pas développée ; après, retirer la variante ne change pas le + code rendu. + +## C-15-6-26 — 15-6b : un code, un rôle, deux remèdes ; tables de refus du guide ; comparaison séparée de la construction du refus (révise C-15-6-18 ; C-15-6-3 révisé par C-15-6-18 et ce choix) + +- **Contexte** : validation P3 de la 15-6b (lentilles R et F, Sonnet ; remédiation Opus 5.5). + R1 = F1 (MEDIUM) : l'AC3 bis ajoutée en P2 (C-15-6-18) rendait, pour un compte d'arrondi, de nature + ou de TVA due **désigné dans les réglages**, le message du règlement unitaire, qui renvoie à « un + autre compte (banque, caisse…) » alors que l'utilisateur n'a choisi aucun compte — cas du dialogue + de solde, qui affiche `err.message` (`invoices/[id]/+page.svelte:509`). R2 = F2 (MEDIUM) : l'AC10 + visait le § 10 générique de `docs/api-external.md` et oubliait la table des refus du solde + (`:274-283`). F3 (MEDIUM) : six à sept sites répétaient comparaison, lecture du numéro et + construction du refus. Le corps de C-15-6-3 dit encore « deux clés » et « résolu par le type » pour + les comptes d'écart : révisé par C-15-6-18 puis par ce choix, non réécrit. Décisions de + l'orchestrateur. +- **Retenu** : (a) un seul code `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, discriminant + `role: SettlementAccountRole` dans la variante et en `details.role` (`counterparty`, `rounding`, + `write_off_nature`, `vat_payable`) ; quatrième clé `error-settlement-designated-account-is-receivable` + (sélecteur Fluent sur `$role`) qui renvoie à *Paramètres → Facturation* ; deux cas dans le libellé + `failed[]` du rapprochement et au manuel ; `claim: Payable` ne se combine qu'avec `counterparty`. + (b) Guide : une ligne dans la table du solde, une phrase « Refus » sous le § des règlements client et + sous celui du règlement fournisseur (qui couvre les deux routes de lot) ; le § 10 n'est pas touché. + (c) Helper commun dans `invoice_settlements.rs` : `ensure_not_claim_account` (comparaison **pure**, + seule partie empruntée par la 15-6d), `claim_account_refusal` (lit le numéro à l'échec seulement), + `claim_account_refusal_details` (mapping HTTP, `FailedProposal`, `PaymentBatchFailedItem`). + (d) `confirm_batch` relit `supplier_invoice_number` par une `SELECT` ; la variante ne gagne pas de + champ. (e) Signal D5 levé (MEDIUM → MEDIUM, deux défauts nés de la remédiation P2, confinés à + l'AC3 bis), déclaré, sans découpage ; la P4 est une passe complète. +- **Écartées** : un second code pour le compte désigné (même défaut `D X / C X`, un intégrateur le + traite d'un seul bras ; la différence de remède est une information) ; un message unique reformulé + (vague pour les deux cas) ; inscrire le code au § 10 (rompt la convention) ; un helper qui compare et + construit en une fonction (inempruntable par la 15-6d, dont le refus est le `VALIDATION_ERROR` du flux + ventilé) ; ajouter `supplier_invoice_number` à `pay_in_tx` ou à la variante. +- **Réversible** : oui avant le développement ; après, `details.role` et la quatrième clé sont des + ajouts compatibles, retirer `role` romprait un contrat d'API documenté. + +## C-15-6-27 — 15-6c : le `FOR UPDATE` de `before` testé, attente prouvée, pas de verrou pour une dé-liaison, place du manuel, routes bancaires au guide + +- **Contexte** : validation P3 de la 15-6c (lentilles R et F, Sonnet ; remédiation Opus 5.5) — 3 + MEDIUM, 11 LOW. F1 : le `FOR UPDATE` de `before` dans `company_invoice_settings::update` ferme une + course, mais aucun test ne l'épinglait (les tests 11 et 12 passent avec un `SELECT` simple). F2 : les + attentes des tests étaient bornées à 300 ms. R3-1 : le paragraphe du manuel d'administration était + placé juste avant un paragraphe sans en-tête, qui en serait devenu la suite. F8 : les routes + `/bank-accounts` ne figurent nulle part dans `docs/api-external.md`. Décisions de l'orchestrateur. +- **Retenu** : test 12 bis — une transaction tenue pose S sur les réglages par + `claim_accounts_in_share_mode`, le PUT doit attendre au `FOR UPDATE` (rougit si on le retire) ; + tests 11, 12 et 12 bis prouvent l'attente par `attendre_une_requete_en_cours`, jamais par un délai ; + une cible `NULL` (dé-liaison, création ou remplacement sans compte) ne lit pas les réglages et ne pose + aucun verrou S (`ClaimAccounts::default()`) ; `$account` = numéro du compte lu sans verrou après la + décision du refus, repli `#` ; `$bank` = `bank_name` ; paragraphe du manuel d'administration + après `:2033` (borne `:2017-2033`), qui présente d'abord les comptes débiteurs et créanciers puis la + règle réciproque ; `docs/api-external.md` : une ligne *Comptes bancaires* au § 7 et le code au § 10, + sans section neuve ; portée réelle des verrous dite (next-key et intervalle, attente transitoire + possible, non mesurée) ; tests Vitest existants adaptés (`BankAccountJournalLinkForm.test.ts`, + `settings-invoicing-page.test.ts`), aucun ne change de sens. +- **Écartées** : garder le délai de 300 ms (passe à vide sur machine chargée) ; verrouiller aussi pour + une dé-liaison (attente inutile, le contrôle ne joue jamais sur `NULL`) ; insérer le paragraphe après + *Montant minimum* (`:2031`) ; documenter toute la ressource bancaire au guide (hors périmètre) ; ne + rien ajouter au § 7 (le § 10 renverrait à des routes que le guide ne présente pas). +- **Réversible** : oui, la fiche n'est pas codée. + +## C-15-6-28 — 15-6d : contrôle du compte de banque actif sur le chemin par règle ; gardes ventilées figées ; pas de garde de génération ; comparaison empruntée au helper de la 15-6b (complète C-15-6-23) + +- **Contexte** : validation P2 de la 15-6d (lentilles R et F, Opus ; remédiation Opus 5.5) — R-1 + (MEDIUM) = F-4 (LOW) : `accept_one_rule` ne contrôle pas l'activité du compte de banque, que les + trois autres chemins contrôlent avant leur garde d'égalité ; « même réponse sur tous les chemins » + était faux pour un compte de banque archivé. F-2 (MEDIUM) : l'ordre « égalité avant postabilité » de + C-15-6-23 reposait sur deux gardes ventilées qu'aucun test n'exerce. F-6 (LOW) : la garde de + génération protégeait un cas que la page ne produit pas (`{#key selectedId}`). F3 de la P3 de la + 15-6b : helper commun dans `invoice_settlements`. Décisions de l'orchestrateur. +- **Retenu** : `accept_one_rule` gagne, juste après l'étape 1, l'étape « compte de banque actif » + d'`accept_one_split` (`reconciliation.rs:1912-1938` ; `DATABASE_ERROR`, + `BANK_ACCOUNT_NOT_CONFIGURED` + `details.bankAccountId`), avant la garde d'égalité — même réponse + sur les quatre chemins (actif → `VALIDATION_ERROR`, non imputable → `VALIDATION_ERROR`, archivé → + `BANK_ACCOUNT_NOT_CONFIGURED`) ; changement délibéré d'un refus existant, relevé en T0, testé + (test 7). Deux témoins figent les gardes ventilées (`post_split`, `accept_one_split`). Écran : + `listBankAccounts()` une fois au montage, sans garde de génération (le `{#key}` de la page remonte + le composant ; son retrait exigerait la garde, dit au doc-comment). Backend : la **comparaison** + emprunte `ensure_not_claim_account` (fonction pure de la 15-6b, C-15-6-26) ; la **construction** du + refus reste celle du flux ventilé, ramenée à deux fonctions locales. La 15-6d passe donc après la + 15-6b. L'angle mort « course résiduelle » est retiré : la valeur comparée est celle qui construit + l'écriture, aucun verrou à ajouter. +- **Écartées** : écrire la divergence du chemin par règle comme angle mort (un même défaut rendrait + trois réponses) ; garder la garde de génération « par cohérence avec `loadGen` » (justification + fausse, test d'un cas impossible) ; emprunter aussi la construction de refus de la 15-6b (changerait + la forme `VALIDATION_ERROR` fixée par C-15-6-9 / C-15-6-23) ; `withoutAccountIds` à l'écran (écarté + par C-15-6-23). +- **Réversible** : oui, tant qu'elle n'est pas développée. + +## C-15-6-29 — 15-6a : le verrou partagé couvre tous les comptes que l'avoir écrit ; rejeu de la route sur interblocage ; ligne absente → `Invariant` (rectifie C-15-6-24 ; étend C-15-6-25) + +- **Contexte** : validation P4 de la 15-6a (lentilles R et F, Opus ; remédiation Opus 5.5). F-1 + (MEDIUM) : la TVA due et les comptes de produit demandaient leur S **après** l'exercice, face au X + que le solde du reste y prend **avant** le sien — cycle préexistant que la 15-5d décrit et ferme pour + la validation, non pour l'avoir. R4-1 ≈ F-2 (MEDIUM) : l'analyse de C-15-6-24 (« S + S ne bloque ni + le rapprochement ni un second avoir ») était fausse pour l'arrondi — le rapprochement prend X sur + l'arrondi et sur l'exercice avant son `UPDATE invoices`, et le cycle avoir ↔ rapprochement de la + même facture préexiste par l'exercice (**#536**, ouverte par l'orchestrateur) ; la route de l'avoir + ne rejoue pas un 1213 (500). F-3 (LOW) : cycle à trois neuf par un X en attente. R4-9 = F-5 (LOW) : + ligne absente du résultat du verrou non spécifiée. Décisions de l'orchestrateur. +- **Retenu** : (1) le `LOCK IN SHARE MODE`, pris avant l'exercice, en **une** requête `ORDER BY id`, + couvre la créance et l'arrondi de la vente, la TVA due des réglages (si l'avoir porte de la TVA et + que le réglage est posé) et les comptes de produit effectifs (repli D-B2 compris) — règle écrite : + un compte qu'un autre flux tient en X avant l'exercice se verrouille avant l'exercice ; (2) la 6 ter + lit `active` dans le résultat du verrou (l'instantané REPEATABLE READ, établi au snapshot des lignes, + précède le verrou), en gardant son code, son message par ligne et sa place ; (3) un compte de TVA + due archivé rend `CreditNoteAccountsArchived` (`ACCOUNT_ARCHIVED` nommé) au lieu de + `INACTIVE_OR_INVALID_ACCOUNTS` anonyme — même remède que la créance et l'arrondi ; (4) un id que le + verrou ne rend pas → `DbError::Invariant` ; (5) la route `POST /api/v1/credit-notes` enveloppe le + dépôt dans `retry_with` (`is_deadlock_error`), patron de `write_off_invoice_handler`, figé par un + test qui provoque un interblocage déterministe dont l'avoir est la victime ; (6) trois cycles + résiduels inscrits à l'inventaire (avoir ↔ rapprochement, #536 ; avoir ↔ solde du reste par ordres + opposés entre deux comptes ; X en attente), tous détectés par InnoDB et rejoués. L'angle mort « un + compte lu sans verrou pendant l'avoir » disparaît. C-15-6-24 reste vrai sur le mode (S) et la place + (avant l'exercice) ; sa justification « S + S ne bloque pas le rapprochement » est rectifiée ici. +- **Écartées** : garder le verrou à la créance et à l'arrondi et inscrire le cycle de la TVA due et + des produits en angle mort (la 15-5d a fermé la même forme pour la validation ; l'avoir serait le + dernier chemin à l'ouvrir) ; un refus `ACCOUNT_ARCHIVED` pour les produits aussi (changerait le + refus par ligne de la 6 ter, figé par ses tests et son message) ; laisser la ligne absente au filet + anonyme de `create_in_tx` ; laisser la route sans rejeu (un 500 opaque sur « Créer un avoir » quand + l'avoir est la victime). +- **Réversible** : oui, tant qu'elle n'est pas développée. + +## C-15-6-30 — 15-6b : le compte d'arrondi porte le rôle `rounding` quel que soit le geste ; validation et avoir en angles morts écrits ; message de liste vide conditionné + +- **Contexte** : validation P4 de la 15-6b (lentilles R et F, Opus ; remédiation Opus 5.5). R1 = F1 + (MEDIUM) : le reste d'arrondi du solde (5 bis) et la nature `rounding` étaient classés + `write_off_nature`, alors qu'ils lisent `default_rounding_account_id`, la colonne du compte d'arrondi + du règlement — le message renvoyait au champ d'escompte (recyclage du discriminant posé en P3, + C-15-6-26). F2 (MEDIUM) : « résolu par le type » faux pour la validation (créance des réglages sans + type contrôlé) et pour l'avoir (TVA due, produit de repli). F3 (MEDIUM) : le message de liste vide + accusait le compte débiteurs dans une société sans compte bancaire. F4 (LOW) : « désignez » s'adresse + à un Comptable sans accès aux réglages. Décisions de l'orchestrateur. +- **Retenu** : `role: rounding` pour tout compte lu dans `default_rounding_account_id` (règlement, + rapprochement, reste d'arrondi et nature `rounding` du solde) ; `write_off_nature` pour `discount`, + `bank_fees`, `bad_debt` seulement ; le rôle suit la colonne lue, pas la route ; ordre des refus au + solde : nature, reste d'arrondi, TVA due. Angles morts écrits, sans garde neuve : créance retypée à + la validation (**#537**, ouverte par l'orchestrateur), TVA due et produit de repli de l'avoir + (#525). Message de liste vide seulement si au moins un compte bancaire a été écarté par le filtre. + Messages : « un administrateur doit désigner », comme C36. Sélecteur Fluent multi-lignes, repli Rust + par rôle. **Signal D5 levé (recyclage de F1), pas de découpage** : recyclage contenu au même + discriminant (une ligne de classement, un test), F2/F3 d'origine, aucun module de plus ; un troisième + recyclage sur l'AC3 bis en P5 en déciderait la sortie en story propre. +- **Écartées** : un rôle distinct « reste d'arrondi » (deux valeurs pour une même colonne, un + intégrateur verrait deux rôles pour un même compte désigné) ; garder la validation et l'avoir + (sixième et septième modules, hors de la classe « règlement ») ; un second message pour « aucun + compte bancaire » (aucun état vide n'existe aujourd'hui, le comportement actuel suffit) ; + « désignez » avec une branche d'écran par rôle (écartée par C36). +- **Réversible** : oui, tant qu'elle n'est pas développée. + +## C-15-6-31 — 15-6d : clôture de la validation ; C-15-6-28 révise C-15-6-23 (garde de génération, course résiduelle) ; texte du refus selon le champ + +- **Contexte** : validation P3 de la 15-6d (lentilles R et F, Sonnet ; remédiation Opus 5.5) — 10 LOW, + 0 au-dessus. F3-1 : C-15-6-23 « Retenu » dit toujours « effet dépendant de `bankAccountId`, garde de + génération » et « course résiduelle de `post_manual` écrite », que C-15-6-28 retire en se disant + « complète » ; le registre ne se réécrit pas. F3-2 : « le même refus que `post_split` » n'est pas le + même texte (`splits[{idx}].…`). R1 : l'étape ajoutée à `accept_one_rule` est atteignable depuis + l'écran. +- **Retenu** : C-15-6-28 **révise** C-15-6-23 sur deux points — plus de garde de génération à l'écran, + plus d'angle mort « course résiduelle » — ; le reste de C-15-6-23 tient. Une fonction de refus + `counterparty_is_bank_ledger_error(field)` pour `post_split` et `post_manual`, même code, texte selon + le champ ; tests sur le code seul. Compte de banque archivé avant l'ouverture de l'écran : angle mort + écrit, aucun contrôle ajouté à `get_proposals`. Manuel : le refus seul est affirmé sans réserve. + CHANGELOG : `### Modifié` pour le changement délibéré de l'AC2. Validation close. +- **Écartées** : réécrire C-15-6-23 (interdit par la consigne du registre) ; un message identique sur + les deux routes (`post_manual` n'a pas d'index de ligne) ; contrôler l'activité du compte lié dans + `get_proposals` (hors périmètre, la garde d'acceptation suffit). +- **Réversible** : oui, tant qu'elle n'est pas développée. + +## C-15-6-32 — 15-6a : alignement sur la 15-5e réécrite par C54 — le rejeu de la route vient de la 15-5e, le verrou reste pour la seule course de lecture, aucun réordonnancement, cycles nommés sans prétention d'absence ; helper de verrou de liste partagé avec la 15-5d ; « émet de la TVA » à source unique (révise C-15-6-29 sur le rejeu et les cycles) + +- **Contexte** : validation P5 de la 15-6a (lentilles R et F, Sonnet ; remédiation Opus 5.5). R5-1 = + F5-1 (MEDIUM) : la fiche ignorait la 15-5e (C52). R5-2 (MEDIUM) et F5-3 (LOW) : cycles non + inscrits (avoir ↔ solde du reste par TVA due ↔ arrondi ; avoir ↔ règlement par compte interne). + F5-5 (LOW) : deux helpers jumeaux (15-5d en X, 15-6a en S). R5-4 = F5-2 (LOW) : condition « l'avoir + émet de la TVA » calculée après le verrou. Consigne initiale de l'orchestrateur : « une seule règle + d'ordre des verrous pour l'epic, celle de la 15-5e », avec « l'arrondi d'abord ». **Correction de + consigne reçue pendant la remédiation** : la 15-5e est réécrite selon **C54** (la défense contre + l'interblocage est le rejeu ; la 15-5e rejoue toutes les routes d'écriture, avoir compris, et ne pose + plus « l'arrondi d'abord », C55) ; ne pas réordonner l'avoir pour fermer des cycles, garder les + verrous qui ferment une course de lecture, retirer toute affirmation d'absence de cycle. +- **Retenu** : (1) dépendances « après 15-5a à 15-5e » ; (2) l'AC7 ne réécrit ni le doc-comment + canonique, ni le « 5 bis », ni le doc-comment de la route, que la 15-5e réécrit ou pose ; elle ne + corrige qu'une phrase qui décrirait l'ancien ordre de l'avoir, s'il y en a une ; (3) la route + `POST /api/v1/credit-notes` est rejouée **par la 15-5e** — la 15-6a ne spécifie plus de `retry_with` + (révise C-15-6-29) ; son test 15 fige que ce rejeu couvre l'attente neuve créée par le verrou ; + (4) le verrou `LOCK IN SHARE MODE` des comptes écrits reste **en une instruction `ORDER BY id`**, + avant l'exercice, pour la seule **course de lecture** (archivage concurrent, `active` lu frais) ; + aucun réordonnancement ; (5) les cycles connus — #536 (rejeu, C57), solde du reste (nature + commune, ou TVA due ↔ arrondi), règlement par compte interne, X en attente — sont **nommés, non + exhaustifs**, couverts par le rejeu ; plus d'« ensemble clos » ; (6) **un seul** helper de verrou + d'une liste de comptes, paramétré par le mode, créé par la première story mergée (a priori la + 15-5d), réutilisé par la 15-6a ; `EXPLAIN` et test d'identifiant étranger comme la 15-5d (C51) ; + (7) agrégation de la TVA de l'avoir extraite en fonction pure partagée par le générateur et le + calcul des ids ; refus `ConfigurationRequired(TVA)` laissé à sa place ; (8) une ligne pour l'avoir + au Pattern 5, dans la forme que la 15-5e lui donne. +- **Écartées** : « l'arrondi de la vente d'abord » (demandé par la consigne initiale, retiré par la + correction : un réordonnancement pour fermer un cycle, contraire à C54/C55) ; un « ensemble clos » + de cycles (affirmation d'absence, contraire à C54) ; garder un `retry_with` propre à la route (doublon + de la 15-5e) ; un second helper en S (DRY) ; avancer le générateur avant le verrou (déplacerait le + refus de TVA devant `FISCAL_YEAR_INVALID`) ; dupliquer `total_vat > 0`. +- **Réversible** : oui, tant qu'elle n'est pas développée. **À répercuter hors du worktree** (par + l'orchestrateur) : la fiche 15-5d peut dire que son helper prend un mode. + +## C-15-6-33 — 15-6b : alignement sur la 15-5e réécrite par C54 (aucun ordre ne change ; chaque comparaison suit sa lecture) ; trois clés plates ; critère de T2 par nom sur liste fermée ; sujet de refus typé (révise C-15-6-26 et C-15-6-30 sur la forme des messages) + +- **Contexte** : validation P5 de la 15-6b (lentilles R et F, Sonnet ; remédiation Opus 5.5). F1 + (MEDIUM) : la 15-5e (C52) réordonnait `settle_invoice` et `write_off_invoice` ; sa réécriture par + C54, reçue en cours de remédiation, retire ce réordonnancement et rejoue les routes. R5-1 (MEDIUM) : + le sélecteur Fluent `$role` rougit un test de `kesh-i18n` et se résout mal côté frontend. R5-2 + (MEDIUM) : la borne `sitesTotal` existe déjà. R5-3 = F2 (MEDIUM) : le critère de T2 (P4) ne voyait + pas un littéral coupé par rustfmt. F3 (MEDIUM) : `user-manual.tex:1754`. F5, F6, F7, R5-4 à R5-8 (LOW). +- **Retenu** : (1) dépendances « après 15-5a à 15-5e », étapes citées par leur nom ; (2) **chaque + comparaison suit aussitôt la lecture de son compte**, dans l'ordre des lectures du code, que la + 15-5e ne change pas : au solde, nature → reste d'arrondi → TVA due ; au règlement, contrepartie → + trop-perçu → compte d'arrondi ; une nature égale à la créance précède les refus de configuration + des comptes lus après elle (test 10 ter) ; (3) trois clés plates + (`error-settlement-{rounding,write-off,vat-payable}-account-is-receivable`), un rôle une clé, sans + `match` de repli ; (4) `sitesTotal` relevé pour tous les sites neufs (+4, +6 avec la 15-5c), recompté + sur l'état rebasé ; (5) critère de T2 : `grep -rnF` du nom, commentaires exclus, sur une liste fermée + de cinq fichiers, chaque occurrence lue ; (6) helper de construction à sujet typé + (`ClaimSubject::{Counterparty(ClaimSide), Designated(DesignatedRole)}`) ; (7) `:1754` au manuel ; + phrase « Refus » du paiement fournisseur avant la ligne d'annulation ; (8) produit de repli de l'avoir + écrit « non tracé » (#525 ne le couvre pas), signalé à l'orchestrateur. **Signal D5 non déclenché** : + F1 vient d'un changement extérieur (la 15-5e), les recyclages (R5-1, F2) sont hors de l'AC3 bis. +- **Écartées** : suivre l'ordre « l'arrondi d'abord » de la première version de la 15-5e (retiré par + C54/C55) ; inscrire la clé à sélecteur à `SELECTEURS_RESOLUS_COTE_SERVEUR` (ajoute `loader.rs` et un + repli Rust par `match` ; la 15-5c a choisi les clés plates pour la même raison) ; toutes les + comparaisons après la dernière lecture (ordre des refus dépendant de refus de configuration + étrangers au défaut) ; un critère `rg -U` multi-lignes (plus fragile qu'une liste fermée) ; un + `debug_assert!` pour `Payable` + rôle désigné (muet en production). +- **Réversible** : oui, tant qu'elle n'est pas développée. + +## C-15-6-34 — 15-6b : clôture de la validation à la P6 ; produit de repli et validation d'achat tracés (#525, #537) ; règle de placement « après son compte et la créance » ; manuel d'administration ; dispersion déclarée sans découpage + +- **Contexte** : validation P6 de la 15-6b (Opus, lentilles R et F) : 0 au-dessus de LOW (R 11 LOW, + F 7 LOW, R6-4 = F6-6). Après la P5, l'orchestrateur a tracé le produit de repli de l'avoir sur #525 + et la forme côté dette de la validation d'achat sur #537 (commentaires du 2026-10-08) ; la fiche les + disait « non tracé » / « signalé ». F6-1 : le manuel d'administration n'avait aucun verdict. F6-7 : + le critère de dispersion de l'amendement D5 est rempli au barème de la règle (neuf modules). +- **Retenu** : (1) **validation close** (0 > LOW, § *Review Iteration Rule*) ; tous les LOW appliqués ; + (2) « tracé par #525 / #537 » aux trois sites de la fiche et dans la 15-6a ; (3) règle de placement : + chaque comparaison suit la lecture de son compte **et** celle de la créance (la nature est lue avant + la créance au solde) — aucun réordonnancement (C55) ; (4) manuel d'administration : `:2027` inchangée, + une phrase à `:2029`, une condition à `:2033`, frontière de la 15-6c alignée ; (5) présélection du + dialogue de règlement dans un effet distinct qui n'efface pas la saisie ; (6) clés de lot de + `details` ajoutées par le mapping HTTP seul ; (7) `DesignatedRole` avec `ClaimSubject` dans + `invoice_settlements.rs` ; (8) **dispersion déclarée au Project Lead, non découpée** : le « barème du + geste » n'est plus invoqué ; raison — fiche convergée en six passes, gestes liés par une variante, un + sujet typé, un lecteur sœur et trois fonctions d'écran ; le risque visé (non-convergence) ne s'est + pas réalisé ; arbitrage de Guy en revue de fin d'epic. +- **Écartées** : une P7 (aucun finding > LOW ; la boucle s'arrête) ; un angle mort écrit pour le + manuel d'administration au lieu de deux phrases (le remède du message envoie l'administrateur à ce + manuel) ; découper la story à ce stade (rouvrirait deux validations pour séparer ce que la + conception lie) ; reconstruire le `default` de `t_args` par un `match` (le `format!` du patron + `errors.rs:88-92` suffit). +- **Réversible** : oui, tant qu'elle n'est pas développée ; le découpage reste ouvert à l'arbitrage + de Guy. + +## C-15-6-35 — 15-6a : un seul contrat pour le helper de verrou de liste (mode, `pub`, ne refuse rien, type nommé distinct de `LockedAccount`) ; critère DRY à exception écrite ; partenaires réels du cycle (iv) ; dépendance ferme à la 15-5d + +- **Contexte** : validation P6 de la 15-6a (Opus, lentilles R et F) : 4 MEDIUM distincts — F6-1 = R6-1 + (deux contrats pour la fonction de verrou), R6-2 (critère DRY faux : `opening_complement.rs:518-537`), + R6-3 (doctrine retirée restée aux Dev Notes et en T0), F6-2 = R6-4 (validation impossible comme + partenaire du cycle (iv)) ; trois recyclent la remédiation P5. +- **Retenu** : (1) la puce « Un seul helper » est le **seul** contrat : paramétré par le mode, à + l'emplacement de la 15-5d, `id, number, active, postable`, ne refuse rien (« ligne absente » et + partage chez `create_credit_note`), **`pub`** (test 17 d'intégration), type de ligne nommé et public + **distinct** de `opening_complement::LockedAccount` (p. ex. `LockedAccountState`), ou celui de la + 15-5d s'il existe ; le paragraphe « Refus nommé » y renvoie ; (2) critère DRY : aucune autre requête + de verrou d'une liste d'ids de comptes **hors** `opening_complement.rs:518-537`, exception écrite + (forme distincte, hors périmètre) ; (3) Dev Notes et T0 : plus de « règle de l'AC6 » ni de + conformité à vérifier — le verrou avant l'exercice sert la course de lecture, la défense est le + rejeu (C54) ; (4) cycle (iv) : partenaires = flux qui prennent l'exercice sans la ligne des réglages + (saisie manuelle, règlement, solde du reste, rapprochement) ; validation et saisie fournisseur + exclues ; (5) la 15-5d devient une dépendance **ferme** (la story attend) ; (6) test 17 : issue + attendue rouge d'après la mesure du dépôt, puis `owned_account_ids`, sauf adoption par la 15-5d ; + (7) **signal D5 levé** (MEDIUM → MEDIUM, recyclage de la P5) et dispersion (six modules métier, plus + la plomberie) **déclarés**, pas de découpage : recyclage contenu au texte de l'AC6, sans règle métier + ni module ; P7 complète (Sonnet) due. +- **Écartées** : réutiliser `opening_complement::LockedAccount` (privé, autres colonnes, flux hors + epic) ou lui prendre son nom (homonymie) ; refondre `create_opening_complement` sur le helper + (changement sans motif d'un flux livré) ; restreindre le critère DRY aux seuls flux de facturation + (moins décidable qu'une exception nommée) ; garder la branche « si la 15-5d n'est pas mergée » + (deux chemins à spécifier pour un ordre d'epic déjà fixé) ; découper l'AC6 (le défaut est une + propagation incomplète, que le grep du symptôme traite). +- **Réversible** : oui, tant qu'elle n'est pas développée. **À faire par l'orchestrateur** : + commentaire sur #536 (l'avoir est couvert par le rejeu de la 15-5e ; la 15-6a ne ferme aucun cycle — + finding F6-6). + +## C-15-6-36 — 15-6a : un seul type de ligne pour le helper de verrou (révise C-15-6-35 (1)) ; C-15-6-32 (6) et C-15-6-29 marqués révisés ; manuel : le rôle repris avant la réactivation + +- **Contexte** : validation P7 de la 15-6a (Sonnet, lentilles R et F) : 1 MEDIUM (R7-1), 10 LOW. R7-1 : la fiche + (AC6) disait « la 15-6a prend le sien » si la 15-5d a nommé le type, le registre (C-15-6-35 (1)) « ou celui de + la 15-5d s'il existe » ; deux consignes qui s'excluent, la première produisant deux types publics de même forme. +- **Retenu** : (1) **un seul type, jamais deux** : si la 15-5d a nommé **et exposé (`pub`)** le type de ligne de + son helper, la 15-6a l'emploie ; sinon elle le rend public sous le nom qu'il porte (ou `LockedAccountState` + s'il n'en a pas). **Cette entrée révise C-15-6-35 (1)** (« type nommé et public distinct de `LockedAccount`, + ou celui de la 15-5d s'il existe »), la fiche (AC6) étant alignée ; (2) **C-15-6-32 (6)** (« créé par la + première story mergée, a priori la 15-5d ») est **révisé** : la 15-5d est une dépendance ferme (C-15-6-35 (5)) ; + **C-15-6-29** (« angle mort : cycle avec chaque validation dès la 15-5d ») est **révisé** : la validation + n'est pas partenaire du cycle (iv) (C-15-6-35 (4)) ; (3) le cycle (iv) ne cite `invoices.rs:2006` et `:2172` + que pour la validation ; l'ordre de la saisie fournisseur est relevé par T0 ; (4) test 17 : appel direct du + helper avec des ids explicites, sans `UPDATE` des réglages ; tests 2 et 7 : « par SQL direct » ; (5) le + `keshwarning` du manuel dit de retirer d'abord le rôle *Créances clients* du nouveau compte si nécessaire avant + de réactiver (`user-manual.tex:358-359`) ; (6) numéros de ligne relevés de `credit_notes.rs` et + `opening_complement.rs` corrigés (T0 les refait de toute façon). +- **Écartées** : deux types (un par story) ; remplacer le type de la 15-5d par celui de la 15-6a (change le retour + d'un helper d'une autre story). +- **Réversible** : oui, tant qu'elle n'est pas développée. **Pour le message de PR** (F7-6) : `closes #523` + renvoie au commentaire du 2026-10-08 sur #523 — la contrainte « sans casser le refus nommé de #486 » est levée + en esprit, pas à la lettre (l'assertion de `invoices_validate_vat.rs:846` change). + +## C-15-6a-1 — 15-6a : T0 fait, développement suspendu jusqu'au merge de la 15-5d ; helper employé tel quel (partagé, sans mode), rendu `pub` sans second type + +- **Contexte** : T0 de la 15-6a sur `origin/main` `9cb5083b`. La 15-5d (helper `lock_designated_accounts_in_tx`, + test de C35) est développée et revue (branche `story/15-5d-garde-usage-comptes-reglage`, `5624ca78`) mais **non + mergée**. La fiche en fait une dépendance **ferme** (« si l'une n'est pas mergée au moment de T0, la story + attend », R6-10 ; C-15-6-35 a écarté la branche « si la 15-5d n'est pas mergée »). Sur la branche de la 15-5d, + le helper est déjà en **partagé seul** (C87), déjà sur le patron `owned_account_ids` (C88), `FORCE INDEX + (PRIMARY)`, `pub(in crate::repositories)`, type de ligne privé `LockedDesignatedAccount` dans le newtype + `DesignatedAccountsSnapshot` ; son test de C35 est dans `invoices_validate_vat.rs`. +- **Retenu** : (1) **arrêt après le T0**, relevés et écarts consignés (Change Log de la fiche, « Alignement sur le + livré (T0) ») ; (2) une fois la 15-5d mergée, la 15-6a **emploie le helper tel quel, sans paramètre de mode** + (aucun appelant exclusif ne reste ; un mode à une seule valeur serait du code mort) et rend `pub` la fonction, + `LockedDesignatedAccount` et l'accès aux lignes de `DesignatedAccountsSnapshot` sous leurs noms (C-15-6-36 : + un seul type) ; (3) le test 13 se ré-ancre dans `invoices_validate_vat.rs`, le test 17 est attendu vert + d'emblée ; (4) la ligne Pattern 5 de l'avoir prend la forme « par renvoi » (C68). +- **Écartées** : fusionner la branche de la 15-5d dans celle de la 15-6a (les consignes interdisent tout merge ; + et la résolution du conflit 15-5d ↔ 15-5e2 sur `invoice_settlements_write.rs` reviendrait à qui merge la 15-5d) ; + écrire un helper jumeau (contraire au critère DRY de l'AC6, conflit certain au merge) ; développer T1/T2 seuls + (story livrée en deux temps, sans motif — T1/T2 ne prennent qu'une fraction du travail). +- **Réversible** : oui. **À faire par l'orchestrateur** : merger la 15-5d, puis relancer le développement de la + 15-6a sur `main` à jour (le T0 n'aura à refaire que les numéros de ligne des fichiers que la 15-5d touche : + `company_invoice_settings.rs`, `invoices.rs`, `invoice_settlements_write.rs`, `invoices_validate_vat.rs`, manuels). + +## C-15-6a-2 — 15-6a (dev) : rebase sur `5e4bec50` par union ; helper de la 15-5d rendu `pub` avec un accesseur, sans mode + +- **Contexte** : la 15-5d est mergée (`5e4bec50`) ; la branche de la 15-6a portait la planification (fiches 15-6*, + registre) et le T0. Rebase : conflit du registre (entrées disjointes — 15-11a/15-5d côté `main`, C-15-6-1 à 36 + côté branche) et de l'en-tête de `sprint-status.yaml` (deux lignes `(19)`). Au code mergé, le helper + `lock_designated_accounts_in_tx` est partagé seul, privé au module `repositories`, et rend un newtype à champ privé. +- **Retenu** : (1) union sans dédoublonnage nécessaire (aucun titre commun, vérifié par `uniq -d`) ; la ligne + `last_updated` du T0 renumérotée `(24)` et placée en tête, la mention de sa numérotation d'origine gardée ; + (2) le helper est employé **tel quel, sans paramètre de mode** (C-15-6a-1 confirmé) ; sont rendus `pub` la + fonction, `LockedDesignatedAccount` (champs `pub`) et `DesignatedAccountsSnapshot`, auquel s'ajoute un + accesseur en lecture `accounts()`. Le champ du newtype reste privé : on ne fabrique pas d'instantané hors du + helper, et `check_written` reste `pub(in crate::repositories)`. +- **Écartées** : rendre le champ du newtype `pub` (permettrait de construire un faux instantané hors du verrou) ; + un second type pour l'avoir (C-15-6-36) ; ajouter un mode à une seule valeur. +- **Réversible** : oui (visibilité seule). + +## C-15-6a-3 — 15-6a (dev) : placement des tests, bras HTTP factorisé, passages du manuel laissés tels quels + +- **Contexte** : développement de la 15-6a (T1–T5). Plusieurs points laissés « au choix » ou non tranchés par la fiche. +- **Retenu** : (1) **test 15** dans `crates/kesh-api/tests/rejeu_interblocage_e2e.rs` (la fiche laissait le choix avec + `invoice_echeancier_e2e.rs`) : le fichier porte déjà le harnais du patron (`transaction_lourde`, `victime`, + `CaptureRejeu`), ce qui évite une troisième copie et ajoute le témoin `exiger_un_rejeu("credit_notes::create")` ; + « premier numéro » vérifié par la séquence (un seul numéro tiré), non par la forme du numéro. (2) **Tests 4 et 17** : + seconde société par `INSERT INTO companies` direct (patron du test `foreign_account_is_never_locked_and_is_refused` + de la 15-5d), non par `companies::create` — le lecteur et le helper n'en lisent que l'identifiant. (3) **Test 10** : + montage léger prévu par la fiche (en-tête d'écriture sans ligne, même société), nom de la fiche gardé. (4) **Test 13** + renommé `credit_note_credits_a_non_postable_sale_receivable` et doté d'une assertion sur le compte crédité (sans + elle, il ne figeait rien de la 15-6a). (5) **Bras HTTP** : `ReversalAccountsArchived` et `CreditNoteAccountsArchived` + partagent `archived_accounts_response(archived, clé, amorce du repli)` (DRY) — corps et statut inchangés pour la + contre-passation. (6) **Manuel** : `user-manual.tex:928` (« L'avoir n'est pas soumis à ce contrôle … même si sa + créance ou sa TVA due est devenue non imputable ») **reste vrai** — la créance de la vente non imputable est toujours + créditée — et n'est pas réécrit ; seule `:380` (« relit la créance … dans les réglages ») l'est. La balance âgée + (`:1846-1851`) et « un avoir le reprend et l'annule » restent vrais. (7) **CHANGELOG** : l'entrée de la 15-5d qui + annonçait « ce que corrigeront #473 et #525 » est réécrite dans la même version non publiée (0.13.0), pour ne pas + contredire l'entrée neuve. +- **Écartées** : un fichier de test neuf pour le rejeu de l'avoir ; une `companies::create` complète pour un identifiant ; + dupliquer le bras HTTP ; réécrire `:928`. +- **Réversible** : oui. + +## C-15-6a-4 — Revue de code P1 de la 15-6a : remédiation sans code de production, une dette écrite + +- **Contexte** : la revue P1 (Sonnet ×3) rend 0 au-dessus de LOW et 14 LOW. Plusieurs se corrigent par des + tests, des doc-comments ou de la documentation ; un seul (B-3, double dérivation de l'ensemble des comptes de + produit dans `create_credit_note`) ne se corrige que par du code de production. +- **Option retenue** : corriger tout ce qui se corrige hors production exécutable (renommage du test 10, test 13 + discriminant constaté rouge sous mutation, test 19 sur l'`Invariant` « écriture de vente sans ligne de débit », + doc-comments, section « Émettre un avoir » de `docs/api-external.md`, manuel et PDF) ; écrire **B-3 comme dette** + (P3, à ouvrir en issue par l'orchestrateur : dériver `sites` de `revenue_ids` par un seul helper, ou un + `debug_assert!` d'inclusion) ; écrire comme **angle mort** le second `Invariant` de l'AC3 (« facture validée sans + écriture de vente »), que `chk_invoices_validated_has_je` empêche de monter. Le test 19 est placé dans + `credit_notes_repository.rs` (patron du test 3, helpers `emit` / `assert_nothing_written`). +- **Écartées** : poser le garde de B-3 maintenant (rouvrirait la boucle — la remédiation toucherait la production, + ce qui interdit de clore après elle) ; un test du second `Invariant` par désactivation de la contrainte + (`SET check_constraint_checks = 0`) — il monterait un état que la base interdit, sans valeur de preuve. +- **Réversibilité** : totale ; la dette se solde par une story de quelques lignes. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 544d6323..7b07563f 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -1,3 +1,6 @@ +# last_updated: 2026-10-09 (29) (renuméroté au rebase sur de285ea8) (15-6a done — revue de code P1 close, rebasée sur ec745d0c ; gates 2906/2906, Vitest 1091/1091, E2E 245 / 7 KF-029 + 2 invoices.spec.ts liés à l'heure UTC) +# last_updated: 2026-10-09 (28) (renuméroté au rebase sur de285ea8) (15-6a développée, en review — l'avoir crédite la créance et l'arrondi de la vente, comptes verrouillés en partage avant l'exercice ; gates au commit de code : backend 2895/2895, Vitest 1091/1091, E2E 247 / 7 KF-029 ; choix C-15-6a-2, C-15-6a-3) +# last_updated: 2026-10-08 (27) (numéroté (19) sur sa branche, renuméroté (24) au rebase sur 5e4bec50, puis (27) au rebase sur de285ea8) (15-6a : T0 fait sur 9cb5083b — route de l'avoir déjà rejouée (15-5e2), helper de verrou de la 15-5d en partagé seul et non mergé → story en attente de la 15-5d ; C-15-6a-1) # last_updated: 2026-10-09 (26) (15-11b intégrée sur ec745d0c (15-7a1) — registre par union ; gates sur l'état rebasé : backend 2912/2912, Vitest 1091/1091, E2E 245 / 7 KF-029 + 2 KF-045 avant 12:00 UTC) # last_updated: 2026-10-09 (25) (15-11b done — rebasée sur 5e4bec50 (15-5d) ; revue de code close : P1 Sonnet ×3 1 MEDIUM / 14 LOW → P2 ciblée Haiku 0 ; gates sur l'état rebasé : backend 2902/2902, Vitest 1091/1091, E2E 247 / 7 KF-029 ; choix C-15-11b-4) # last_updated: 2026-10-09 (24) (15-11b développée → review — env_nonempty seule lecture, test (L) lexical ; gates 2877/2877, E2E 247 / 7 KF-029 ; C-15-11b-1, C-15-11b-2) @@ -338,7 +341,11 @@ development_status: 15-5e-ordre-des-verrous-reglements: split # 2026-10-08 DÉCOUPÉE après la validation P3 (Sonnet ×2 : R 7 LOW, F 2 MEDIUM / 7 LOW ; F3-2 — la dérogation de découpage ne reposait pas sur l'exception de la règle) en 15-5e1 (socle) + 15-5e2 (rollout), choix C61. Corps vidé (version complète au commit 94f1365e) ; Change Logs P1–P3 conservés (trend : P1 1 HIGH / 6 MEDIUM → réécriture C54 → P2 6 MEDIUM distincts → P3 2 MEDIUM). Historique : créée par le découpage de la 15-5d (C52, C53), réécrite selon C54 (rejeu sur interblocage des flux d'écriture), validée P2 (C58–C60). 15-5e1-socle-rejeu: done # 2026-10-08 REVUE DE CODE CLOSE (P1 Sonnet ×3 : 3 MEDIUM, 9 LOW, remédiés en 1dc41152 → P2 ciblée Haiku : 0, vérifiée par l'orchestrateur). DEV FAITE (gate backend 2793/2793, 4 ignorés ; frontend 979/979 ; E2E 240 passés / 7 KF-029 attendus ; R3-7 mesurée VRAIE sur 10.11.16 — issue à ouvrir ; dix mutations rouges ; choix C-15-5e1-1..3). — VALIDATION CLOSE (P7 ciblée Haiku : 0, vérifiée par l'orchestrateur). closes #463 #491, refs #536 #429. DÉPEND de 15-5a ET 15-5b (mergées, f289414e) ; passe AVANT 15-5e2 et 15-5d. Story-zéro (C61) : inventaire fermé des routes qui écrivent au journal (21 + 4 exemptées) gravé dans une seconde colonne d'audit_route_registry.rs (statut transitoire ARejouer, partition 8/13/4/90 = 115 ; volet (c) par analyse syn du corps du handler — C63, C70) ; enveloppes nommées kesh_db::retry::retry_on_deadlock et kesh_api::retry::retry_app_on_deadlock / is_app_deadlock, nom d'opération en champ du warn! de retry_with (C62) ; règlement client (#491), annulation de règlement (#463), validation (#536) et saisie fournisseur (C66) rejoués, tests « route victime » sur une vraie 1213 (tests 2-5) + test du prédicat et de l'enveloppe AppError (test 1), registre (test 6) ; doc-comments canoniques de validate_invoice et de supplier_invoices::create_in_tx, avance des réglages de la saisie fournisseur (R3-7 hypothèse forte, mesurée en T0 — C70) ; enregistrement des réglages de facturation rejoué (test 7, SansEcritureAuJournal au registre — C70), « 5 bis », module retry.rs (ce que la 15-5d attend — C65, C66) ; CHANGELOG Corrigé + Modifié (C64, C69). Dérogation de découpage : 11 modules, signal D5 déclaré (C67, révisé par C70). 7 AC, 6 tâches, 7 tests. Validation : P4 Opus ×2 (3 MEDIUM, 19 LOW) remédiée ; P5 Sonnet ×2 (1 MEDIUM — recyclage du volet (c), signal D5, changement de méthode —, 11 LOW) remédiée ; P6 Opus ×2 (1 MEDIUM — F6-1, le test 7 pouvait passer sans rejeu hors MariaDB 10.11, recyclage de la remédiation P5, signal D5 —, 10 LOW distincts) remédiée : témoin du rejeu (warn! de kesh_db::retry capté) aux tests 2-5 et 7, cycle du test 7 formé en T0 (C74) ; EN VALIDATION : passe ciblée P7 (Haiku) à lancer sur la remédiation P6. 15-5e2-rejeu-des-autres-flux: done # 2026-10-08 DÉVELOPPÉE (commit 6125d50b, sur ec675288 qui porte la 15-8b) : 12 routes rejouées (9 DbError, 3 AppError « une tentative »), 6 sites retry_with migrés, post_accept seul en retry_with ; registre 22/0/4/89, volet (c bis) ; test 8 (post_manual victime) vert, cycle formé en T0 sur 10.11.16 ; commentaires d'ordre, Pattern 5, api-external § 10, CHANGELOG, manuels #484 ; gates au commit de code — backend 2837/2837 (4 ignorés), frontend 1086/1086, E2E 246 / 7 KF-029 + 1 pollution (sidebar :75 vert seul) ; choix C-15-5e2-1..6. Revue de code à lancer (lentille adversariale sur les trois extractions). Avant : — closes #536 #484, refs #463 #491. DÉPEND de 15-5e1 ; ordre libre avec 15-5d, sûr aussi pour la documentation (C65, C68). Rollout (C61) : les 17 autres routes Rejouee (13 à rejouer — 10 DbError, 3 AppError —, 3 sites retry_with migrés vers les enveloppes, post_accept inchangée ; la saisie fournisseur est passée à la 15-5e1, C66), revue fichier par fichier + lentille adversariale sur les trois extractions ; registre sans ARejouer (21/4/90), retry_with restreint à post_accept ; mentions retry_with d'opening_complement.rs (C69) ; commentaires d'ordre restants (journal_entries étapes 0 et 0-bis, accept_one_rule, accept_one_split, post_manual, post_split, fiscal_years.rs:489-502), inventaire au symptôme insensible à la casse (205 lignes / 42 fichiers sur f289414e) ; Pattern 5 (lignes par renvoi aux doc-comments canoniques, ordre du lot, Required et When to Use réécrits) ; api-external § 10 ; CHANGELOG (texte final complet) ; manuels #484 (SERIALIZABLE, innodb_deadlock_detect), make admin user, brochure non commitée (C64). 6 AC, 6 tâches. Validation : P4 Opus ×2 (5 MEDIUM dont 4 distincts et d'origine, signal D5 déclaré, pas de découpage ; 15 LOW) remédiée ; P5 Sonnet ×2 (0 au-dessus de LOW, 9 LOW) remédiée — VALIDATION CLOSE (trend P1 1 HIGH/6 MED → P2 6 MED → P3 2 MED → découpage → P4 5 MED → P5 0) ; développement après le merge de la 15-5e1. - 15-6-creance-juste-avoir-reglement: backlog # 2026-10-08 — #473, #474 (P1) + 15-6-creance-juste-avoir-reglement: split # 2026-10-08 SPÉCIFIÉE et DÉCOUPÉE d'emblée (règle de splitting, plus de cinq modules — choix C-15-6-1) en 15-6a + 15-6b + 15-6c ; 15-6d ajoutée à la validation P1 (C-15-6-9). Fiche d'index. #473, #474, #523, #524 (P1) + 15-6a-avoir-credite-la-creance-de-la-vente: done # 2026-10-09 REVUE DE CODE P1 CLOSE (Sonnet ×3 : 0 > LOW, 14 LOW ; remédiation 3a7bddcc sans code de production, B-3 en dette — C-15-6a-4) ; rebasée sur ec745d0c, gates sur l'état rebasé : backend 2906/2906, Vitest 1091/1091, E2E 245 / 7 KF-029 + 2 invoices.spec.ts (heure UTC, hors branche). Avant : 2026-10-09 DÉVELOPPÉE (commits 55143ec0 code et tests, ef1e7b47 docs) — 16 tests neufs, 2 modifiés (9, 13), 11 rouges d'abord, 4 mutations rouges ; gates au commit de code : backend 2895/2895, Vitest 1091/1091, E2E 247 / 7 KF-029 ; choix C-15-6a-2, C-15-6a-3. Revue de code à lancer. Avant : DÉVELOPPEMENT REPRIS après le merge de la 15-5d (5e4bec50), T0 confirmé au code mergé (C-15-6a-2). Avant : 2026-10-08 T0 FAIT (alignement sur le livré, C-15-6a-1) — EN ATTENTE du merge de la 15-5d (dépendance ferme : helper lock_designated_accounts_in_tx et test de C35 sur sa branche seule) ; aucun écart de règle métier. Avant : 2026-10-08 — closes #473, closes #523. Créance ET arrondi lus sur l'écriture de vente (lecteurs partagés ; compte archivé → INACTIVE_OR_INVALID_ACCOUNTS, test :846 modifié délibérément) ; P2 : comptes de la vente verrouillés, archivé → ACCOUNT_ARCHIVED nommé (C-15-6-16) ; TVA due en angle mort tracé #525 (C-15-6-8) ; P3 : verrou LOCK IN SHARE MODE avant l'exercice (C-15-6-24), variante CreditNoteAccountsArchived, code ACCOUNT_ARCHIVED, libellé « Impossible d'émettre l'avoir » (C-15-6-25), signal D5 déclaré. P4 : verrou partagé étendu à tous les comptes écrits (TVA due, produits), rejeu retry_with de POST /credit-notes, cycle avoir ↔ rapprochement tracé #536, ligne absente → Invariant (C-15-6-29). P5 : APRÈS 15-5a à 15-5e ; rejeu de la route porté par la 15-5e (C54), verrou gardé pour la course de lecture seule, aucun réordonnancement, cycles nommés ; helper de verrou de liste partagé avec 15-5d ; TVA à source unique (C-15-6-32). Validations P1 à P5 appliquées ; P6 complète à lancer. + 15-6b-contrepartie-distincte-de-la-creance: ready-for-dev # 2026-10-08 — refs #474. DÉPEND de 15-6a ; de préférence après 15-5c (#492, C-15-6-12). Refus SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT : règlement client/fournisseur, deux modes, rapprochement (failed[]), lots de paiement à la création et à la confirmation (C-15-6-10) ; lecteur sœur fournisseur (C-15-6-11) ; P2 : comptes d'écart comparés à la créance (C-15-6-18), lot bloqué contextualisé (C-15-6-19), signal D5 déclaré sans découpage (C-15-6-20) ; P3 : details.role et clé distincte pour le compte désigné, helper commun emprunté par 15-6d (C-15-6-26), signal D5 déclaré ; P4 : rôle rounding du compte d'arrondi quel que soit le geste, validation et avoir en angles morts (#537, #525), message de liste vide conditionné, « un administrateur doit » (C-15-6-30), signal D5 déclaré (recyclage de F1, pas de découpage). P5 : APRÈS 15-5a à 15-5e ; trois clés plates, chaque comparaison suit sa lecture, critère de T2 par nom sur liste fermée, sitesTotal relevé, user-manual :1754 (C-15-6-33) ; signal D5 levé, non déclenché. Validations P1 à P5 appliquées ; P6 complète à lancer. + 15-6c-configuration-sans-ecriture-nulle: ready-for-dev # 2026-10-08 — closes #474. APRÈS le merge de 15-5b et de 15-6b. Compte bancaire ≠ débiteurs/créanciers, et réciproquement ; exemption « inchangé » ; contrôles dans la transaction, deux gestes sérialisés (C-15-6-13) ; écrans filtrés sur les réglages (C-15-6-14) ; P2 : LOCK IN SHARE MODE, 409 avant le refus des réglages (C-15-6-21), tests étendus de 15-5b, APRÈS 15-5d aussi (C-15-6-22), signal D5 déclaré sans découpage ; P3 : test 12 bis du FOR UPDATE de before, attentes prouvées, pas de verrou pour une dé-liaison (C-15-6-27). Validations P1, P2 et P3 appliquées ; P4 ciblée (Haiku) : 0, vérifié — VALIDATION CLOSE. + 15-6d-contrepartie-distincte-de-la-banque: ready-for-dev # 2026-10-08 — closes #524. Créée à la validation P1 de 15-6b (C-15-6-9). Rebase APRÈS 15-5b ; de préférence après 15-5c (manuel du rapprochement). post_manual et accept_one_rule refusent la contrepartie égale au compte de la banque, même refus et même place que le flux ventilé ; règle sur ce compte plus proposée (C-15-6-23) ; P2 : compte de banque actif sur le chemin par règle, gardes ventilées figées, APRÈS 15-6b (comparaison empruntée, C-15-6-28) ; P3 : 10 LOW appliqués (C-15-6-31). Validations P1, P2 et P3 appliquées — VALIDATION CLOSE. 15-7-trace-onboarding: split # 2026-10-08 DÉCOUPÉE en 15-7a (production) + 15-7b (démo et remise à zéro) après la validation P1 (1 HIGH, 8 MEDIUM, 10 LOW distincts ; règle de splitting franchie, findings R7/F-5) — choix C-15-7-8. Corps vidé ; Change Log de P1 conservé. SPÉCIFIÉE le 2026-10-08 (choix C-15-7-1 à 7). 15-7a-trace-installation-production: split # 2026-10-08 DÉCOUPÉE à la validation P2 (5 MEDIUM, 15 LOW distincts ; R2-3/F-1 : neuf modules) en 15-7a1 + 15-7a2 — choix C-15-7-19. Corps vidé. Née du découpage de la 15-7 (C-15-7-8). 15-7a1-socle-transactions-onboarding: done # 2026-10-09 — REVUE P1 CLOSE (Sonnet ×3, 0 > LOW, 8 LOW : 2 commentaires + 3 tests ; E-1/E-3 → 15-7a2, E-2 → 15-7b1 ; C-15-7a1-3) ; rebasée sur 8f9811d8, gate 2867/2867, E2E 8 échecs attendus. DÉVELOPPÉE (a76a6dbc) : gate complet 2844/2844, E2E 7 échecs KF-029 attendus, 10 mutations rouges ; C-15-7a1-1, C-15-7a1-2. — refs #434. Socle kesh-db SANS changement de comportement ni d'audit : variantes _in_tx (accounts, bank_accounts, company_invoice_settings — fin du MIRROR —, vat_rates ligne à ligne), companies::clear_stub_in_tx, onboarding::lock_state_in_tx (LOCK_SQL). Six modules (story-zéro, signal D5 déclaré). Validation P1 appliquée (2 MEDIUM) ; P2 appliquée (1 HIGH recyclé, 2 MEDIUM : test 8 complété par finalize, séquence [user.created, fiscal_year.created], SQL du verrou — C-15-7-30) ; P3 appliquée (1 MEDIUM : graphie du squash, fichier accounts_repository.rs, pub LOCK_SQL — C-15-7-33) ; P4 ciblée (Haiku) : 0, vérifiée par l'orchestrateur ; **validation CLOSE**. diff --git a/crates/kesh-api/src/errors.rs b/crates/kesh-api/src/errors.rs index a5d491c4..fbefb45f 100644 --- a/crates/kesh-api/src/errors.rs +++ b/crates/kesh-api/src/errors.rs @@ -3043,42 +3043,20 @@ impl IntoResponse for AppError { "Cette facture est validée : dévalidez-la d'abord, puis supprimez le brouillon.", ), ), - // ⚠️ **400**, comme le gabarit `CreditNoteRevenueAccountsArchived` - // dont il reprend la forme : un compte archivé est une donnée - // d'entrée invalide, là où un refus de propriété est un conflit - // d'état. Le refus NOMME les comptes — un « interdit » sec ne - // serait pas utilisable, la réactivation étant le chemin de sortie - // (`PUT /api/v1/accounts/{id}/reactivate`). - DbError::ReversalAccountsArchived(archived) => { - let detail = archived - .iter() - .map(|a| { - a.account_number - .clone() - .unwrap_or_else(|| a.account_id.to_string()) - }) - .collect::>() - .join(", "); - let fallback = format!( - "Impossible de contre-passer — compte(s) archivé(s) : {detail}. Réactivez le ou les comptes concernés." - ); - let mut args = FluentArgs::new(); - args.set("detail", detail.clone()); - let msg = t_args("journal-entries-reverse-account-archived", &fallback, &args); - let body = serde_json::json!({ - "error": { - "code": "ACCOUNT_ARCHIVED", - "message": msg, - "details": { - "rejected": archived.iter().map(|a| serde_json::json!({ - "accountId": a.account_id, - "accountNumber": a.account_number, - })).collect::>(), - }, - } - }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() - } + // ⚠️ **400** qui NOMME les comptes : raisons au doc-comment de + // `archived_accounts_response`. + DbError::ReversalAccountsArchived(archived) => archived_accounts_response( + &archived, + "journal-entries-reverse-account-archived", + "Impossible de contre-passer", + ), + // Story 15-6a (#473, #523 ; C-15-6-25) — la jumelle de l'avoir : + // même code, même forme ; seul le message parle du geste. + DbError::CreditNoteAccountsArchived(archived) => archived_accounts_response( + &archived, + "credit-note-account-archived", + "Impossible d'émettre l'avoir", + ), // ⚠️ Refuser est VOULU : supprimer une écriture qu'on a corrigée // effacerait la correction. Rendu explicite plutôt que laissé // remonter comme une violation de clé étrangère au message opaque. @@ -3572,6 +3550,52 @@ fn supplier_invoice_cancel_blocked_text( } } +/// Le 400 `ACCOUNT_ARCHIVED` d'une contre-passation dont un compte a été +/// archivé — [`DbError::ReversalAccountsArchived`] et sa jumelle de l'avoir +/// [`DbError::CreditNoteAccountsArchived`] (Story 15-6a) : même code, même +/// `details.rejected[]` (`{ accountId, accountNumber }`), seul le message +/// (`key`, et son repli français commençant par `fallback_lead`) diffère. +/// +/// ⚠️ **400**, comme le gabarit `CreditNoteRevenueAccountsArchived` : un compte +/// archivé est une donnée d'entrée invalide, là où un refus de propriété est un +/// conflit d'état. Le refus NOMME les comptes — un « interdit » sec ne serait +/// pas utilisable, la réactivation étant le chemin de sortie +/// (`PUT /api/v1/accounts/{id}/reactivate`). +fn archived_accounts_response( + archived: &[kesh_db::errors::ArchivedAccount], + key: &str, + fallback_lead: &str, +) -> Response { + let detail = archived + .iter() + .map(|a| { + a.account_number + .clone() + .unwrap_or_else(|| a.account_id.to_string()) + }) + .collect::>() + .join(", "); + let fallback = format!( + "{fallback_lead} — compte(s) archivé(s) : {detail}. Réactivez le ou les comptes concernés." + ); + let mut args = FluentArgs::new(); + args.set("detail", detail.clone()); + let msg = t_args(key, &fallback, &args); + let body = serde_json::json!({ + "error": { + "code": "ACCOUNT_ARCHIVED", + "message": msg, + "details": { + "rejected": archived.iter().map(|a| serde_json::json!({ + "accountId": a.account_id, + "accountNumber": a.account_number, + })).collect::>(), + }, + } + }); + (StatusCode::BAD_REQUEST, Json(body)).into_response() +} + #[cfg(test)] mod tests { use super::*; @@ -3876,6 +3900,34 @@ mod tests { assert!(msg.contains("3200"), "le compte doit être nommé : {msg}"); } + /// Story 15-6a (test 18) — le bras de `CreditNoteAccountsArchived` : 400, + /// code `ACCOUNT_ARCHIVED` (celui de la contre-passation), `details.rejected[]` + /// par compte (`accountId`, `accountNumber`), et un message du geste de + /// l'avoir — clé `credit-note-account-archived`, ou son repli français si + /// l'i18n n'est pas initialisée — qui nomme le compte. + #[tokio::test] + async fn credit_note_accounts_archived_is_400_account_archived() { + let resp = AppError::from(DbError::CreditNoteAccountsArchived(vec![ + kesh_db::errors::ArchivedAccount { + account_id: 17, + account_number: Some("1100".into()), + }, + ])) + .into_response(); + let (status, body) = response_body(resp).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "ACCOUNT_ARCHIVED"); + assert_eq!( + body["error"]["details"]["rejected"], + serde_json::json!([{ "accountId": 17, "accountNumber": "1100" }]) + ); + let msg = body["error"]["message"].as_str().unwrap(); + assert!( + msg.contains("1100") && msg.contains("avoir"), + "le compte et le geste doivent être nommés : {msg}" + ); + } + /// Story 16-3b — le conflit de numéro de client porte **son propre code**, /// et pas celui de l'IDE. /// diff --git a/crates/kesh-api/src/routes/reconciliation.rs b/crates/kesh-api/src/routes/reconciliation.rs index 1761f1cc..c27aa1b4 100644 --- a/crates/kesh-api/src/routes/reconciliation.rs +++ b/crates/kesh-api/src/routes/reconciliation.rs @@ -1216,7 +1216,8 @@ async fn accept_one( /// **Postabilité — pas de garde ici, et c'est voulu** (Story 15-5b, AC6) : /// aucun compte de cette écriture ne vient du client. Le compte bancaire est /// un compte de configuration (D-A0, toléré devenu non imputable) ; la créance -/// est lue **sur l'écriture de vente** de la facture ; le compte d'arrondi vient +/// est lue **sur l'écriture de vente** de la facture +/// (`invoice_settlements::sale_receivable_account`, Story 15-6a) ; le compte d'arrondi vient /// des réglages, déjà gardé `postable = TRUE` par /// `company_invoice_settings::rounding_account_for_write`. Les deux autres /// flux d'acceptation (`split`, `rule`) et les rapprochements manuel et ventilé @@ -1467,28 +1468,19 @@ async fn accept_one_invoice( // facture et son encaissement. Miroir strict de l'étape (2) de // `pay_in_tx`, qui lit la ligne de CRÉDIT de l'écriture d'achat. // - // La créance est la PREMIÈRE ligne au débit de l'écriture de vente, en - // position 0, pour le TTC — d'où `ORDER BY jel.id LIMIT 1`. ⚠️ Pas la - // SEULE : un arrondi à 5 centimes négatif (Story 25-4-c4-a) ajoute une - // ligne de débit, toujours APRÈS la créance - // (`generate_invoice_journal_lines_rounded`). - let receivable_row: Option<(i64,)> = sqlx::query_as( - "SELECT jel.account_id FROM journal_entry_lines jel \ - JOIN journal_entries je ON je.id = jel.entry_id \ - WHERE jel.entry_id = ? AND je.company_id = ? AND jel.debit > 0 \ - ORDER BY jel.id LIMIT 1", - ) - .bind(sale_entry_id) - .bind(company_id) - .fetch_optional(&mut **tx) - .await - .map_err(|e| FailedProposal { - bank_transaction_id, - error_code: "DATABASE_ERROR".to_string(), - details: Some(serde_json::json!({ "message": e.to_string() })), - })?; + // Lecteur partagé avec le règlement, le solde du reste et l'avoir + // (Story 15-6a) : la règle et l'ordre des lignes (créance PREMIÈRE ligne + // au débit, arrondi négatif après elle) sont à son doc-comment. + let receivable_row = + invoice_settlements::sale_receivable_account(tx, company_id, sale_entry_id) + .await + .map_err(|e| FailedProposal { + bank_transaction_id, + error_code: "DATABASE_ERROR".to_string(), + details: Some(serde_json::json!({ "message": e.to_string() })), + })?; let receivable_account_id = match receivable_row { - Some((id,)) => id, + Some(id) => id, None => { return Err(FailedProposal { bank_transaction_id, diff --git a/crates/kesh-api/tests/invoice_echeancier_e2e.rs b/crates/kesh-api/tests/invoice_echeancier_e2e.rs index 774b7176..50413dea 100644 --- a/crates/kesh-api/tests/invoice_echeancier_e2e.rs +++ b/crates/kesh-api/tests/invoice_echeancier_e2e.rs @@ -797,6 +797,51 @@ async fn get_credited_invoice_reports_zero_amount_due(pool: MySqlPool) { assert_eq!(montant(&v, "amountSettled"), rust_decimal::Decimal::ZERO); } +/// Story 15-6a (test 12) — la frontière HTTP du changement de contrat : la +/// créance de la vente archivée, `POST /api/v1/credit-notes` rend **400** +/// `ACCOUNT_ARCHIVED`, nomme 1100 dans `details.rejected[]`, et dit +/// « Impossible d'émettre l'avoir ». Il rendait `INACTIVE_OR_INVALID_ACCOUNTS`, +/// anonyme. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn credit_note_route_names_the_archived_sale_receivable(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool) + .await + .expect("seed_accounting_company"); + let contact_id = seed_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let (id, _v) = create_validated_invoice( + &pool, + seeded.company_id, + contact_id, + seeded.admin_user_id, + NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(), + dec!(100.00), + ) + .await; + let receivable = seeded.accounts["1100"]; + sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?") + .bind(receivable) + .execute(&pool) + .await + .unwrap(); + + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + let resp = post_credit_note(&app, &token, id).await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "ACCOUNT_ARCHIVED", "got {body:?}"); + assert_eq!( + body["error"]["details"]["rejected"], + json!([{ "accountId": receivable, "accountNumber": "1100" }]) + ); + let message = body["error"]["message"].as_str().unwrap(); + assert!( + message.contains("Impossible d'émettre l'avoir") && message.contains("1100"), + "message : {message}" + ); +} + /// #456 — un avoir sur une facture réglée en partie est refusé en **409**, avec /// son code et un message qui dit quoi faire. #[sqlx::test(migrations = "../kesh-db/test-schema")] diff --git a/crates/kesh-api/tests/reconciliation_e2e.rs b/crates/kesh-api/tests/reconciliation_e2e.rs index 5b0c141e..6033747b 100644 --- a/crates/kesh-api/tests/reconciliation_e2e.rs +++ b/crates/kesh-api/tests/reconciliation_e2e.rs @@ -4916,6 +4916,90 @@ async fn a_rounding_gap_without_a_usable_account_is_refused_per_proposal(pool: M assert_eq!(status, "pending", "la transaction reste à rapprocher"); } +/// Story 15-6a (AC1, AC2 ; test 10) — **témoin** du refus +/// `INVOICE_SALE_ENTRY_MALFORMED`, écrit AVANT que la requête de créance de +/// `accept_one_invoice` ne soit remplacée par le lecteur partagé +/// `invoice_settlements::sale_receivable_account` : aucun autre test ne figeait +/// ce refus, et la copie remplacée devait garder un témoin. Il passe avant et +/// après le remplacement. +/// +/// Montage léger (choix de la fiche) : la facture est repointée sur un en-tête +/// d'écriture SANS ligne, de la même société — le lecteur ne trouve aucune +/// ligne de débit et rend `None`. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn invoice_proposal_with_a_sale_entry_without_debit_line_is_malformed(pool: MySqlPool) { + let ctx = setup_company(&pool, "Malforme", "CH4431999123000889012", Role::Comptable).await; + let day = NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(); + let inv_date = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(); + let (inv_id, je_id) = seed_validated_invoice( + &pool, + ctx.company_id, + ctx.contact_id, + "INV-MAL-1", + inv_date, + dec!(100.00), + ) + .await; + let fy_id: i64 = sqlx::query_scalar("SELECT fiscal_year_id FROM journal_entries WHERE id = ?") + .bind(je_id) + .fetch_one(&pool) + .await + .unwrap(); + let empty_entry = insert_fake_journal_entry(&pool, ctx.company_id, fy_id).await; + sqlx::query("UPDATE invoices SET journal_entry_id = ? WHERE id = ?") + .bind(empty_entry) + .bind(inv_id) + .execute(&pool) + .await + .unwrap(); + let tx_id = seed_bank_transactions( + &pool, + ctx.company_id, + ctx.bank_account_id, + ctx.user_id, + &unique_hash("sale_entry_malformed"), + day, + day, + vec![make_new_tx( + ctx.company_id, + ctx.bank_account_id, + day, + Some(day), + dec!(100.00), + "CHF", + "INV-MAL-1", + Some("Malforme Client"), + )], + ) + .await[0]; + 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(0), + "got {body:?}" + ); + let failed = body["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "got {body:?}"); + assert_eq!(failed[0]["bankTransactionId"], tx_id); + assert_eq!(failed[0]["errorCode"], "INVOICE_SALE_ENTRY_MALFORMED"); + assert_eq!( + failed[0]["details"], + serde_json::json!({ + "reason": "no_debit_line_on_sale_entry", + "saleEntryId": empty_entry, + }) + ); + 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")] diff --git a/crates/kesh-api/tests/rejeu_interblocage_e2e.rs b/crates/kesh-api/tests/rejeu_interblocage_e2e.rs index e38243b1..94b1ccc1 100644 --- a/crates/kesh-api/tests/rejeu_interblocage_e2e.rs +++ b/crates/kesh-api/tests/rejeu_interblocage_e2e.rs @@ -1155,3 +1155,87 @@ async fn manual_match_is_replayed_when_it_is_the_deadlock_victim(pool: MySqlPool ); capture.exiger_un_rejeu("reconciliation::manual"); } + +// ============================================================ +// Story 15-6a (test 15) — l'avoir victime sur l'attente neuve de ses comptes +// ============================================================ + +/// Le rejeu de `POST /api/v1/credit-notes` (posé par la Story 15-5e2) couvre +/// l'attente **neuve** que la Story 15-6a crée : le verrou partagé des comptes +/// de l'avoir, pris avant l'exercice (AC6). +/// +/// Montage déterministe (patron du fichier) : la transaction de test, alourdie +/// (table de lest hors de tout flux métier — un lest dans une table applicative +/// poserait des verrous de clé étrangère sur `accounts` et changerait le cycle), +/// tient la créance 1100 en exclusif ; l'avoir, qui tient déjà la facture et les +/// réglages, est vu en attente de son `LOCK IN SHARE MODE` sur `accounts` ; la +/// transaction de test demande alors la facture → cycle, InnoDB annule l'avoir +/// (la plus légère), la transaction de test annule, et le rejeu repart à neuf : +/// 201, un seul avoir, facture annulée, premier numéro d'avoir. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn credit_note_route_replays_when_it_is_the_deadlock_victim(pool: MySqlPool) { + let ctx = monter(&pool).await; + disable_rounding_to_5_centimes(&pool, ctx.company_id()) + .await + .unwrap(); + let capture = CaptureRejeu::installer(); + let facture = facture_validee(&pool, &ctx, "100.00").await; + + // (1) La transaction de test : lourde, puis la créance de la vente. + let mut lourde = transaction_lourde(&pool).await; + sqlx::query("SELECT id FROM accounts WHERE id = ? FOR UPDATE") + .bind(ctx.compte("1100")) + .fetch_one(&mut *lourde) + .await + .unwrap(); + + // (2) L'avoir : facture, réglages, puis le verrou partagé de ses comptes. + let route = requete_en_tache( + &ctx, + reqwest::Method::POST, + "/api/v1/credit-notes", + Some(json!({ "invoiceId": facture, "date": aujourd_hui().to_string() })), + ); + // (3) La facture, que tient l'avoir. + let (status, corps) = victime( + &pool, + lourde, + route, + &["FROM accounts", "LOCK IN SHARE MODE"], + "SELECT id FROM invoices WHERE id = ? FOR UPDATE", + facture, + ) + .await; + + assert_eq!(status, 201, "⛔ l'interblocage doit être rejoué : {corps}"); + assert_eq!( + compter( + &pool, + "SELECT COUNT(*) FROM credit_notes WHERE invoice_id = ?", + facture + ) + .await, + 1, + "un seul avoir" + ); + let statut: String = sqlx::query_scalar("SELECT status FROM invoices WHERE id = ?") + .bind(facture) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(statut, "cancelled"); + // Premier numéro : la tentative annulée n'a rien consommé (le compteur se + // rembobine au rollback), la séquence n'a servi qu'une fois. + assert_eq!( + compter( + &pool, + "SELECT CAST(COALESCE(SUM(next_number - 1), 0) AS SIGNED) FROM credit_note_number_sequences \ + WHERE company_id = ?", + ctx.company_id() + ) + .await, + 1, + "un seul numéro d'avoir tiré : {corps}" + ); + capture.exiger_un_rejeu("credit_notes::create"); +} diff --git a/crates/kesh-db/src/errors.rs b/crates/kesh-db/src/errors.rs index 02bff15a..7032edaf 100644 --- a/crates/kesh-db/src/errors.rs +++ b/crates/kesh-db/src/errors.rs @@ -877,6 +877,23 @@ pub enum DbError { #[error("Comptes archivés sur l'écriture à contre-passer ({})", .0.len())] ReversalAccountsArchived(Vec), + /// Un compte que l'**avoir** écrit hors des comptes de produit — la + /// **créance** et le compte d'**arrondi** que la vente a mouvementés, ou le + /// compte de **TVA due** des réglages — a été **archivé** (Story 15-6a, #473, + /// #523 ; choix C-15-6-25, C-15-6-29). + /// + /// Lu **sous verrou partagé**, avant l'exercice (`create_credit_note`) : un + /// archivage concurrent attend la fin de l'avoir, et l'état actif est frais. + /// Jumelle de [`DbError::ReversalAccountsArchived`] — l'avoir **est** une + /// contre-passation — dont elle reprend la forme et le code + /// (`ACCOUNT_ARCHIVED`, HTTP **400**, `details.rejected[]`, ordre des + /// identifiants) ; seul le message diffère (« Impossible d'émettre + /// l'avoir »), pour parler le vocabulaire du geste. Un compte de **produit** + /// archivé reste nommé par ligne par + /// [`DbError::CreditNoteRevenueAccountsArchived`]. + #[error("Comptes archivés sur l'avoir à émettre ({})", .0.len())] + CreditNoteAccountsArchived(Vec), + /// L'écriture a été contre-passée : on ne la modifie ni ne la supprime /// plus (Story 24-4a ; la modification, Story 15-8a). /// @@ -946,6 +963,10 @@ pub enum DbError { /// Un champ de configuration requis pour l'opération est absent /// (Story 5.2 : `default_receivable_account_id` ou /// `default_revenue_account_id` manquant dans `company_invoice_settings`). + /// Depuis la Story 15-6a, l'avoir ne lit plus la créance dans les + /// réglages (il la lit sur l'écriture de vente) : un débiteurs vide ne + /// l'atteint plus ; la validation de facture, si. L'avoir peut encore le + /// rendre pour le produit de repli ou la TVA due. /// Mappé vers HTTP 400 `CONFIGURATION_REQUIRED` côté API. #[error("Configuration manquante : {0}")] ConfigurationRequired(String), @@ -990,7 +1011,9 @@ pub enum RoundingContext { /// Un paiement égal au reste arrondi au centime solde la facture /// (règlement manuel, rapprochement — Story 25-4-c3-b). Payment, - /// Une pièce émise — facture validée, avoir — porte un arrondi à 5 centimes. + /// Une facture validée porte un arrondi à 5 centimes. L'**avoir** n'y passe + /// plus : il contre-passe l'arrondi sur le compte que la vente a mouvementé + /// (Story 15-6a, #523 — `invoice_settlements::sale_rounding_account`). Issuance, } @@ -1031,6 +1054,7 @@ impl DbError { // celui-ci n'est que le repli générique du mapping structuré. Self::EntryNotReversable { .. } => "ENTRY_NOT_REVERSABLE", Self::ReversalAccountsArchived(_) => "ACCOUNT_ARCHIVED", + Self::CreditNoteAccountsArchived(_) => "ACCOUNT_ARCHIVED", Self::InvoiceNotUnvalidatable { blocker, .. } => blocker.code(), Self::SettlementNotCancellable { blocker } => blocker.code(), Self::ReconciliationNotCancellable { blocker } => blocker.code(), diff --git a/crates/kesh-db/src/repositories/company_invoice_settings.rs b/crates/kesh-db/src/repositories/company_invoice_settings.rs index b7eb0885..952ae8cd 100644 --- a/crates/kesh-db/src/repositories/company_invoice_settings.rs +++ b/crates/kesh-db/src/repositories/company_invoice_settings.rs @@ -589,21 +589,26 @@ pub(in crate::repositories) struct GeneratedLines { pub roles: BTreeSet, } -/// Un compte désigné tel que l'a lu le verrou de -/// [`lock_designated_accounts_in_tx`]. +/// Un compte tel que l'a lu le verrou de [`lock_designated_accounts_in_tx`]. +/// +/// Public depuis la Story 15-6a (choix C-15-6-36, C-15-6a-2) : l'avoir lit ses +/// lignes, et un test d'intégration appelle le helper directement. **Un seul +/// type** de ligne pour le verrou d'une liste de comptes. #[derive(Debug, Clone, sqlx::FromRow)] -struct LockedDesignatedAccount { - id: i64, - number: String, - active: bool, - postable: bool, +pub struct LockedDesignatedAccount { + pub id: i64, + pub number: String, + pub active: bool, + pub postable: bool, } /// L'instantané **verrouillé** des comptes candidats d'un flux, rendu par /// [`lock_designated_accounts_in_tx`] ; ses lignes restent verrouillées (en /// partagé) jusqu'au commit, il n'y a donc rien à relire pour le contrôle. +/// +/// Son champ reste privé : un instantané ne se fabrique que par le verrou. #[derive(Debug)] -pub(in crate::repositories) struct DesignatedAccountsSnapshot(Vec); +pub struct DesignatedAccountsSnapshot(Vec); /// **Premier temps** de la garde à l'usage des comptes de réglage (Story 15-5d, /// #429 ; choix C27, C43, C49, C51, C87, C88) : verrouille **tous les comptes @@ -617,6 +622,12 @@ pub(in crate::repositories) struct DesignatedAccountsSnapshot(Vec &[LockedDesignatedAccount] { + &self.0 + } + /// **Second temps** de la garde (Story 15-5d ; choix C39, C40, C49) : /// contrôle, sur l'instantané verrouillé, **les seuls comptes des rôles /// effectivement écrits** par le générateur (`roles`), traduits en diff --git a/crates/kesh-db/src/repositories/credit_notes.rs b/crates/kesh-db/src/repositories/credit_notes.rs index 91060892..5e6c3dda 100644 --- a/crates/kesh-db/src/repositories/credit_notes.rs +++ b/crates/kesh-db/src/repositories/credit_notes.rs @@ -9,7 +9,12 @@ //! //! Refus (erreurs métier) : facture inexistante, non `validated` (AC2), //! déjà encaissée `paid_at IS NOT NULL` (AC2bis), déjà créditée (AC3), -//! exercice fermé sur la date de l'avoir (AC10), comptes non configurés. +//! exercice fermé sur la date de l'avoir (AC10), produit de repli ou TVA due +//! non configurés dans les réglages, compte de l'avoir archivé — +//! `CreditNoteAccountsArchived` (créance ou arrondi de la vente, TVA due) et +//! `CreditNoteRevenueAccountsArchived` (comptes de produit). Depuis la Story +//! 15-6a, la créance et le compte d'arrondi se lisent sur l'écriture de vente, +//! non dans les réglages. use rust_decimal::Decimal; use sqlx::MySqlPool; @@ -131,11 +136,43 @@ pub async fn list( Ok((items, total)) } +/// La TVA d'un avoir **agrégée par taux** — somme des TVA arrondies par ligne, +/// itération par taux croissant (Story 15-6a, AC6 ; choix C-15-6-32). +/// +/// **Source unique** de « l'avoir émet de la TVA » ([`credit_note_emits_vat`]) : +/// le générateur [`generate_credit_note_journal_lines`] l'emploie pour écrire +/// les lignes, et [`create_credit_note`] pour décider, **avant** l'exercice, si +/// le compte de TVA due fait partie des comptes à verrouiller. Un +/// `any(vat_rate > 0)` en divergerait (une ligne à taux positif dont la TVA +/// s'arrondit à zéro). +fn credit_note_vat_by_rate( + lines: &[(Decimal, Decimal, Option)], +) -> std::collections::BTreeMap { + use kesh_core::accounting::vat::line_vat_amount; + let mut vat_by_rate = std::collections::BTreeMap::new(); + for (line_total, vat_rate, _) in lines { + *vat_by_rate.entry(*vat_rate).or_insert(Decimal::ZERO) += + line_vat_amount(*line_total, *vat_rate); + } + vat_by_rate +} + +/// L'avoir écrit-il au moins une ligne de TVA due ? — total des TVA agrégées +/// par [`credit_note_vat_by_rate`] strictement positif (somme des arrondis par +/// ligne, jamais réarrondie). +fn credit_note_emits_vat(vat_by_rate: &std::collections::BTreeMap) -> bool { + vat_by_rate.values().copied().sum::() > Decimal::ZERO +} + /// Génère les lignes de l'écriture de **contre-passation** d'un avoir (DC2). /// /// Inverse exact de `invoices::generate_invoice_journal_lines` (swap débit↔crédit, /// montants **positifs**) : -/// - `[0]` Crédit créance (1100) = HT + TVA (annule la créance TTC) +/// - `[0]` Crédit créance (`receivable_account_id`, 1100 d'ordinaire) = HT + TVA +/// (annule la créance TTC). Ce compte est celui que l'écriture de VENTE a +/// débité (Story 15-6a, #473), lu par +/// [`super::invoice_settlements::sale_receivable_account`] — non le réglage +/// débiteurs du moment. /// - `[1..M]` Débit produit, **une ligne par compte de produit effectif** dont le /// montant agrégé est `> 0`, triées par `account_id` croissant (annule chaque /// crédit produit de la facture) — Story 16-1a, décision D5 @@ -191,24 +228,21 @@ fn generate_credit_note_journal_lines( vat_payable_account_id: Option, ) -> Result, DbError> { use crate::entities::NewJournalEntryLine; - use kesh_core::accounting::vat::line_vat_amount; use std::collections::BTreeMap; let mut total_ht = Decimal::ZERO; // Agrégation HT par compte effectif : miroir strict de la facture (D4/D5). let mut ht_by_account: BTreeMap = BTreeMap::new(); - // Agrégation TVA par taux : BTreeMap → itération ASC (cohérent facture, AC6). - let mut vat_by_rate: BTreeMap = BTreeMap::new(); - - for (line_total, vat_rate, revenue_account_id) in lines { + for (line_total, _, revenue_account_id) in lines { total_ht += *line_total; let effective_account = revenue_account_id.unwrap_or(default_revenue_account_id); *ht_by_account .entry(effective_account) .or_insert(Decimal::ZERO) += *line_total; - let vat = line_vat_amount(*line_total, *vat_rate); - *vat_by_rate.entry(*vat_rate).or_insert(Decimal::ZERO) += vat; } + // Agrégation TVA par taux : BTreeMap → itération ASC (cohérent facture, AC6). + // Source unique, partagée avec le calcul des comptes à verrouiller. + let vat_by_rate = credit_note_vat_by_rate(lines); // Somme des arrondis par ligne — NE PAS réarrondir. let total_vat: Decimal = vat_by_rate.values().copied().sum(); @@ -237,7 +271,7 @@ fn generate_credit_note_journal_lines( // (2..N) Débit TVA due par taux > 0 (annule la TVA due). Compte requis // seulement si une ligne doit réellement être émise. - if total_vat > Decimal::ZERO { + if credit_note_emits_vat(&vat_by_rate) { let vat_account = vat_payable_account_id.ok_or_else(|| { DbError::ConfigurationRequired("default_vat_payable_account_id".into()) })?; @@ -258,6 +292,38 @@ fn generate_credit_note_journal_lines( /// Crée et émet un avoir total contre-passant une facture validée (single-step, /// DC5). Transaction atomique miroir de `invoices::validate_invoice`. +/// +/// # Les comptes de l'avoir (Story 15-6a, #473, #523) +/// +/// La contre-passation vise les **comptes que la vente a mouvementés** : la +/// **créance** et l'**arrondi** sont relus sur l'écriture de vente +/// ([`super::invoice_settlements::sale_receivable_account`], +/// [`super::invoice_settlements::sale_rounding_account`]), les **comptes de +/// produit** sur les lignes de la facture (D5). Seuls le **produit de repli** +/// des lignes sans compte (D-B2) et la **TVA due** (#525, angle mort tracé) +/// viennent encore des réglages du moment. +/// +/// # Ordre des locks +/// +/// ```text +/// (1) invoices FOR UPDATE +/// invoice_settlements FOR UPDATE (garde 25-4-a) +/// credit_notes (avoir existant) FOR UPDATE +/// (3) company_invoice_settings FOR UPDATE (get_or_create_default_in_tx) +/// (3 ter) accounts — tous les comptes que l'avoir écrit, LOCK IN SHARE MODE, par id +/// (4) fiscal_years FOR UPDATE +/// (5) credit_note_number_sequences FOR UPDATE +/// (7) journal_entries / journal_entry_lines (S de clé étrangère sur chaque compte) +/// ``` +/// +/// La règle de l'epic est au doc-comment canonique de +/// [`super::invoices::validate_invoice`] : l'ordre est une **convention qui +/// réduit la fréquence** des interblocages, il ne les exclut pas, et la +/// **défense est le rejeu** de la route (`POST /api/v1/credit-notes`, rejouée +/// sur interblocage — Story 15-5e2). Propre à l'avoir : la ligne des réglages, +/// prise avant les comptes, le sérialise avec la validation et la saisie +/// fournisseur, qui la prennent aussi avant l'exercice ; deux avoirs simultanés +/// ne se sérialisent que là (S contre S sur les comptes ne bloque pas). pub async fn create_credit_note( pool: &MySqlPool, new: NewCreditNote, @@ -357,15 +423,132 @@ pub async fn create_credit_note( .map_err(map_db_error)?; // (3) Config company (lazy create + lock). + // + // ⛔ La CRÉANCE ne vient plus des réglages (Story 15-6a, #473) : ils + // disent quel compte débitera la prochaine facture, pas celui que + // celle-ci a débité. Elle se lit en (3 bis) sur l'écriture de vente, et + // un réglage débiteurs vide n'empêche plus d'émettre un avoir. Viennent + // encore des réglages : le produit de REPLI des lignes sans compte + // (D-B2, exigé ici comme avant) et la TVA due (#525, angle mort tracé). let settings = company_invoice_settings::get_or_create_default_in_tx(&mut tx, company_id).await?; - let receivable_account_id = settings.default_receivable_account_id.ok_or_else(|| { - DbError::ConfigurationRequired("default_receivable_account_id".into()) - })?; let revenue_account_id = settings .default_revenue_account_id .ok_or_else(|| DbError::ConfigurationRequired("default_revenue_account_id".into()))?; + // (3 bis) Les comptes de la VENTE (Story 15-6a, #473, #523) : la créance + // (première ligne au débit) et, si la facture porte un arrondi, le compte + // d'arrondi (dernière ligne, recoupée avec l'arrondi figé). Lectures + // simples : les lignes d'une écriture de vente sont gelées. L'ÉTAT de ces + // comptes, lui, ne l'est pas — il se lit sous verrou en (3 ter). + let sale_entry_id = invoice + .journal_entry_id + .ok_or_else(|| DbError::Invariant("facture validée sans écriture de vente".into()))?; + let receivable_account_id = + super::invoice_settlements::sale_receivable_account(&mut tx, company_id, sale_entry_id) + .await? + .ok_or_else(|| { + DbError::Invariant("écriture de vente sans ligne de débit".into()) + })?; + let rounding_amount = invoice.rounding_amount; + // ⛔ Un arrondi NUL n'exige aucun compte — factures émises sans arrondi, + // antérieures comprises (validation P3 de la 25-4-c4-a). + let rounding_account_id = if rounding_amount.is_zero() { + None + } else { + Some( + super::invoice_settlements::sale_rounding_account( + &mut tx, + company_id, + sale_entry_id, + rounding_amount, + ) + .await?, + ) + }; + + // (3 ter) Tous les comptes que l'avoir écrira, verrouillés EN PARTAGE, + // en une requête, par identifiant, AVANT l'exercice (Story 15-6a, AC6 ; + // choix C-15-6-24, C-15-6-29, C-15-6-32). + // + // Pourquoi un verrou : fermer une COURSE DE LECTURE. Sous REPEATABLE + // READ, une lecture simple rend l'instantané de la transaction — ouvert + // dès le snapshot des lignes (2) — et ne voit pas un archivage validé + // depuis ; c'est le trou de la garde `active` de `create_in_tx`. Lu ici + // sous verrou, `active` est frais, et un archivage concurrent + // (`UPDATE accounts`, verrou exclusif) attend la fin de l'avoir au lieu + // de le précéder. + // + // Pourquoi PARTAGÉ : c'est assez pour faire attendre un `UPDATE`, et S + // contre S ne bloque pas — l'insertion des lignes reprend de toute façon + // un S de clé étrangère sur chaque compte écrit. Un exclusif ajouterait + // un cycle avec les flux qui tiennent la créance en S (rapprochement). + // + // Pourquoi TOUS les comptes écrits : la course de lecture vaut pour + // chacun — créance et arrondi de la vente, TVA due si l'avoir en émet + // (même source que le générateur), comptes de produit effectifs. La + // 6 ter lit `active` dans ce résultat, non dans une lecture simple. + // + // Pourquoi avant l'exercice : convention du doc-comment canonique de + // `validate_invoice` (comptes → exercice), qui réduit la fréquence des + // interblocages sans les exclure ; la défense est le rejeu de la route. + let triplets: Vec<(Decimal, Decimal, Option)> = invoice_lines + .iter() + .map(|l| (l.line_total, l.vat_rate, l.revenue_account_id)) + .collect(); + let vat_payable_to_lock = if credit_note_emits_vat(&credit_note_vat_by_rate(&triplets)) { + // Réglage vide : rien à verrouiller, le générateur rend son refus + // `ConfigurationRequired` à sa place, en (7). + settings.default_vat_payable_account_id + } else { + None + }; + let revenue_ids: Vec = triplets + .iter() + .map(|(_, _, a)| a.unwrap_or(revenue_account_id)) + .collect(); + let mut ids: Vec = std::iter::once(receivable_account_id) + .chain(rounding_account_id) + .chain(vat_payable_to_lock) + .chain(revenue_ids) + .collect(); + ids.sort_unstable(); + ids.dedup(); + let locked = + company_invoice_settings::lock_designated_accounts_in_tx(&mut tx, company_id, &ids) + .await?; + let locked = locked.accounts(); + // Ligne absente : compte d'une autre société sur une écriture ou une + // ligne corrompue — inatteignable par l'application (aucune clé + // étrangère ne porte la société, un `UPDATE` direct y parvient). + if locked.len() != ids.len() { + return Err(DbError::Invariant( + "compte de l'avoir introuvable dans la société".into(), + )); + } + // Créance, arrondi, TVA due archivés → refus nommé, avant l'exercice. + // Les comptes de produit archivés restent à la 6 ter, à sa place. + let archived: Vec = locked + .iter() + .filter(|a| { + !a.active + && (a.id == receivable_account_id + || Some(a.id) == rounding_account_id + || Some(a.id) == vat_payable_to_lock) + }) + .map(|a| crate::errors::ArchivedAccount { + account_id: a.id, + account_number: Some(a.number.clone()), + }) + .collect(); + if !archived.is_empty() { + return Err(DbError::CreditNoteAccountsArchived(archived)); + } + let locked_accounts: std::collections::HashMap = locked + .iter() + .map(|a| (a.id, (a.active, a.number.clone()))) + .collect(); + // (4) Exercice ouvert couvrant la date de l'avoir (DC10). let fy = fiscal_years::find_open_covering_date(&mut tx, company_id, date) .await? @@ -441,45 +624,19 @@ pub async fn create_credit_note( // ne porte (miroir du `None` côté facture). sites.push((0, revenue_account_id)); } - let mut ids: Vec = sites.iter().map(|(_, a)| *a).collect(); - ids.sort_unstable(); - ids.dedup(); - - let placeholders = ids.iter().map(|_| "?").collect::>().join(","); - let sql = format!( - "SELECT id, number FROM accounts \ - WHERE company_id = ? AND active = TRUE AND id IN ({placeholders})" - ); - let mut q = sqlx::query_as::<_, (i64, String)>(&sql).bind(company_id); - for id in &ids { - q = q.bind(id); - } - let active_rows = q.fetch_all(&mut *tx).await.map_err(map_db_error)?; - let active_ids: std::collections::HashSet = - active_rows.iter().map(|(id, _)| *id).collect(); - - if active_ids.len() != ids.len() { - // Récupérer les numéros des comptes inactifs pour un message - // actionnable (le SELECT ci-dessus les a exclus). - let placeholders = ids.iter().map(|_| "?").collect::>().join(","); - let sql = format!( - "SELECT id, number FROM accounts \ - WHERE company_id = ? AND id IN ({placeholders})" - ); - let mut q = sqlx::query_as::<_, (i64, String)>(&sql).bind(company_id); - for id in &ids { - q = q.bind(id); - } - let numbers: std::collections::HashMap = q - .fetch_all(&mut *tx) - .await - .map_err(map_db_error)? - .into_iter() - .collect(); - + // ⚠️ `active` se lit dans le résultat du verrou (3 ter), jamais dans + // une lecture simple : sous REPEATABLE READ, celle-ci rendrait + // l'instantané d'avant le verrou (Story 15-6a, AC6). Chaque compte + // y est présent — la ligne absente a été refusée en (3 ter). + let inactive = |account_id: &i64| { + !locked_accounts + .get(account_id) + .is_some_and(|(active, _)| *active) + }; + if sites.iter().any(|(_, a)| inactive(a)) { let mut rejected: Vec = sites .iter() - .filter(|(_, a)| !active_ids.contains(a)) + .filter(|(_, a)| inactive(a)) .map( |(position, account_id)| crate::errors::RejectedRevenueAccount { line_number: if *position == 0 { @@ -488,7 +645,9 @@ pub async fn create_credit_note( Some(*position) }, account_id: *account_id, - account_number: numbers.get(account_id).cloned(), + account_number: locked_accounts + .get(account_id) + .map(|(_, number)| number.clone()), reason: crate::errors::RevenueAccountRejection::Inactive, }, ) @@ -499,11 +658,7 @@ pub async fn create_credit_note( } } - // (7) Écriture de contre-passation. - let triplets: Vec<(Decimal, Decimal, Option)> = invoice_lines - .iter() - .map(|l| (l.line_total, l.vat_rate, l.revenue_account_id)) - .collect(); + // (7) Écriture de contre-passation (triplets calculés en (3 ter)). let mut entry_lines = generate_credit_note_journal_lines( &triplets, receivable_account_id, @@ -517,17 +672,11 @@ pub async fn create_credit_note( // ajusté ; l'écart passe en ligne finale, au DÉBIT du compte d'arrondi // s'il était positif sur la facture, au CRÉDIT s'il était négatif. // - // ⛔ Un arrondi NUL n'exige aucun compte — factures émises sans arrondi, - // antérieures comprises (validation P3 de la story). Le compte est - // revérifié au moment d'écrire (#486 : il a pu être archivé depuis). - let rounding_amount = invoice.rounding_amount; - if !rounding_amount.is_zero() { - let rounding_account_id = super::company_invoice_settings::rounding_account_for_write( - &mut tx, - company_id, - crate::errors::RoundingContext::Issuance, - ) - .await?; + // ⛔ Le compte est celui que la VENTE a mouvementé (Story 15-6a, #523), + // lu en (3 bis) et contrôlé actif sous verrou en (3 ter) — non celui que + // les réglages désignent aujourd'hui : sinon l'ancien compte garderait + // l'écart et le nouveau prendrait l'opposé. + if let Some(rounding_account_id) = rounding_account_id { entry_lines[0].credit += rounding_amount; entry_lines.push(crate::entities::NewJournalEntryLine { account_id: rounding_account_id, @@ -607,8 +756,10 @@ pub async fn create_credit_note( .bind(line.vat_rate) .bind(line.line_total) // Story 16-1a (D5) : le compte de la ligne de facture est recopié - // dans le snapshot — c'est ce qui rend la contre-passation - // l'« inverse exact » même si le défaut société change ensuite. + // dans le snapshot — c'est ce qui rend le DÉBIT de produit l'inverse + // exact du crédit de la facture, même si le défaut société change + // ensuite. La créance et l'arrondi, eux, ne sont pas recopiés : ils + // sont relus sur l'écriture de vente (Story 15-6a, #473, #523). .bind(line.revenue_account_id) .execute(&mut *tx) .await diff --git a/crates/kesh-db/src/repositories/invoice_settlements.rs b/crates/kesh-db/src/repositories/invoice_settlements.rs index 0fe885c9..2c7b52e4 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements.rs @@ -509,6 +509,114 @@ where .map_err(map_db_error) } +// --------------------------------------------------------------------------- +// Story 15-6a (#473, #523) — les comptes de la vente, lus sur son écriture +// --------------------------------------------------------------------------- + +/// Le compte de **créance** d'une facture, lu sur son **écriture de vente** — +/// jamais sur les réglages (Story 15-6a, #473). +/// +/// ⛔ **La créance se lit sur l'écriture de vente, jamais sur les réglages.** +/// Les réglages disent quel compte la *prochaine* facture débitera ; une +/// facture déjà validée a débité le compte que portait son écriture, et tout +/// geste qui la solde — règlement, solde du reste, encaissement par +/// rapprochement, avoir — doit créditer **ce** compte-là, pour qu'il revienne +/// à zéro quoi qu'il soit arrivé à la configuration entre-temps. +/// +/// La créance est la **première** ligne au débit de l'écriture — d'où +/// `ORDER BY jel.id LIMIT 1`. Pas la **seule** : un arrondi à 5 centimes +/// **négatif** (Story 25-4-c4-a) ajoute une ligne de débit, toujours **après** +/// elle (`invoices::generate_invoice_journal_lines_rounded`). L'ordre est celui +/// de `jel.id`, qui suit l'ordre d'insertion des lignes ; une écriture +/// enregistrée ne se modifie plus (gardes de `journal_entries::update` et +/// `delete_in_tx` sur une écriture possédée par une pièce). +/// +/// Portée par `je.company_id` (anti-IDOR) : une écriture d'une autre société +/// rend `None`, comme une écriture sans ligne de débit. Chaque appelant garde +/// **son** refus pour `None` (`DbError::Invariant` pour le règlement, le solde +/// du reste et l'avoir ; `INVOICE_SALE_ENTRY_MALFORMED` en `failed[]` pour le +/// rapprochement). Ne prend aucun verrou : les lignes d'une écriture de vente +/// sont gelées. +pub async fn sale_receivable_account( + conn: &mut sqlx::MySqlConnection, + company_id: i64, + sale_entry_id: i64, +) -> Result, DbError> { + sqlx::query_scalar( + "SELECT jel.account_id FROM journal_entry_lines jel \ + JOIN journal_entries je ON je.id = jel.entry_id \ + WHERE jel.entry_id = ? AND je.company_id = ? AND jel.debit > 0 \ + ORDER BY jel.id LIMIT 1", + ) + .bind(sale_entry_id) + .bind(company_id) + .fetch_optional(&mut *conn) + .await + .map_err(map_db_error) +} + +/// Le compte d'**arrondi à 5 centimes** d'une facture, lu sur la **dernière** +/// ligne de son écriture de vente et **recoupé** avec l'arrondi figé sur la +/// facture (Story 15-6a, #523). +/// +/// La validation pousse l'écart d'arrondi en **dernière** ligne +/// (`invoices::generate_invoice_journal_lines_rounded`) : au **crédit** si +/// l'arrondi est positif, au **débit** s'il est négatif. L'avoir le contre-passe +/// sur **ce** compte, non sur celui que les réglages désignent au moment de +/// l'avoir — sinon l'ancien compte garderait l'écart et le nouveau prendrait +/// l'opposé. +/// +/// **Pourquoi le recoupement** : un lecteur positionnel qui ne vérifierait rien +/// rendrait en silence n'importe quelle ligne (une écriture retouchée, une +/// ligne ajoutée après l'arrondi). La dernière ligne doit donc porter +/// exactement l'arrondi : `rounding_amount > 0` ⇒ `credit = rounding_amount` +/// et `debit = 0` ; `rounding_amount < 0` ⇒ `debit = −rounding_amount` et +/// `credit = 0`. Toute autre forme — pas de ligne, sens ou montant différent — +/// est un [`DbError::Invariant`]. +/// +/// **Pourquoi pas `usable_designated_account`** : celle-ci contrôle un compte +/// **désigné** (type charge ou produit, actif, imputable). La doctrine de la +/// contre-passation est « mêmes comptes que l'origine, seule l'inactivité +/// bloque » (6 ter de la Story 16-1a) : l'état actif du compte rendu ici est +/// contrôlé par l'appelant, sous verrou. +/// +/// À n'appeler que si `rounding_amount ≠ 0` : une facture émise sans arrondi +/// n'a pas de ligne d'arrondi. Portée par `je.company_id`. Aucun verrou. +pub async fn sale_rounding_account( + conn: &mut sqlx::MySqlConnection, + company_id: i64, + sale_entry_id: i64, + rounding_amount: Decimal, +) -> Result { + let last: Option<(i64, Decimal, Decimal)> = sqlx::query_as( + "SELECT jel.account_id, jel.debit, jel.credit FROM journal_entry_lines jel \ + JOIN journal_entries je ON je.id = jel.entry_id \ + WHERE jel.entry_id = ? AND je.company_id = ? \ + ORDER BY jel.id DESC LIMIT 1", + ) + .bind(sale_entry_id) + .bind(company_id) + .fetch_optional(&mut *conn) + .await + .map_err(map_db_error)?; + let matches = |debit: Decimal, credit: Decimal| { + if rounding_amount > Decimal::ZERO { + credit == rounding_amount && debit.is_zero() + } else if rounding_amount < Decimal::ZERO { + debit == -rounding_amount && credit.is_zero() + } else { + false + } + }; + match last { + Some((account_id, debit, credit)) if matches(debit, credit) => Ok(account_id), + _ => Err(DbError::Invariant(format!( + "écriture de vente {sale_entry_id} : la dernière ligne n'est pas l'arrondi de la \ + facture ({rounding_amount})" + ))), + } +} + // --------------------------------------------------------------------------- // Story 25-5-a (#386) — lecture exhaustive pour l'export de souveraineté // --------------------------------------------------------------------------- diff --git a/crates/kesh-db/src/repositories/invoice_settlements_write.rs b/crates/kesh-db/src/repositories/invoice_settlements_write.rs index aa7d0b54..bf41b04a 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements_write.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements_write.rs @@ -99,20 +99,12 @@ pub async fn settle_invoice( // (2) ⛔ Le compte de créance vient de l'écriture de vente. Miroir strict de // l'étape (2) de `pay_in_tx`, qui lit la ligne de CRÉDIT de l'achat. - // La créance est la PREMIÈRE ligne au débit — pas la seule si l'arrondi - // à 5 centimes est négatif (Story 25-4-c4-a), qui vient après elle. - let receivable_account_id: i64 = sqlx::query_scalar( - "SELECT jel.account_id FROM journal_entry_lines jel \ - JOIN journal_entries je ON je.id = jel.entry_id \ - WHERE jel.entry_id = ? AND je.company_id = ? AND jel.debit > 0 \ - ORDER BY jel.id LIMIT 1", - ) - .bind(sale_entry_id) - .bind(company_id) - .fetch_optional(&mut *tx) - .await - .map_err(map_db_error)? - .ok_or_else(|| DbError::Invariant("écriture de vente sans ligne de débit".into()))?; + // Lecteur partagé (Story 15-6a) : la règle et l'ordre des lignes sont à + // son doc-comment. + let receivable_account_id: i64 = + invoice_settlements::sale_receivable_account(&mut tx, company_id, sale_entry_id) + .await? + .ok_or_else(|| DbError::Invariant("écriture de vente sans ligne de débit".into()))?; // (3) Contrepartie selon le mode. let counterparty_account_id = match choice { @@ -439,21 +431,14 @@ pub async fn write_off_invoice( } // (4) Le compte de la nature, relu et revérifié au moment d'écrire ; la - // créance, première ligne de débit de l'écriture de vente. + // créance, lue sur l'écriture de vente par le lecteur partagé + // (Story 15-6a). let nature_account_id = company_invoice_settings::write_off_account_for_write(&mut tx, company_id, nature).await?; - let receivable_account_id: i64 = sqlx::query_scalar( - "SELECT jel.account_id FROM journal_entry_lines jel \ - JOIN journal_entries je ON je.id = jel.entry_id \ - WHERE jel.entry_id = ? AND je.company_id = ? AND jel.debit > 0 \ - ORDER BY jel.id LIMIT 1", - ) - .bind(sale_entry_id) - .bind(company_id) - .fetch_optional(&mut *tx) - .await - .map_err(map_db_error)? - .ok_or_else(|| DbError::Invariant("écriture de vente sans ligne de débit".into()))?; + let receivable_account_id: i64 = + invoice_settlements::sale_receivable_account(&mut tx, company_id, sale_entry_id) + .await? + .ok_or_else(|| DbError::Invariant("écriture de vente sans ligne de débit".into()))?; // (5) La TVA corrigée, au prorata des taux (escompte, perte). let shares = if nature.corrects_vat() { diff --git a/crates/kesh-db/src/repositories/invoices.rs b/crates/kesh-db/src/repositories/invoices.rs index 81e72de4..5bb7bf29 100644 --- a/crates/kesh-db/src/repositories/invoices.rs +++ b/crates/kesh-db/src/repositories/invoices.rs @@ -1888,9 +1888,16 @@ pub(in crate::repositories) fn generate_invoice_journal_lines( /// le compte de différences d'arrondi — au **crédit** s'il est positif (le total /// monte), au **débit** s'il est négatif. /// -/// ⛔ **La créance reste la PREMIÈRE ligne au débit.** Le règlement manuel et le -/// rapprochement la lisent par `jel.debit > 0 ORDER BY jel.id LIMIT 1` : un -/// arrondi négatif, écrit au débit, doit donc venir après elle. +/// ⛔ **La créance reste la PREMIÈRE ligne au débit.** Le règlement manuel, le +/// solde du reste, le rapprochement et l'avoir la lisent par +/// `jel.debit > 0 ORDER BY jel.id LIMIT 1` +/// (`invoice_settlements::sale_receivable_account`) : un arrondi négatif, écrit +/// au débit, doit donc venir après elle. +/// +/// ⛔ **L'arrondi reste la DERNIÈRE ligne** (Story 15-6a, #523). L'avoir l'y +/// relit et la recoupe avec l'arrondi figé sur la facture +/// (`invoice_settlements::sale_rounding_account`) : une ligne ajoutée après lui +/// bloquerait tous les avoirs de factures arrondies (`DbError::Invariant`). pub(in crate::repositories) fn generate_invoice_journal_lines_rounded( lines: &[InvoiceLine], receivable_account_id: i64, diff --git a/crates/kesh-db/tests/credit_notes_repository.rs b/crates/kesh-db/tests/credit_notes_repository.rs index 0c16ade8..7ea92027 100644 --- a/crates/kesh-db/tests/credit_notes_repository.rs +++ b/crates/kesh-db/tests/credit_notes_repository.rs @@ -12,7 +12,10 @@ use chrono::NaiveDate; use kesh_db::entities::contact::{ContactType, NewContact}; use kesh_db::entities::{NewCreditNote, NewInvoice, NewInvoiceLine, SettlementChoice}; use kesh_db::errors::DbError; -use kesh_db::repositories::{contacts, credit_notes, invoice_settlements_write, invoices}; +use kesh_db::repositories::{ + company_invoice_settings, contacts, credit_notes, invoice_settlements, + invoice_settlements_write, invoices, +}; use kesh_db::test_fixtures::{SeededCompany, seed_accounting_company}; use rust_decimal::Decimal; use rust_decimal_macros::dec; @@ -654,3 +657,449 @@ async fn credit_note_waits_for_a_concurrent_settlement(pool: MySqlPool) { result.map(|c| c.credit_note.id) ); } + +// --------------------------------------------------------------------------- +// Story 15-6a (#473, #523) — l'avoir crédite la créance de la vente ; ses +// comptes sont verrouillés en partage avant l'exercice. +// --------------------------------------------------------------------------- + +/// Émet l'avoir d'une facture à `date`. +async fn emit( + pool: &MySqlPool, + seeded: &SeededCompany, + invoice_id: i64, + date: NaiveDate, +) -> Result { + credit_notes::create_credit_note( + pool, + NewCreditNote { + company_id: seeded.company_id, + invoice_id, + date, + }, + seeded.admin_user_id, + ) + .await +} + +/// Solde (débit − crédit) d'un compte sur les écritures de la société. +async fn balance(pool: &MySqlPool, company_id: i64, account_id: i64) -> Decimal { + sqlx::query_scalar( + "SELECT COALESCE(SUM(jel.debit) - SUM(jel.credit), 0) FROM journal_entry_lines jel \ + JOIN journal_entries je ON je.id = jel.entry_id \ + WHERE je.company_id = ? AND jel.account_id = ?", + ) + .bind(company_id) + .bind(account_id) + .fetch_one(pool) + .await + .unwrap() +} + +async fn archive(pool: &MySqlPool, account_id: i64) { + sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?") + .bind(account_id) + .execute(pool) + .await + .unwrap(); +} + +/// Rien d'écrit par un avoir refusé : ni avoir, ni écriture de plus, ni numéro +/// d'avoir tiré, facture toujours `validated`. +async fn assert_nothing_written(pool: &MySqlPool, invoice_id: i64, entries_before: i64) { + let notes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_notes") + .fetch_one(pool) + .await + .unwrap(); + let entries: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(pool) + .await + .unwrap(); + let drawn: i64 = sqlx::query_scalar( + "SELECT CAST(COALESCE(SUM(next_number - 1), 0) AS SIGNED) FROM credit_note_number_sequences", + ) + .fetch_one(pool) + .await + .unwrap(); + let status: String = sqlx::query_scalar("SELECT status FROM invoices WHERE id = ?") + .bind(invoice_id) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!( + (notes, entries, drawn, status.as_str()), + (0, entries_before, 0, "validated"), + "rien d'écrit" + ); +} + +async fn count_entries(pool: &MySqlPool) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(pool) + .await + .unwrap() +} + +/// Le refus nommé de l'avoir, réduit à `(id, numéro)` par compte. +fn archived_of(err: &DbError) -> Vec<(i64, Option)> { + match err { + DbError::CreditNoteAccountsArchived(a) => a + .iter() + .map(|x| (x.account_id, x.account_number.clone())) + .collect(), + other => panic!("attendu CreditNoteAccountsArchived, obtenu {other:?}"), + } +} + +/// Test 1 (#473) — l'avoir crédite la créance que la VENTE a débitée (1100), +/// même après que les réglages désignent un autre compte débiteurs (1101) : +/// solde de 1100 à zéro, aucune ligne sur 1101. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_credits_the_sale_receivable_after_settings_change(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(1000.00))]).await; + let new_receivable: i64 = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type) \ + VALUES (?, '1101', 'Débiteurs (nouveau)', 'Asset')", + ) + .bind(seeded.company_id) + .execute(&pool) + .await + .unwrap() + .last_insert_id() as i64; + sqlx::query( + "UPDATE company_invoice_settings SET default_receivable_account_id = ? \ + WHERE company_id = ?", + ) + .bind(new_receivable) + .bind(seeded.company_id) + .execute(&pool) + .await + .unwrap(); + + let issued = emit(&pool, &seeded, invoice_id, d(2026, 7, 1)) + .await + .expect("avoir"); + let receivable = seeded.accounts["1100"]; + let creance = issued + .journal_entry + .lines + .iter() + .find(|l| l.credit > Decimal::ZERO) + .unwrap(); + assert_eq!( + (creance.account_id, creance.credit), + (receivable, dec!(1081.00)) + ); + assert_eq!(balance(&pool, seeded.company_id, receivable).await, dec!(0)); + assert!( + issued + .journal_entry + .lines + .iter() + .all(|l| l.account_id != new_receivable), + "aucune ligne sur le compte désigné depuis" + ); +} + +/// Test 2 (#473) — le réglage débiteurs VIDÉ après la validation (SQL direct) +/// n'empêche plus l'avoir. Borné à la créance : le produit par défaut reste posé. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_needs_no_receivable_setting(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(1000.00))]).await; + sqlx::query( + "UPDATE company_invoice_settings SET default_receivable_account_id = NULL \ + WHERE company_id = ?", + ) + .bind(seeded.company_id) + .execute(&pool) + .await + .unwrap(); + + emit(&pool, &seeded, invoice_id, d(2026, 7, 1)) + .await + .expect("avoir sans réglage débiteurs"); + assert_eq!( + balance(&pool, seeded.company_id, seeded.accounts["1100"]).await, + dec!(0) + ); +} + +/// Test 3 — la créance de la vente archivée après la validation : refus qui +/// NOMME 1100 (`ACCOUNT_ARCHIVED`), rien d'écrit. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_refused_when_sale_receivable_archived(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(1000.00))]).await; + archive(&pool, seeded.accounts["1100"]).await; + let entries_before = count_entries(&pool).await; + + let err = emit(&pool, &seeded, invoice_id, d(2026, 7, 1)) + .await + .expect_err("créance archivée"); + assert_eq!( + archived_of(&err), + vec![(seeded.accounts["1100"], Some("1100".to_string()))] + ); + assert_nothing_written(&pool, invoice_id, entries_before).await; +} + +/// Test 4 — le lecteur de créance est porté par la société : l'écriture de vente +/// d'une société, lue avec l'identifiant d'une autre, rend `None`. +#[sqlx::test(migrations = "./test-schema")] +async fn sale_receivable_account_is_scoped_to_the_company(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(1000.00))]).await; + let sale_entry: i64 = sqlx::query_scalar("SELECT journal_entry_id FROM invoices WHERE id = ?") + .bind(invoice_id) + .fetch_one(&pool) + .await + .unwrap(); + let other_company = other_company(&pool).await; + + let mut conn = pool.acquire().await.unwrap(); + let own = + invoice_settlements::sale_receivable_account(&mut conn, seeded.company_id, sale_entry) + .await + .unwrap(); + assert_eq!(own, Some(seeded.accounts["1100"]), "témoin : sa société"); + let foreign = + invoice_settlements::sale_receivable_account(&mut conn, other_company, sale_entry) + .await + .unwrap(); + assert_eq!(foreign, None); +} + +/// Une seconde société, minimale. +async fn other_company(pool: &MySqlPool) -> i64 { + sqlx::query( + "INSERT INTO companies (name, address, org_type, accounting_language, \ + instance_language) VALUES ('Autre SA', 'Rue 3\n1000 Lausanne', 'Independant', \ + 'FR', 'FR')", + ) + .execute(pool) + .await + .unwrap() + .last_insert_id() as i64 +} + +/// Test 11 — la course de l'AC6 : un archivage de la créance, tenu non validé, +/// fait ATTENDRE l'avoir sur le verrou de ses comptes ; validé, l'avoir voit le +/// compte archivé et le nomme, rien d'écrit. +/// +/// ⚠️ Motifs couplés à la forme du verrou (`FROM accounts`, `LOCK IN SHARE +/// MODE`) : changer la requête du helper impose de les revoir. La facture est +/// **sans arrondi** pour que seul ce verrou lise `accounts` sous verrou. Sur le +/// code d'avant la Story 15-6a, l'avoir n'y attend jamais (il bute plus tard sur +/// la clé étrangère de l'insertion des lignes) : c'est le délai de +/// `attendre_une_requete_en_cours` qui rougit. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_waits_for_a_concurrent_archive_of_the_sale_receivable(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(1000.00))]).await; + let receivable = seeded.accounts["1100"]; + let entries_before = count_entries(&pool).await; + + let mut concurrent = pool.begin().await.unwrap(); + sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?") + .bind(receivable) + .execute(&mut *concurrent) + .await + .unwrap(); + + let p = pool.clone(); + let (company_id, user_id) = (seeded.company_id, seeded.admin_user_id); + let avoir = tokio::spawn(async move { + credit_notes::create_credit_note( + &p, + NewCreditNote { + company_id, + invoice_id, + date: d(2026, 7, 1), + }, + user_id, + ) + .await + }); + let vue = kesh_db::test_fixtures::attendre_une_requete_en_cours( + &pool, + &["FROM accounts", "LOCK IN SHARE MODE"], + || avoir.is_finished(), + ) + .await; + if !vue { + panic!( + "l'avoir a fini sans attendre le verrou des comptes : {:?}", + avoir.await.map(|r| r.map(|c| c.credit_note.id)) + ); + } + concurrent.commit().await.unwrap(); + + let err = avoir.await.expect("tâche").expect_err("créance archivée"); + assert_eq!( + archived_of(&err), + vec![(receivable, Some("1100".to_string()))] + ); + assert_nothing_written(&pool, invoice_id, entries_before).await; +} + +/// Test 14 — ordre des refus : la créance archivée l'emporte sur l'absence +/// d'exercice ouvert — le verrou des comptes de l'avoir précède l'exercice. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_archived_sale_receivable_is_refused_before_the_fiscal_year(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(1000.00))]).await; + archive(&pool, seeded.accounts["1100"]).await; + + let err = emit(&pool, &seeded, invoice_id, d(2031, 1, 15)) + .await + .expect_err("refusé"); + assert_eq!( + archived_of(&err), + vec![(seeded.accounts["1100"], Some("1100".to_string()))], + "et non FiscalYearInvalid" + ); +} + +/// Test 16 (C-15-6-29) — le compte de TVA due des réglages archivé après la +/// validation d'une facture avec TVA : refus qui le NOMME, rien d'écrit. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_refused_when_vat_payable_archived(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(1000.00))]).await; + let vat = seeded.accounts["2000"]; + archive(&pool, vat).await; + let entries_before = count_entries(&pool).await; + + let err = emit(&pool, &seeded, invoice_id, d(2026, 7, 1)) + .await + .expect_err("TVA due archivée"); + assert_eq!(archived_of(&err), vec![(vat, Some("2000".to_string()))]); + assert_nothing_written(&pool, invoice_id, entries_before).await; +} + +/// Test 17 (C51, C88) — le helper de verrou de liste, appelé en direct avec la +/// créance et l'identifiant d'un compte d'une AUTRE société, ne verrouille pas +/// la ligne étrangère (patron `owned_account_ids`) : une sonde `FOR UPDATE +/// NOWAIT` y réussit, alors qu'elle échoue sur la créance (témoin). La ligne +/// étrangère est absente de l'instantané. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_lock_leaves_a_foreign_account_unlocked(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let other = other_company(&pool).await; + let foreign: i64 = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type) \ + VALUES (?, '1100', 'Débiteurs étrangers', 'Asset')", + ) + .bind(other) + .execute(&pool) + .await + .unwrap() + .last_insert_id() as i64; + let receivable = seeded.accounts["1100"]; + + let mut holder = pool.begin().await.unwrap(); + let snapshot = company_invoice_settings::lock_designated_accounts_in_tx( + &mut holder, + seeded.company_id, + &[foreign, receivable], + ) + .await + .unwrap(); + let ids: Vec = snapshot.accounts().iter().map(|a| a.id).collect(); + assert_eq!(ids, vec![receivable], "la ligne étrangère est absente"); + + async fn nowait(pool: &MySqlPool, id: i64) -> bool { + let mut probe = pool.begin().await.unwrap(); + let r = sqlx::query("SELECT id FROM accounts WHERE id = ? FOR UPDATE NOWAIT") + .bind(id) + .fetch_all(&mut *probe) + .await; + probe.rollback().await.unwrap(); + r.is_ok() + } + assert!( + nowait(&pool, foreign).await, + "le compte d'une autre société ne doit pas être verrouillé" + ); + assert!( + !nowait(&pool, receivable).await, + "témoin : la créance de la société est tenue en partagé" + ); + holder.rollback().await.unwrap(); +} + +/// Test 19 (revue de code P1, finding A-3 ; AC3) — l'écriture de vente SANS +/// ligne de débit : la facture, repointée en SQL sur un en-tête d'écriture vide +/// de la même société, n'a plus de créance lisible. L'avoir est refusé +/// `DbError::Invariant` (500), rien d'écrit — il ne retombe pas sur le réglage +/// débiteurs. +/// +/// ⚠️ Le second `Invariant` de l'AC3 (« facture validée sans écriture de +/// vente ») n'a pas de test : `chk_invoices_validated_has_je` interdit de monter +/// une facture `validated` dont `journal_entry_id` est `NULL`, même en SQL +/// direct. Angle mort assumé, gardé par la contrainte elle-même. +#[sqlx::test(migrations = "./test-schema")] +async fn credit_note_refused_when_the_sale_entry_has_no_debit_line(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let contact = make_contact(&pool, seeded.company_id, seeded.admin_user_id).await; + let invoice_id = + create_and_validate(&pool, &seeded, contact, &[(dec!(8.10), dec!(1000.00))]).await; + let (fy_id, entry_date): (i64, NaiveDate) = sqlx::query_as( + "SELECT je.fiscal_year_id, je.entry_date FROM journal_entries je \ + JOIN invoices i ON i.journal_entry_id = je.id WHERE i.id = ?", + ) + .bind(invoice_id) + .fetch_one(&pool) + .await + .unwrap(); + let empty_entry: i64 = sqlx::query( + "INSERT INTO journal_entries (company_id, fiscal_year_id, entry_number, entry_date, \ + journal, description, version, created_at, updated_at) \ + SELECT ?, ?, COALESCE(MAX(entry_number), 0) + 1, ?, 'Ventes', 'en-tête vide', 1, \ + NOW(3), NOW(3) FROM journal_entries WHERE company_id = ? AND fiscal_year_id = ?", + ) + .bind(seeded.company_id) + .bind(fy_id) + .bind(entry_date) + .bind(seeded.company_id) + .bind(fy_id) + .execute(&pool) + .await + .unwrap() + .last_insert_id() as i64; + sqlx::query("UPDATE invoices SET journal_entry_id = ? WHERE id = ?") + .bind(empty_entry) + .bind(invoice_id) + .execute(&pool) + .await + .unwrap(); + let entries_before = count_entries(&pool).await; + + let err = emit(&pool, &seeded, invoice_id, d(2026, 7, 1)) + .await + .expect_err("écriture de vente sans ligne de débit"); + match &err { + DbError::Invariant(msg) => assert!( + msg.contains("sans ligne de débit"), + "message inattendu : {msg}" + ), + other => panic!("attendu Invariant, obtenu {other:?}"), + } + assert_nothing_written(&pool, invoice_id, entries_before).await; +} diff --git a/crates/kesh-db/tests/invoices_validate_vat.rs b/crates/kesh-db/tests/invoices_validate_vat.rs index 15a8a858..f3485b91 100644 --- a/crates/kesh-db/tests/invoices_validate_vat.rs +++ b/crates/kesh-db/tests/invoices_validate_vat.rs @@ -839,9 +839,17 @@ mod arrondi_5_centimes { } /// ⛔ Avoir d'une facture ARRONDIE dont le compte d'arrondi a été archivé - /// depuis (#486) : refus au contexte émission, et RIEN d'écrit — ni avoir, ni - /// écriture, ni numéro consommé (la transaction emporte le compteur tiré plus - /// tôt). Revue de code P1, lentille B. + /// depuis (#486) : refus, et RIEN d'écrit — ni avoir, ni écriture, ni numéro + /// consommé. Revue de code P1, lentille B. + /// + /// **Modifié délibérément par la Story 15-6a** (AC6, choix C-15-6-25) : le + /// refus était `RoundingAccountNotConfigured { Issuance }`, dont le message + /// renvoie à *Paramètres → Facturation* — remède faux, l'avoir ne lisant plus + /// ce réglage (#523). Il est désormais `CreditNoteAccountsArchived`, qui + /// NOMME le compte à réactiver — celui que la vente a mouvementé. Le refus + /// vient du verrou des comptes de l'avoir, AVANT la séquence : aucun numéro + /// n'est tiré. L'assertion « premier numéro après réactivation » fige donc + /// l'ORDRE (refus avant la séquence), et non plus le rollback du compteur. #[sqlx::test(migrations = "./test-schema")] async fn a_credit_note_is_refused_when_the_rounding_account_was_archived(pool: MySqlPool) { let (seeded, contact, rounding) = setup(&pool).await; @@ -865,15 +873,16 @@ mod arrondi_5_centimes { let err = credit_notes::create_credit_note(&pool, new(), seeded.admin_user_id) .await .expect_err("compte d'arrondi archivé"); - assert!( - matches!( - err, - DbError::RoundingAccountNotConfigured { - context: RoundingContext::Issuance - } + match &err { + DbError::CreditNoteAccountsArchived(archived) => assert_eq!( + archived, + &vec![kesh_db::errors::ArchivedAccount { + account_id: rounding, + account_number: Some("6940".into()), + }] ), - "got {err:?}" - ); + other => panic!("attendu CreditNoteAccountsArchived(6940), obtenu {other:?}"), + } let notes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_notes") .fetch_one(&pool) .await @@ -904,6 +913,175 @@ mod arrondi_5_centimes { ); } + /// Avoir émis par le dépôt, à la date de la facture. + async fn avoir( + pool: &MySqlPool, + seeded: &SeededCompany, + invoice_id: i64, + ) -> Result { + credit_notes::create_credit_note( + pool, + NewCreditNote { + company_id: seeded.company_id, + invoice_id, + date: NaiveDate::from_ymd_opt(INVOICE_DATE.0, INVOICE_DATE.1, INVOICE_DATE.2) + .unwrap(), + }, + seeded.admin_user_id, + ) + .await + } + + /// Un second compte de charge `6941`, désigné comme compte d'arrondi — + /// `designate_rounding_account` insère toujours `6940` et ne peut pas + /// servir deux fois. + async fn redesignate_rounding(pool: &MySqlPool, company_id: i64) -> i64 { + let id = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type) \ + VALUES (?, '6941', 'Différences d''arrondi (nouveau)', '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 + } + + /// Solde (débit − crédit) d'un compte sur toutes les écritures. + async fn solde(pool: &MySqlPool, account_id: i64) -> Decimal { + sqlx::query_scalar( + "SELECT COALESCE(SUM(debit) - SUM(credit), 0) FROM journal_entry_lines \ + WHERE account_id = ?", + ) + .bind(account_id) + .fetch_one(pool) + .await + .unwrap() + } + + /// Story 15-6a (test 5) — sur une facture à arrondi NÉGATIF, l'écriture de + /// vente porte deux lignes de débit (créance, puis arrondi) : le lecteur de + /// créance rend la créance, pas le compte d'arrondi. + #[sqlx::test(migrations = "./test-schema")] + async fn sale_receivable_reader_skips_a_negative_rounding_line(pool: MySqlPool) { + let (seeded, contact, _rounding) = setup(&pool).await; + let v = create_and_validate(&pool, &seeded, contact, &[(dec!(0), dec!(234.52))]) + .await + .expect("validate"); + assert_eq!(v.invoice.rounding_amount, dec!(-0.02)); + let mut conn = pool.acquire().await.unwrap(); + let got = invoice_settlements::sale_receivable_account( + &mut conn, + seeded.company_id, + v.journal_entry.entry.id, + ) + .await + .unwrap(); + assert_eq!(got, Some(seeded.accounts["1100"])); + } + + /// Story 15-6a (test 6, #523) — l'avoir contre-passe l'arrondi sur le compte + /// que la VENTE a mouvementé (6940), même après qu'un autre compte (6941) a + /// été désigné : solde de 6940 à zéro, aucune ligne sur 6941. + #[sqlx::test(migrations = "./test-schema")] + async fn a_credit_note_reverses_the_sale_rounding_after_redesignation(pool: MySqlPool) { + let (seeded, contact, rounding) = setup(&pool).await; + let v = create_and_validate(&pool, &seeded, contact, &[(dec!(0), dec!(123.44))]) + .await + .expect("validate"); + assert_eq!(v.invoice.rounding_amount, dec!(0.01)); + let other = redesignate_rounding(&pool, seeded.company_id).await; + + let cn = avoir(&pool, &seeded, v.invoice.id).await.expect("avoir"); + assert_eq!( + lignes(&cn.journal_entry), + vec![ + (seeded.accounts["1100"], dec!(0), dec!(123.45)), + (seeded.accounts["3000"], dec!(123.44), dec!(0)), + (rounding, dec!(0.01), dec!(0)), + ] + ); + assert_eq!(solde(&pool, rounding).await, dec!(0), "6940 revient à zéro"); + let on_other: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM journal_entry_lines WHERE account_id = ?") + .bind(other) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(on_other, 0, "aucune ligne sur le compte désigné depuis"); + } + + /// Story 15-6a (test 7, #523) — le réglage d'arrondi VIDÉ après la validation + /// (SQL direct) n'empêche plus l'avoir : la ligne d'arrondi vise 6940. + #[sqlx::test(migrations = "./test-schema")] + async fn a_credit_note_needs_no_rounding_setting(pool: MySqlPool) { + let (seeded, contact, rounding) = setup(&pool).await; + let v = create_and_validate(&pool, &seeded, contact, &[(dec!(0), dec!(123.44))]) + .await + .expect("validate"); + sqlx::query( + "UPDATE company_invoice_settings SET default_rounding_account_id = NULL \ + WHERE company_id = ?", + ) + .bind(seeded.company_id) + .execute(&pool) + .await + .unwrap(); + + let cn = avoir(&pool, &seeded, v.invoice.id) + .await + .expect("avoir sans réglage d'arrondi"); + assert_eq!( + lignes(&cn.journal_entry).last(), + Some(&(rounding, dec!(0.01), dec!(0))) + ); + assert_eq!(solde(&pool, rounding).await, dec!(0)); + } + + /// Story 15-6a (test 8) — le lecteur d'arrondi RECOUPE la dernière ligne avec + /// l'arrondi de la facture : appelé avec le signe opposé, il refuse + /// (`Invariant`) au lieu de rendre une ligne en silence. Le bon signe rend + /// bien le compte (témoin). + #[sqlx::test(migrations = "./test-schema")] + async fn sale_rounding_reader_refuses_a_mismatched_line(pool: MySqlPool) { + let (seeded, contact, rounding) = setup(&pool).await; + let v = create_and_validate(&pool, &seeded, contact, &[(dec!(0), dec!(123.44))]) + .await + .expect("validate"); + let entry = v.journal_entry.entry.id; + let mut conn = pool.acquire().await.unwrap(); + let ok = invoice_settlements::sale_rounding_account( + &mut conn, + seeded.company_id, + entry, + dec!(0.01), + ) + .await + .unwrap(); + assert_eq!(ok, rounding, "témoin : le bon signe rend le compte"); + for wrong in [dec!(-0.01), dec!(0.02)] { + let err = invoice_settlements::sale_rounding_account( + &mut conn, + seeded.company_id, + entry, + wrong, + ) + .await + .expect_err("forme différente"); + assert!(matches!(err, DbError::Invariant(_)), "{wrong} : {err:?}"); + } + } + /// La dévalidation remet l'arrondi figé à zéro. #[sqlx::test(migrations = "./test-schema")] async fn unvalidation_resets_the_rounding(pool: MySqlPool) { @@ -1392,17 +1570,49 @@ mod garde_usage_comptes_reglage { assert_nothing_written(&pool, &seeded, id).await; } - /// L'AVOIR est exempté de la garde à l'usage, délibérément (C35) : une - /// facture dont la créance des réglages est devenue non imputable reste - /// annulable. + /// L'AVOIR ne contrôle pas l'imputabilité de ce qu'il contre-passe + /// (choix C-15-6-2, C-15-6-7 ; Story 15-6a) : la créance **de la vente**, + /// rendue non imputable après la validation, reste celle que l'avoir + /// crédite, et l'avoir est émis — « mêmes comptes que l'origine, seule + /// l'inactivité bloque ». + /// + /// **Changé de sens par la Story 15-6a** : ce test figeait l'exemption de + /// l'avoir à la garde à l'usage (C35), quand l'avoir relisait la créance dans + /// les réglages. Il ne la lit plus là : sans ce ré-ancrage, le test serait + /// resté vert pour une autre raison, sans plus rien figer. + /// + /// **Discriminant sur la source** (revue de code P1, finding A-1) : les + /// réglages désignent, après la validation, un AUTRE compte débiteurs + /// (1101, imputable et actif). Un avoir qui relirait la créance dans les + /// réglages créditerait 1101 et passerait la garde : l'assertion sur 1100 + /// rougit alors (constaté sous la mutation « créance lue sur les + /// réglages »). #[sqlx::test(migrations = "./test-schema")] - async fn credit_note_is_exempt_from_the_guard(pool: MySqlPool) { + async fn credit_note_credits_a_non_postable_sale_receivable(pool: MySqlPool) { let (seeded, contact) = setup(&pool).await; let id = draft(&pool, &seeded, contact, &[(dec!(8.10), dec!(100.00), None)]).await; validate(&pool, &seeded, id).await.expect("validée"); set_postable(&pool, seeded.accounts["1100"], false).await; + let settings_receivable: i64 = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type) \ + VALUES (?, '1101', 'Débiteurs (réglages)', 'Asset')", + ) + .bind(seeded.company_id) + .execute(&pool) + .await + .unwrap() + .last_insert_id() as i64; + sqlx::query( + "UPDATE company_invoice_settings SET default_receivable_account_id = ? \ + WHERE company_id = ?", + ) + .bind(settings_receivable) + .bind(seeded.company_id) + .execute(&pool) + .await + .unwrap(); - credit_notes::create_credit_note( + let cn = credit_notes::create_credit_note( &pool, NewCreditNote { company_id: seeded.company_id, @@ -1413,7 +1623,25 @@ mod garde_usage_comptes_reglage { seeded.admin_user_id, ) .await - .expect("l'avoir est émis malgré la créance non imputable (C35)"); + .expect("l'avoir est émis malgré la créance non imputable"); + let creance = cn + .journal_entry + .lines + .iter() + .find(|l| l.credit > Decimal::ZERO) + .expect("ligne de crédit"); + assert_eq!( + (creance.account_id, creance.credit), + (seeded.accounts["1100"], dec!(108.10)), + "le crédit vise la créance de la vente" + ); + assert!( + cn.journal_entry + .lines + .iter() + .all(|l| l.account_id != settings_receivable), + "aucune ligne sur le compte débiteurs des réglages" + ); } /// Un compte d'une AUTRE société posé comme TVA due des réglages diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 284d4ae4..be17eb20 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -306,6 +306,7 @@ invoice-line-account-not-revenue = { $subject }: Konto { $number } ist kein Ertr invoice-line-account-not-postable = { $subject }: Konto { $number } ist nicht bebuchbar — wählen Sie ein anderes Konto invoice-line-revenue-account-invalid = Ungültiges Ertragskonto — { $detail } credit-note-revenue-account-archived = Gutschrift kann nicht ausgestellt werden — { $detail }. Reaktivieren Sie die betroffenen Konten. +credit-note-account-archived = Gutschrift kann nicht ausgestellt werden — archivierte(s) Konto/Konten: { $detail }. Reaktivieren Sie die betroffenen Konten. invoice-error-total-zero = Diese Rechnung hat einen Gesamtbetrag von null und kann nicht validiert werden. Erfassen Sie mindestens eine Position mit einem Einzelpreis grösser als null. credit-note-error-total-zero = Diese Rechnung hat einen Gesamtbetrag von null: Es kann keine Gutschrift ausgestellt werden. diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index df665c22..2c71f582 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -306,6 +306,7 @@ invoice-line-account-not-revenue = { $subject }: account { $number } is not a re invoice-line-account-not-postable = { $subject }: account { $number } is not postable — pick another account invoice-line-revenue-account-invalid = Invalid revenue account — { $detail } credit-note-revenue-account-archived = Cannot issue the credit note — { $detail }. Reactivate the affected accounts. +credit-note-account-archived = Cannot issue the credit note — archived account(s): { $detail }. Reactivate the accounts concerned. invoice-error-total-zero = This invoice has a total amount of zero and cannot be validated. Add at least one line with a unit price greater than zero. credit-note-error-total-zero = This invoice has a total amount of zero: no credit note can be issued. diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index ba24e01c..6944c99c 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -306,6 +306,7 @@ invoice-line-account-not-revenue = { $subject } : le compte { $number } n'est pa invoice-line-account-not-postable = { $subject } : le compte { $number } n'est pas imputable — choisissez un autre compte invoice-line-revenue-account-invalid = Compte de produit invalide — { $detail } credit-note-revenue-account-archived = Impossible d'émettre l'avoir — { $detail }. Réactivez le ou les comptes concernés. +credit-note-account-archived = Impossible d'émettre l'avoir — compte(s) archivé(s) : { $detail }. Réactivez le ou les comptes concernés. invoice-error-total-zero = Cette facture est d'un montant total nul : elle ne peut pas être validée. Renseignez au moins une ligne avec un prix unitaire supérieur à zéro. credit-note-error-total-zero = Cette facture est d'un montant total nul : aucun avoir ne peut être émis. diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 391e19ef..94fc69e4 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -306,6 +306,7 @@ invoice-line-account-not-revenue = { $subject }: il conto { $number } non è un invoice-line-account-not-postable = { $subject }: il conto { $number } non è imputabile — scegliete un altro conto invoice-line-revenue-account-invalid = Conto di ricavo non valido — { $detail } credit-note-revenue-account-archived = Impossibile emettere la nota di credito — { $detail }. Riattivate i conti interessati. +credit-note-account-archived = Impossibile emettere la nota di credito — conto/conti archiviato/i: { $detail }. Riattivate i conti interessati. invoice-error-total-zero = Questa fattura ha un importo totale nullo: non può essere convalidata. Inserite almeno una riga con un prezzo unitario superiore a zero. credit-note-error-total-zero = Questa fattura ha un importo totale nullo: non è possibile emettere alcuna nota di credito. diff --git a/docs/MULTI-TENANT-SCOPING-PATTERNS.md b/docs/MULTI-TENANT-SCOPING-PATTERNS.md index f2bec1ff..6877aad2 100644 --- a/docs/MULTI-TENANT-SCOPING-PATTERNS.md +++ b/docs/MULTI-TENANT-SCOPING-PATTERNS.md @@ -322,6 +322,7 @@ Rationale: this matches the natural dependency direction (state machine → tena | `POST /projects/*` (create/update/archive/unarchive) | companies (sentinel) → projects | `routes/projects.rs` (Story 19-1) | | `fiscal_years::create / update_name / close / find_*_locked` | fiscal_years only (single table, internal tx) — `FOR UPDATE` locks pour pré-check unicité/overlap et figer le before-snapshot d'audit log. Pas de chaîne cross-table. | `kesh-db/src/repositories/fiscal_years.rs` | | `invoices::validate_invoice` | see the canonical doc-comment of `invoices::validate_invoice` (« Ordre des locks ») — the order is written there only | `kesh-db/src/repositories/invoices.rs` | +| `POST /credit-notes` | see the doc-comment of `credit_notes::create_credit_note` (« Ordre des locks ») — the order is written there only; all the accounts the credit note writes are locked **in share mode, by id, before the fiscal year** (Story 15-6a); replayed on deadlock (Story 15-5e2) | `repositories/credit_notes.rs::create_credit_note` | | `POST /reconciliation/accept` (`accept_batch`) | **Per proposal**: rounding account (`rounding_account_for_write`), then fiscal year (`find_open_covering_date`) for an invoice proposal (`accept_one_invoice`); fiscal year, then sentinel and projects for a rule or split proposal (`accept_one_rule`, `accept_one_split`). **Between proposals**, in the same transaction (one savepoint per proposal): the fiscal year held by proposal *n* precedes the rounding account, the sentinel or the projects of proposal *n+1* — **fiscal year → rounding account** at the batch level, the reverse of `invoices::validate_invoice`: that is the cycle of #536, closed by replaying both sides | `routes/reconciliation.rs` | | `PUT /api/v1/journal-entries/{id}` (Story 15-8a, #532) | **journal_entries → [companies → projects] → fiscal_years (the entry's year, then later years by `start_date`) → (accounts, shared, via the FKs of the line `INSERT`)** | `repositories/journal_entries.rs::update` | | `DELETE /api/v1/journal-entries/{id}` (Story 15-8b, #532) | **journal_entries + fiscal_years (the entry and its year, one joined `FOR UPDATE`) → fiscal_years (later years by `start_date`) → (FK checks of the `DELETE` on the rows that reference the entry)**, no `INSERT` but the audit row | `repositories/journal_entries.rs::delete_in_tx` | diff --git a/docs/api-external.md b/docs/api-external.md index df0e2702..67d0062c 100644 --- a/docs/api-external.md +++ b/docs/api-external.md @@ -346,6 +346,18 @@ Corps : `{ "version": n }` — le verrou optimiste. Réponse : la facture, même | Aucun exercice ouvert ne couvre la date | `FISCAL_YEAR_INVALID` | `400` | | Date dans une période verrouillée | `PERIOD_LOCKED` | `400` | +### Émettre un avoir — refus de compte + +**`POST /api/v1/credit-notes`** — l'avoir contre-passe l'écriture de la facture sur **les comptes que la vente a mouvementés** : il crédite le compte débiteurs que la facture a **débité** et contre-passe l'arrondi sur le compte d'arrondi que la facture a mouvementé, non sur ceux des réglages du moment ; un réglage débiteurs vide ne l'empêche donc plus *(depuis la v0.13.0)*. Le compte de TVA due, lui, reste celui des réglages. + +| Refus | Code | Statut | +|---|---|---| +| Compte débiteurs ou compte d'arrondi de la vente archivé, ou compte de TVA due des réglages archivé (si l'avoir porte de la TVA) — contrôlé **avant** l'exercice | `ACCOUNT_ARCHIVED` | `400`, `details.rejected[{accountId, accountNumber}]` | +| Compte de produit d'une ligne archivé | `CREDIT_NOTE_REVENUE_ACCOUNT_ARCHIVED` | `400`, `details.rejected[]` nomme la ligne | +| Compte de produit par défaut non désigné dans les réglages (exigé dans tous les cas), ou compte de TVA due non désigné alors que l'avoir porte de la TVA | `CONFIGURATION_REQUIRED` | `400` | + +⚠️ **Changement** *(v0.13.0)* : un compte débiteurs, d'arrondi ou de TVA due archivé rendait `400 INACTIVE_OR_INVALID_ACCOUNTS` (ou `ROUNDING_ACCOUNT_NOT_CONFIGURED` pour l'arrondi) ; un réglage débiteurs vide rendait `CONFIGURATION_REQUIRED`. + ### Annuler le règlement d'une facture fournisseur **`POST /api/v1/supplier-invoices/{id}/settlement/cancel`** — écriture (`read-write`), ouverte aux clés comme `POST /supplier-invoices/{id}/pay`. Sans corps. Contre-passe l'écriture de règlement (datée du jour) et ramène la facture à `open` : `settlementType`, `settlementJournalEntryId`, `paidAt`… reviennent à `null`. Le lot de paiement confirmé qui l'a éventuellement réglée **n'est pas modifié**. Réponse : `{ invoice, reversalJournalEntryId }`. Distinct de `POST /supplier-invoices/{id}/cancel`, qui annule la **facture** elle-même (ci-dessous). diff --git a/docs/manual/fr/admin-manual.pdf b/docs/manual/fr/admin-manual.pdf index 7b379e95..bfe2b9dc 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 af53fc37..c362e09d 100644 --- a/docs/manual/fr/admin-manual.tex +++ b/docs/manual/fr/admin-manual.tex @@ -2150,7 +2150,7 @@ \subsection{Loi sur la TVA (LTVA)} \paragraph{Compte créanciers.} La même page, section \emph{Comptes par défaut}, désigne aussi le \textbf{compte créanciers} (dette fournisseurs, passif~; \texttt{2000} dans les plans livrés, désigné d'office à la création d'une société), que crédite la saisie d'une facture fournisseur. Il obéit au même principe~: un compte \textbf{actif et imputable}, refusé non imputable à la désignation~; devenu non imputable depuis, il reste affiché dans les paramètres et fait refuser la saisie et la complétion des factures fournisseurs jusqu'à ce qu'un administrateur le remplace. Il en va de même pour le compte de \emph{créance client}~: devenu non imputable, il fait refuser la validation des factures. \textbf{Après une mise à jour vers la v0.13.0}, vérifiez que ces comptes sont imputables. Les \emph{avoirs} ne sont pas soumis à ce contrôle. -\paragraph{Compte de différences d'arrondi.} Comme le veut l'usage suisse, le \textbf{total d'une facture émise est arrondi à 5~centimes} (123.44 devient 123.45, 234.52 devient 234.50), avec une ligne « Arrondi » sur la facture ; ce réglage est \textbf{actif par défaut} et se désactive dans la même section, case \emph{« Arrondir le total des factures émises à 5~centimes »} --- les factures déjà émises gardent leur total. L'écart, sans TVA, est écrit \textbf{à la validation} et reste attaché à la facture ; un avoir le reprend et l'annule. Par ailleurs, une facture émise sans arrondi dont le total porte plus de deux décimales --- quantité ou prix unitaire fractionnaire --- laisse, une fois payée au centime, un écart d'un demi-centime au plus ; et le \emph{solde du reste} d'une telle facture y passe sa fraction de centime, quelle que soit la nature choisie --- d'où l'exigence de ce compte pour solder un reste à fraction de centime. Ces écarts vont 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. \textbf{Sans ce compte, une facture dont le total n'est pas déjà un multiple de 5~centimes ne peut pas être validée} : la validation est refusée, avec un message qui renvoie aux paramètres. Pour une société créée avant cette version, désignez-le donc avant d'émettre. +\paragraph{Compte de différences d'arrondi.} Comme le veut l'usage suisse, le \textbf{total d'une facture émise est arrondi à 5~centimes} (123.44 devient 123.45, 234.52 devient 234.50), avec une ligne « Arrondi » sur la facture ; ce réglage est \textbf{actif par défaut} et se désactive dans la même section, case \emph{« Arrondir le total des factures émises à 5~centimes »} --- les factures déjà émises gardent leur total. L'écart, sans TVA, est écrit \textbf{à la validation} et reste attaché à la facture ; un avoir le reprend et l'annule. Changer de compte d'arrondi ne déplace pas les factures déjà émises~: leur avoir contre-passe l'arrondi sur le compte qu'elles ont mouvementé. Gardez donc l'ancien compte actif tant qu'un avoir peut les viser (sinon~: le réactiver, émettre l'avoir, le ré-archiver). Par ailleurs, une facture émise sans arrondi dont le total porte plus de deux décimales --- quantité ou prix unitaire fractionnaire --- laisse, une fois payée au centime, un écart d'un demi-centime au plus ; et le \emph{solde du reste} d'une telle facture y passe sa fraction de centime, quelle que soit la nature choisie --- d'où l'exigence de ce compte pour solder un reste à fraction de centime. Ces écarts vont 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. \textbf{Sans ce compte, une facture dont le total n'est pas déjà un multiple de 5~centimes ne peut pas être validée} : la validation est refusée, avec un message qui renvoie aux paramètres. Pour une société créée avant cette version, désignez-le donc avant d'émettre. \paragraph{Comptes du solde du reste.} La même page, section \emph{Solde du reste}, désigne les comptes qui recevront ce qui reste dû quand une facture est soldée sans être entièrement payée, selon la \textbf{nature} de l'écart : l'\textbf{escompte accordé} au client, les \textbf{frais bancaires} retenus par sa banque, la \textbf{perte sur un débiteur}. Chacun doit être un compte de \textbf{charge ou de produit}, actif et \textbf{imputable}. Les plans livrés les proposent, \textbf{désignés d'office} à la création d'une société : \texttt{3800} pour l'escompte (\emph{Déductions sur ventes} pour une PME, \emph{Déductions sur prestations} pour un indépendant), \texttt{6900} \emph{Charges financières} pour les frais bancaires, et \texttt{3805} \emph{Pertes sur créances}, ajouté aux trois plans, pour les pertes. Le plan des \textbf{associations} ne propose \textbf{aucun compte d'escompte} --- son \texttt{3800} est \emph{Autres produits} --- : une association qui accorde des escomptes choisit elle-même le compte. Pour une société créée avant cette version, les trois réglages sont vides : créez au besoin les comptes dans le plan comptable, puis choisissez-les ici. diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index c73cf984..b7b0fbac 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 73033416..afe84c8c 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -361,7 +361,7 @@ \subsection{Suppression d'un compte} \label{sec:plan-comptable-archivage} \begin{keshwarning} -\textbf{Avant d'archiver un compte de produit, pensez aux avoirs.} Un compte de produit imputé par des factures \textbf{déjà validées} reste nécessaire tant qu'un avoir peut être émis sur ces factures — ce qui n'a pas de limite de temps. Une fois le compte archivé, l'émission d'un avoir sur ces factures est \textbf{refusée} (le motif est expliqué au \S\ref{sec:avoirs}). +\textbf{Avant d'archiver un compte de produit, pensez aux avoirs.} Un compte de produit imputé par des factures \textbf{déjà validées} reste nécessaire tant qu'un avoir peut être émis sur ces factures — ce qui n'a pas de limite de temps. Il en va de même du \textbf{compte débiteurs} et du \textbf{compte de différences d'arrondi} que ces factures ont mouvementés, ainsi que du \textbf{compte de TVA due} désigné dans les réglages --- celui du moment, non celui que la facture a crédité --- si la facture porte de la TVA. Une fois le compte archivé, l'émission d'un avoir sur ces factures est \textbf{refusée} (le motif est expliqué au \S\ref{sec:avoirs}). Ce n'est pas bloquant : la marche à suivre est de \textbf{réactiver} le compte, d'émettre l'avoir, puis de le \textbf{ré-archiver}. Les deux mouvements étant journalisés, la manœuvre reste traçable. \end{keshwarning} @@ -377,7 +377,7 @@ \subsection{Rôles des comptes} \end{keshwarning} \begin{keshnote} -Dans cette version, l'indicateur \emph{postable} \textbf{bloque la saisie manuelle} : Kesh refuse une écriture saisie à la main dont une ligne vise un compte non imputable — un compte de regroupement, le compte de résultat de l'exercice ou un compte de clôture. Les autres chemins d'écriture le vérifient aussi~: la validation d'une facture contrôle le compte de produit de chaque ligne, l'enregistrement d'un règlement son compte de contrepartie, la saisie d'une facture fournisseur son compte de charge~; le rapprochement bancaire — manuel, éclaté, ou par acceptation d'une proposition — refuse un compte de contrepartie non imputable, et une règle d'affectation ne peut ni être créée sur un tel compte, ni y être déplacée, ni être réactivée s'il l'est devenu. Les réglages de facturation refusent un compte non imputable \textbf{au moment où vous le désignez}, et la fiche d'un compte bancaire refuse d'y lier un tel compte. Un compte \textbf{désigné dans les réglages de facturation} et devenu non imputable \emph{après} sa désignation --- la créance, la TVA due, le compte créanciers, la TVA récupérable --- est contrôlé \textbf{au moment où une écriture automatique veut l'utiliser}~: la validation d'une facture et la saisie (ou la complétion) d'une facture fournisseur sont alors refusées, avec un message qui nomme le compte et renvoie à \emph{Paramètres} → \emph{Facturation}, où un administrateur doit désigner à sa place un compte imputable --- l'un de ses sous-comptes, si vous l'avez scindé. Deux exceptions, voulues~: l'\textbf{avoir} relit la créance et la TVA due dans les réglages au moment de l'avoir, sans les contrôler, pour qu'une facture émise reste toujours annulable~; et le \textbf{compte de produit par défaut} n'est pas contrôlé à l'usage. +Dans cette version, l'indicateur \emph{postable} \textbf{bloque la saisie manuelle} : Kesh refuse une écriture saisie à la main dont une ligne vise un compte non imputable — un compte de regroupement, le compte de résultat de l'exercice ou un compte de clôture. Les autres chemins d'écriture le vérifient aussi~: la validation d'une facture contrôle le compte de produit de chaque ligne, l'enregistrement d'un règlement son compte de contrepartie, la saisie d'une facture fournisseur son compte de charge~; le rapprochement bancaire — manuel, éclaté, ou par acceptation d'une proposition — refuse un compte de contrepartie non imputable, et une règle d'affectation ne peut ni être créée sur un tel compte, ni y être déplacée, ni être réactivée s'il l'est devenu. Les réglages de facturation refusent un compte non imputable \textbf{au moment où vous le désignez}, et la fiche d'un compte bancaire refuse d'y lier un tel compte. Un compte \textbf{désigné dans les réglages de facturation} et devenu non imputable \emph{après} sa désignation --- la créance, la TVA due, le compte créanciers, la TVA récupérable --- est contrôlé \textbf{au moment où une écriture automatique veut l'utiliser}~: la validation d'une facture et la saisie (ou la complétion) d'une facture fournisseur sont alors refusées, avec un message qui nomme le compte et renvoie à \emph{Paramètres} → \emph{Facturation}, où un administrateur doit désigner à sa place un compte imputable --- l'un de ses sous-comptes, si vous l'avez scindé. Deux exceptions, voulues~: l'\textbf{avoir} n'est pas soumis à ce contrôle, pour qu'une facture émise reste toujours annulable --- il crédite la créance que la facture a débitée, lue sur l'écriture de la facture et non dans les réglages, et relit la TVA due dans les réglages au moment de l'avoir, sans la contrôler~; et le \textbf{compte de produit par défaut} n'est pas contrôlé à l'usage. Trois cas échappent encore à ce contrôle, parce qu'un compte peut devenir non imputable \emph{après} avoir été choisi — typiquement quand vous lui créez des sous-comptes~: (1)~le compte comptable d'un compte bancaire \textbf{déjà lié} reste accepté, et continue de servir au rapprochement et aux règlements~; (2)~une règle d'affectation active dont le compte est devenu non imputable reste affichée «~Active~», mais Kesh ne la propose plus~; (3)~le compte de produit d'une \textbf{fiche article} n'est pas contrôlé sur la fiche — il l'est sur la ligne de facture, sauf s'il s'agit du compte de produit par défaut. @@ -1231,7 +1231,7 @@ \subsection{Avoirs et notes de crédit} \begin{itemize} \item crée un avoir reprenant toutes les lignes de la facture, avec son propre numéro issu d'une séquence dédiée (par défaut \keshcommand{AV-2026-0001}, configurable dans \emph{Paramètres → Facturation}) ; - \item génère automatiquement l'\textbf{écriture de contre-passation} : l'inverse exact de l'écriture de la facture (la créance client, le produit et la TVA due — une ligne par taux — sont contre-passés), de sorte que le \textbf{solde du client revient à zéro} ; + \item génère automatiquement l'\textbf{écriture de contre-passation} : l'inverse exact de l'écriture de la facture (la créance client, le produit et la TVA due — une ligne par taux — sont contre-passés), de sorte que le \textbf{solde du client revient à zéro}. L'avoir crédite le \textbf{compte débiteurs que la facture a débité}, et contre-passe l'éventuel arrondi à 5~centimes sur le \textbf{compte que la facture a mouvementé} --- même si les comptes par défaut ont été changés dans \emph{Paramètres} → \emph{Facturation} depuis la validation : c'est ce qui ramène ces comptes à zéro. \textbf{Limite connue de cette version}~: la TVA, elle, est contre-passée sur le compte de TVA due \textbf{actuellement désigné} dans les réglages~; si ce réglage a changé entre la facture et son avoir, la TVA de la facture reste sur l'ancien compte et l'avoir débite le nouveau ; \item bascule la facture d'origine au statut \textbf{« annulée »} (sa propre écriture reste intacte). \end{itemize} @@ -1267,6 +1267,16 @@ \subsection{Avoirs et notes de crédit} \noindent Cette manœuvre n'a rien d'officieux : la réactivation \emph{et} le ré-archivage sont tous deux inscrits au \textbf{journal d'audit}. Un réviseur voit donc exactement pourquoi le compte a été rouvert quelques minutes, et dans quel ordre — ce qui préserve la valeur probatoire de votre comptabilité (CO art. 957a). \end{keshwarning} +\begin{keshwarning} +\textbf{Avoir refusé : compte débiteurs, d'arrondi ou de TVA archivé.} L'avoir écrit aussi sur jusqu'à trois comptes qui ne sont pas des comptes de produit : le \textbf{compte débiteurs que la facture a débité}, le \textbf{compte qui a reçu son arrondi} à 5~centimes, s'il y en a un, et, si la facture porte de la TVA, le \textbf{compte de TVA due désigné dans les réglages}. Si l'un d'eux a été archivé, Kesh refuse d'émettre l'avoir avec le message «~Impossible d'émettre l'avoir --- compte(s) archivé(s)~: \ldots{} Réactivez le ou les comptes concernés.~», qui \textbf{nomme le compte}. + +La marche à suivre est la \textbf{même procédure en trois étapes} que ci-dessus~: réactiver le compte, émettre l'avoir, ré-archiver. Si vous aviez entre-temps donné le rôle \emph{Créances clients} à un nouveau compte débiteurs, retirez-le d'abord de ce nouveau compte~: la réactivation est refusée tant qu'un autre compte porte le rôle (\S\ref{sec:plan-comptable-archivage}). + +Pour le compte de \textbf{TVA due}, il existe une autre issue, et c'est souvent la bonne~: ce compte est celui \emph{des réglages}, non celui que la facture a crédité (limite connue, ci-dessus). Un administrateur peut donc désigner dans \emph{Paramètres} → \emph{Facturation} un compte de TVA due actif --- de préférence celui que la facture a crédité ---, plutôt que de réactiver un compte que la facture n'a peut-être jamais mouvementé. + +Ce refus est distinct du précédent~: celui du compte de produit nomme une \textbf{ligne} de la facture, celui-ci un \textbf{compte}. Les deux peuvent se suivre~: le compte débiteurs, d'arrondi ou de TVA est contrôlé d'abord. +\end{keshwarning} + \begin{keshnote} En version actuelle, l'avoir est \textbf{total} (il annule la facture entière). L'avoir \emph{partiel} et le remboursement d'une facture \emph{déjà encaissée} (un avoir est refusé sur une facture \textbf{réglée, même en partie}) sont prévus pour une version ultérieure. Les paiements sortants par fichier \texttt{pain.001.001.09} sont, eux, \textbf{disponibles} (cf. \S\ref{sec:paiements-fournisseurs}). \end{keshnote}