diff --git a/CHANGELOG.md b/CHANGELOG.md index 997a3817f..94028e2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ### Added +- **Désigner le compte de différences d'arrondi dans les paramètres ([#476](https://github.com/guycorbaz/kesh/issues/476)).** *Paramètres → Facturation* porte une nouvelle section, *Différences d'arrondi* : le compte — charge ou produit, actif et imputable — qui recevra l'écart d'un demi-centime au plus qu'un paiement arrondi au centime laisse sur une facture dont le total a plus de deux décimales. On le crée dans le plan comptable s'il n'existe pas, puis on le choisit ici. Le réglage n'est encore lu par aucune écriture : le passage des écarts en écriture suit. *(API : `defaultRoundingAccountId` dans `GET` et `PUT /company/invoice-settings` ; absent du corps d'un `PUT`, il est préservé ; à `null`, effacé.)* + - **Consulter le journal d'audit à l'écran.** La trace de chaque opération qui modifie les livres se lisait depuis la v0.12.0 par l'interface de programmation seulement, ce qui supposait un outil technique. Elle se consulte désormais dans l'application — *Administration → Journal d'audit*, pour les comptables et les administrateurs : filtres par période, type d'entité, numéro et action, actions et types traduits dans la langue de l'installation, détail de chaque entrée, export CSV. ⚠️ Les dates filtrent sur des **jours UTC** ; le tableau affiche l'heure locale. ([#378](https://github.com/guycorbaz/kesh/issues/378)) - **Annuler un règlement client.** Un règlement enregistré par erreur était jusqu'ici **définitivement incorrigible** : la contre-passation directe de son écriture était refusée, et la seule sortie — une écriture manuelle — laissait la facture se dire réglée quand le grand livre disait le contraire. La fiche facture liste désormais ses règlements, et chacun s'**annule par contre-passation** : une écriture inverse **datée du jour**, le règlement retiré de la facture, le montant redevenu dû. Les deux écritures restent visibles au grand livre. diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md new file mode 100644 index 000000000..6e5c85cbb --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md @@ -0,0 +1,290 @@ +# Story 25.4-c3-a1 : Le réglage du compte de différences d'arrondi + +Status: done + +**Issue : [#476]** — ⛔ la PR porte `refs #476` (la sœur **25-4-c3-b** la fermera). + +**Née du découpage de la 25-4-c3** (règle de splitting : sept modules). Arbitrages de Guy le +2026-09-30 : l'écart d'arrondi au centime se **passe en écriture** sur un **compte de différences +d'arrondi** ; ce compte est désigné par **un réglage dans les paramètres** (pas un rôle de compte) ; il +doit pouvoir être **créé dans le plan comptable et choisi dans les paramètres** ; les plans livrés en +proposeront un (« 6940 Différences d'arrondi », charge), désigné d'office à l'onboarding — c'est la +sœur **25-4-c3-a2**. La sœur **25-4-c3-b** fera l'arrondi et passera l'écart en écriture, en lisant ce +réglage. + +**Empilée sur la 25-4-c2** (PR #485, non mergée) : branche `story/25-4-c3-arrondi-centime`. + +## Story + +En tant qu'administrateur de la comptabilité, +je veux désigner, dans les paramètres de facturation, le compte qui reçoit les différences d'arrondi, +afin que Kesh sache où passer l'écart d'un demi-centime quand un paiement arrondi solde une facture. + +## Ce qui existe, vérifié dans le code + +- **Le précédent : les comptes de TVA** (Story 18-1a). Trois colonnes facultatives de + `company_invoice_settings` (`default_vat_payable_account_id`, `default_vat_recoverable_account_id`, + `default_vat_decompte_account_id`), ajoutées par `20260614000001_vat_accounts_config.sql:53-62` (FK + `ON DELETE RESTRICT`), que l'onboarding ne remplit pas et qui se règlent dans *Paramètres → + Facturation*. C'est le patron à suivre, à la différence près des types et de la postabilité (plus bas). +- **L'entité** : `crates/kesh-db/src/entities/company_invoice_settings.rs:20-40` (+ la structure de mise + à jour qui suit, remplacement intégral). +- **Le dépôt** : `crates/kesh-db/src/repositories/company_invoice_settings.rs` — liste des colonnes + (`:28`), JSON d'audit (`:41`), comparaison « rien n'a changé » (`:120`), `update` (`:131`, `:174-186`), + `insert_with_defaults` (`:277`, projection `:358`) et son miroir `insert_with_defaults_in_tx` (`:403`, + projection `:476`) — ⚠️ commentaire `MIRROR` : les deux se tiennent à la main. +- **La route** : `crates/kesh-api/src/routes/company_invoice_settings.rs` — `validate_account` + (`:94-124`) contrôle société, existence, `active` et **un** type attendu ; les appels (`:~175-222`). + ⚠️ Elle **ne contrôle pas `postable`**. +- **L'écran** : `frontend/src/routes/(app)/settings/invoicing/+page.svelte` — listes filtrées par type + (`:51-57`, `a.active && a.postable && a.accountType === …`), `withCurrentAccount` (`:70`) pour garder + visible un compte déjà choisi, chargement (`:91`), envoi (`:124`), relecture après conflit (`:146`), + sélecteur (`:325-328`). Type : `frontend/src/lib/features/invoices/invoices.types.ts`. +- **L'export de souveraineté** : `crates/kesh-api/src/exports/csv_tables.rs:905-946` + (`serialize_company_invoice_settings_csv`) — la garde d'exhaustivité de la 25-5-a le fera rougir si la + colonne n'y est pas. +- **La sauvegarde** : `crates/kesh-db/src/backup.rs:66` liste la table ; un `.keshbackup` **antérieur** + n'aura pas la colonne. +- **Le manuel** : `docs/manual/fr/admin-manual.tex:1994` (*Configuration des comptes TVA*, depuis + *Paramètres → Facturation*). + +## Acceptance Criteria + +**AC 1 — La colonne.** Migration **non breaking** : `company_invoice_settings.default_rounding_account_id +BIGINT NULL`, FK vers `accounts(id)` `ON DELETE RESTRICT` (patron `20260614000001`). DDL seul, aucune +donnée écrite (⇒ pas de triage P7). Garde-fous de la § *Migration breaking policy* : ligne à +`docs/migrations-idempotence-audit.md` et **ses compteurs recomptés** (P5) ; `grep -rn +"migrations.len()\|apply_migrations_up_to" crates/` inspecté (P6) ; squash `test-schema` régénéré et +`test_schema_guard` vert ; tout manifeste de sommes de contrôle des migrations mis à jour +(`migrations.sha384` si présent, précédent 24-5). ⛔ P8 : une fois appliquée, la migration ne se modifie +plus. + +**AC 2 — L'entité, le dépôt, l'API.** Le champ `default_rounding_account_id: Option` (JSON +`defaultRoundingAccountId`) traverse l'entité, la mise à jour, **toutes** les projections du dépôt +(`update`, les deux `insert_with_defaults*`, `get_or_create_default*`), le JSON d'audit et la comparaison +« rien n'a changé ». `GET`/`PUT /company/invoice-settings` le portent. `insert_with_defaults*` le laissent +`NULL` (la 25-4-c3-a2 le posera à l'onboarding). + +**AC 3 — La validation.** Le compte choisi doit appartenir à la société, être **actif**, être +**imputable** (`postable`) — les écritures automatiques passent `enforce_postable = false`, c'est donc +ici que la garde doit tenir (`invoice_settlements_write.rs:136-161`) —, et être de type **charge +(`Expense`) ou produit (`Revenue`)** : un écart d'arrondi est un résultat, dans un sens ou dans +l'autre. `validate_account` est étendue en conséquence (liste de types acceptés, contrôle de +`postable`) **sans changer** le comportement des champs existants — ⚠️ ne pas imposer `postable` aux +comptes TVA sans le dire : c'est hors périmètre, à signaler en issue si c'est un trou. + +**AC 4 — L'écran.** *Paramètres → Facturation* : un sélecteur « Compte de différences d'arrondi », +facultatif, listant les comptes actifs, imputables, de charge ou de produit ; un compte déjà choisi +reste visible même s'il sort du filtre (`withCurrentAccount`). Un texte d'aide dit à quoi il sert et +qu'on peut le **créer dans le plan comptable** puis le choisir ici. Libellés dans les 4 locales ; +`lint-i18n-ownership` et la garde `i18n-keys.test.ts` (compteur de sites recompté). + +**AC 5 — Les gardes voisines.** Un compte désigné ici ne peut pas disparaître en silence : +- **suppression** : **sans objet** — aucune route ne supprime un compte (`crates/kesh-api/src/lib.rs:340-352` : + création, modification, archivage, réactivation seulement) ; la FK `ON DELETE RESTRICT` reste le + filet en base ; +- **archivage** : **aucune garde n'existe aujourd'hui pour les comptes TVA désignés** (validation P1). + Ne pas l'inventer ici : ouvrir une issue qui couvre les **quatre** comptes désignés dans les + réglages (trois TVA et l'arrondi), et le dire dans le Dev Agent Record. La c3-b refusera de toute + façon d'écrire sur un compte archivé ou non imputable (garde au moment de l'écriture). + +**AC 6 — Export et sauvegarde.** L'export CSV de la table porte la colonne (garde d'exhaustivité verte). +La sauvegarde, elle, lit ses colonnes dans `INFORMATION_SCHEMA` (`export_table`, `column_constraints`) : +elle suit d'elle-même. Un `.keshbackup` produit **avant** cette migration s'importe toujours +(`check_schema_compat` : colonne absente, facultative) et la colonne vaut `NULL` — le prouver par un +test, patron `full_import_without_company_column_merges_archive_entries_as_null` +(`crates/kesh-api/tests/admin_full_import_e2e.rs:2167`, technique `strip_column`). Un `.keshbackup` +postérieur importé par un binaire antérieur est refusé en `400 IMPORT_SCHEMA_MISMATCH` (mécanisme +générique, déjà testé) : rien à faire. + +**AC 7 — Tests.** Dépôt : le champ survit à `update` et à la relecture ; validation API : refus d'un +compte d'une autre société, archivé, **non imputable**, de type actif/passif ; acceptation d'une charge +et d'un produit ; Vitest de l'écran (options filtrées, compte courant conservé) ; E2E Playwright : +choisir un compte, enregistrer, relire. + +**AC 8 — Textes.** `admin-manual.tex` : à côté des comptes TVA, un paragraphe *Compte de différences +d'arrondi* — à quoi il sert (renvoi à l'écart d'un demi-centime qu'un paiement arrondi laisse ; la +25-4-c3-b le passera en écriture), qu'il est facultatif **tant qu'aucun écart ne se présente**, qu'on +peut le créer dans le plan comptable (charge ou produit, imputable) puis le choisir. PDF régénéré et +contrôlé aplati. `docs/api-external.md` si les réglages de facturation y sont décrits. CHANGELOG +`[0.12.1]` *Added*. + +## Tasks / Subtasks + +- [x] **T1 — migration et squash** (AC 1). +- [x] **T2 — entité, dépôt, route, validation** (AC 2, 3, 5). +- [x] **T3 — export et sauvegarde** (AC 6). +- [x] **T4 — écran et i18n** (AC 4). +- [x] **T5 — tests** (AC 7). +- [x] **T6 — textes** (AC 8). +- [x] **T7 — gates** : backend complet (base remise à zéro ; ⛔ `kesh-db` touché ⇒ gate complet même en + cours de boucle), frontend complet, **E2E complet**. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- ⛔ **Un rôle de compte** : arbitrage de Guy — c'est un réglage. Un rôle serait de plus une migration + breaking (un binaire antérieur ne décode pas un rôle inconnu). +- ⛔ **Écrire des données dans la migration** (créer le compte 6940 chez les sociétés existantes) : les + sociétés existantes le créent dans le plan et le choisissent (arbitrage) ; les plans livrés, c'est la + c3-a2. +- ⛔ **Un numéro de compte dans le code applicatif** (`14-3a`, migration `20260722000001:3-8`). +- ⛔ **Utiliser le réglage pour écrire** : c'est la c3-b. +- ⚠️ **Les deux `insert_with_defaults*` sont miroirs** : les modifier ensemble. +- **Sans objet** : `docs/optimistic-locking-patterns.md:48` énumère les champs comparés par `update` — + instantané de la Story 7-3, jamais tenu à jour depuis (il omet déjà les trois comptes TVA, + `credit_note_number_format` et `default_payable_account_id`). Ne pas le mettre à jour ici. +- ⚠️ `migrations_upgrade_path.rs` porte `assert_eq!(total, 69, …)` : il rougira — c'est son rôle + (P6) ; le passer à 70 **et** vérifier l'assertion de montage qui l'accompagne. +- ⚠️ Aucun E2E n'est dédié à *Paramètres → Facturation* : le spec de l'AC 7 est à créer. + +### Où regarder + +| Fichier | Pourquoi | +|---|---| +| `crates/kesh-db/migrations/20260614000001_vat_accounts_config.sql` | patron de la colonne | +| `crates/kesh-db/src/entities/company_invoice_settings.rs` | entité et mise à jour | +| `crates/kesh-db/src/repositories/company_invoice_settings.rs` | projections, `update`, audit | +| `crates/kesh-api/src/routes/company_invoice_settings.rs` | `validate_account`, `update_invoice_settings` | +| `crates/kesh-api/src/exports/csv_tables.rs:905-946` | export | +| `crates/kesh-db/src/backup.rs` | sauvegarde, import d'une sauvegarde antérieure | +| `frontend/src/routes/(app)/settings/invoicing/+page.svelte` | écran | +| `crates/kesh-db/tests/company_invoice_settings_repository.rs`, `crates/kesh-api/tests/idor_multi_tenant_e2e.rs` | tests existants qui énumèrent les champs | +| `docs/manual/fr/admin-manual.tex:1994` | manuel | + +### Gardes-fous du dépôt + +- Modules : `kesh-db`, `kesh-api`, `frontend`, `kesh-i18n` (+ `docs`) — sous le seuil. +- Migration : P5, P6, P8 ; P7 sans objet (DDL seul) ; squash et `test_schema_guard`. + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5 (`claude-opus-5-5`). + +### Debug Log References + +- Mutations (restaurées, fichier touché ensuite) : garde `postable` neutralisée → `settings_rounding_account_is_validated` + rouge (« charge non imputable » acceptée) ; préservation de l'absent remplacée par `None` → + `settings_rounding_account_absent_preserves_null_clears` rouge. Le test d'import antérieur est + discriminant par construction : sans le retrait de la colonne, l'import restaurerait le compte. +- Le jeu E2E `with-company` ne porte que deux comptes de résultat (3000, 4000) : le Playwright choisit + la charge 4000, pas un compte du plan PME. +- Deux gardes frontend ont rougi et ont été suivies : le compteur de sites i18n (1752 → 1756, recompté + 32 → 36 dans la page) et `e2e-selecteurs-traduits` (le spec visait « Enregistrer » par son libellé) → + `data-testid="settings-invoicing-save"`. +- Gate E2E : 229 passed / 19 skipped / 7 failed — les 7 KF-029 (#97), rien d'autre (run à 15:55 UTC). + +### Completion Notes List + +- **Migration** `20260930000001_invoice_settings_rounding_account.sql` : `ADD COLUMN + default_rounding_account_id BIGINT NULL` + FK `fk_cis_rounding` `ON DELETE RESTRICT`, DDL seul, non + breaking. P5 : ligne d'audit `tracked-by-sqlx`, compteurs **recomptés** (70 fichiers = 70 lignes ; + 8 + 62 + 0). P6 : `migrations_upgrade_path` 69 → 70 **et** soustracteur 35 → 36 (frontière 34 + inchangée), valeur grepée dans tout le fichier (sept sites mis à jour, les historiques laissés). P7 + sans objet. P8 : ligne ajoutée à `migrations.sha384`. Squash régénéré (`scripts/regen-test-schema.sh`, + rejeu vérifié). +- **Dépôt et entité** : le champ traverse `COLUMNS`, le JSON d'audit, `is_no_op_change`, l'`UPDATE` et + les deux projections miroirs de `insert_with_defaults*` (qui le laissent `NULL`). +- **API** : `GET`/`PUT /company/invoice-settings` portent `defaultRoundingAccountId`. **Absent du corps + d'un `PUT` : préservé** (comme `creditNoteNumberFormat`, #216) ; **à `null` : effacé** — par + `double_option`, déplacé de `reconciliation_rules.rs` vers `crate::helpers` (partagé, DRY). + `validate_account_of` généralise `validate_account` (plusieurs types, postabilité exigée ou non) sans + changer les champs existants ; le compte d'arrondi exige charge **ou** produit, actif, **imputable**, + de la société. +- **Export** : colonne ajoutée au CSV. **Sauvegarde** : dynamique (`INFORMATION_SCHEMA`), rien à changer ; + l'import d'un backup antérieur est prouvé par test. +- **Écran** : section *Différences d'arrondi* dans *Paramètres → Facturation* — sélecteur filtré (actifs, + imputables, charges et produits), compte courant conservé (`withCurrentAccount`), texte d'aide qui + renvoie au plan comptable ; trois clés dans les quatre catalogues. +- **AC 5** : aucune route ne supprime un compte ; l'archivage non gardé des comptes désignés (TVA et + arrondi) → **issue #486**. +- **Tests** (périmètre : `e52cde9f` → cette branche) : +1 dépôt, +2 API (validation sur sept cas, + absent / `null`), +1 import de sauvegarde, +3 Vitest (nouveau fichier), +1 Playwright (nouveau spec). +- **Textes** : `admin-manual.tex` — paragraphe *Compte de différences d'arrondi*, qui dit que le réglage + n'est encore lu par aucune écriture ; PDF régénéré, contrôlé aplati. CHANGELOG `[0.12.1]` *Added*. + `docs/api-external.md` ne décrit pas ces réglages : sans objet. + +### File List + +- `CHANGELOG.md` +- `crates/kesh-api/src/exports/csv_tables.rs` +- `crates/kesh-api/src/helpers.rs` +- `crates/kesh-api/src/routes/company_invoice_settings.rs` +- `crates/kesh-api/src/routes/reconciliation_rules.rs` +- `crates/kesh-api/tests/admin_full_import_e2e.rs` +- `crates/kesh-api/tests/idor_multi_tenant_e2e.rs` +- `crates/kesh-db/migrations.sha384` +- `crates/kesh-db/migrations/20260930000001_invoice_settings_rounding_account.sql` +- `crates/kesh-db/src/entities/company_invoice_settings.rs` +- `crates/kesh-db/src/repositories/company_invoice_settings.rs` +- `crates/kesh-db/test-schema/0001_schema_squash.sql` +- `crates/kesh-db/tests/company_invoice_settings_repository.rs` +- `crates/kesh-db/tests/migrations_upgrade_path.rs` +- `crates/kesh-i18n/locales/de-CH/messages.ftl` +- `crates/kesh-i18n/locales/en-CH/messages.ftl` +- `crates/kesh-i18n/locales/fr-CH/messages.ftl` +- `crates/kesh-i18n/locales/it-CH/messages.ftl` +- `docs/manual/fr/admin-manual.pdf` +- `docs/manual/fr/admin-manual.tex` +- `docs/migrations-idempotence-audit.md` +- `frontend/src/lib/features/invoices/invoices.types.ts` +- `frontend/src/lib/shared/i18n-keys.test.ts` +- `frontend/src/routes/(app)/settings/invoicing/+page.svelte` +- `frontend/src/routes/(app)/settings/invoicing/settings-invoicing-page.test.ts` +- `frontend/tests/e2e/settings-rounding-account.spec.ts` +- `_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md` +- `_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p1.md` +- `_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p2.md` +- `_bmad-output/implementation-artifacts/25-4-c3-a1-review-prompt-p1.md` +- `_bmad-output/implementation-artifacts/25-4-c3-a1-review-prompt-p2.md` +- `_bmad-output/implementation-artifacts/sprint-status.yaml` + +## Change Log + +- **2026-09-30** — Créée au découpage de la 25-4-c3 (sept modules), selon les arbitrages de Guy : réglage + dans les paramètres, compte créable dans le plan et choisi ici ; les plans livrés et l'onboarding à la + c3-a2 ; l'arrondi et l'écriture à la c3-b. +- **2026-09-30** — Validation P1 (Sonnet, prompt `25-4-c3-a1-validate-prompt-p1.md`) : **2 MEDIUM**, + vérifiés. AC 5 visait une suppression de compte que l'API n'offre pas → sans objet ; l'archivage non + gardé des comptes TVA → issue couvrant les quatre comptes des réglages. `optimistic-locking-patterns.md:48` + → écrit sans objet (instantané figé). Faits utiles reportés : sauvegarde dynamique, précédent de test + `strip_column`, `assert_eq!(total, 69)`, pas d'E2E de l'écran. Toutes les références exactes ; P5 + sain (69 = 69, 8 + 61 + 0). +- **2026-09-30** — Validation P2 **ciblée** (Haiku, prompt `25-4-c3-a1-validate-prompt-p2.md`, sur + `06c075be`) : **0 finding**, preuves jointes sur les quatre axes (routes d'archivage relues, + `INFORMATION_SCHEMA` et `is_required()` lus, test `strip_column` et `assert_eq!(total, 69)` trouvés, + P5 recompté). La remédiation ne touchait que la fiche : **validation close, 0 > LOW.** + + **Bilan** — P1 Sonnet 2 MEDIUM → P2 Haiku ciblée 0. Modèles : Sonnet, Haiku. +- **2026-09-30** — Implémentée (`bmad-dev-story`). Gates **réellement exécutés** : backend complet sur base + remise à zéro **2520/2520** (4 ignorés), fmt, clippy ; frontend `check`, `lint-i18n-ownership`, + **839/839**, build ; **E2E 229 / 19 / 7** (les 7 KF-029). Issue #486 ouverte. Statut → `review`. +- **2026-09-30** — Revue de code P1 (3 lentilles Sonnet, prompt `25-4-c3-a1-review-prompt-p1.md`, sur + `55d98555`) : Blind Hunter 2 MEDIUM / 9 LOW, Edge Case Hunter 1 HIGH / 1 MEDIUM, Acceptance Auditor **0** + (décomptes recomptés depuis la source). Vérifiés. **Corrigés** : HIGH (Edge) — le compte d'arrondi était + revalidé à chaque `PUT` même reconduit tel quel : devenu archivé ailleurs (#486), il aurait bloqué tout + enregistrement des paramètres, format de numérotation compris → **validé seulement s'il change** ; test + `settings_unchanged_archived_rounding_account_does_not_block_other_changes`, éprouvé par mutation ; la + même face du défaut pour les comptes TVA, hors périmètre, **commentée sur #486**. MEDIUM (Blind + Edge) — + le Playwright laissait le réglage posé s'il échouait en route → `try/finally`, patron de + `company-contact-details.spec.ts`. LOW retenus : cas « produit non imputable / archivé » ajoutés ; + `double_option` en `pub(crate)`. **Laissés LOW** : message « A ou B » pour un troisième type futur ; type + SQL `BIGINT` (identique au patron TVA, squash vérifié par Edge) ; `toMatchObject` du Vitest ; pas de test + du 400 ni du 409 côté écran. Gates ciblés : `idor_multi_tenant_e2e` 3/3 sur l'arrondi, Playwright vert, + garde des sélecteurs E2E verte, fmt, clippy ; **`kesh-db` non touché** par la remédiation. +- **2026-09-30** — Revue de code P2 **ciblée** (Haiku, prompt `25-4-c3-a1-review-prompt-p2.md`, sur + `aa76eb2c`) : **1 LOW** (le `finally` du Playwright pourrait masquer l'erreur d'origine s'il échouait + lui-même), 0 > LOW, quatre axes prouvés. Recoupée par l'orchestrateur sur la course entre deux `PUT` : + une valeur reconduite est celle en place, donc validée quand elle fut posée ; un `PUT` concurrent qui la + change bouge `version`, et le nôtre sort en 409. **Revue close.** Gate complet au dernier commit : backend + **2521/2521** (base remise à zéro), frontend **839/839**, **E2E 228 / 19 / 8** — les 7 KF-029, et + `products.spec.ts:109`, **vert rejoué seul deux fois** (pollution d'état, famille déjà relevée à la liste + des échecs attendus : `products.spec.ts:166`). + + **Bilan de la revue** — P1 Sonnet ×3 : 1 HIGH / 2 MEDIUM corrigés, LOW triés → P2 Haiku ciblée : 0 > LOW. + Statut → `done`. + +[#476]: https://github.com/guycorbaz/kesh/issues/476 diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-review-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-review-prompt-p1.md new file mode 100644 index 000000000..d69213964 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-review-prompt-p1.md @@ -0,0 +1,52 @@ +# Prompts — revue de code P1, Story 25-4-c3-a1 (le réglage du compte d'arrondi) + +*Versionné le 2026-09-30. Trois lentilles en parallèle, **Sonnet**, contexte frais. Diff : le commit +d'implémentation, `git show 55d98555 (hors `_bmad-output`, PDF et squash)`, écrit dans +`/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/c3a1-p1.diff`.* + +## Commun aux trois lentilles + +**Rendu** : findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact (`fichier:ligne`), +**preuve** (code lu cité, commande et sortie), correction proposée. ⛔ **La liste des axes réellement +exercés ET de ceux qui ne l'ont pas été** — un rapport sans elle ne compte pas. + +**Interdits** : n'écrire aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une +base — `scripts/prepare-release.sh`, `scripts/regen-test-schema.sh`, `scripts/install-hooks.sh`, +`scripts/test-fast.sh`, `scripts/mem-guard.sh`, `make`, `latexmk`, tout `git commit`/`push`/`add`/ +`stash`/`reset`/`rebase`/`checkout`/`switch`/`worktree`, `sqlx migrate`, `cargo test`/`nextest`, +`npm run`, `npx playwright`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers le scratchpad, `cargo check`. + +## Lentille 1 — Blind Hunter (`bmad-review-adversarial-general`) + +Reçoit **le diff seul**, aucun contexte projet. Revue adversariale générale. +## Lentille 1 — Blind Hunter (`bmad-review-adversarial-general`) + +Reçoit **le diff seul**, aucun contexte projet. Revue adversariale générale. + +## Lentille 2 — Edge Case Hunter (`bmad-review-edge-case-hunter`) + +Diff **et** lecture du dépôt. +- **Tous les sites qui énumèrent les colonnes** de `company_invoice_settings` : `grep -rn "company_invoice_settings\|default_payable_account_id\|defaultPayableAccountId" crates/ frontend/src` — + une projection oubliée casse au runtime (`ColumnNotFound`), une fixture frontend oubliée casse le type. +- `PUT /company/invoice-settings` : sémantique « absent préservé / `null` effacé » (`double_option`) — + interaction avec le verrou optimiste, le court-circuit no-op, l'audit ; le déplacement de + `double_option` vers `crate::helpers` change-t-il quoi que ce soit pour `reconciliation_rules` ? +- `validate_account_of` : types acceptés, `postable`, société, archivé ; les champs historiques gardent-ils + exactement leur comportement ? +- Migration : P1 (non breaking), P5 (compteurs de `docs/migrations-idempotence-audit.md` — recompte-les), + P6 (`migrations_upgrade_path.rs` : `total` et soustracteur, frontière), P8 (`migrations.sha384`), squash. +- Sauvegarde : l'import d'un backup antérieur (test `strip_column`) — est-il réellement discriminant ? + Un backup **postérieur** vers un binaire antérieur ? +- Frontend : sélecteur, `withCurrentAccount`, envoi ; le test Vitest et le Playwright prouvent-ils ce qu'ils + annoncent ? Le Playwright laisse-t-il la base propre s'il échoue en route ? + +## Lentille 3 — Acceptance Auditor + +Diff, fiche `_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md`, lecture du dépôt. +Chaque AC (1 à 8) contre le code ; « Ce qu'il ne faut pas faire » (pas de rôle, pas de donnée écrite par la +migration, pas de numéro dans le code, pas d'écriture lisant le réglage) ; le Dev Agent Record (affirme-t-il +seulement ce qui a tourné ? décomptes recomptés ?). ⛔ **Le manuel** : `docs/manual/fr/admin-manual.tex` +(paragraphe *Compte de différences d'arrondi*) et son **PDF aplati** +(`pdftotext docs/manual/fr/admin-manual.pdf - | tr '\n' ' ' | tr -s ' '`) — dit-il vrai sur le code, et +sur ce qui n'est pas encore livré ? Le CHANGELOG dit-il vrai (sémantique de l'API) ? diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-review-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-review-prompt-p2.md new file mode 100644 index 000000000..6ac6b67ff --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-review-prompt-p2.md @@ -0,0 +1,34 @@ +# Prompt — revue de code P2 ciblée, Story 25-4-c3-a1 (le réglage du compte d'arrondi) + +*Versionné le 2026-09-30. **Passe ciblée** : une lentille (Haiku 4.5), contexte frais, braquée sur la +remédiation P1 — `git show aa76eb2c`, écrit dans +`/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/c3a1-p2.diff`. +Dépôt `/home/gcorbaz/devel/kesh`, lecture seule. Fiche : `_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md`.* + +## Axes — tous obligatoires + +1. **La validation conditionnelle** (`crates/kesh-api/src/routes/company_invoice_settings.rs`, fichier + courant) : `default_rounding_account_id` n'est validé que s'il diffère de `current`. Cherche un chemin + où un compte **invalide** est désormais accepté à tort : `current` lu hors transaction avant le + `update` — une course entre deux `PUT` peut-elle faire enregistrer un compte jamais validé ? Le verrou + optimiste (`version`) le rattrape-t-il ? Un compte d'une **autre société** peut-il passer ainsi ? +2. **Le test neuf** `settings_unchanged_archived_rounding_account_does_not_block_other_changes` + (`crates/kesh-api/tests/idor_multi_tenant_e2e.rs`) : prouve-t-il ce qu'il annonce ? +3. **Le Playwright** (`frontend/tests/e2e/settings-rounding-account.spec.ts`) : le `finally` peut-il + lui-même échouer et masquer l'erreur d'origine ? Laisse-t-il la base propre dans tous les cas ? +4. **`double_option` en `pub(crate)`** : les deux sites l'utilisent-ils toujours + (`grep -rn "double_option" crates/kesh-api/src`) ? + +## Ce que tu rends + +- **Findings** : sévérité, endroit exact, **preuve** : la commande exécutée **et sa sortie copiée**, ou + l'extrait de code lu avec son numéro de ligne. Un finding sans preuve ne sera pas retenu ; un « 0 + finding » sans preuve non plus. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base +(`scripts/*`, `make`, `latexmk`, `git commit`/`push`/`add`/`stash`/`reset`/`rebase`/`checkout`/`switch`, +`sqlx migrate`, `cargo test`/`nextest`, `npm run`, `npx playwright`, `gh issue create`/`comment`/`edit`). +Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p1.md new file mode 100644 index 000000000..d728df6a9 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p1.md @@ -0,0 +1,56 @@ +# Prompt — validation P1, Story 25-4-c3-a1 (le réglage du compte d'arrondi) + +*Versionné le 2026-09-30. **Une lentille** (Sonnet), contexte frais.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-4-c3-arrondi-centime`. Fiche à valider : +`_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md`. Issue : `gh issue view 476` +(lire les commentaires : arbitrages de Guy). Registre : `sprint-status.yaml`, lignes `25-4-c3*`. Règles : +`CLAUDE.md` (§ *Migration breaking policy* P1–P8 en particulier). Checklist : +`.claude/skills/bmad-create-story/checklist.md`. + +Les arbitrages de Guy (réglage et non rôle ; compte créable dans le plan et choisi dans les paramètres ; +découpage a1 / a2 / b) sont **retenus** : ne pas les contester, en contester la mise en œuvre. + +## Axes — tous obligatoires + +1. **Chaque référence `fichier:ligne`** existe et dit ce que la fiche affirme. +2. **Inventaire des sites qui énumèrent les colonnes de `company_invoice_settings`** — pars du symptôme : + `grep -rn "default_vat_decompte_account_id\|defaultVatDecompteAccountId\|company_invoice_settings" crates/ frontend/src docs --include=*.rs --include=*.ts --include=*.svelte --include=*.sql --include=*.md --include=*.tex`. + Chaque site qui énumère les champs (projection SQL, `FromRow`, JSON d'audit, export, tests, types + frontend, fixtures E2E, seed, onboarding) doit apparaître dans la fiche ou être explicitement sans + objet. Un site oublié qui casse au **runtime** (`ColumnNotFound`) est au moins HIGH. +3. **La migration** : est-elle réellement non breaking au sens de P1 (un binaire antérieur lit-il une + table avec une colonne en plus — `SELECT *` quelque part ?) ; P5 (compteurs de l'audit) ; P6 (sites + positionnels) ; le squash et `test_schema_guard` ; y a-t-il un manifeste de sommes de contrôle des + migrations (`find crates -name "*.sha384"`) ; `migrations_upgrade_path.rs` a-t-il un `assert_eq!(total, N)` ? +4. **La sauvegarde (AC 6)** : lis `crates/kesh-db/src/backup.rs` et l'import. Un `.keshbackup` antérieur, + sans la colonne, s'importe-t-il (insertion par nom de colonnes ? `parse_and_verify` compare-t-il les + colonnes ?). Et un `.keshbackup` **postérieur** importé par un binaire antérieur ? +5. **La validation (AC 3)** : `validate_account` — l'étendre sans changer le comportement des champs + existants est-il faisable ? Les comptes TVA sont-ils contrôlés `postable` ailleurs ? Un compte de + groupe (`postable = false`) peut-il aujourd'hui être choisi comme compte TVA ? +6. **Les gardes voisines (AC 5)** : que se passe-t-il aujourd'hui quand on archive ou supprime un compte + désigné comme compte TVA dans les réglages (route d'archivage des comptes, message d'erreur de la FK) ? +7. **L'écran (AC 4)** : faisabilité dans `settings/invoicing/+page.svelte`, type frontend, garde i18n + (compteur exact), E2E existant de cet écran (`frontend/tests/e2e/`). +8. **Le manuel** : `docs/manual/fr/admin-manual.tex` autour de `:1994`, et **PDF aplati** + (`pdftotext docs/manual/fr/admin-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers + `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`). + `docs/api-external.md` décrit-il les réglages de facturation ? +9. **Périmètre** : modules recomptés depuis les tâches ; frontière avec la c3-a2 et la c3-b nette ? + +## Ce que tu rends + +- **Findings** : sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit exact, **preuve** (commande exécutée et sa + sortie, ou code lu cité), correction proposée. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** Un rapport sans elle ne + compte pas. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base — +`scripts/prepare-release.sh`, `scripts/regen-test-schema.sh`, `scripts/install-hooks.sh`, +`scripts/test-fast.sh`, `scripts/mem-guard.sh`, `make`, `latexmk`, tout `git commit`/`push`/`add`/ +`stash`/`reset`/`rebase`/`checkout`/`switch`/`worktree`, `sqlx migrate`, `cargo test`/`nextest`, +`npm run`, `npx playwright`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers le scratchpad, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p2.md new file mode 100644 index 000000000..7518df4c0 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-4-c3-a1-validate-prompt-p2.md @@ -0,0 +1,38 @@ +# Prompt — validation P2 ciblée, Story 25-4-c3-a1 (le réglage du compte d'arrondi) + +*Versionné le 2026-09-30. **Passe ciblée** : une lentille (Haiku 4.5), contexte frais, braquée sur la +remédiation P1 — `git show 06c075be -- _bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md` +(contexte ; les numéros de ligne font foi dans les fichiers courants). Fiche : +`_bmad-output/implementation-artifacts/25-4-c3-a1-reglage-compte-arrondi.md`, **état actuel**. Dépôt +`/home/gcorbaz/devel/kesh`, lecture seule.* + +⚠️ La fiche décrit du travail **à faire** : ne signale pas comme défaut que le code ne porte pas encore +ce qu'elle prévoit. Juge la fiche : exactitude de ce qu'elle affirme, faisabilité de ce qu'elle demande. + +## Axes — tous obligatoires + +1. **AC 5** : `crates/kesh-api/src/lib.rs:340-352` — est-il exact qu'aucune route ne supprime un compte + (`grep -rn "delete" crates/kesh-api/src/routes/accounts.rs crates/kesh-db/src/repositories/accounts.rs`) ? + Est-il exact que l'archivage d'un compte désigné comme compte TVA n'est gardé nulle part (lis la + route et le dépôt d'archivage des comptes) ? +2. **AC 6** : `crates/kesh-db/src/backup.rs` — `export_table` et `column_constraints` lisent-ils bien + `INFORMATION_SCHEMA` ? `check_schema_compat` accepte-t-il une colonne absente et facultative ? Le test + `full_import_without_company_column_merges_archive_entries_as_null` existe-t-il à + `crates/kesh-api/tests/admin_full_import_e2e.rs:2167` et utilise-t-il `strip_column` ? +3. **Dev Notes** : `migrations_upgrade_path.rs` porte-t-il `assert_eq!(total, 69` ? + `docs/optimistic-locking-patterns.md:48` est-il bien l'énumération décrite ? +4. **Cohérence interne** : AC ↔ tâches, Change Log. + +## Ce que tu rends + +- **Findings** : sévérité, endroit exact, **preuve** : la commande exécutée **et sa sortie copiée**, ou + l'extrait de code lu avec son numéro de ligne. Un finding sans preuve ne sera pas retenu ; un « 0 + finding » sans preuve non plus. +- ⛔ **La liste des axes réellement exercés ET de ceux qui ne l'ont pas été.** + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base +(`scripts/*`, `make`, `latexmk`, `git commit`/`push`/`add`/`stash`/`reset`/`rebase`/`checkout`/`switch`, +`sqlx migrate`, `cargo test`/`nextest`, `npm run`, `npx playwright`, `gh issue create`/`comment`/`edit`). +Autorisés : lecture, `grep`, `git log`/`show`/`diff`, `gh issue view`, `cargo check`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 59fe6b285..0db52f83e 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -360,8 +360,11 @@ development_status: 25-4-b2-residuel-aux-rappels: done # 2026-09-27 revue CLOSE en 2 passes (1H/1M/1L -> 0 >LOW), gates verts (2510, 835, E2E conforme). PR a ouvrir : closes #416. # 2026-09-27 CREEE : frais non comptabilises => QR = reste du SANS frais (regle existante, admin-manual:1200) ; PDF de rappel = variante titree ; Q1 ligne "Frais de rappel", Q2 deja regle masque a zero, M5 papier hors perimetre (#477) ; validation CLOSE en 5 passes (1H/3M -> 0+1M -> 5M -> 1M -> 0 >LOW), pas de decoupage (Guy). # [#416, closes] le rappel reclame le reste du (texte, QR, PDF montant initial / deja regle / reste), frais configurables masques a zero ; point ouvert : frais non comptabilises. 25-4-b-residuel-aux-agregats: split # 2026-09-27 DECOUPEE en b1 (agregats) / b2 (rappels) — six modules apres les arbitrages Q1. # [#416] + montant des RELANCES (TTC complet, trouve a l'inventaire) — Q1 TRANCHEE 2026-09-27 : la QR du rappel porte le RESTE DU (= TTC sans reglement), reste a payer sur le PDF et dans le texte ; point ouvert : frais de rappel non comptabilises vs trop-percu. 25-4-c-residuel-au-rapprochement: done # 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/7M -> P2 Haiku x2 0 >LOW apres verification) ; gate complet vert (2513, 836, E2E 226/19/9 attendus) ; #482 ouverte (signe a l acceptation) ; non poussee — PR closes #420 sur accord de Guy. # 2026-09-30 IMPLEMENTEE — gates backend 2513/2513, frontend 836/836, E2E 226/19/9 (9 attendus) ; #481 ouverte (manuel). # 2026-09-29 VALIDATION CLOSE en 5 passes (P4 1M/1L, P5 ciblee 0 >LOW) — prete pour dev. # 2026-09-29 PERIMETRE REDUIT au reste du dans le rapprochement (verrou -> c2 #480, arrondi -> c3 #476) ; validation P1 3H, P2 2M (orchestrateur), P3 1C/3H -> decoupage ; validation reprend en P4. # 2026-09-28 CREEE : le filtre SQL EXCLUT la facture (pas seulement score 0) ; 3 questions a Guy (Q1 inclure #476 arrondi, Q2 manuel du score faux, Q3 montant affiche). # [#420] — DEUX sites : filtre SQL des candidats (HAVING total_ttc) avant le score, puis score et re-score a l'acceptation. - 25-4-c2-verrou-acceptation: done # 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/4M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2516, 836, E2E 228/19/7 KF-029) ; non poussee, empilee sur #483 — PR closes #480 apres le merge de #483. # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#480] 2026-09-29 NEE du decoupage de la 25-4-c (validation P3, severite P2 -> P3 non decroissante ; accord de Guy) — accept_one_invoice ne verrouille pas la facture : double reglement possible contre un reglement manuel partiel (settle_invoice ne bumpe version qu'au solde). ⛔ un simple FOR UPDATE au chargement desarme le controle optimiste (REPEATABLE READ : version lue a jour, amount_due perime) — lecture verrouillante du reste du, ou bump de version au reglement partiel. Interblocage lot/exercice -> 500 sans rejeu (retry_with ?). Findings P3 F1-F3 dans le Change Log de la 25-4-c. DOIT suivre la 25-4-c. - 25-4-c3-arrondi-centime: backlog # [#476] 2026-09-29 NEE du decoupage de la 25-4-c — le reste du peut porter 4 decimales ; comparer au centime : 8 sites (filtre, score, gardes de trop-percu :1336 et settlements_write:167, soldes :1451 et :233, reouverture :464, dialogue SettleInvoiceDialog:111) ; helper sur Money::round_to_centimes, reminder_amount_due rebranche. ⚠️ QUESTION COMPTABLE A GUY : payer 10.01 une facture de 10.0050 laisse la creance crediteur de 0.0050 au grand livre (DECIMAL(19,4)) et affiche « Reste du -0.01 » — assumer, afficher arrondi, ecriture d'ecart, ou arrondir le TTC a la validation. Findings P1 F1-F3, P3 F4/F8/F10/F12 dans le Change Log de la 25-4-c. + 25-4-c2-verrou-acceptation: done # 2026-09-30 PR #485 OUVERTE (closes #480), apres merge de #483 (merge -s ours, arbre identique). REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/4M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2516, 836, E2E 228/19/7 KF-029) ; non poussee, empilee sur #483 — PR closes #480 apres le merge de #483. # 2026-09-30 IMPLEMENTEE — gates 2516, 836, E2E 225/19/10 (tous expliques) ; #484 ouverte. # 2026-09-30 VALIDATION CLOSE en 2 passes (P1 1H/2M -> P2 0 >LOW). CREEE (empilee sur 25-4-c, PR #483) : invariant « tout ecrit sur invoice_settlements incremente invoices.version » — seul le reglement manuel partiel y manque ; PAS de FOR UPDATE ; rejeu sur interblocage (1213 remonte en 1305 au ROLLBACK TO SAVEPOINT) inclus par defaut. # [#480] 2026-09-29 NEE du decoupage de la 25-4-c (validation P3, severite P2 -> P3 non decroissante ; accord de Guy) — accept_one_invoice ne verrouille pas la facture : double reglement possible contre un reglement manuel partiel (settle_invoice ne bumpe version qu'au solde). ⛔ un simple FOR UPDATE au chargement desarme le controle optimiste (REPEATABLE READ : version lue a jour, amount_due perime) — lecture verrouillante du reste du, ou bump de version au reglement partiel. Interblocage lot/exercice -> 500 sans rejeu (retry_with ?). Findings P3 F1-F3 dans le Change Log de la 25-4-c. DOIT suivre la 25-4-c. + 25-4-c3-arrondi-centime: split # 2026-09-30 DECOUPEE (sept modules > 5) en c3-a1 (reglage), c3-a2 (compte dans les plans + onboarding), c3-b (arrondi et ecriture d ecart). Arbitrages de Guy : reglage dans les parametres (pas un role) ; compte creable dans le plan et choisi dans les parametres ; 6940 « Differences d arrondi » (charge) dans les 3 plans, designe a l onboarding ; refus si non configure quand un ecart se presente.# 2026-09-30 ARBITRAGE DE GUY : l ecart d arrondi au centime se PASSE EN ECRITURE, sur un compte de differences d arrondi (ni simple affichage, ni arrondi du TTC a la validation). # [#476] 2026-09-29 NEE du decoupage de la 25-4-c — le reste du peut porter 4 decimales ; comparer au centime : 8 sites (filtre, score, gardes de trop-percu :1336 et settlements_write:167, soldes :1451 et :233, reouverture :464, dialogue SettleInvoiceDialog:111) ; helper sur Money::round_to_centimes, reminder_amount_due rebranche. ⚠️ QUESTION COMPTABLE A GUY : payer 10.01 une facture de 10.0050 laisse la creance crediteur de 0.0050 au grand livre (DECIMAL(19,4)) et affiche « Reste du -0.01 » — assumer, afficher arrondi, ecriture d'ecart, ou arrondir le TTC a la validation. Findings P1 F1-F3, P3 F4/F8/F10/F12 dans le Change Log de la 25-4-c. + 25-4-c3-a1-reglage-compte-arrondi: done # 2026-09-30 REVUE CLOSE en 2 passes (P1 Sonnet x3 1H/2M -> P2 Haiku ciblee 0 >LOW) ; gate complet vert (2521, 839, E2E 228/19/8 expliques) ; non poussee. # 2026-09-30 IMPLEMENTEE — gates 2520, 839, E2E 229/19/7 KF-029 ; #486 ouverte (archivage des comptes des reglages). # [#476, refs] 2026-09-30 VALIDATION CLOSE en 2 passes (P1 2M -> P2 0). CREEE : colonne company_invoice_settings.default_rounding_account_id (non breaking), validation (actif, imputable, charge ou produit), ecran Parametres > Facturation, export, sauvegarde anterieure, manuel admin. + 25-4-c3-a2-compte-arrondi-plans: backlog # [#476, refs] 6940 « Differences d arrondi » (Expense) dans pme/independant/association (4 langues) ; marqueur de plan (pas de numero dans le code) ; onboarding et seed posent default_rounding_account_id. Apres c3-a1. + 25-4-c3-b-arrondi-au-centime: backlog # [#476, closes] le reste du arrondi au centime aux 8 comparaisons (helper sur Money::round_to_centimes, reminder_amount_due rebranche, dialogue) ; au solde, reglement enregistre au reste BRUT et ecart en 3e ligne sur le compte du reglage ; refus lisible si reglage absent. Apres c3-a1. 25-4-d-solder-le-reste: backlog # [#384] ramenee de la 25-6 le 2026-09-27 (Guy) — perte sur debiteur, escompte, frais : clore une facture partiellement reglee ; la part TVA reduit la TVA due. 25-4-propager-le-residuel: split # 2026-09-26 DECOUPEE (six modules > 5) en 25-4-a / 25-4-b / 25-4-c, puis 25-4-d (#384, 2026-09-27) ; #455 et #456 rattachees. # [#416] [#420] — la 24-2 a introduit le reglement PARTIEL ; le residuel n'est propage ni a la balance agee ni a l'echeancier, et **le score de rapprochement compare au TTC et non au residuel** : le virement du solde d'une facture partiellement reglee **score 0**. 25-5-a-export-souverainete: done # 2026-09-26 **MERGEE (PR #452, #386 fermee) SANS REVUE DE CODE** ; revue a posteriori CLOSE en 3 passes sur `story/25-5-a-revue-code` (4 HIGH P1, 1 HIGH orchestrateur P2 — colonnes manquantes, garde de colonnes —, P3 ciblee test seul) ; gates 2466/2466, E2E 221/8/19 sans regression ; reports #465, #466. # 2026-09-23 **SPECIFIEE et VALIDEE en 1 passe** (0 CRITICAL, 4 HIGH, 3 MEDIUM, 3 LOW, tous corriges). [#386] — issue du DECOUPAGE de la 25-5 (arbitrage de Guy) : #385 (Balance vs Bilan) et #386 n'ont en commun que le mot « etat », et touchent des modules disjoints. ⛔ **Trois faits qui corrigent l'issue** : 21 tables absentes et non 10 ; le trou s'est CREUSE depuis son ecriture (`invoice_settlements`, Epic 24) ; et SIX repositories sur huit n'ont aucune lecture exhaustive — le vrai cout, absent de l'issue. Arbitrage de Guy : l'export CSV ne porte que les donnees COMPTABLES (11 dedans), les backups portent tout ; 10 exclusions AVEC motif ecrit, dont 3 tables de SECRETS. Les justificatifs (fichiers) partent en story separee sur la SAUVEGARDE. ⛔ La vraie livraison est la GARDE D'EXHAUSTIVITE (patron D4-ter + P7), non les 11 exports : trois epics ont rouvert le trou en silence. ⚠️ Pieges releves par la validation : le nombre 19 code en dur a SEPT endroits dont deux `debug_assert_eq!` qui PANIQUENT ; `audit_log` n'a AUCUN dump exhaustif (ses fonctions sont bornees, la route rejette au-dela de 10 000 lignes) ; `csv_sanitize` existe et n'est pas employee par cet export ; deux sites de manuel DEJA faux (un dossier `reports/` invente, un « 38 » pour 39). diff --git a/crates/kesh-api/src/exports/csv_tables.rs b/crates/kesh-api/src/exports/csv_tables.rs index 2cd74584b..6f74932d0 100644 --- a/crates/kesh-api/src/exports/csv_tables.rs +++ b/crates/kesh-api/src/exports/csv_tables.rs @@ -921,6 +921,7 @@ pub fn serialize_company_invoice_settings_csv( "journal_entry_description_template", "credit_note_number_format", "default_payable_account_id", + "default_rounding_account_id", "version", "created_at", "updated_at", @@ -939,6 +940,7 @@ pub fn serialize_company_invoice_settings_csv( txt(cis.journal_entry_description_template.clone()), txt(cis.credit_note_number_format.clone()), fmt_opt_i64(cis.default_payable_account_id), + fmt_opt_i64(cis.default_rounding_account_id), cis.version.to_string(), fmt_dt(cis.created_at), fmt_dt(cis.updated_at), diff --git a/crates/kesh-api/src/helpers.rs b/crates/kesh-api/src/helpers.rs index 5ad9b457e..a26225c8d 100644 --- a/crates/kesh-api/src/helpers.rs +++ b/crates/kesh-api/src/helpers.rs @@ -72,6 +72,21 @@ pub async fn get_company_for( }) } +/// Deserializer « double option » (Story 19-5) : distingue un champ **absent** +/// (→ `None`, inchangé) d'un champ **présent à `null`** (→ `Some(None)`, +/// effacement). Sans ce helper, serde replie `null` sur le `None` externe et +/// un effacement via un corps partiel serait un no-op silencieux (bug HIGH +/// Pass 1 Blind Hunter, 19-5). À combiner avec `#[serde(default)]` pour gérer le +/// cas absent. Partagé par les règles de rapprochement et les réglages de +/// facturation (Story 25-4-c3-a1). +pub(crate) fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(Some) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/kesh-api/src/routes/company_invoice_settings.rs b/crates/kesh-api/src/routes/company_invoice_settings.rs index 3af42dc62..a90512ef0 100644 --- a/crates/kesh-api/src/routes/company_invoice_settings.rs +++ b/crates/kesh-api/src/routes/company_invoice_settings.rs @@ -41,6 +41,8 @@ pub struct InvoiceSettingsResponse { pub journal_entry_description_template: String, pub credit_note_number_format: String, pub default_payable_account_id: Option, + /// Story 25-4-c3-a1 (#476) — compte de différences d'arrondi. + pub default_rounding_account_id: Option, pub version: i32, } @@ -58,6 +60,7 @@ impl From for InvoiceSettingsResponse { journal_entry_description_template: s.journal_entry_description_template, credit_note_number_format: s.credit_note_number_format, default_payable_account_id: s.default_payable_account_id, + default_rounding_account_id: s.default_rounding_account_id, version: s.version, } } @@ -79,6 +82,12 @@ pub struct UpdateInvoiceSettingsRequest { #[serde(default)] pub credit_note_number_format: Option, pub default_payable_account_id: Option, + /// Story 25-4-c3-a1 (#476) — compte de différences d'arrondi. **Absent du + /// corps : préservé** (comme `credit_note_number_format`, #216), pour qu'un + /// client qui ignore encore le champ — clé d'API, onglet ouvert avant la mise + /// à jour — ne l'efface pas en silence. **Présent à `null` : effacé.** + #[serde(default, deserialize_with = "crate::helpers::double_option")] + pub default_rounding_account_id: Option>, pub version: i32, } @@ -97,6 +106,33 @@ async fn validate_account( account_id: Option, expected: AccountType, field_label: &str, +) -> Result<(), AppError> { + validate_account_of( + state, + company_id, + account_id, + &[expected], + false, + field_label, + ) + .await +} + +/// Forme générale de [`validate_account`] (Story 25-4-c3-a1) : plusieurs types +/// acceptés, et la postabilité exigée ou non. +/// +/// ⚠️ `require_postable` est exigé pour un compte que des **écritures +/// automatiques** utiliseront : elles passent `enforce_postable = false` +/// (`journal_entries::create_in_tx`), si bien que la garde doit tenir ici, au +/// moment où le compte est désigné. Les champs historiques ne l'exigent pas +/// (comportement inchangé). +async fn validate_account_of( + state: &AppState, + company_id: i64, + account_id: Option, + accepted: &[AccountType], + require_postable: bool, + field_label: &str, ) -> Result<(), AppError> { let Some(id) = account_id else { return Ok(()); @@ -114,10 +150,16 @@ async fn validate_account( "{field_label} : compte archivé" ))); } - if account.account_type != expected { + if !accepted.contains(&account.account_type) { + let attendus: Vec<&str> = accepted.iter().map(|t| t.as_str()).collect(); return Err(AppError::Validation(format!( "{field_label} : type de compte incompatible (attendu {})", - expected.as_str() + attendus.join(" ou ") + ))); + } + if require_postable && !account.postable { + return Err(AppError::Validation(format!( + "{field_label} : compte non imputable (compte de regroupement ou de clôture)" ))); } Ok(()) @@ -219,6 +261,31 @@ pub async fn update_invoice_settings( ) .await?; + // Compte de différences d'arrondi (Story 25-4-c3-a1) : un écart d'arrondi est + // un résultat, dans un sens ou dans l'autre — charge ou produit, imputable. + // Absent du corps : la valeur en place est préservée. + // + // ⚠️ Validé **seulement s'il change**. Le compte désigné peut devenir archivé + // ou non imputable par une autre route (l'archivage n'est pas gardé, #486) ; + // le revalider à chaque enregistrement bloquerait tout changement SANS + // rapport — un format de numérotation — sur un champ que l'utilisateur n'a + // pas touché. L'écriture de l'écart (Story 25-4-c3-b) refusera d'écrire sur un + // compte devenu invalide : c'est là que la garde doit tenir, pas ici. + let default_rounding_account_id = req + .default_rounding_account_id + .unwrap_or(current.default_rounding_account_id); + if default_rounding_account_id != current.default_rounding_account_id { + validate_account_of( + &state, + company.id, + default_rounding_account_id, + &[AccountType::Expense, AccountType::Revenue], + true, + "Compte de différences d'arrondi", + ) + .await?; + } + // 5. Persister. let update = CompanyInvoiceSettingsUpdate { invoice_number_format: req.invoice_number_format, @@ -231,6 +298,7 @@ pub async fn update_invoice_settings( journal_entry_description_template: req.journal_entry_description_template, credit_note_number_format, default_payable_account_id: req.default_payable_account_id, + default_rounding_account_id, }; let settings = company_invoice_settings::update( &state.pool, diff --git a/crates/kesh-api/src/routes/reconciliation_rules.rs b/crates/kesh-api/src/routes/reconciliation_rules.rs index 4fb9fcea7..eac9e936c 100644 --- a/crates/kesh-api/src/routes/reconciliation_rules.rs +++ b/crates/kesh-api/src/routes/reconciliation_rules.rs @@ -107,20 +107,6 @@ fn default_priority() -> i32 { DEFAULT_PRIORITY } -/// Deserializer « double option » (Story 19-5) : distingue un champ **absent** -/// (→ `None`, inchangé) d'un champ **présent à `null`** (→ `Some(None)`, -/// effacement). Sans ce helper, serde replie `null` sur le `None` externe et -/// l'effacement du projet par défaut via PATCH serait un no-op silencieux -/// (bug HIGH Pass 1 Blind Hunter). À combiner avec `#[serde(default)]` pour -/// gérer le cas absent. -fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> -where - T: serde::Deserialize<'de>, - D: serde::Deserializer<'de>, -{ - serde::Deserialize::deserialize(deserializer).map(Some) -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateRuleRequest { @@ -135,9 +121,9 @@ pub struct UpdateRuleRequest { /// Projet analytique par défaut (Story 19-5) — sémantique deux niveaux : /// absent = inchangé ; `null` = effacer ; `` = affecter. Mappé sur /// `UpdateReconciliationRule.default_project_id: Option>`. - /// `deserialize_with = "double_option"` est **obligatoire** pour que + /// `deserialize_with = "crate::helpers::double_option"` est **obligatoire** pour que /// `null` produise `Some(None)` (effacement) plutôt que `None` (inchangé). - #[serde(default, deserialize_with = "double_option")] + #[serde(default, deserialize_with = "crate::helpers::double_option")] pub default_project_id: Option>, } diff --git a/crates/kesh-api/tests/admin_full_import_e2e.rs b/crates/kesh-api/tests/admin_full_import_e2e.rs index cffd7dedd..cbb225475 100644 --- a/crates/kesh-api/tests/admin_full_import_e2e.rs +++ b/crates/kesh-api/tests/admin_full_import_e2e.rs @@ -2338,3 +2338,54 @@ async fn characterization_full_import_keeps_company_ids_as_written(pool: MySqlPo assert_eq!(import_company, Some(u1.company_id)); assert_ne!(import_company, Some(u2.company_id)); } + +/// **Story 25-4-c3-a1 (#476) — un backup antérieur au compte de différences +/// d'arrondi reste importable, et le réglage arrive `NULL`.** +/// +/// La sauvegarde lit ses colonnes dans `INFORMATION_SCHEMA` : elle suit la +/// colonne d'elle-même. Ce test prouve l'autre sens — un `.keshbackup` produit +/// avant la migration `20260930000001`, donc sans la colonne, passe +/// `check_schema_compat` (colonne facultative) et laisse le réglage vide : la +/// société le désignera à nouveau dans les paramètres. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn full_import_without_rounding_account_column_leaves_the_setting_null(pool: MySqlPool) { + let app = spawn_app(pool.clone()).await; + let ctx = seed_admin(&pool, "arrondi_absent").await; + let charge = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type) \ + VALUES (?, '6940', 'Différences d''arrondi', 'Expense')", + ) + .bind(ctx.company_id) + .execute(&pool) + .await + .expect("compte de charge") + .last_insert_id() as i64; + sqlx::query( + "INSERT INTO company_invoice_settings (company_id, default_rounding_account_id) \ + VALUES (?, ?) ON DUPLICATE KEY UPDATE default_rounding_account_id = VALUES(default_rounding_account_id)", + ) + .bind(ctx.company_id) + .bind(charge) + .execute(&pool) + .await + .expect("réglage"); + + let backup = export_backup(&app, &ctx.jwt).await; + let (mut manifest, data) = unzip(&backup); + strip_column( + &mut manifest, + "company_invoice_settings", + "default_rounding_account_id", + ); + import_ok(&app, &ctx.jwt, &manifest, &data).await; + + let rounding: Vec> = + sqlx::query_scalar("SELECT default_rounding_account_id FROM company_invoice_settings") + .fetch_all(&pool) + .await + .expect("lecture du réglage"); + assert!( + !rounding.is_empty() && rounding.iter().all(Option::is_none), + "un backup sans la colonne laisse le réglage vide : {rounding:?}" + ); +} diff --git a/crates/kesh-api/tests/idor_multi_tenant_e2e.rs b/crates/kesh-api/tests/idor_multi_tenant_e2e.rs index 1a64adffb..853f5fe21 100644 --- a/crates/kesh-api/tests/idor_multi_tenant_e2e.rs +++ b/crates/kesh-api/tests/idor_multi_tenant_e2e.rs @@ -804,3 +804,165 @@ async fn settings_update_without_credit_note_format_preserves_and_returns_200(po "le format d'avoir doit être préservé quand le champ est absent" ); } + +/// `PUT /company/invoice-settings` depuis la config courante, le compte de +/// différences d'arrondi posé à `rounding` — ou **absent** du corps si `None`. +/// Rend le statut et le corps de la réponse. +async fn put_rounding_account( + app: &TestApp, + token: &str, + rounding: Option, +) -> (u16, serde_json::Value) { + let settings: serde_json::Value = app + .client + .get(app.url("/api/v1/company/invoice-settings")) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .expect("get") + .json() + .await + .expect("json"); + let mut body = json!({ + "invoiceNumberFormat": settings["invoiceNumberFormat"], + "defaultReceivableAccountId": settings["defaultReceivableAccountId"], + "defaultRevenueAccountId": settings["defaultRevenueAccountId"], + "defaultVatPayableAccountId": settings["defaultVatPayableAccountId"], + "defaultVatRecoverableAccountId": settings["defaultVatRecoverableAccountId"], + "defaultVatDecompteAccountId": settings["defaultVatDecompteAccountId"], + "defaultSalesJournal": settings["defaultSalesJournal"], + "journalEntryDescriptionTemplate": settings["journalEntryDescriptionTemplate"], + "version": settings["version"], + }); + if let Some(value) = rounding { + body["defaultRoundingAccountId"] = value; + } + let resp = app + .client + .put(app.url("/api/v1/company/invoice-settings")) + .header("Authorization", format!("Bearer {token}")) + .json(&body) + .send() + .await + .expect("put"); + let status = resp.status().as_u16(); + (status, resp.json().await.unwrap_or(serde_json::Value::Null)) +} + +/// Story 25-4-c3-a1 (#476) — le compte de différences d'arrondi : charge **ou** +/// produit, actif, **imputable**, de la société. Tout autre compte est refusé en +/// 400 ; le refus du compte non imputable est la garde que les écritures +/// automatiques ne portent pas (`enforce_postable = false`). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn settings_rounding_account_is_validated(pool: MySqlPool) { + truncate_all(&pool).await.expect("truncate"); + let (company_id, accounts) = create_seeded_company(&pool).await; + let (_other_company, other_accounts) = create_seeded_company(&pool).await; + create_company_user_with_role(&pool, company_id, "alice", "password123", Role::Admin).await; + // Un compte de charge de regroupement (non imputable), et un archivé. + let mut extra = std::collections::HashMap::new(); + for (code, account_type, active, postable) in [ + ("6000", "Expense", true, false), + ("6100", "Expense", false, true), + ("3900", "Revenue", true, false), + ("3910", "Revenue", false, true), + ] { + let id = sqlx::query( + "INSERT INTO accounts (company_id, number, name, account_type, active, postable) \ + VALUES (?, ?, 'Compte test', ?, ?, ?)", + ) + .bind(company_id) + .bind(code) + .bind(account_type) + .bind(active) + .bind(postable) + .execute(&pool) + .await + .expect("account insert") + .last_insert_id() as i64; + extra.insert(code, id); + } + let app = spawn_app(pool.clone()).await; + let token = login(&app, "alice", "password123").await; + + for (label, account, expected) in [ + ("charge imputable", accounts["4000"], 200), + ("produit imputable", accounts["3000"], 200), + ("actif", accounts["1000"], 400), + ("passif", accounts["2000"], 400), + ("charge non imputable", extra["6000"], 400), + ("charge archivée", extra["6100"], 400), + ("produit non imputable", extra["3900"], 400), + ("produit archivé", extra["3910"], 400), + ("charge d'une autre société", other_accounts["4000"], 400), + ] { + let (status, body) = put_rounding_account(&app, &token, Some(json!(account))).await; + assert_eq!(status, expected, "{label} : {body}"); + if expected == 200 { + assert_eq!(body["defaultRoundingAccountId"], account, "{label}"); + } + } +} + +/// Story 25-4-c3-a1 — **absent** du corps, le compte d'arrondi est **préservé** +/// (un client qui ignore encore le champ ne l'efface pas) ; **présent à `null`**, +/// il est effacé. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn settings_rounding_account_absent_preserves_null_clears(pool: MySqlPool) { + truncate_all(&pool).await.expect("truncate"); + let (company_id, accounts) = create_seeded_company(&pool).await; + create_company_user_with_role(&pool, company_id, "alice", "password123", Role::Admin).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app, "alice", "password123").await; + + let (status, body) = put_rounding_account(&app, &token, Some(json!(accounts["4000"]))).await; + assert_eq!(status, 200, "{body}"); + + let (status, body) = put_rounding_account(&app, &token, None).await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + body["defaultRoundingAccountId"], accounts["4000"], + "absent du corps : préservé" + ); + + let (status, body) = put_rounding_account(&app, &token, Some(serde_json::Value::Null)).await; + assert_eq!(status, 200, "{body}"); + assert!( + body["defaultRoundingAccountId"].is_null(), + "présent à null : effacé ; corps = {body}" + ); +} + +/// Story 25-4-c3-a1 — un compte d'arrondi devenu **archivé** après sa désignation +/// (l'archivage n'est pas gardé, #486) ne doit pas bloquer l'enregistrement d'un +/// réglage SANS rapport : reconduit tel quel, il n'est pas revalidé. Le changer +/// pour un compte invalide, lui, reste refusé. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn settings_unchanged_archived_rounding_account_does_not_block_other_changes( + pool: MySqlPool, +) { + truncate_all(&pool).await.expect("truncate"); + let (company_id, accounts) = create_seeded_company(&pool).await; + create_company_user_with_role(&pool, company_id, "alice", "password123", Role::Admin).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app, "alice", "password123").await; + + let (status, body) = put_rounding_account(&app, &token, Some(json!(accounts["4000"]))).await; + assert_eq!(status, 200, "{body}"); + // Le compte est archivé ailleurs, après coup. + sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?") + .bind(accounts["4000"]) + .execute(&pool) + .await + .expect("archivage"); + + // L'écran renvoie la valeur telle qu'il l'a lue : reconduite, elle passe. + let (status, body) = put_rounding_account(&app, &token, Some(json!(accounts["4000"]))).await; + assert_eq!( + status, 200, + "⛔ un compte d'arrondi reconduit ne doit pas bloquer l'enregistrement : {body}" + ); + // Désigner un compte invalide, en revanche, reste refusé. + let (status, body) = put_rounding_account(&app, &token, Some(json!(accounts["1000"]))).await; + assert_eq!(status, 400, "{body}"); +} diff --git a/crates/kesh-db/migrations.sha384 b/crates/kesh-db/migrations.sha384 index 68267e87c..04dda8d24 100644 --- a/crates/kesh-db/migrations.sha384 +++ b/crates/kesh-db/migrations.sha384 @@ -82,3 +82,4 @@ 20260910000001 5b9000c3178f56136a67d57f402e709ae70ab9025ffa818853c614fd55df9521e8ecc5b83c1cfab934a681a8c85d1a5a 20260915000001 7cf8f78b3d9af607fcd2b52bb54f0190880c65df4062d3ce67a62f81bbdf5ef884a42533c579b1184684deaf75259d2b 20260917000001 ca59103a2ff33cde3ae613f14f6a1a54291315e9c91040ee4a393dee9180307d1b4b2a695f0ffa2f4ed28e5b40d0f023 +20260930000001 9fb2d6d97dd32fa924ed44cc9660d02a7b5c059704a6605f4b61f0a2e507e8ef8dfe977b5d3dfef5cd3df3e3ef7d07ac diff --git a/crates/kesh-db/migrations/20260930000001_invoice_settings_rounding_account.sql b/crates/kesh-db/migrations/20260930000001_invoice_settings_rounding_account.sql new file mode 100644 index 000000000..c0edf2c1d --- /dev/null +++ b/crates/kesh-db/migrations/20260930000001_invoice_settings_rounding_account.sql @@ -0,0 +1,22 @@ +-- Story 25-4-c3-a1 (#476) — le compte de différences d'arrondi, désigné dans +-- les paramètres de facturation. +-- +-- Arbitrage du Project Lead (2026-09-30) : l'écart d'arrondi au centime +-- qu'un paiement arrondi laisse sur une facture se PASSE EN ÉCRITURE, sur un +-- compte désigné par un RÉGLAGE (pas un rôle de compte). Le compte se crée +-- dans le plan comptable et se choisit ici ; les plans livrés en proposeront +-- un (Story 25-4-c3-a2). La Story 25-4-c3-b écrira l'écart en lisant ce réglage. +-- +-- Patron : les comptes TVA (`20260614000001_vat_accounts_config.sql`) — colonne +-- facultative, FK `ON DELETE RESTRICT`. +-- +-- DDL seul, AUCUNE donnée écrite : les sociétés existantes désignent leur +-- compte elles-mêmes (triage P7 sans objet). Non-breaking (`ADD COLUMN` +-- nullable, ignoré par les anciens binaires, qui listent leurs colonnes) → +-- PAS de bump `kesh_version_min_required`. Ligne ajoutée à +-- `docs/migrations-idempotence-audit.md` (politique P5). + +ALTER TABLE company_invoice_settings + ADD COLUMN default_rounding_account_id BIGINT NULL, + ADD CONSTRAINT fk_cis_rounding + FOREIGN KEY (default_rounding_account_id) REFERENCES accounts(id) ON DELETE RESTRICT; diff --git a/crates/kesh-db/src/entities/company_invoice_settings.rs b/crates/kesh-db/src/entities/company_invoice_settings.rs index 1113c05c8..0d31464a3 100644 --- a/crates/kesh-db/src/entities/company_invoice_settings.rs +++ b/crates/kesh-db/src/entities/company_invoice_settings.rs @@ -35,6 +35,12 @@ pub struct CompanyInvoiceSettings { /// Compte créanciers (2000) par défaut, contrepartie de l'écriture d'achat /// des factures fournisseurs, type Liability. Story 12.2. pub default_payable_account_id: Option, + /// Compte de **différences d'arrondi** — charge ou produit, actif et + /// imputable — qui reçoit l'écart d'un demi-centime au plus qu'un paiement + /// arrondi au centime laisse sur une facture (Story 25-4-c3-a1, #476). + /// Facultatif : il n'est lu que quand un écart se présente (Story 25-4-c3-b). + /// Un réglage et non un rôle de compte (arbitrage du 2026-09-30). + pub default_rounding_account_id: Option, pub version: i32, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, @@ -56,4 +62,7 @@ pub struct CompanyInvoiceSettingsUpdate { pub journal_entry_description_template: String, pub credit_note_number_format: String, pub default_payable_account_id: Option, + /// Story 25-4-c3-a1 — absent du corps, il vaut `None` (comme les comptes + /// TVA) : le remplacement est intégral. + pub default_rounding_account_id: Option, } diff --git a/crates/kesh-db/src/repositories/company_invoice_settings.rs b/crates/kesh-db/src/repositories/company_invoice_settings.rs index 2c2d01ee0..ebf126e74 100644 --- a/crates/kesh-db/src/repositories/company_invoice_settings.rs +++ b/crates/kesh-db/src/repositories/company_invoice_settings.rs @@ -28,6 +28,7 @@ const COLUMNS: &str = "company_id, invoice_number_format, default_receivable_acc default_vat_recoverable_account_id, default_vat_decompte_account_id, \ default_sales_journal, journal_entry_description_template, \ credit_note_number_format, default_payable_account_id, \ + default_rounding_account_id, \ version, created_at, updated_at"; fn settings_snapshot_json(s: &CompanyInvoiceSettings) -> serde_json::Value { @@ -43,6 +44,7 @@ fn settings_snapshot_json(s: &CompanyInvoiceSettings) -> serde_json::Value { "journalEntryDescriptionTemplate": s.journal_entry_description_template, "creditNoteNumberFormat": s.credit_note_number_format, "defaultPayableAccountId": s.default_payable_account_id, + "defaultRoundingAccountId": s.default_rounding_account_id, "version": s.version, }) } @@ -122,6 +124,7 @@ fn is_no_op_change( && before.journal_entry_description_template == changes.journal_entry_description_template && before.credit_note_number_format == changes.credit_note_number_format && before.default_payable_account_id == changes.default_payable_account_id + && before.default_rounding_account_id == changes.default_rounding_account_id } /// Met à jour la config (tous les champs) avec verrou optimiste et audit. @@ -174,7 +177,7 @@ pub async fn update( default_vat_recoverable_account_id = ?, default_vat_decompte_account_id = ?, \ default_sales_journal = ?, \ journal_entry_description_template = ?, credit_note_number_format = ?, \ - default_payable_account_id = ?, \ + default_payable_account_id = ?, default_rounding_account_id = ?, \ version = version + 1 \ WHERE company_id = ? AND version = ?", ) @@ -188,6 +191,7 @@ pub async fn update( .bind(&changes.journal_entry_description_template) .bind(&changes.credit_note_number_format) .bind(changes.default_payable_account_id) + .bind(changes.default_rounding_account_id) .bind(company_id) .bind(expected_version) .execute(&mut *tx) @@ -358,7 +362,7 @@ pub async fn insert_with_defaults( cis.default_vat_recoverable_account_id, cis.default_vat_decompte_account_id, \ cis.default_sales_journal, \ cis.journal_entry_description_template, cis.credit_note_number_format, \ - cis.default_payable_account_id, \ + cis.default_payable_account_id, cis.default_rounding_account_id, \ cis.version, cis.created_at, cis.updated_at \ FROM company_invoice_settings cis \ JOIN accounts ar ON ar.id = cis.default_receivable_account_id AND ar.active = TRUE \ @@ -476,7 +480,7 @@ pub async fn insert_with_defaults_in_tx( cis.default_vat_recoverable_account_id, cis.default_vat_decompte_account_id, \ cis.default_sales_journal, \ cis.journal_entry_description_template, cis.credit_note_number_format, \ - cis.default_payable_account_id, \ + cis.default_payable_account_id, cis.default_rounding_account_id, \ cis.version, cis.created_at, cis.updated_at \ FROM company_invoice_settings cis \ JOIN accounts ar ON ar.id = cis.default_receivable_account_id AND ar.active = TRUE \ diff --git a/crates/kesh-db/test-schema/0001_schema_squash.sql b/crates/kesh-db/test-schema/0001_schema_squash.sql index 9fb9bca3f..014a18cca 100644 --- a/crates/kesh-db/test-schema/0001_schema_squash.sql +++ b/crates/kesh-db/test-schema/0001_schema_squash.sql @@ -1,6 +1,6 @@ -- SQUASH DU SCHÉMA DE TEST — Story 22-5 (#251). GÉNÉRÉ, NE PAS ÉDITER. -- Régénérer : scripts/regen-test-schema.sh --- Équivalent des 69 migrations de crates/kesh-db/migrations/, +-- Équivalent des 70 migrations de crates/kesh-db/migrations/, -- rejouées en UN batch DDL par base éphémère de test. -- -- Le garde-fou crates/kesh-db/tests/test_schema_guard.rs compare ce schéma @@ -303,6 +303,7 @@ CREATE TABLE `company_invoice_settings` ( `default_vat_decompte_account_id` bigint(20) DEFAULT NULL, `credit_note_number_format` varchar(64) NOT NULL DEFAULT 'AV-{YEAR}-{SEQ:04}', `default_payable_account_id` bigint(20) DEFAULT NULL, + `default_rounding_account_id` bigint(20) DEFAULT NULL, PRIMARY KEY (`company_id`), KEY `fk_cis_receivable` (`default_receivable_account_id`), KEY `fk_cis_revenue` (`default_revenue_account_id`), @@ -311,10 +312,12 @@ CREATE TABLE `company_invoice_settings` ( KEY `fk_cis_vat_recoverable` (`default_vat_recoverable_account_id`), KEY `fk_cis_vat_decompte` (`default_vat_decompte_account_id`), KEY `fk_cis_payable_account` (`default_payable_account_id`), + KEY `fk_cis_rounding` (`default_rounding_account_id`), CONSTRAINT `fk_cis_company` FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`), CONSTRAINT `fk_cis_payable_account` FOREIGN KEY (`default_payable_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_receivable` FOREIGN KEY (`default_receivable_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_revenue` FOREIGN KEY (`default_revenue_account_id`) REFERENCES `accounts` (`id`), + CONSTRAINT `fk_cis_rounding` FOREIGN KEY (`default_rounding_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_vat_decompte` FOREIGN KEY (`default_vat_decompte_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_vat_payable` FOREIGN KEY (`default_vat_payable_account_id`) REFERENCES `accounts` (`id`), CONSTRAINT `fk_cis_vat_recoverable` FOREIGN KEY (`default_vat_recoverable_account_id`) REFERENCES `accounts` (`id`), diff --git a/crates/kesh-db/tests/company_invoice_settings_repository.rs b/crates/kesh-db/tests/company_invoice_settings_repository.rs index f6c530e39..c170fd0d8 100644 --- a/crates/kesh-db/tests/company_invoice_settings_repository.rs +++ b/crates/kesh-db/tests/company_invoice_settings_repository.rs @@ -278,6 +278,7 @@ async fn update_no_op_returns_unchanged_entity_no_audit(pool: MySqlPool) { journal_entry_description_template: settings.journal_entry_description_template.clone(), credit_note_number_format: settings.credit_note_number_format.clone(), default_payable_account_id: settings.default_payable_account_id, + default_rounding_account_id: None, }, ) .await @@ -347,6 +348,7 @@ async fn update_partial_change_bumps_version(pool: MySqlPool) { journal_entry_description_template: settings.journal_entry_description_template.clone(), credit_note_number_format: settings.credit_note_number_format.clone(), default_payable_account_id: settings.default_payable_account_id, + default_rounding_account_id: None, }, ) .await @@ -438,6 +440,7 @@ async fn update_vat_accounts_round_trip(pool: MySqlPool) { journal_entry_description_template: settings.journal_entry_description_template.clone(), credit_note_number_format: settings.credit_note_number_format.clone(), default_payable_account_id: settings.default_payable_account_id, + default_rounding_account_id: None, }, ) .await @@ -531,6 +534,7 @@ async fn update_vat_account_foreign_id_rejected_by_fk(pool: MySqlPool) { .clone(), credit_note_number_format: settings_a.credit_note_number_format.clone(), default_payable_account_id: settings_a.default_payable_account_id, + default_rounding_account_id: None, }, ) .await; @@ -938,3 +942,64 @@ async fn insert_with_defaults_in_tx_fails_fast_when_no_receivable_role(pool: MyS // Le caller possède la tx → rollback explicite (la fonction ne le fait pas). tx.rollback().await.unwrap(); } + +/// Story 25-4-c3-a1 (#476) — le compte de différences d'arrondi traverse +/// `update` et la relecture (COLUMNS / FromRow / UPDATE), et changer **lui seul** +/// n'est pas un no-op : `version` bouge, l'audit est écrit. +#[sqlx::test(migrations = "./test-schema")] +async fn update_rounding_account_round_trip(pool: MySqlPool) { + let company = company_with_pme_chart(&pool, "Arrondi Co").await; + let admin_user_id = create_admin_user(&pool, company).await; + let settings = company_invoice_settings::insert_with_defaults(&pool, company) + .await + .unwrap(); + assert_eq!( + settings.default_rounding_account_id, None, + "l'onboarding ne le pose pas (Story 25-4-c3-a2)" + ); + // Un compte de charge du plan PME. + let rounding = account_id_by_number(&pool, company, "6900").await; + + let result = company_invoice_settings::update( + &pool, + company, + settings.version, + admin_user_id, + CompanyInvoiceSettingsUpdate { + invoice_number_format: settings.invoice_number_format.clone(), + default_receivable_account_id: settings.default_receivable_account_id, + default_revenue_account_id: settings.default_revenue_account_id, + default_vat_payable_account_id: settings.default_vat_payable_account_id, + default_vat_recoverable_account_id: settings.default_vat_recoverable_account_id, + default_vat_decompte_account_id: settings.default_vat_decompte_account_id, + default_sales_journal: settings.default_sales_journal, + journal_entry_description_template: settings.journal_entry_description_template.clone(), + credit_note_number_format: settings.credit_note_number_format.clone(), + default_payable_account_id: settings.default_payable_account_id, + default_rounding_account_id: Some(rounding), + }, + ) + .await + .unwrap(); + assert_eq!( + result.version, + settings.version + 1, + "changer le seul compte d'arrondi n'est pas un no-op" + ); + assert_eq!(result.default_rounding_account_id, Some(rounding)); + + let reread = company_invoice_settings::get_or_create_default(&pool, company) + .await + .unwrap(); + assert_eq!(reread.default_rounding_account_id, Some(rounding)); + + let audited: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE entity_type = 'company_invoice_settings' \ + AND JSON_EXTRACT(details_json, '$.after.defaultRoundingAccountId') = ?", + ) + .bind(rounding) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(audited, 1, "l'audit porte le compte d'arrondi"); +} diff --git a/crates/kesh-db/tests/migrations_upgrade_path.rs b/crates/kesh-db/tests/migrations_upgrade_path.rs index c26521479..6908f5ec0 100644 --- a/crates/kesh-db/tests/migrations_upgrade_path.rs +++ b/crates/kesh-db/tests/migrations_upgrade_path.rs @@ -35,9 +35,9 @@ async fn apply_migrations_up_to( assert!( n <= all.len(), "apply_migrations_up_to: n={} > total={} — vérifier que le calcul \ - `total - 35` (fenêtre d'upgrade, FRONTIÈRE figée à 34) reste \ + `total - 36` (fenêtre d'upgrade, FRONTIÈRE figée à 34) reste \ cohérent avec l'ajout de migrations futures. Si une migration a été ajoutée à \ - la branche, l'assertion `total == 69` du test upgrade_path_preserves_data \ + la branche, l'assertion `total == 70` du test upgrade_path_preserves_data \ doit également échouer, c'est son rôle : elle signale qu'il faut décider \ explicitement si la fenêtre s'élargit (bumper `total` seul) ou si la \ frontière doit rester à 34 (bumper `total` ET la fenêtre). Cf. garde-fou \ @@ -54,8 +54,8 @@ async fn apply_migrations_up_to( sub.run(pool).await } -/// AC #15a — cas générique upgrade path : `total - 35` migrations appliquées -/// (**34** à ce jour) + seed + `MIGRATOR.run()` final, qui applique les **35** +/// AC #15a — cas générique upgrade path : `total - 36` migrations appliquées +/// (**34** à ce jour) + seed + `MIGRATOR.run()` final, qui applique les **36** /// dernières. Assertion : seed préservé à travers la fenêtre d'upgrade. /// /// ⚠️ Les nombres ci-dessus se recomptent, ils ne se relisent pas — cf. le @@ -95,14 +95,15 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { // + closing_accounts_not_postable (Story 24-5, #375) = 66, // + audit_log_actor_label (Story 25-1a, #376) = 67, // + audit_log_company_id (Story 25-1c-zero, refs #378) = 68, - // + journal_entry_number_sequences (Story 25-2-c, refs #381) = 69. + // + journal_entry_number_sequences (Story 25-2-c, refs #381) = 69, + // + invoice_settings_rounding_account (Story 25-4-c3-a1, refs #476) = 70. let total = kesh_db::MIGRATOR.migrations.len(); assert_eq!( - total, 69, - "69 migrations attendues (68 précédentes + Story 25-2-c : journal_entry_number_sequences)" + total, 70, + "70 migrations attendues (69 précédentes + Story 25-4-c3-a1 : invoice_settings_rounding_account)" ); - // Étape 1 : applique toutes les migrations sauf les 35 dernières. La + // Étape 1 : applique toutes les migrations sauf les 36 dernières. La // fenêtre d'upgrade démarre donc à la 35ᵉ, `20260614000001_vat_accounts_config` // (Story 18-1a), et court jusqu'à la dernière du dépôt. Ne pas ré-énumérer // ici les migrations de la fenêtre : une liste nominative se périme à chaque @@ -110,17 +111,17 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { // plus bas. Le seul nombre qui fait foi est celui du `total - N` ci-dessous. // // Note `total - N` : expression relative à la longueur totale, tandis que - // l'assertion `total == 69` ci-dessus est INTENTIONNELLEMENT codée en dur + // l'assertion `total == 70` ci-dessus est INTENTIONNELLEMENT codée en dur // pour fail-loud sur toute évolution non revue. À chaque migration ajoutée, // le mainteneur doit (1) bumper ce compte (2) incrémenter `N` du même pas, // de sorte que `total - N` — la frontière — reste **constant**. // // Frontière actuelle : **34**. Le test applique donc les 34 premières // migrations (jusqu'à `20260613000001_vat_rates_crud` incluse), seede des - // données, puis joue les 35 restantes comme « fenêtre d’upgrade ». + // données, puis joue les 36 restantes comme « fenêtre d’upgrade ». // - // ⚠️ `N` DOIT être incrémenté en même temps que `total`. Le laisser à 34 - // avec `total = 69` porterait la frontière à 35 : le test continuerait de + // ⚠️ `N` DOIT être incrémenté en même temps que `total`. Le laisser à 35 + // avec `total = 70` porterait la frontière à 35 : le test continuerait de // passer en testant une fenêtre plus étroite d'une migration. // Story 16-1a : 21 → 22, frontière inchangée (56 - 22 = 55 - 21 = 34). // Story 16-1a-bis : 22 → 23, frontière inchangée (57 - 23 = 34). @@ -165,16 +166,17 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { // s'élargit donc d'un cran à chaque migration ajoutée, ce qui est le sens // voulu : « depuis un socle figé, jusqu'à la dernière du dépôt ». // Story 25-2-c : 34 → 35, frontière inchangée (69 - 35 = 34). + // Story 25-4-c3-a1 : 35 → 36, frontière inchangée (70 - 36 = 34). // // ⚠️ J'ai d'abord porté `total` à 69 SANS toucher à ce soustracteur : le socle // est alors passé de 34 à 35 migrations, la fenêtre a glissé d'un cran, et le // test a rougi non plus sur son compteur mais sur `COUNT(accounts) : expected // 4, got 2` — un échec qui ne ressemble en rien à sa cause. *Les deux nombres // se bougent du même pas, et le commentaire ci-dessus le disait.* - let n_before_upgrade_window = total - 35; + let n_before_upgrade_window = total - 36; apply_migrations_up_to(&pool, n_before_upgrade_window) .await - .expect("apply_migrations_up_to(total - 35) failed"); + .expect("apply_migrations_up_to(total - 36) failed"); // Étape 2 : seed 1 company + 1 user + 2 accounts + 1 invoice + 1 contact. let company_id: i64 = sqlx::query_scalar( @@ -243,7 +245,7 @@ async fn upgrade_path_preserves_data(pool: MySqlPool) { .await .expect("INSERT invoice failed"); - // Étape 3 : appliquer les 35 migrations restantes via MIGRATOR.run(). + // Étape 3 : appliquer les 36 migrations restantes via MIGRATOR.run(). kesh_db::MIGRATOR .run(&pool) .await diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 96d451e3b..5f5cf114e 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -1613,6 +1613,9 @@ settings-invoicing-numbering-title = Nummerierung settings-invoicing-seq-range = (NN zwischen 1 und 10) settings-invoicing-description-help = Platzhalter: {"{"}YEAR{"}"}, {"{"}INVOICE_NUMBER{"}"}, {"{"}CONTACT_NAME{"}"}. settings-invoicing-description-invalid = Ungültiger Buchungstext +settings-invoicing-rounding-title = Rundungsdifferenzen +settings-invoicing-rounding-hint = Konto, das die Differenz von höchstens einem halben Rappen aufnimmt, die eine auf den Rappen gerundete Zahlung auf einer Rechnung hinterlässt. Aufwand oder Ertrag, buchbar. Falls es noch nicht existiert, legen Sie es im Kontenplan an und wählen Sie es dann hier aus. +settings-invoicing-rounding-account = Konto für Rundungsdifferenzen settings-invoicing-default-accounts-title = Standardkonten settings-invoicing-select-none = — Auswählen — settings-invoicing-load-error = Fehler beim Laden diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 7bbf7ca85..b08af60ee 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -1620,6 +1620,9 @@ settings-invoicing-numbering-title = Numbering settings-invoicing-seq-range = (NN between 1 and 10) settings-invoicing-description-help = Placeholders: {"{"}YEAR{"}"}, {"{"}INVOICE_NUMBER{"}"}, {"{"}CONTACT_NAME{"}"}. settings-invoicing-description-invalid = Invalid entry label +settings-invoicing-rounding-title = Rounding differences +settings-invoicing-rounding-hint = Account that receives the difference of at most half a centime that a payment rounded to the centime leaves on an invoice. Expense or revenue, postable. If it does not exist yet, create it in the chart of accounts, then choose it here. +settings-invoicing-rounding-account = Rounding differences account settings-invoicing-default-accounts-title = Default accounts settings-invoicing-select-none = — Select — settings-invoicing-load-error = Loading error diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 59fdad516..0e17c9cfd 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -600,6 +600,9 @@ settings-invoicing-numbering-title = Numérotation settings-invoicing-seq-range = (NN entre 1 et 10) settings-invoicing-description-help = Placeholders : {"{"}YEAR{"}"}, {"{"}INVOICE_NUMBER{"}"}, {"{"}CONTACT_NAME{"}"}. settings-invoicing-description-invalid = Libellé invalide +settings-invoicing-rounding-title = Différences d'arrondi +settings-invoicing-rounding-hint = Compte qui reçoit l'écart d'un demi-centime au plus qu'un paiement arrondi au centime laisse sur une facture. Charge ou produit, imputable. S'il n'existe pas encore, créez-le dans le plan comptable, puis choisissez-le ici. +settings-invoicing-rounding-account = Compte de différences d'arrondi settings-invoicing-default-accounts-title = Comptes par défaut settings-invoicing-select-none = — Sélectionner — settings-invoicing-load-error = Erreur de chargement diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 5bcd832b7..55c658f3e 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -1614,6 +1614,9 @@ settings-invoicing-numbering-title = Numerazione settings-invoicing-seq-range = (NN tra 1 e 10) settings-invoicing-description-help = Segnaposto: {"{"}YEAR{"}"}, {"{"}INVOICE_NUMBER{"}"}, {"{"}CONTACT_NAME{"}"}. settings-invoicing-description-invalid = Testo di registrazione non valido +settings-invoicing-rounding-title = Differenze di arrotondamento +settings-invoicing-rounding-hint = Conto che riceve la differenza di al massimo mezzo centesimo che un pagamento arrotondato al centesimo lascia su una fattura. Costo o ricavo, registrabile. Se non esiste ancora, createlo nel piano dei conti e poi sceglietelo qui. +settings-invoicing-rounding-account = Conto delle differenze di arrotondamento settings-invoicing-default-accounts-title = Conti predefiniti settings-invoicing-select-none = — Selezionare — settings-invoicing-load-error = Errore di caricamento diff --git a/docs/manual/fr/admin-manual.pdf b/docs/manual/fr/admin-manual.pdf index ae3e9545f..d4c8452e2 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 1e662d596..bad334b9d 100644 --- a/docs/manual/fr/admin-manual.tex +++ b/docs/manual/fr/admin-manual.tex @@ -2001,6 +2001,8 @@ \subsection{Loi sur la TVA (LTVA)} Ces comptes sont ajoutés automatiquement aux plans comptables (PME, indépendant, association) ; les installations existantes sont complétées sans modifier les comptes déjà utilisés. Tant que le compte \emph{TVA due} n'est pas configuré, la validation d'une facture portant de la TVA est refusée (message « configuration requise »). L'usage côté comptable (décompte, réconciliation) est décrit dans le \emph{manuel utilisateur}, section \emph{Décompte TVA}. +\paragraph{Compte de différences d'arrondi.} Une facture dont le total porte plus de deux décimales --- le cas d'une quantité ou d'un prix unitaire fractionnaire --- laisse, une fois payée au centime, un écart d'un demi-centime au plus. Cet écart est destiné au \textbf{compte de différences d'arrondi}, désigné depuis \emph{Paramètres} → \emph{Facturation}, section \emph{Différences d'arrondi}. Ce doit être un compte de \textbf{charge ou de produit}, actif et \textbf{imputable} (pas un compte de regroupement). S'il n'existe pas encore dans votre plan comptable, créez-le depuis le plan comptable, puis choisissez-le dans les paramètres. Le réglage est facultatif tant qu'aucun écart ne se présente. \textbf{Dans cette version, il n'est encore lu par aucune écriture} : le passage des écarts en écriture arrive avec une version prochaine, qui le dira ici. + Le format de décompte officiel AFC (e-décompte ESTV) et la conformité LTVA Art.~70 (factures électroniques signées) restent hors périmètre à ce stade. \subsection{Documentation à conserver} diff --git a/docs/migrations-idempotence-audit.md b/docs/migrations-idempotence-audit.md index e55daa801..72874a565 100644 --- a/docs/migrations-idempotence-audit.md +++ b/docs/migrations-idempotence-audit.md @@ -14,7 +14,7 @@ Cet audit est **purement informationnel** : les fichiers `.sql` historiques ne s - **`tracked-by-sqlx`** — l'idempotence est garantie uniquement par le tracking `_sqlx_migrations`. Re-exécution manuelle hors sqlx échouerait avec un code d'erreur MariaDB précisé en justification (1050 table exists, 1060 duplicate column, 1061 duplicate key, 1091 index not found, etc.). - **`no`** — re-exécution échouerait toujours (non utilisé sur le repo actuel). -## Table d'audit (69 migrations) +## Table d'audit (70 migrations) > Une ligne par fichier de `crates/kesh-db/migrations/`, **triée par version croissante**. Toute nouvelle migration s'ajoute **à l'intérieur de ce tableau**, jamais en fin de fichier — cf. la note de la section « Maintenance future ». @@ -89,12 +89,13 @@ Cet audit est **purement informationnel** : les fichiers `.sql` historiques ne s | `20260910000001_audit_log_actor_label.sql` | yes | `ALTER TABLE audit_log ADD COLUMN actor_label VARCHAR(64) NOT NULL DEFAULT '' , DROP FOREIGN KEY fk_audit_log_user` + un `UPDATE` de backfill. **Verdict `yes`** : le backfill est gardé par `actor_label = ''`, donc une re-exécution manuelle hors sqlx est **sans effet** — c'est le critère du verdict, et non l'absence de DDL *(le DDL, lui, échouerait en `1060 Duplicate column name` ; c'est le backfill qui porte le verdict, cf. la mise en garde de `20260909000001`)*. ⚠️ **ELLE ÉCRIT DES DONNÉES** ⇒ **inscrite au registre `POST_RESTORE_BACKFILLS` en CLASSE B** (garde-fou P7), sentinelle `(audit_log, actor_label)` — valide, la colonne étant ajoutée par le **MÊME** `ALTER TABLE` que l'`UPDATE`. **Prépare la conservation de la piste au restore** (Story 25-1a) : `user_id` devient un **pointeur logique sans FK**, comme `actor_api_key_id` et `entity_id` avant lui, et `actor_label` porte le nom de l'acteur **au moment de l'écriture** — un instantané, pas une jointure. Sans lui, retirer la FK transformerait un mensonge (un `user_id` réattribué à quelqu'un d'autre après import) en trou. **Non-breaking** (`ADD COLUMN` avec défaut + `DROP FOREIGN KEY` — un binaire antérieur ignore la colonne et n'a pas besoin de la FK pour insérer) → pas de bump `kesh_version_min_required`, donc pas de bump de version Cargo (P1/P2/P2-bis) — Story 25-1a, #376. | | `20260915000001_audit_log_company_id.sql` | yes | `ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS company_id BIGINT NULL, ADD INDEX IF NOT EXISTS idx_audit_log_company_date (company_id, created_at)` + un `UPDATE … JOIN users` de backfill gardé `company_id IS NULL`. **Verdict `yes`** : le DDL est conditionnel — rejoué, il rend les notes 1060 et 1061 sans erreur — et le backfill est gardé, donc une re-exécution manuelle hors sqlx est **sans effet**. ⚠️ **ELLE ÉCRIT DES DONNÉES** ⇒ triée en **`EXEMPT_MIGRATIONS`** (garde-fou P7) avec `ExemptionBasis::PerishableSince(20260827000001)` : fondement de **parc** et non de fenêtre — aucune version publiée dans `[20260827000001 .. 20260915000001)`. **Pas** au registre de rejeu, par arbitrage du 2026-09-11 : ni `COALESCE` ni rejeu, `NULL` signifiant « société indéterminable », état permanent. `company_id` est un **pointeur logique sans FK**, rempli pour les entrées futures par sous-SELECT dans `insert_in_tx` ; elle prépare la consultation par société de la Story 25-1c (#378). **Non-breaking** (`ADD COLUMN` nullable + `ADD INDEX`) → pas de bump `kesh_version_min_required`, donc pas de bump de version Cargo (P1/P2/P2-bis) — Story 25-1c-zero, refs #378. | | `20260917000001_journal_entry_number_sequences.sql` | tracked-by-sqlx | `CREATE TABLE journal_entry_number_sequences` sans `IF NOT EXISTS` ; re-exécution hors sqlx échouerait erreur 1050. ⚠️ *C'est le DDL qui porte le verdict : l'`INSERT … SELECT` d'amorçage qui le suit n'est atteignable qu'une fois la table créée, donc jamais rejouable seul — à la différence de `20260909000001`, où un `UPDATE` nu et gardé décide du verdict `yes`.* **Le `MAX(entry_number) + 1` des écritures était l'EXCEPTION du dépôt** : `invoice_number_sequences` (20260417000001) et `credit_note_number_sequences` sont déjà des compteurs persistants de portée `(company_id, fiscal_year_id)` — exactement celle d'`entry_number`. Ferme la **réattribution** d'un numéro d'écriture (supprimer la dernière libérait son numéro pour une écriture au contenu différent — symptôme **muet**) ; le **trou** subsiste et reste explicable, assumé par un critère de la story. ⚠️ **ELLE ÉCRIT DES DONNÉES** (amorçage depuis l'existant, sans lequel le compteur repartirait de 1 et heurterait aussitôt l'`UNIQUE`) ⇒ triée en **`EXEMPT_MIGRATIONS`** (garde-fou P7), fondement **couverture** (`ExemptionBasis::Durable`) — et **non** `Hors fenêtre`, que le contrôle symétrique a refusé : cette migration *est* la fenêtre, et le marqueur est réservé à un fait de chronologie. La migration crée elle-même la table, donc un backup dépourvu de ses lignes est un backup dépourvu de la table — refusé au contrôle de couverture de `parse_and_verify`. Même motif que `20260428000001` (vat_rates). ⛔ **Sa création de table REFERME la fenêtre d'importabilité** et périme les deux entrées alors au registre (`20260828000001`, `20260910000001`), déplacées vers `RETIRED_BACKFILLS` et exemptées — le module annonçait ce cas : *« toute nouvelle table applicative aura le même effet »*. **Non-breaking** (`CREATE TABLE` d'une table qu'un binaire antérieur ignore, continuant de servir son `MAX + 1`) → pas de bump `kesh_version_min_required` ni de version Cargo (P1/P2/P2-bis) — Story 25-2-c, refs #381. | +| `20260930000001_invoice_settings_rounding_account.sql` | tracked-by-sqlx | `ALTER TABLE company_invoice_settings ADD COLUMN default_rounding_account_id BIGINT NULL` + FK `fk_cis_rounding` (`ON DELETE RESTRICT`), sans `IF NOT EXISTS` ; re-exécution hors sqlx échouerait erreur 1060. **DDL seul, aucune donnée écrite** : les sociétés existantes désignent leur compte de différences d'arrondi dans *Paramètres → Facturation* (arbitrage du 2026-09-30) — triage P7 sans objet. Patron des comptes TVA (`20260614000001`). **Non-breaking** (`ADD COLUMN` nullable ignoré par un binaire antérieur, qui liste ses colonnes) → pas de bump `kesh_version_min_required` ni de version Cargo (P1/P2/P2-bis) — Story 25-4-c3-a1, refs #476. | ## Statistiques -- **Total** : 69 migrations (26 historiques + 1 Story 10-2 + 1 Story v011-2 + 1 Story v014-1 + 2 Story 17-2a + 2 Story 17-4a + 1 Story 11-1 + 1 Story 18-1a + 1 Story 12-1 + 1 Story 12-2 + 1 Story 12-3 + 1 Story 12-5b + 1 Story 19-1 + 1 Story 19-3 + 1 Story 19-4 + 1 Story 19-5 + 3 #213 + 1 Story 20-1 + 3 Story 20-3b1 + 1 Story 21-1 + 2 Story 21-3 + 1 Story 21-5a + 1 Story 14-3a + 1 Story 16-1a + 1 Story 16-1a-bis + 1 Story 16-2a + 1 Story 16-3a + 1 Story 16-3b + 1 Story 22-1 + 1 Story 24-2 + 1 Story 24-3 + 1 Story 24-4a + 1 Story 24-4c + 1 Story 24-5 + 1 Story 25-1a + 1 Story 25-1c-zero + 1 Story 25-2-c). +- **Total** : 70 migrations (26 historiques + 1 Story 10-2 + 1 Story v011-2 + 1 Story v014-1 + 2 Story 17-2a + 2 Story 17-4a + 1 Story 11-1 + 1 Story 18-1a + 1 Story 12-1 + 1 Story 12-2 + 1 Story 12-3 + 1 Story 12-5b + 1 Story 19-1 + 1 Story 19-3 + 1 Story 19-4 + 1 Story 19-5 + 3 #213 + 1 Story 20-1 + 3 Story 20-3b1 + 1 Story 21-1 + 2 Story 21-3 + 1 Story 21-5a + 1 Story 14-3a + 1 Story 16-1a + 1 Story 16-1a-bis + 1 Story 16-2a + 1 Story 16-3a + 1 Story 16-3b + 1 Story 22-1 + 1 Story 24-2 + 1 Story 24-3 + 1 Story 24-4a + 1 Story 24-4c + 1 Story 24-5 + 1 Story 25-1a + 1 Story 25-1c-zero + 1 Story 25-2-c + 1 Story 25-4-c3-a1). - **Idempotence `yes`** : 8 (`country_code`, `invoice_paid_at`, `bank_imports_relax_hash_unique`, `users_email`, `invoice_lines_revenue_account_backfill`, `closing_accounts_not_postable`, `audit_log_actor_label`, `audit_log_company_id`). -- **Idempotence `tracked-by-sqlx`** : 61 (toutes les autres). +- **Idempotence `tracked-by-sqlx`** : 62 (toutes les autres). - **Idempotence `no`** : 0. > Ces compteurs se **recomptent depuis le tableau**, ils ne s'incrémentent pas de confiance. Précédent : jusqu'à la Story 16-1a (2026-07-28), `tracked-by-sqlx` annonçait 45 pour 52 réelles — 11 lignes de migration des Epics 20/21 avaient été ajoutées **sous** la section « Maintenance future » au lieu du tableau, où elles ne se rendaient même pas en markdown et échappaient à tout comptage. Sept passes de `validate` avaient « re-vérifié » ce compteur sans le recompter. Lignes remontées et compteurs rétablis en revue de code de la 16-1a. diff --git a/frontend/src/lib/features/invoices/invoices.types.ts b/frontend/src/lib/features/invoices/invoices.types.ts index c2abf9f61..ad7aef1dd 100644 --- a/frontend/src/lib/features/invoices/invoices.types.ts +++ b/frontend/src/lib/features/invoices/invoices.types.ts @@ -112,6 +112,8 @@ export interface InvoiceSettingsResponse { defaultVatDecompteAccountId: number | null; defaultSalesJournal: JournalCode; journalEntryDescriptionTemplate: string; + /** Compte de différences d'arrondi — charge ou produit (Story 25-4-c3-a1). */ + defaultRoundingAccountId: number | null; version: number; } @@ -124,6 +126,8 @@ export interface UpdateInvoiceSettingsRequest { defaultVatDecompteAccountId: number | null; defaultSalesJournal: JournalCode; journalEntryDescriptionTemplate: string; + /** Compte de différences d'arrondi — charge ou produit (Story 25-4-c3-a1). */ + defaultRoundingAccountId: number | null; version: number; } diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts index d68b723a3..723844b02 100644 --- a/frontend/src/lib/shared/i18n-keys.test.ts +++ b/frontend/src/lib/shared/i18n-keys.test.ts @@ -343,7 +343,13 @@ const ATTENDU = { // `grep -c "i18nMsg("` aux deux bornes de `ReconciliationProposals.svelte` // (17 → 18) — la mention `reconciliation-labels-amount-due-of` (« reste dû // sur … »), clé neuve dans les quatre catalogues. - sitesTotal: 1752, + // + // Story 25-4-c3-a1 (#476) : **1752 → 1756**, recompté par ce test et par + // `grep -o "i18nMsg("` aux deux bornes de `settings/invoicing/+page.svelte` + // (+4) — la section « Différences d'arrondi » : titre, aide, libellé du + // sélecteur (trois clés neuves `settings-invoicing-rounding-*` dans les quatre + // catalogues) et son option vide, qui réemploie `settings-invoicing-select-none`. + sitesTotal: 1756, sitesNonResolus: 34, relais: 7, sitesGabarit: 10, diff --git a/frontend/src/routes/(app)/settings/invoicing/+page.svelte b/frontend/src/routes/(app)/settings/invoicing/+page.svelte index 6c6bb7db4..e6d752d11 100644 --- a/frontend/src/routes/(app)/settings/invoicing/+page.svelte +++ b/frontend/src/routes/(app)/settings/invoicing/+page.svelte @@ -42,6 +42,7 @@ let vatPayableId = $state(null); let vatRecoverableId = $state(null); let vatDecompteId = $state(null); + let roundingId = $state(null); let salesJournal = $state('Ventes'); let version = $state(0); @@ -56,6 +57,13 @@ let liabilityAccounts = $derived( accounts.filter((a) => a.active && a.postable && a.accountType === 'Liability'), ); + // Story 25-4-c3-a1 : un écart d'arrondi est un résultat — charge ou produit. + let resultAccounts = $derived( + accounts.filter( + (a) => + a.active && a.postable && (a.accountType === 'Expense' || a.accountType === 'Revenue'), + ), + ); // Issue #271 : chaque ` + + {#each roundingOptions as a (a.id)} + + {/each} + + + +
-