diff --git a/CHANGELOG.md b/CHANGELOG.md index 25986289c..e120cc9fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ### Fixed +- **Le tableau de bord disait faux ([#388](https://github.com/guycorbaz/kesh/issues/388), [#389](https://github.com/guycorbaz/kesh/issues/389)).** La tuile *Dernières écritures* n'appelait rien et affichait « Aucune écriture » sur une comptabilité pleine ; elle liste désormais les cinq dernières écritures. *Factures ouvertes* n'affichait aucun montant et, pour un rôle Consultation, toujours « Aucune facture ouverte » : elle donne maintenant le nombre de factures ouvertes et leur **reste dû**, dont la part échue, à tous les rôles. Une tuile qui ne peut pas se charger le dit. Le solde des comptes bancaires est nommé **solde comptable** — il vient du grand livre, pas de la banque —, et la tuile le confronte au **dernier relevé CAMT importé** : solde du relevé, date et **écart**, corrigé des dates de valeur des mouvements rapprochés. Un compte du grand livre partagé par plusieurs comptes bancaires n'est plus compté deux fois dans le total, et les soldes ne passent plus par des nombres flottants. + - **La balance des comptes ne concordait pas avec le bilan ([#385](https://github.com/guycorbaz/kesh/issues/385)).** Elle ne montrait que les **mouvements** de la période : le compte « clients » de la balance 2026 ne valait pas celui du bilan 2026, qui reporte les soldes depuis l'origine. Deux rapports, un compte, deux nombres — c'est ce qu'un réviseur trouve en dix minutes. **Désormais**, chaque compte porte son **solde d'ouverture**, ses mouvements et son **solde de clôture**. Un compte de bilan reporte son solde depuis l'origine ; un compte de produits ou de charges repart de zéro au début de l'exercice. La clôture d'un compte de bilan **est** son solde au bilan ; celle d'un compte de résultat est son montant **depuis le début de l'exercice** — égal au compte de résultat quand la période commence au premier jour de l'exercice —, et ouverture comme clôture concordent toujours avec le grand livre. Une ligne **« Résultat reporté (calculé) »** porte le résultat des exercices antérieurs, qu'aucun compte ne porte faute d'écriture de clôture, et un **contrôle ✓/⚠️** vérifie que les ouvertures s'équilibrent avec elle (écrit en toutes lettres dans le PDF quand il tombe). Un compte archivé figure tant qu'il porte un solde. @@ -52,6 +54,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ### Changed +- **« Total liquidités » devient « Total (solde comptable) »** sur le tableau de bord ([#389](https://github.com/guycorbaz/kesh/issues/389)) : le chiffre est le solde du grand livre, pas l'argent en banque. *(API : `GET /api/v1/bank-accounts` ajoute `statementClosingBalance`, `statementDate` et `ledgerBalanceAtStatement` ; les montants restent des chaînes décimales.)* + - **La balance des comptes change de colonnes — écran, PDF et CSV ([#385](https://github.com/guycorbaz/kesh/issues/385)).** Ouverture | Débit | Crédit | Clôture : la colonne **« Solde »**, qui était le net des mouvements de la période et se prenait pour un solde de compte, disparaît de l'écran et du PDF. ⚠️ **Dans le CSV, la colonne `Solde` est remplacée** par `SoldeOuverture` (avant `TotalDebit`) et `SoldeCloture` (en dernier) : un outil qui lit ce fichier par position doit être adapté. Le CSV porte aussi la ligne du résultat reporté. *(API : `openingBalance` et `closingBalance` par compte, `retainedEarnings` et `openingBalanced` au rapport ; `balance` garde son sens — le net des mouvements.)* - **Le bouton *Créer un avoir* s'affiche aussi sur une facture payée**, et c'est voulu. Il était masqué sur une facture payée mais pas sur une facture réglée en partie : l'écran ne couvrait que la moitié de la règle. Il reste désormais visible, et Kesh explique le refus dans le dialogue — comme le bouton *Dévalider*. diff --git a/_bmad-output/implementation-artifacts/25-6-a-review-prompt-p1.md b/_bmad-output/implementation-artifacts/25-6-a-review-prompt-p1.md new file mode 100644 index 000000000..09d7a141f --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-a-review-prompt-p1.md @@ -0,0 +1,46 @@ +# Prompt — revue de code P1, Story 25-6-a (le tableau de bord dit vrai) + +*Versionné le 2026-10-03. Trois lentilles (Sonnet), contexte frais chacune.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-a-tableau-de-bord`. **Diff à revoir : `git diff 91ca244f 9e17de0c`** +(non-régression `8713c9de`, backend `297003c2`, frontend et docs `9e17de0c`). Fiche : +`_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md` (AC 1–8, limites, Dev Agent Record). Issues : +`gh issue view 388`, `389`. Règles : `CLAUDE.md`. + +## Lentilles + +- **A — Blind hunter** : le diff **seul**, sans la fiche. SQL de `list_by_company_with_balances` (liaisons dans l'ordre — + compte-les contre les `?` ; sous-requêtes corrélées ; `ROW_NUMBER()` ; `CAST … AS SIGNED` ; filtres de société dans + CHAQUE sous-requête) ; `Decimal` ; Svelte 5 (`$props`, `$derived`, `{@const}`) ; `big.js` (aucun `Number` sur un + montant non arrondi) ; gestion d'erreur des quatre chargements de la page. +- **B — Edge-case hunter** : le diff et le code environnant. + - Le calcul, refait chiffré : écriture de rapprochement à **plusieurs lignes** sur le compte lié ; rapprochement par + **éclatement** (split) ; relevé dont `period_to` est antérieur à toute écriture ; `include_archived` vrai/faux et le + partage ; un compte bancaire sans relevé mais lié ; deux sociétés. + - La page : un rôle Consultation (403 évité ?), un appel qui échoue pendant que les autres réussissent, le mode guidé, + un montant négatif, `lastTransactionDate` désormais formaté (`formatSwissDate`) — d'autres lecteurs l'attendaient-ils + brut ? `formatChfBalance` : ses appelants passent-ils tous une chaîne (`grep -rn formatChfBalance frontend/src`) ? + - Les E2E neufs : déterministes ? la facture créée par un test est-elle la seule ouverte (le compteur peut-il valoir + autre chose) ? l'axe sans relevé couvre-t-il ce qu'il prétend ? +- **C — Acceptance auditor** : chaque AC tenu ? Recompte depuis la source : `sitesTotal`, `relais`, `sitesNonResolus` + (`frontend/src/lib/shared/i18n-keys.test.ts`), parité des clés `homepage-*` dans les 4 locales, décomptes de tests du + Dev Agent Record aux deux bornes `91ca244f` / `9e17de0c`. **Pars du symptôme** : + `grep -rn "liquidit\|currentBalance\|Number(" frontend/src/lib/features/bank-accounts frontend/src/lib/features/homepage "frontend/src/routes/(app)"`. + Les tests prouvent-ils ce qu'ils disent (chaque mutation déclarée tuée l'est-elle par l'assertion écrite) ? Le + **manuel** (`docs/manual/fr/user-manual.tex` et **PDF aplati**, `pdftotext … | tr '\n' ' ' | tr -s ' '` vers + `target/gate-logs/`) dit-il ce que fait le code — § *Tableau de bord*, *Solde comptable, relevé et écart*, *Import + bancaire* ? Le CHANGELOG ? Les deux écarts déclarés au Dev Agent Record (testid remplacé, relevé non couvert en E2E) + sont-ils acceptables ? + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (commande et sortie, ou code cité relu), +scénario d'échec, correction. Pour tout finding affirmant qu'un code est absent ou présent : la sortie d'un `grep -nF`. +⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/25-6-a-review-prompt-p2.md b/_bmad-output/implementation-artifacts/25-6-a-review-prompt-p2.md new file mode 100644 index 000000000..f477788f4 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-a-review-prompt-p2.md @@ -0,0 +1,45 @@ +# Prompt — revue de code P2, Story 25-6-a (le tableau de bord dit vrai) + +*Versionné le 2026-10-03. Une passe (Opus), contexte frais, les trois lentilles. Trend : P1 0 CRITICAL/HIGH, 4 MED +(Sonnet ×3). La remédiation de P1 touche plusieurs modules (SQL `kesh-db`, trois composants, i18n, manuel) : protocole +complet, avec une attention particulière au diff de la remédiation.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-a-tableau-de-bord`. **Diff complet : `git diff 91ca244f 9f14ca58`** ; +**remédiation de P1 : `git diff 6489d6b7 9f14ca58`**. Fiche : `_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md` +(AC 1–8, limites, Dev Agent Record, Change Log). Issues : `gh issue view 388`, `389`. Règles : `CLAUDE.md`. + +## Ce que tu vérifies + +1. **La remédiation de P1** : + - le SQL de `list_by_company_with_balances` (`crates/kesh-db/src/repositories/bank_accounts.rs`) : la table `agg` + désormais bornée aux comptes liés — compte les `?` contre les `.bind` (cinq) ; le résultat de `currentBalance` est-il + inchangé pour TOUT compte lié, y compris un compte lié archivé avec `include_archived` faux ? + - le test `seule_une_transaction_rapprochee_corrige` (`crates/kesh-db/tests/bank_account_statement_gap.rs`) tue-t-il + vraiment la mutation « filtre `status` retiré » — les DEUX sous-requêtes portent-elles le filtre, et le test + exerce-t-il celle qui compte ? + - `BankAccountsCard.svelte` : l'état `error`, l'« écart non calculable » (sa condition est-elle juste dans tous les cas — + compte non lié avec relevé, compte partagé sans relevé ?), le `{#if}`/`{:else}` bien fermé ; + - `format.ts` : le `-0` évité — `Big(…).eq(0)` sur `-0.004` arrondi ? + - la page (`routes/(app)/+page.svelte`) : la tuile bancaire en échec, puis un rechargement réussi ? + - le reclassement de F1 (« plusieurs transactions sur une écriture, non atteignable ») : vérifie les cinq sites de + `reconciliation.rs` cités au Change Log — lient-ils tous une écriture créée dans la même fonction ? Un autre + chemin (`invoices.rs`, règlement de facture, 25-3) pose-t-il `matched_entry_id` ? +2. **Ce que P1 n'a pas couvert** : la page des comptes bancaires (`routes/(app)/bank-accounts/+page.svelte`) avec + `currentBalance` en chaîne — tri, comparaisons, `formatBalance` ; tout autre lecteur de `BankAccountSummary` qui + ferait de l'arithmétique sur `currentBalance` (`grep -rn "currentBalance" frontend/src`). +3. **Le manuel** — `docs/manual/fr/user-manual.tex` § *Tableau de bord*, *Solde comptable, relevé et écart*, *Import + bancaire*, et les trois chemins « Administration → Comptes bancaires » ; **PDF aplati** vers `target/gate-logs/`. +4. **Recomptes** : `sitesTotal` 1767 (`frontend/src/lib/shared/i18n-keys.test.ts`, recompte `grep -o "i18nMsg("` des + trois tuiles), parité des clés `homepage-*` (30 par locale) et `bank-accounts-labels-balance` dans les 4 locales. + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (commande et sortie, `grep -nF` pour toute +présence ou absence, calcul chiffré), correction proposée. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`, `node -e` sans effet de bord. diff --git a/_bmad-output/implementation-artifacts/25-6-a-review-prompt-p3.md b/_bmad-output/implementation-artifacts/25-6-a-review-prompt-p3.md new file mode 100644 index 000000000..cd77d39d2 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-a-review-prompt-p3.md @@ -0,0 +1,35 @@ +# Prompt — revue de code P3 ciblée, Story 25-6-a + +*Versionné le 2026-10-03. **Une lentille** (Haiku), contexte frais — passe ciblée sur la remédiation de P2. Trend : +P1 4 MED (Sonnet ×3) → P2 2 MED (Opus). La remédiation de P2 ne touche, en production, qu'une chaîne de repli Svelte ; +le reste est tests, AC et manuel.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-a-tableau-de-bord`. **Diff à relire, un seul commit, aplati : +`git show 4f94afd9`.** + +## Lentille — regression hunter + +1. **Le filtre `status` sur ses deux copies** : `grep -nF "bt.status = 'reconciled'" crates/kesh-db/src/repositories/bank_accounts.rs` + — deux lignes ? Le test `seule_une_transaction_rapprochee_corrige_le_miroir` + (`crates/kesh-db/tests/bank_account_statement_gap.rs`) monte-t-il exactement le cas de la SECONDE sous-requête + (`booked_after_entered_before` : `booking_date > period_to`, écriture `≤ period_to`) ? Calcule ce qu'il attend, avec et + sans le filtre. +2. **L'AC 4 amendé** (`_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md`) : dit-il la même chose que le + code (`bank_accounts.rs`, branche `(None, Some(r))`) et que le test `sans_compte_lie_le_releve_reste` ? Reste-t-il dans + la fiche une phrase qui dit encore les trois champs `null` sans compte lié ? +3. **Le test du câblage** (`frontend/src/routes/(app)/homepage-page.test.ts`) : mocke-t-il exactement les modules que la + page importe (`grep -n "import" "frontend/src/routes/(app)/+page.svelte"`) ? Prouverait-il quelque chose si + `listBankAccounts` réussissait ? +4. **Résidus** : `grep -rn "Deux limites\|'Solde')" docs/manual/fr/user-manual.tex "frontend/src/routes/(app)/bank-accounts/+page.svelte"`. + +## Ce que tu rends + +Findings avec sévérité, endroit, **preuve obligatoire : la commande ET sa sortie, ou le calcul** — un finding sans +preuve sera rejeté, un « 0 finding » sans les preuves des quatre vérifications aussi. ⛔ La liste des axes exercés ET non +exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base (`scripts/*` dont +`scripts/prepare-release.sh`, `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, `cargo test`/`nextest`, +`npm run`, `npx`, `gh issue create`/`comment`/`edit`). Autorisés : lecture, `grep`, `sed`, `git log`/`show`/`diff`. diff --git a/_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md b/_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md new file mode 100644 index 000000000..4bcc673d1 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md @@ -0,0 +1,444 @@ +# Story 25.6-a : Le tableau de bord dit vrai — dernières écritures, factures ouvertes, solde comptable + +Status: done + +**Issues : [#388], [#389]**, que cette story **ferme** : la PR porte `closes #388, closes #389` dans le **titre ET le +corps**. Branche `story/25-6-a-tableau-de-bord`, partie de `main` (`d7c74f02`). + +⛔ **Issue du DÉCOUPAGE de la 25-6** (2026-10-03, recommandation non contestée de Guy) : la 25-6 réunissait trois issues +isolées. #388 et #389 portent sur la page d'accueil ; **#387** (figer le PDF d'une facture) touche stockage, migration +et rendu — elle part en **25-6-b**. Ensemble, elles dépassaient le seuil de cinq modules de la § *Règle de splitting +préventif*. + +## Story + +En tant que **personne qui ouvre Kesh**, +je veux que **la page d'accueil montre ce que contiennent réellement mes livres** — mes dernières écritures, mes factures +ouvertes et leur montant, et un solde bancaire nommé pour ce qu'il est, +afin de **ne pas lire « Aucune écriture » sur une comptabilité pleine**, ni prendre un solde comptable pour l'argent en +banque. + +## Les faits, vérifiés dans le code + +- **La tuile « Dernières écritures » n'appelle rien** (`frontend/src/routes/(app)/+page.svelte:82-98`) : elle affiche + inconditionnellement `homepage-entries-empty` (« Aucune écriture. ») ou sa variante guidée. Le endpoint existe : + `GET /api/v1/journal-entries` (`crates/kesh-api/src/routes/journal_entries.rs:240-405`), **tout rôle authentifié** + (`lib.rs:710-715`, `authenticated_routes`), tri par défaut `entry_date` **décroissant** (`kesh-core/src/listing/mod.rs`), + `limit` borné à 500. Client frontend : `features/journal-entries/journal-entries.api.ts:27`. +- **La tuile « Factures ouvertes » n'affiche aucun montant** (`+page.svelte:100-128`) : seulement « N facture(s) à + rappeler », **pour Admin/Comptable**, depuis `/dunning/reminders` (Comptable+) — un rôle Consultation voit « Aucune + facture ouverte » en permanence. Le résumé existe : `GET /api/v1/invoices/due-dates` (`lib.rs:751`, **tout rôle**) rend + `DueDatesSummary { unpaidCount, unpaidTotal, overdueCount, overdueTotal }` (`kesh-db/src/repositories/invoices.rs`, `DueDatesSummary` et `due_dates_summary`) + — des **restes dus** depuis la 25-4-b1 (#416), factures `validated` non soldées. Client : `invoices.api.ts:118`. +- **Le « solde bancaire » est le solde comptable** : `bank_accounts::list_by_company_with_balances` + (`kesh-db/src/repositories/bank_accounts.rs:620-690`) rend `SUM(debit) − SUM(credit)` du compte de grand livre lié, + toutes dates. L'accueil l'affiche sous « Comptes bancaires » et le totalise en « **Total liquidités** » + (`+page.svelte:130-185`) — rien ne dit qu'il s'agit du solde **comptable**. +- **Le solde du relevé est déjà stocké** : `bank_imports.closing_balance DECIMAL(18,2) NULL` et `period_to DATE` (une + ligne par import, `idx_bank_imports_company_account_imported`) — jamais lu par l'accueil. +- ⚠️ **Montants en `Number`** : `bank-accounts.api.ts:55-69` convertit `currentBalance` (chaîne décimale du serveur) en + `Number`, et l'accueil somme ces nombres (`+page.svelte:62-65`). Contraire à la règle du dépôt (`big.js` pour tout + montant). Second consommateur : `routes/(app)/bank-accounts/+page.svelte:397`. +- **E2E existants de l'accueil** : `homepage-reminders.spec.ts`, `homepage-settings.spec.ts`. +- **Manuel** : `docs/manual/fr/user-manual.tex:215-235` (§ *Tableau de bord*) — « Dernières écritures — accès rapide au + journal », « Comptes bancaires — soldes calculés » : à réécrire. + +## Acceptance Criteria + +**AC 1 — Dernières écritures, branchées.** La tuile appelle `GET /api/v1/journal-entries?limit=5` (tri par défaut : +`entry_date DESC, entry_number DESC`) et liste au plus **cinq** écritures : date, numéro, libellé, montant (somme des +débits de l'écriture, calculée côté client en `big.js` depuis les lignes que la liste rend déjà — la liste lit les +lignes par écriture, cinq requêtes de plus : acceptable à cette taille, aucun champ d'API ajouté). Une contre-passation +s'affiche comme toute écriture, sous son propre numéro. Chaque ligne mène à l'écriture (`/journal-entries/{id}`). Sans écriture, le texte vide actuel +(et sa variante guidée) reste. Échec de l'appel : la tuile le dit (« indisponible ») au lieu de prétendre qu'il n'y a +rien — et l'échec **prime sur la variante guidée**. Le bouton « Saisir une écriture » n'apparaît que pour Admin/Comptable (Consultation ne saisit pas). + +**AC 2 — Factures ouvertes, chiffrées, pour tous les rôles.** La tuile appelle `GET /api/v1/invoices/due-dates?limit=1` +(seul le `summary` sert ; la liste paginée qui l'accompagne est réduite à une ligne) et affiche **N factures ouvertes — reste dû total** et, s'il y en a, **dont M échues — montant**. Les montants +passent par `big.js` et le formatage suisse du dépôt. Le compteur « à rappeler » (Admin/Comptable) reste. Le bouton +« Créer une facture » n'apparaît que pour Admin/Comptable. Sans facture ouverte, le texte vide actuel reste ; échec de +l'appel : « indisponible ». + +**AC 3 — Le solde est nommé « solde comptable », et compté une fois.** Dans la tuile « Comptes bancaires », chaque solde est libellé +**« Solde comptable »** et le total **« Total (solde comptable) »** au lieu de « Total liquidités ». Le libellé d'aide dit +d'où vient le chiffre : le grand livre, non la banque. **Le total ne compte chaque compte de grand livre qu'une fois** : +tous les comptes liés au même compte de grand +livre ont **par construction** le même solde (la somme porte sur le même `account_id`) : on en garde un, sans autre +règle ; rien n'empêche deux comptes bancaires d'être liés au même compte (`idx_bank_accounts_journal_account` n'est pas unique, +`validate_journal_account_id` ne contrôle pas l'usage), et le total les compterait deux fois ; une note le signale +quand des comptes partagent un même compte lié. La clé `homepage-bank-total-liquidity` est **remplacée** par +`homepage-bank-total-ledger` (4 locales) et le `data-testid` `homepage-bank-total-liquidity` par `homepage-bank-total` +(cité par aucun test). + +**AC 4 — Le solde du dernier relevé, et l'écart.** `GET /api/v1/bank-accounts` gagne, par compte, le **dernier relevé +importé** qui porte un solde de clôture : `statementClosingBalance` (`Decimal` | `null`), `statementDate` (`period_to`) et +`ledgerBalanceAtStatement` (le solde comptable du compte lié **au `period_to` inclus**). L'accueil affiche, quand un relevé +existe : « Relevé du {date} : {montant} » et, si `ledgerBalanceAtStatement ≠ statementClosingBalance`, **« Écart : +{différence} »** (solde comptable à cette date − relevé), mis en évidence. Sans relevé, rien de plus. Les champs neufs +sont `null` quand le compte n'a aucun relevé avec solde ; **sans compte de grand livre lié**, le relevé +(`statementClosingBalance`, `statementDate`) est **rendu** — c'est un fait bancaire — et seuls `currentBalance` et +`ledgerBalanceAtStatement` sont `null` (*amendé le 2026-10-03, revue de code P1, C-2 ; la première rédaction voulait les +trois champs à `null`*) ; `ledgerBalanceAtStatement` (donc l'écart) est aussi `null` quand **plusieurs comptes bancaires partagent le +compte de grand livre** — son solde ne s'attribue pas à l'un d'eux. « Dernier » = `period_to` le plus récent **parmi les +relevés qui portent un solde** (un relevé sans `closing_balance` plus récent ne masque pas un plus ancien), départagé par +`imported_at` puis `id`. **Signe** : `débit − crédit` du compte lié, quel que soit son type — c'est la convention du +relevé (un découvert est négatif au CAMT, `DBIT`, comme le solde d'un compte de passif lié à une ligne de crédit tirée). +**Date de valeur** (arbitrage de Guy, 2026-10-03, voie (a)) : le solde du relevé CAMT (`CLBD`, `camt053/mod.rs:541`) +vaut à la fin de la période du relevé (`period_to`, lu de `FrToDt/ToDtTm`, `:336` — la date propre du bloc `` +n'est pas lue) et reflète les mouvements par date de **comptabilisation bancaire**, alors que les écritures de rapprochement sont datées à la date +de **valeur** (`reconciliation.rs:1958`, `:2295`, `kesh-reconciliation/src/manual.rs:35`). `ledgerBalanceAtStatement` +est donc **corrigé** par les transactions bancaires **rapprochées** du compte (`bank_transactions.status = 'reconciled'`, +`matched_entry_id` non nul) dont les deux dates tombent de part et d'autre de `period_to` : **+** le mouvement que +l'écriture `matched_entry_id` porte **sur le compte de grand livre lié actuel** (`SUM(jel.debit − jel.credit)`, +`jel.entry_id = matched_entry_id AND jel.account_id = `, une fois par écriture) pour les transactions dont +`booking_date ≤ period_to` et dont l'écriture (`journal_entries.entry_date`) est **postérieure** ; **−** ce même +mouvement pour celles dont `booking_date > period_to` et dont l'écriture est **antérieure ou égale**. La date de +l'écriture est **lue**, jamais déduite de `value_date` (un rapprochement manuel peut la choisir). On somme la **ligne de +l'écriture**, pas `bank_transactions.amount` (validation P3) : une écriture posée sur un **autre** compte — lien du compte +bancaire **changé** après des rapprochements, `set_journal_account_id_for_company` ne le garde pas — sort d'elle-même de +la correction, et la correction ne suppose plus que la ligne bancaire vaille `amount` (vrai aujourd'hui sur les cinq +chemins de rapprochement, `reconciliation.rs:1575, 2022, 2386, 3082, 3547`, mais rien ne le garantit demain). Une transaction **non rapprochée** n'entre pas dans la correction : son absence du +grand livre est précisément l'écart que la tuile doit montrer. + +**Échelle** : l'écart se calcule **côté client**, en `big.js`, au centime — les deux soldes arrondis à 2 décimales +(`.round(2, Big.roundHalfUp)`, l'arrondi du dépôt — `vat-purchase.ts:12` ; **pas** `round_dp`, qui est l'arrondi +bancaire) avant la soustraction ; le relevé est en `DECIMAL(18,2)`, le grand livre à 4 décimales. **Partage** : le +nombre de comptes bancaires liés à un même compte de grand livre se compte **en SQL, sur tous les comptes de la +société, archivés compris** (l'historique d'un compte archivé alimente le même compte de grand livre) — la réponse ne +dépend donc pas de `includeArchived`. **Imports CSV** : ils ne portent jamais de solde de clôture +(`kesh-import/src/csv/parser.rs:342`) — un compte alimenté par CSV seul n'a pas de relevé, et un import CSV plus récent +ne masque pas un relevé CAMT plus ancien. + +**AC 5 — Les montants ne passent plus par `Number`.** `currentBalance` et les trois champs neufs restent des **chaînes +décimales** côté frontend (`BankAccountSummary`), le total et l'écart se calculent en `big.js`. `formatChfBalance` +(`features/bank-accounts/format.ts:21`, aujourd'hui `(balance: number)`) prend une **chaîne ou un `Big`** et formate +`new Big(v).toFixed(2)` — TypeScript refuse une chaîne nue dans `Intl.NumberFormat.format`. Les deux lecteurs de +`currentBalance` (accueil, page des comptes bancaires) sont mis à jour. Forme imposée : +`format(new Big(v).round(2, Big.roundHalfUp).toNumber())` — un `string` (même issu de `toFixed`) ne compile pas, et le +`Number` d'un montant **déjà arrondi au centime** est exact jusqu'à ~9·10¹³ CHF. Côté `Raw`, les champs neufs sont **optionnels** +et normalisés à `null` aux quatre sites de `parseBankAccount` (`bank-accounts.api.ts:101,125,149,164` — les routes de +mutation rendent un `BankAccount` nu) ; les fixtures de `bank-accounts.api.test.ts` et de +`BankAccountJournalLinkForm.test.ts` complétées. + +**AC 6 — L'API et la doc.** Les champs neufs dans la réponse de `GET /api/v1/bank-accounts` — seule route qui rend +`BankAccountWithBalance` (`routes/bank_accounts.rs:345-356`, inventaire fait) —, documentés au DTO. **Contrat** : une +seule requête par appel, **par compte bancaire** (et non plus par compte de grand livre) : `LEFT JOIN` du dernier relevé +avec solde du compte bancaire, et deux sommes sur le compte lié — `SUM(debit) − SUM(credit)` toutes dates (le +`currentBalance` d'aujourd'hui, **inchangé**) et la même somme bornée par `CASE WHEN je.entry_date <= period_to` —, pas +de seconde copie du calcul ; le dernier relevé par compte se réduit à une ligne avant les sommes (`ROW_NUMBER()`, +MariaDB 10.11). La **correction de date de valeur** se calcule dans une **table dérivée (ou sous-requête corrélée) par +compte bancaire**, jamais par une jointure `bank_transactions ⋈ journal_entries` dans le même `FROM` que les lignes du +grand livre — elle multiplierait ces lignes par le nombre de transactions et fausserait les deux `SUM`. Le test de +non-régression de `currentBalance` tourne **avec des transactions rapprochées présentes**, ce qui détecte ce produit +cartésien. ⚠️ **Le calcul actuel n'a aucun test** : un test de **non-régression** est écrit et vert **contre le code +actuel, dans un commit séparé, avant la réécriture** — compte lié avec écritures, compte lié **sans** écriture +(`currentBalance` sérialisé `"0"`, pas `"0.0000"` : asserté sur le **JSON** de la route, une assertion `Decimal` ne +verrait rien), compte non lié (`null`), ordre de la liste. Il est **exempté** de la règle « aurait échoué avant ». + +**AC 7 — Tests.** Chacun aurait échoué avant le patch : +- `kesh-db` : relevé le plus récent choisi — deux imports dont le **plus récent importé est le plus ancien en date** + (l'ordre d'import ne décide pas), et une égalité de `period_to` départagée ; un relevé sans `closing_balance` **plus + récent** qu'un relevé avec solde ne le masque pas ; solde comptable **à la date du relevé** (une écriture postérieure + n'y entre pas, une écriture du jour même y entre ; un relevé daté **après** la dernière écriture rend le solde + courant) ; compte sans grand livre lié → `null` ; **deux comptes bancaires sur le même compte de grand livre** → + `ledgerBalanceAtStatement` `null` ; un compte lié de **passif** (ligne de crédit tirée) → solde et relevé de même + signe, écart nul ; isolation par société ; non-régression de `currentBalance` / `lastTransactionDate` ; + **date de valeur** — un crédit de 200 comptabilisé par la banque le jour du relevé, rapproché par une écriture datée + du lendemain : écart **nul** ; le cas inverse (comptabilisé le lendemain, écriture du jour) : écart nul ; une + transaction **non rapprochée** du jour du relevé : écart égal à son montant ; un rapprochement manuel dont la date + d'écriture diffère de `value_date` : la date **lue** fait foi ; le chemin **facture** (écriture datée + `value_date.unwrap_or(booking_date)`, `reconciliation.rs:1270`) ; un **lien changé** après rapprochement (écritures sur + l'ancien compte) : hors de la correction ; un rapprochement **annulé** (`reconciliation_cancel.rs:331` — + `matched_entry_id` à `NULL`, `pending`) : hors de la correction ; +- `kesh-api` : forme de la réponse (`statementClosingBalance`, `statementDate`, `ledgerBalanceAtStatement` en chaînes) ; +- **Vitest, par composant** — les trois tuiles sont **extraites** en composants (`features/homepage/` + `RecentEntriesCard.svelte`, `OpenInvoicesCard.svelte`, `BankAccountsCard.svelte`, données en props, appels dans la + page) : aucune page n'est testée aujourd'hui, et monter `+page.svelte` exigerait cinq mocks. ⚠️ Le dossier est + `homepage/`, pas `dashboard/` : `lint-i18n-ownership` n'admet dans `features/X/` que les clés `X-*`, et les clés de + l'accueil sont `homepage-*`. Cas : dernières écritures + listées / vide / vide **guidé** / échec (l'échec prime sur le guidé) ; factures ouvertes chiffrées pour un rôle + **Consultation** ; « dont M échues » absent à zéro ; vide guidé — sans « première » (une facture émise puis soldée + laisse la tuile vide : « Créez votre première facture » serait faux) ; un rôle **Consultation** voit le texte vide + **non guidé**, sans injonction à une action qu'il ne peut faire ; « Solde comptable » et le total renommé ; total + **dédoublonné** par compte lié, avec sa note ; écart affiché seulement s'il est non nul **au centime** (`"100.0049"` + contre `"100.00"` : pas d'écart ; `"100.005"` contre `"100.01"` : pas d'écart — le cas qui départage l'arrondi du + dépôt de l'arrondi bancaire ; et `"-100.005"` contre `"-100.01"` — l'écart est **signé**, et la référence + `vat-purchase.ts:12` ne vaut que pour des montants positifs) ; boutons d'action absents pour Consultation ; total en `big.js` (`0.1 + 0.2`) ; + `formatChfBalance` sur une chaîne à 4 décimales ; +- **E2E** (sélecteurs `data-testid`, jamais un libellé traduit — garde `e2e-selecteurs-traduits`) — testids : + `homepage-entry-row`, `homepage-entries-unavailable`, `homepage-invoices-open-count`, `homepage-invoices-open-total`, + `homepage-invoices-overdue`, `homepage-bank-statement-{id}`, `homepage-bank-gap-{id}`, `homepage-bank-total` : + - une écriture postée par l'API apparaît dans la tuile — helper **neuf** `createJournalEntryViaApi` dans + `tests/e2e/helpers/api-fixtures.ts` (aucun n'existe) ; + - une facture validée apparaît dans « Factures ouvertes » avec son montant ; + - un rôle **Consultation**, créé par l'API sur le patron de `homepage-reminders.spec.ts:59-88`, voit les factures + ouvertes chiffrées et aucun bouton d'action — la frontière HTTP n'est vérifiée que là ; + - un passage **axe** sur l'accueil **peuplé** (écriture, facture, compte avec relevé et écart) — celui de + `homepage-settings.spec.ts:65-70` tourne sur un accueil vide et ne voit rien de ce que la story ajoute. Les + écritures sont une liste (`ul`/`li`), chaque lien a un nom accessible, l'écart a un libellé (pas la couleur seule) ; + - montage déterministe : `seedTestState('with-company')` en `beforeAll` (patron `homepage-reminders.spec.ts:19`), + l'écriture datée pour être la plus récente. +- Les E2E existants de l'accueil (`homepage-reminders.spec.ts`, `homepage-settings.spec.ts`) gardent leurs sélecteurs : + les `data-testid` `homepage-card-*` et `homepage-reminders-count`, et le titre de la tuile « Comptes bancaires », sont + conservés. + +**AC 8 — Le manuel et le CHANGELOG.** `user-manual.tex` § *Tableau de bord* réécrit : les trois tuiles telles qu'elles +sont, le solde **comptable** expliqué (le grand livre, pas la banque ; il ne reflète un encaissement qu'une fois +celui-ci comptabilisé), le dernier relevé et l'écart, et ce que voit un rôle Consultation. PDF régénéré, contrôlé +**aplati**. Le manuel dit que l'écart tient compte des **dates de valeur** des mouvements rapprochés, et ses limites (une écriture +saisie à la main n'est pas reliée au relevé). Le manuel renvoie à l'**import bancaire** pour l'origine du relevé (le solde de clôture du fichier CAMT). La capture +`dashboard.png` (`% TODO capture`, `:220`) ne correspondra plus : limite assumée, déjà marquée. Le glossaire (« Actif : +liquidités, créances… », `:2065`) parle de liquidités au sens comptable : **inchangé**. Manuels DE/IT/EN : un README +chacun, rien à traduire. CHANGELOG, section `## [0.12.1] — Non publié` : `Fixed` (#388, #389), sous la section `### Changed` **existante** +(« Total liquidités » → « Total (solde comptable) »). Le § *Tableau de bord* dit que **seuls les relevés CAMT.053 portent +un solde de clôture** — un import CSV n'en a pas ; la section *Import bancaire* le dit aussi. README et site relus +(`grep -rni "tableau de bord\|liquidit" README.md website/`). + +## Tasks / Subtasks + +- [x] **T1 — le dernier relevé et le solde à sa date** (AC 4, 6), `kesh-db` `bank_accounts.rs`. +- [x] **T2 — le DTO** (AC 4, 6), `kesh-api` `routes/bank_accounts.rs`. +- [x] **T3 — les montants en chaînes** (AC 5), `bank-accounts.api.ts`, `format.ts`, page des comptes bancaires, fixtures. +- [x] **T4 — l'accueil** (AC 1, 2, 3, 4) : trois composants extraits dans `features/homepage/`, la page qui les + alimente ; l'i18n (4 locales, parité, `sitesTotal`, relevé des libellés en dur). Les composants appellent `i18nMsg` + **directement** (la garde `lint-i18n-ownership` ne lit que `i18nMsg(`, `scripts/lint-i18n-ownership.js:151` — le + relais `msg()` de la page lui échappe). **Clés neuves** : `homepage-entries-unavailable`, + `homepage-invoices-unavailable`, `homepage-invoices-open` (« { $n } facture(s) ouverte(s) — { $amount } »), + `homepage-invoices-overdue` (« dont { $n } échue(s) — { $amount } »), `homepage-bank-ledger-balance` (« Solde + comptable »), `homepage-bank-ledger-help`, `homepage-bank-total-ledger`, `homepage-bank-shared-ledger-note`, + `homepage-bank-statement` (« Relevé du { $date } : { $amount } », date au format suisse), `homepage-bank-gap` + (« Écart : { $amount } ») ; `homepage-invoices-empty-guided` **réécrite** sans « première » ; + `homepage-bank-total-liquidity` **retirée**. +- [x] **T5 — tests** (AC 7). +- [x] **T6 — manuel, CHANGELOG** (AC 8). +- [x] **T7 — gates** : backend complet, frontend complet, **E2E complet**. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- **Ne pas** présenter le solde du relevé comme « le solde bancaire actuel » : c'est le solde **au `period_to`** du + dernier relevé importé, daté à l'écran. +- **Ne pas** créer d'endpoint « tableau de bord » agrégé : les trois endpoints existent, sont ouverts à tout rôle, et un + agrégat recopierait leurs règles (DRY). +- **Ne pas** recopier le calcul du reste dû : `due-dates` le porte déjà (25-4-b1). +- **Ne pas** afficher « Aucune … » quand l'appel a échoué — c'est le défaut même de #388. + +### Limites assumées + +- **Dépendance avec la 25-4-d** (chaîne non mergée) : la tuile suit `due-dates`, qui compte les factures `validated` + sans `paid_at`. Une facture **soldée** par la 25-4-d y pose `paid_at` — elle sort donc des factures ouvertes. Si ce + contrat changeait, la tuile compterait une facture au reste nul. +- `due-dates` classe « échue » sur `UTC_DATE()`, non sur la date suisse : entre minuit et 1 h / 2 h, une facture peut + être classée un jour trop tôt. Hérité, non corrigé ici. +- À date égale, « dernières » suit `entry_number`, unique **par exercice** : le départage n'est pas chronologique entre + deux exercices. + +- Le solde comptable ne reflète un encaissement qu'une fois celui-ci **comptabilisé** (rapprochement ou règlement) : c'est + précisément ce que l'écart avec le relevé rend visible. +- Le relevé ne vaut que pour sa date : entre deux imports, l'écart n'est pas recalculé contre la banque réelle. +- La correction de date de valeur ne connaît que les écritures **issues d'un rapprochement** (`matched_entry_id`). Une + écriture saisie à la main pour un mouvement bancaire, à une date différente de sa comptabilisation par la banque, + n'est pas corrigée — l'écart le montre, et c'est juste : rien ne relie cette écriture au relevé. +- **Résidu après annulation de rapprochement** : une transaction comptabilisée par la banque **après** `period_to`, dont + l'écriture d'origine était **antérieure ou égale** et dont le rapprochement a été annulé (la contre-passation est + datée du jour), redevient `pending` et sort de la correction — le grand livre à `period_to` porte l'écriture, le + relevé non : écart fictif. Rare. +- La correction parcourt toutes les transactions rapprochées du compte antérieures à `period_to` à chaque ouverture de + l'accueil (`idx_bank_transactions_pending` sert la recherche). Acceptable à cette échelle ; `EXPLAIN` relevé à la mise + en œuvre. + +### Modules + +`kesh-db` (dernier relevé, solde à une date), `kesh-api` (DTO), `frontend` (accueil et trois composants +`features/homepage/`, API et formateur des comptes bancaires, page des comptes, helper E2E), `kesh-i18n` (+ `docs`, +`CHANGELOG`) — **quatre modules de code**, sous le seuil. + +### References + +- Issues [#388], [#389] ; audit du 2026-08-26 (DAF § 1.4). +- `frontend/src/routes/(app)/+page.svelte` ; `crates/kesh-db/src/repositories/bank_accounts.rs:620-690` ; + `crates/kesh-db/src/repositories/invoices.rs:870-905` (`DueDatesSummary`). + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5 (`claude-opus-5-5`). + +### Debug Log References + +- Deux erreurs de montage dans les tests `kesh-db`, corrigées avant toute conclusion : l'archivage d'un compte bancaire + est la colonne `archived` (pas `active`) ; `seed_accounting_company` ne se pose qu'une fois par base (`admin` est + unique) — la seconde société est montée en SQL brut. +- Garde-fous i18n recomptés depuis la source, pas ajustés : la page d'accueil pesait 17 sites (15 appels par son relais + `msg()`, sa déclaration, son corps) et en pèse 24 (1 + 23 dans les trois tuiles) — `sitesTotal` 1758 → 1765 ; le relais + disparaît — `relais` 7 → 6, `sitesNonResolus` 34 → 32. + +### Completion Notes List + +- **T1, le dernier relevé et le solde à sa date** — `list_by_company_with_balances` rend désormais une + `BankAccountBalances` par compte : `current_balance` et `last_transaction_date` **inchangés** (même expression), plus le + dernier relevé portant un solde (`ROW_NUMBER()` sur `period_to`, `imported_at`, `id`), le solde comptable à sa date et + sa **correction de date de valeur** (deux sous-requêtes corrélées sur les lignes de l'écriture rapprochée, sur le compte + lié actuel, dédoublonnées par `je.id IN (…)`), et le décompte du partage (tous les comptes de la société, archivés + compris). Une requête agrégée par appel, chaque somme dans sa propre table dérivée ou sous-requête — aucune jointure + `bank_transactions` dans le `FROM` des lignes du grand livre. +- **Non-régression, commit séparé et antérieur** (`8713c9de`) : verte contre le code d'origine, puis contre le code + réécrit — JSON `"380.0000"`, `"0"`, `null`, ordre de la liste, avec une transaction rapprochée présente. +- **T2, le DTO** — trois champs neufs, documentés. +- **T3, les montants en chaînes** — `currentBalance` et les champs neufs restent des chaînes ; `formatChfBalance` prend une + chaîne ou un `Big` (arrondi `big.js` demi loin de zéro, puis `Number` d'un montant déjà au centime) ; champs neufs + optionnels côté `Raw`, normalisés à `null` ; fixtures complétées. +- **T4, l'accueil** — trois tuiles extraites dans `features/homepage/` (`RecentEntriesCard`, `OpenInvoicesCard`, + `BankAccountsCard`), calculs purs dans `homepage.ts` (`entryAmount`, `ledgerTotal` dédoublonné, `statementGap` au + centime). La page charge les quatre sources en parallèle. Dix clés neuves × 4 locales, `homepage-invoices-empty-guided` + réécrite, `homepage-bank-total-liquidity` retirée (parité : 28 clés `homepage-*` par locale). +- ⚠️ **Écart à la fiche** : le testid `homepage-invoices-open-total` est remplacé par un attribut `data-amount` sur + `homepage-invoices-open-count` — un élément `sr-only` portant le montant brut l'aurait fait lire deux fois aux lecteurs + d'écran. +- ⚠️ **Couverture E2E du relevé et de l'écart** : non exercée en E2E (il faudrait un import CAMT réel) ; tenue par les dix + tests `kesh-db` et par le Vitest de `BankAccountsCard`. Le passage axe porte sur l'accueil peuplé d'une écriture, d'une + facture et d'un compte bancaire, sans relevé. +- **T5, tests** — **périmètre : de `91ca244f` (validation close, aucun code) à l'arbre de travail** : + - `kesh-db` **+10** (`bank_account_statement_gap.rs`, neuf) ; + - `kesh-api` **+2** (`bank_accounts_e2e.rs` : non-régression, forme des champs neufs) ; + - Vitest **+20** (`homepage.test.ts` 9, `HomepageCards.test.ts` 11) ; + - E2E **+4** (`homepage-dashboard.spec.ts`, neuf) et le helper `createJournalEntryViaApi`. +- **Gates** (T7) : base remise à zéro, `scripts/test-fast.sh` **2559/2559, 4 ignorés** (fmt, clippy, nextest) ; frontend + `check` 0 erreur, `lint-i18n-ownership` PASS, `test:unit` **865/865**, `build` OK ; **E2E complet** sur `kesh_e2e` + reconstruite (run de 16:04 UTC) : **232 passés, 8 échoués, 19 ignorés** — les 8 attendus (KF-029 ×7, + `sidebar-navigation:75` KF-052) ; les 4 tests de `homepage-dashboard.spec.ts` verts. +- **Mutations tuées** (`kesh-db`) : tri par ordre d'import ; imports sans solde admis ; borne `<` au lieu de `<=` (2 + tests) ; correction de date de valeur retirée ; partage compté sur les seuls comptes actifs. +- **T6, manuel et CHANGELOG** — § *Tableau de bord* réécrit, sous-section *Solde comptable, relevé et écart* (dates de + valeur, leurs deux limites, CAMT seul), `\label{sec:import-bancaire}` et une phrase à l'import bancaire ; PDF régénéré + et contrôlé aplati. CHANGELOG `[0.12.1]` : `Fixed` (#388, #389) et `Changed`. README relu : seule mention, une + évolution prévue en v0.5+, toujours vraie. + +### File List + +- `CHANGELOG.md` +- `crates/kesh-db/src/repositories/bank_accounts.rs` +- `crates/kesh-db/tests/bank_account_statement_gap.rs` (neuf) +- `crates/kesh-api/src/routes/bank_accounts.rs` +- `crates/kesh-api/tests/bank_accounts_e2e.rs` +- `crates/kesh-i18n/locales/{de-CH,en-CH,fr-CH,it-CH}/messages.ftl` +- `docs/manual/fr/user-manual.{tex,pdf}` +- `frontend/src/lib/features/bank-accounts/{bank-accounts.api.ts,format.ts,bank-accounts.api.test.ts,BankAccountJournalLinkForm.test.ts}` +- `frontend/src/lib/features/homepage/{homepage.ts,RecentEntriesCard.svelte,OpenInvoicesCard.svelte,BankAccountsCard.svelte,homepage.test.ts,HomepageCards.test.ts}` (neufs) +- `frontend/src/lib/shared/i18n-keys.test.ts` +- `frontend/src/routes/(app)/+page.svelte` +- `frontend/src/routes/(app)/bank-accounts/+page.svelte` +- `frontend/tests/e2e/helpers/api-fixtures.ts` +- `frontend/tests/e2e/homepage-dashboard.spec.ts` (neuf) + +## Change Log + +- **2026-10-03** — Revue de code P3 ciblée (Haiku, prompt `25-6-a-review-prompt-p3.md`) : **0 finding**, preuves des + quatre vérifications fournies et **reprises par l'orchestrateur** (deux copies du filtre `status`, aucun résidu « Deux + limites » / repli `'Solde'`, AC 4 cité seulement dans sa version amendée). **Boucle close** : P1 4 MED (Sonnet ×3) → + P2 2 MED (Opus) → P3 0 (Haiku). Gates finaux : + - backend complet sur base remise à zéro, **2561/2561, 4 ignorés** ; + - frontend `test:unit` **872/872** ; + - **E2E complet** sur `kesh_e2e` reconstruite, frontend et backend reconstruits (16:43 UTC) : **231 passés, 9 échoués, + 19 ignorés**. Ce sont les 8 attendus (KF-029 ×7, KF-052) et `accounts.spec.ts:145`, `page.fill('#username')` + expiré sur `/login` : la signature de la **KF-053 (#478)**, vert rejoué seul. `test-results/` a été mis à l'abri + **avant** le rejeu — trace conservée (`target/gate-logs/test-results-final/`). + + Statut → `done`. +- **2026-10-03** — Revue de code P2 (Opus, prompt `25-6-a-review-prompt-p2.md`) : **0 CRITICAL/HIGH, 2 MED, 4 LOW**, + retenus. **M-1** — le filtre `status` existe en **deux** exemplaires (`booked_before_entered_after`, + `booked_after_entered_before`) et le test de P1 n'en tuait qu'un : le symptôme de P1 reproduit sur la copie jumelle, + faute d'avoir grepé le symptôme. Test miroir `seule_une_transaction_rapprochee_corrige_le_miroir` ; mutation de la + seconde copie **exécutée et tuée**. **M-2** — l'AC 4 contredisait encore le comportement retenu en P1 (relevé sans + compte lié) : amendé et daté. LOW : « Trois limites » au manuel (L-1) ; repli Svelte de `bank-accounts-labels-balance` + à « Solde comptable » (L-2) ; test négatif « compte non lié avec relevé : pas de non calculable » (L-3) ; Vitest du + câblage de la page — un rejet de `listBankAccounts` mène à la tuile en échec, mutation tuée (L-4). La P2 a confirmé le + reclassement de F1 (aucun autre écrivain de `matched_entry_id`) et le SQL borné (5 `?` / 5 `.bind`, `currentBalance` + inchangé y compris pour un compte lié archivé). Gate ciblé : `fmt`, `clippy` workspace, `binary(bank_account_statement_gap)` + 12/12, frontend `check` 0 erreur, `test:unit` **872/872**. **Gate complet au push.** +- **2026-10-03** — Revue de code P1 (Sonnet ×3, prompt `25-6-a-review-prompt-p1.md`) : **0 CRITICAL/HIGH, 4 MED**, LOW. + - **Retenus** : + - un test qui ne tuait pas la mutation revendiquée (`status`) : test neuf avec une transaction `pending` liée, qui + la tue (C-1, mutation exécutée) ; + - le relevé rendu **sans compte lié**, contraire à l'AC 4 et non déclaré : comportement **gardé** (le relevé est un + fait bancaire), documenté au DTO, au manuel et ici (C-2) ; + - aucun test du mappage TypeScript des champs neufs : deux Vitest, réponse de liste avec valeurs et réponse de + mutation sans les champs (C-3). + - **Reclassé** : **F1 (MED → LOW), plusieurs transactions sur une écriture**. Non atteignable : les cinq chemins de + rapprochement lient une écriture qu'ils viennent de créer (`reconciliation.rs:1520, 2016, 2381, 3075, 3541`). + L'invariant est écrit à la doc de `list_by_company_with_balances`. + - **LOW corrigés** : + - la tuile bancaire dit son échec au lieu de disparaître (A-2, F4) ; + - « écart non calculable » pour un compte du grand livre partagé (F3) ; + - `-0.00 CHF` évité (F2) ; + - la requête agrégée bornée aux comptes liés (F5) ; + - commentaires périmés (C-4) ; + - le chemin de menu du manuel aligné sur l'interface, « Administration → Comptes bancaires », aux trois sites, et + la limite « résidu après annulation » écrite (C-5) ; + - « Solde comptable » aussi sur la page des comptes bancaires (C-6) ; + - E2E : présence avant absence, date locale au lieu d'UTC (C-8, F6). + - **Écartés** : + - `canManage` lu au montage : le layout attend l'authentification (A-3, vérifié par la lentille B) ; + - l'isolation par société, sans conséquence aujourd'hui (C-7). + - **Gates de la remédiation** : + - backend complet sur base remise à zéro, **2560/2560**. Un premier run s'était arrêté au premier échec, un test + de performance de `kesh-core` hors périmètre (226 ms > 200 ms sous charge) ; il est passé au second run ; + - frontend `check` 0 erreur, `test:unit` **870/870**, `sitesTotal` 1765 → 1767 ; + - E2E de l'accueil **9/9**. **E2E complet au push.** +- **2026-10-03** — Implémentée (dev-story) : T1–T7, gates ci-dessus. Statut → `review`. +- **2026-10-03** — Validation P4 ciblée (Haiku, prompt `25-6-a-validate-prompt-p4.md`) : **0 finding**, preuves des + quatre vérifications fournies. ⚠️ Son **cas 2 chiffré est mal posé** (il garde le grand livre à 800 alors que + l'écriture du 30 y figure, d'où un « écart −400 volontaire ») — **refait par l'orchestrateur** : grand livre au 30 = 1000, + relevé = 800 (mouvement comptabilisé le 31), correction −200, écart **nul** ; la règle de la fiche est juste. **Boucle + close** : P1 4H/7M (Sonnet ×3) → P2 1H/6M (Opus) → P3 2M (Sonnet) → P4 0 (Haiku). +- **2026-10-03** — Validation P3 (Sonnet, prompt `25-6-a-validate-prompt-p3.md`) : **0 CRITICAL/HIGH, 2 MED, 4 LOW**, + retenus. La correction somme la **ligne de l'écriture sur le compte lié actuel**, pas `amount` — juste après un + changement de lien (M1) ; le contrat de requête impose une table dérivée par compte bancaire, non-régression avec + transactions rapprochées présentes (M2) ; tests annulation, lien changé, chemin facture, arrondi négatif (L1) ; + résidu après annulation et performance en limites (L2, L4) ; `period_to` et `CLBD` reformulés (L3) ; manuel : + correction et limites de la date de valeur (LOW). La P3 a vérifié que la ligne bancaire vaut `amount` sur les cinq + chemins de rapprochement et le sens de la correction (chiffré). Trend : P1 4H/7M → P2 1H/6M → P3 2M. +- **2026-10-03** — Arbitrage de Guy (« ok, vas-y ») : **H1 par la voie (a)** — l'écart est corrigé par les + transactions rapprochées de part et d'autre de la date du relevé, la date de l'écriture étant lue via + `matched_entry_id` ; **pas de découpage**. +- **2026-10-03** — Validation P2 (Opus, prompt `25-6-a-validate-prompt-p2.md`) : **1 HIGH, 6 MED, 8 LOW**. + - Retenus : + - l'arrondi de l'écart en `big.js` côté client (`round_dp` est bancaire) (M1) ; + - la non-régression écrite contre le code actuel, avant la réécriture, JSON `"0"` (M2) ; + - le partage compté en SQL, archivés compris (M3) ; + - les textes guidés (« première ») et la variante Consultation (M4) ; + - axe sur l'accueil peuplé (M5) ; + - les imports CSV sans solde (M6) ; + - `formatChfBalance` qui compile (L1), `CLES_RELEVEES` retiré (L2), `### Changed` existante (L3), `i18nMsg` + direct (L4), clés nommées (L5), règle de dédoublonnage écrite (L6), E2E déterministes (L7). + - ⚠️ **H1 (HIGH) — date de valeur contre date de comptabilisation** : le relevé CAMT (`CLBD`) est un solde par date de + comptabilisation ; les écritures de rapprochement sont datées à la date de **valeur** + (`reconciliation.rs:1958`). Sur des livres entièrement rapprochés, un mouvement de fin de mois à valeur décalée + produit un écart fictif. Deux voies soumises à Guy — **voie (a) retenue**, appliquée à l'AC 4. + - ⚠️ **Signal de découpage** : sévérité maximale HIGH → HIGH (P1 → P2). Défauts **distincts** (P1 : partage, + solde à une date ; P2 : date de valeur), aucun recyclé. **Arbitrage de Guy : pas de découpage.** +- **2026-10-03** — Validation P1 (Sonnet ×3, prompt `25-6-a-validate-prompt-p1.md`) : **4 HIGH, 7 MED**, LOW. + - **Réfuté — A1 (HIGH)**, « signe de l'écart faux pour un compte de passif ». `débit − crédit` est la convention du + relevé quel que soit le type : un passif lié à une ligne de crédit tirée de 1000 vaut −1000, comme le relevé + (`DBIT`). Écrit à l'AC 4, avec un test. + - **Retenus** : + - deux comptes bancaires sur un même compte de grand livre : total dédoublonné, écart `null` (A2) ; + - le test de l'accueil : trois composants extraits (C1) ; + - les cas du solde à une date : ordre d'import inversé, relevé postérieur, relevé sans solde plus récent, compte de + passif, échelle (C2, A6) ; + - le contrat de la requête et la non-régression (B1) ; + - `formatChfBalance` sur une chaîne (B2) ; + - le helper E2E et les testids (C3) ; + - un E2E pour le rôle Consultation (C4) ; + - le mode guidé (C5) ; + - les sites du manuel (C6) ; + - la clé et le testid du total (C7) ; + - `due-dates?limit=1` et la limite `UTC_DATE()` (A4) ; + - la dépendance avec la 25-4-d (A3). + - **LOW** : tri et montant (A5, C9, B5), les sites `Raw` (B3), les références (B4), `CLES_RELEVEES` (B6). +- **2026-10-03** — Créée (Guy : « ok, merge et continue » ; découpage 25-6-a / 25-6-b non contesté). + +[#388]: https://github.com/guycorbaz/kesh/issues/388 +[#389]: https://github.com/guycorbaz/kesh/issues/389 diff --git a/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p1.md new file mode 100644 index 000000000..b27553628 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p1.md @@ -0,0 +1,44 @@ +# Prompt — validation P1, Story 25-6-a (le tableau de bord dit vrai) + +*Versionné le 2026-10-03. Trois lentilles (Sonnet), contexte frais chacune.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-a-tableau-de-bord`. **Fiche :** +`_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md` (commit `fbdf6617`). Issues : `gh issue view 388`, +`gh issue view 389`. Règles : `CLAUDE.md`. La fiche décrit du travail **à faire** : un finding qui reproche au code de ne +pas encore le porter sera rejeté. + +## Lentilles + +- **A — Justesse métier et RBAC.** Ce que la tuile montre est-il vrai pour chaque rôle (Admin, Comptable, Consultation) + et pour une clé API ? Les trois endpoints sont-ils réellement ouverts à Consultation (lis `crates/kesh-api/src/lib.rs`, + blocs `authenticated_routes` / `comptable_routes`) ? `due-dates` rend-il bien des **restes dus** de factures validées non + soldées — et qu'en est-il d'une facture **soldée** (25-4-d, write-off) ou d'un avoir ? L'**écart** relevé / comptable + (AC 4) est-il juste — même date, même signe (compte d'actif : débit − crédit), relevé en `DECIMAL(18,2)` contre un + solde à 4 décimales ? Un compte bancaire **archivé** ? Plusieurs comptes bancaires liés au **même** compte de grand + livre (le total les compterait deux fois) ? +- **B — Faisabilité dans le code.** Chaque référence `fichier:ligne` existe-t-elle et dit-elle ce que la fiche affirme + (`grep -nF`, `sed -n`) ? Le DTO `BankAccountWithBalance` est-il rendu par d'autres routes que `GET /api/v1/bank-accounts` + (inventaire : `grep -rn "BankAccountWithBalance\|list_by_company_with_balances" crates`) ? « Le calcul du solde comptable + à une date partage la requête existante » (AC 6) est-il réalisable sans changer le résultat actuel ? `formatChfBalance` + accepte-t-il une chaîne ? Où vit `i18nMsg` pour l'accueil (`features/onboarding`) et la garde `lint-i18n-ownership` + accepte-t-elle des clés neuves `homepage-*` depuis `routes/(app)/+page.svelte` ? Le périmètre (quatre modules) est-il + exact ? +- **C — Complétude et tests.** Chaque AC est-il testable, et le test prévu aurait-il échoué avant le patch ? Cas + manquants : un relevé dont `period_to` est **postérieur** au solde comptable courant ; deux comptes bancaires ; un + relevé importé **puis** un autre plus ancien (ordre d'import ≠ ordre des dates) ; mode guidé ; les E2E existants de + l'accueil (`frontend/tests/e2e/homepage-*.spec.ts`) — un sélecteur cassé par le renommage ? Le manuel : relis + `docs/manual/fr/user-manual.tex` (§ *Tableau de bord* et tout site qui parle de « liquidités » ou du solde bancaire) **et + le PDF aplati** (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`). Pars + du symptôme : `grep -rn "liquidit\|solde bancaire\|Total liquid" docs frontend/src crates/kesh-i18n README.md website`. + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit, **preuve** (commande ET sortie, `grep -nF` pour toute +présence ou absence), correction proposée. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p2.md new file mode 100644 index 000000000..6d22a2556 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p2.md @@ -0,0 +1,45 @@ +# Prompt — validation P2, Story 25-6-a (le tableau de bord dit vrai) + +*Versionné le 2026-10-03. Une passe (Opus), contexte frais, les trois lentilles. Trend : P1 4H/7M (Sonnet ×3, dont un HIGH +réfuté).* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-a-tableau-de-bord`. **Fiche :** +`_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md` (tête `89511559`). Remédiation de P1 : +`git diff 06c9a685 89511559`. Issues : `gh issue view 388`, `389`. Règles : `CLAUDE.md`. La fiche décrit du travail **à +faire** : un finding qui reproche au code de ne pas encore le porter sera rejeté. + +## Ce que tu vérifies + +1. **La remédiation de P1** : + - **La réfutation du HIGH A1 tient-elle ?** Vérifie le signe du solde CAMT (`crates/kesh-import/src/camt053/`) et + celui d'un compte de passif lié (`débit − crédit`), sur un découvert et une ligne de crédit tirée. Calcul chiffré. + - **Le contrat de la requête (AC 6) est-il réalisable** dans `bank_accounts.rs:620-690` sans changer + `currentBalance` ni `lastTransactionDate` — y compris quand deux comptes bancaires partagent un compte de grand livre + (la requête passe de « par compte de grand livre » à « par compte bancaire ») ? + - **Le dédoublonnage du total (AC 3)** : par `journalAccountId` — quel solde garder, et que devient un compte sans + compte lié ? La note, en quels termes ? + - **`formatChfBalance` sur `Big`** : `new Big(v).toFixed(2)` arrondit-il comme le reste du dépôt (mode d'arrondi de + `big.js` par défaut) ? Ses autres appelants (`grep -rn formatChfBalance frontend/src`) restent-ils compilables ? + - **L'extraction en composants dans `features/homepage/`** : la garde `lint-i18n-ownership` + (`frontend/scripts/lint-i18n-ownership.js`, `keyBelongsToFeature`) admet-elle bien les clés `homepage-*` dans ce + dossier ? D'autres clés que la page emploie aujourd'hui (`grep -o "msg('[a-z-]*'" "frontend/src/routes/(app)/+page.svelte"`) + seraient-elles refusées une fois déplacées ? +2. **Cohérence interne** : la fiche dit-elle la même chose partout (AC, tâches, Dev Notes, *Modules*, limites) ? +3. **Ce que P1 n'a pas regardé** : l'accessibilité des tuiles (titres, liens, axe — un E2E axe existe-t-il pour + l'accueil ?) ; le rendu de l'écart (couleur seule ?) ; une clé API sur ces endpoints ; la taille du `limit=5` face au + N+1 réel de la liste (`journal_entries.rs`, lecture des lignes) ; le mode expert/guidé et les textes de + `homepage-*-empty-guided`. +4. **Le manuel** — `docs/manual/fr/user-manual.tex` § *Tableau de bord* et l'import bancaire, **PDF aplati** vers + `target/gate-logs/`. + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit, **preuve** (commande ET sortie, `grep -nF` pour toute +présence ou absence, calcul chiffré), correction proposée. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`, `node -e` sans effet de bord. diff --git a/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p3.md new file mode 100644 index 000000000..aeaaf553c --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p3.md @@ -0,0 +1,42 @@ +# Prompt — validation P3, Story 25-6-a (le tableau de bord dit vrai) + +*Versionné le 2026-10-03. Une passe (Sonnet), contexte frais. Trend : P1 4H/7M (Sonnet ×3) → P2 1H/6M/8L (Opus). La +remédiation de P2 change une **règle métier** (correction de l'écart par la date de valeur, arbitrée par Guy) : la passe +porte sur la fiche entière, avec une attention particulière au diff.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-a-tableau-de-bord`. **Fiche :** +`_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md` (tête `761f144d`). Remédiation de P2 : +`git diff 917b5242 761f144d`. Issues : `gh issue view 388`, `389`. Règles : `CLAUDE.md`. La fiche décrit du travail **à +faire** : un finding qui reproche au code de ne pas encore le porter sera rejeté. + +## Ce que tu vérifies + +1. **La correction de date de valeur (AC 4)** — refais le calcul, chiffré : + - le montant de la transaction (`bank_transactions.amount`, signé comme le relevé) est-il bien égal au mouvement que + l'écriture de rapprochement porte sur le compte lié (`journal_entry_lines` du compte, dans `matched_entry_id`) ? + Lis les chemins qui posent `matched_entry_id` (`grep -n "matched_entry_id = ?" crates/kesh-api/src/routes/reconciliation.rs`) + — rapprochement d'une facture (avec écart d'arrondi, 25-4-c3), éclatement (split), règle, rapprochement manuel : + la ligne du compte bancaire vaut-elle **toujours** `amount` ? Sinon, la correction est fausse ; + - une transaction rapprochée **puis** son rapprochement annulé (25-3-b) : `matched_entry_id` et `status` reviennent-ils + à l'état non rapproché ? l'écriture annulée et sa contre-passation, datées comment, faussent-elles la correction ? + - un rapprochement qui solde **plusieurs** transactions par une seule écriture, ou l'inverse ? +2. **Cohérence interne** : la fiche dit-elle la même chose partout (AC 1 à 8, tâches, Dev Notes, limites, *Modules*, + Change Log) ? Les clés i18n neuves nommées sont-elles cohérentes avec les AC qui les emploient ? +3. **Les références ajoutées** existent-elles et disent-elles ce qu'affirme la fiche : `camt053/mod.rs:541`, + `reconciliation.rs:1958`, `:2295`, `kesh-reconciliation/src/manual.rs:35`, `csv/parser.rs:342`, `vat-purchase.ts:12`, + `lint-i18n-ownership.js:151`, `homepage-settings.spec.ts:65-70`, `homepage-reminders.spec.ts:19`, `:59-88` ? +4. **Ce que les passes précédentes n'ont pas regardé** : la performance de la requête par compte bancaire (une correction + qui joint `bank_transactions` à `journal_entries`, index disponibles) ; le cas d'un compte bancaire lié **changé** de + compte de grand livre après des rapprochements. + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit, **preuve** (commande ET sortie, `grep -nF` pour toute présence +ou absence, calcul chiffré), correction proposée. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, +`git log`/`show`/`diff`, `gh issue view`. diff --git a/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p4.md new file mode 100644 index 000000000..584b68ec6 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-6-a-validate-prompt-p4.md @@ -0,0 +1,35 @@ +# Prompt — validation P4 ciblée, Story 25-6-a + +*Versionné le 2026-10-03. **Une lentille** (Haiku), contexte frais — passe ciblée sur la remédiation de P3. Trend : +P1 4H/7M → P2 1H/6M → P3 2M/4L.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-6-a-tableau-de-bord`. **Diff à relire, un seul commit, aplati : +`git diff 27ef4413 71f0d3d1`** (la fiche `_bmad-output/implementation-artifacts/25-6-a-tableau-de-bord.md`). La fiche +décrit du travail **à faire** : un finding qui reproche au code de ne pas encore le porter sera rejeté. + +## Lentille — regression hunter + +1. **Résidus de l'ancienne règle** : la correction somme désormais la **ligne de l'écriture sur le compte lié**, plus + `bank_transactions.amount`. Reste-t-il une phrase de la fiche qui parle de sommer `amount` pour la correction ? + `grep -nF "amount" ` — lis chaque occurrence et dis si elle concerne la correction. +2. **Le sens de la correction**, chiffré : relevé au 30 (`CLBD` 1000), une transaction rapprochée de +200 comptabilisée + le 30, écriture le 31 portant `débit 200` sur le compte lié. Solde comptable au 30 = 800 ; correction = ? ; écart = ? + Puis le cas inverse (comptabilisée le 31, écriture le 30). Puis un lien changé : l'écriture porte sur l'ancien compte — + que vaut `SUM(jel.debit − jel.credit)` sur le compte lié actuel ? +3. **Cohérence** : les cas de test ajoutés (annulation, lien changé, chemin facture, arrondi négatif) contredisent-ils une + limite assumée ou un autre AC ? La limite « résidu après annulation » est-elle compatible avec le test « un + rapprochement annulé : hors de la correction » ? +4. **Références ajoutées** : `camt053/mod.rs:336`, `reconciliation.rs:1270`, `reconciliation_cancel.rs:331`, + `bank_accounts.rs` (`set_journal_account_id_for_company`) — existent-elles et disent-elles ce qu'affirme la fiche ? + +## Ce que tu rends + +Findings avec sévérité, endroit, **preuve obligatoire : la commande ET sa sortie, ou le calcul** — un finding sans preuve +sera rejeté, un « 0 finding » sans les preuves des quatre vérifications aussi. ⛔ La liste des axes exercés ET non +exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base (`scripts/*` dont +`scripts/prepare-release.sh`, `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, `cargo test`/`nextest`, +`npm run`, `npx`, `gh issue create`/`comment`/`edit`). Autorisés : lecture, `grep`, `sed`, `git log`/`show`/`diff`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 7f684779b..43d6b28ee 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -370,7 +370,9 @@ development_status: 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). 25-5-b-balance-solde-ouverture: done # 2026-10-03 CREEE, validee (3 passes), implementee, revue close en 2 passes (P1 1 MED -> P2 0) (partie de main). ouverture + mouvements + cloture, ligne calculee du resultat reporte, closes #385. [#385] — la **Balance des comptes ne concorde pas avec le Bilan**, il manque la colonne « solde d'ouverture » (III.4). Detachee de la 25-5 le 2026-09-23 : voir 25-5-a. 25-5-etats-et-export: split # [#385] [#386] — la **Balance des comptes ne concorde pas avec le Bilan**, il manque la colonne « solde d'ouverture » (III.4) ; et **l'export de souverainete omet les achats, les avoirs, les projets et la piste d'audit** (V.6) — *il promet plus qu'il ne tient*. - 25-6-tableau-de-bord-et-dettes: backlog # [#388] [#389] [#387] (#384 ramenee en 25-4-d le 2026-09-27) — la tuile « Dernieres ecritures » **n'appelle rien** et affiche toujours « Aucune ecriture » (*une tuile qui est un decor*) ; le « solde bancaire » est le solde COMPTABLE, donc faux ; imputer l'ECART d'un reglement partiel (escompte, frais bancaires, perte sur debiteur) ; et **le PDF d'une facture n'est jamais archive**, il est regenere a la volee (IV.9) — *un document reemis n'est pas le document envoye*. + 25-6-tableau-de-bord-et-dettes: split # 2026-10-03 DECOUPEE en 25-6-a (#388 #389) et 25-6-b (#387). [#388] [#389] [#387] (#384 ramenee en 25-4-d le 2026-09-27) — la tuile « Dernieres ecritures » **n'appelle rien** et affiche toujours « Aucune ecriture » (*une tuile qui est un decor*) ; le « solde bancaire » est le solde COMPTABLE, donc faux ; imputer l'ECART d'un reglement partiel (escompte, frais bancaires, perte sur debiteur) ; et **le PDF d'une facture n'est jamais archive**, il est regenere a la volee (IV.9) — *un document reemis n'est pas le document envoye*. + 25-6-a-tableau-de-bord: done # 2026-10-03 CREEE, validee (4 passes), implementee, revue close en 3 passes (4M -> 2M -> 0) (partie de main). [#388] [#389] — dernieres ecritures branchees, factures ouvertes chiffrees (tout role), solde COMPTABLE nomme + dernier releve et ecart ; closes #388 #389. + 25-6-b-pdf-facture-archive: backlog # [#387] — figer le PDF de la facture a la validation (patron des justificatifs entrants : SHA-256, ON DELETE RESTRICT). epic-16: done # 2026-08-11 (nuit) MERGÉ — dernière story 16-3b via PR #296 (squash `593bb845`). #152, #144 et #151 fermées. v0.9.0 prête à taguer. # 2026-07-26 KICKOFF (rétro Epic 14, décision D-D ; GO Guy après publication de v0.8.0). Branche `story/16-1-compte-produit-par-ligne` créée sur main `ef6cdf52`. Séquence : 16-1 (#152 socle par ligne) → 16-2 (#144 défaut catalogue) → 16-3 (#151 reliquat PDF). # ÉLU PROCHAIN EPIC — kickoff dès le merge de la PR groupée Epic 14. Reste #144 (compte produit au catalogue) + #152 (compte produit par ligne) ; #151 (récap TVA facture) déjà mergé hors-bande via PR #267 (bb3c8d64, partiel : coord. émetteur / n° client restants). Capte aussi les CR utilisateurs #265/#266/#268. # Facturation avancée (#144/#152/#151) — Epic dédié acté 2026-06-04 # 2026-07-26 ORDRE INVERSÉ au kickoff (#152 avant #144). Motif ground-truth : `invoice_lines` ne # porte AUCUNE colonne de compte ET ne référence même pas `products` (pas de `product_id` — cf. diff --git a/crates/kesh-api/src/routes/bank_accounts.rs b/crates/kesh-api/src/routes/bank_accounts.rs index 2e085e55e..65c9d251e 100644 --- a/crates/kesh-api/src/routes/bank_accounts.rs +++ b/crates/kesh-api/src/routes/bank_accounts.rs @@ -125,8 +125,20 @@ pub struct ListBankAccountsQuery { pub struct BankAccountWithBalance { #[serde(flatten)] pub account: BankAccount, + /// **Solde comptable** du compte de grand livre lié (pas le solde bancaire). pub current_balance: Option, pub last_transaction_date: Option, + /// Story 25-6-a (#389) — solde de clôture du dernier relevé importé qui en + /// porte un (CAMT ; un import CSV n'en a pas). Rendu **même sans compte de + /// grand livre lié** : le relevé est un fait bancaire, seul l'écart exige le + /// compte lié. + pub statement_closing_balance: Option, + /// Date de ce relevé (`period_to`). + pub statement_date: Option, + /// Solde comptable à `statement_date`, corrigé des dates de valeur ; `null` + /// sans relevé, sans compte lié, ou si plusieurs comptes bancaires partagent + /// le compte de grand livre. + pub ledger_balance_at_statement: Option, } // =========================================================================== @@ -352,13 +364,14 @@ pub async fn list_bank_accounts( let payload: Vec = rows .into_iter() - .map( - |(account, current_balance, last_transaction_date)| BankAccountWithBalance { - account, - current_balance, - last_transaction_date, - }, - ) + .map(|(account, balances)| BankAccountWithBalance { + account, + current_balance: balances.current_balance, + last_transaction_date: balances.last_transaction_date, + statement_closing_balance: balances.statement_closing_balance, + statement_date: balances.statement_date, + ledger_balance_at_statement: balances.ledger_balance_at_statement, + }) .collect(); Ok(Json(payload)) diff --git a/crates/kesh-api/tests/bank_accounts_e2e.rs b/crates/kesh-api/tests/bank_accounts_e2e.rs index 1ca9687cb..3a50dbfec 100644 --- a/crates/kesh-api/tests/bank_accounts_e2e.rs +++ b/crates/kesh-api/tests/bank_accounts_e2e.rs @@ -997,3 +997,262 @@ async fn delete_primary_unique_is_allowed_ac10(pool: MySqlPool) { assert_eq!(resp.status(), 200); } + +// =========================================================================== +// Story 25-6-a (#389) — non-régression des soldes, écrite et verte CONTRE LE +// CODE D'AVANT la réécriture de `list_by_company_with_balances` (commit +// séparé). Elle fige ce que la route rend aujourd'hui, au JSON près : une +// assertion sur des `Decimal` ne verrait pas `"0"` devenir `"0.0000"`. +// =========================================================================== + +async fn insert_fiscal_year_2026(pool: &MySqlPool, company_id: i64) -> i64 { + sqlx::query( + "INSERT INTO fiscal_years (company_id, name, start_date, end_date, status) \ + VALUES (?, 'Exercice 2026', '2026-01-01', '2026-12-31', 'Open')", + ) + .bind(company_id) + .execute(pool) + .await + .unwrap() + .last_insert_id() as i64 +} + +/// Écriture équilibrée à deux lignes, en SQL brut. Renvoie son id. +#[allow(clippy::too_many_arguments)] +async fn insert_entry( + pool: &MySqlPool, + company_id: i64, + fiscal_year_id: i64, + number: i64, + date: &str, + debit_account: i64, + credit_account: i64, + amount: &str, +) -> i64 { + let entry_id = sqlx::query( + "INSERT INTO journal_entries (company_id, fiscal_year_id, entry_number, entry_date, journal, description) \ + VALUES (?, ?, ?, ?, 'Banque', 'écriture de test')", + ) + .bind(company_id) + .bind(fiscal_year_id) + .bind(number) + .bind(date) + .execute(pool) + .await + .unwrap() + .last_insert_id() as i64; + for (order, account, debit, credit) in [ + (1, debit_account, amount, "0"), + (2, credit_account, "0", amount), + ] { + sqlx::query( + "INSERT INTO journal_entry_lines (entry_id, account_id, line_order, debit, credit) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(entry_id) + .bind(account) + .bind(order) + .bind(debit) + .bind(credit) + .execute(pool) + .await + .unwrap(); + } + entry_id +} + +async fn add_bank_account(pool: &MySqlPool, company_id: i64, iban: &str) -> i64 { + bank_accounts::create( + pool, + NewBankAccount { + company_id, + bank_name: "PostFinance".into(), + iban: iban.into(), + qr_iban: None, + is_primary: false, + }, + ) + .await + .unwrap() + .id +} + +async fn link(pool: &MySqlPool, bank_account_id: i64, account_id: i64) { + sqlx::query("UPDATE bank_accounts SET journal_account_id = ? WHERE id = ?") + .bind(account_id) + .bind(bank_account_id) + .execute(pool) + .await + .unwrap(); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn list_bank_accounts_balances_non_regression(pool: MySqlPool) { + let ctx = setup_full(&pool, "Acme", "CH4431999123000889012", Role::Consultation).await; + let fy = insert_fiscal_year_2026(&pool, ctx.company_id).await; + let revenue = create_account( + &pool, + ctx.company_id, + ctx.user_id, + "3200", + "Ventes", + AccountType::Revenue, + ) + .await; + let other_asset = create_account( + &pool, + ctx.company_id, + ctx.user_id, + "1021", + "Banque 2", + AccountType::Asset, + ) + .await; + + // A : lié, deux écritures (+500, −120). + link(&pool, ctx.bank_account_id, ctx.asset_account_id).await; + let e1 = insert_entry( + &pool, + ctx.company_id, + fy, + 1, + "2026-03-10", + ctx.asset_account_id, + revenue, + "500.00", + ) + .await; + insert_entry( + &pool, + ctx.company_id, + fy, + 2, + "2026-04-02", + revenue, + ctx.asset_account_id, + "120.00", + ) + .await; + // B : lié, aucune écriture. C : non lié. + let b = add_bank_account(&pool, ctx.company_id, "CH9300762011623852957").await; + link(&pool, b, other_asset).await; + let c = add_bank_account(&pool, ctx.company_id, "CH5604835012345678009").await; + + // Une transaction RAPPROCHÉE présente sur A : la réécriture ne doit pas + // multiplier les lignes du grand livre par les transactions. + let import_id = sqlx::query( + "INSERT INTO bank_imports \ + (company_id, bank_account_id, filename, file_hash, source_format, period_from, period_to, \ + closing_balance, transaction_count, imported_by_user_id) \ + VALUES (?, ?, 'r.xml', 'b1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd', \ + 'CAMT053', '2026-03-01', '2026-03-31', 500.00, 1, ?)", + ) + .bind(ctx.company_id) + .bind(ctx.bank_account_id) + .bind(ctx.user_id) + .execute(&pool) + .await + .unwrap() + .last_insert_id() as i64; + sqlx::query( + "INSERT INTO bank_transactions \ + (company_id, import_id, bank_account_id, booking_date, amount, currency, details, status, matched_entry_id) \ + VALUES (?, ?, ?, '2026-03-10', 500.00, 'CHF', 'tx', 'reconciled', ?)", + ) + .bind(ctx.company_id) + .bind(import_id) + .bind(ctx.bank_account_id) + .bind(e1) + .execute(&pool) + .await + .unwrap(); + + let app = spawn_app(pool.clone()).await; + let resp = app + .client + .get(app.url("/api/v1/bank-accounts")) + .bearer_auth(&ctx.jwt) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + let got: Vec<(i64, Value, Value)> = body + .as_array() + .expect("liste") + .iter() + .map(|r| { + ( + r["id"].as_i64().unwrap(), + r["currentBalance"].clone(), + r["lastTransactionDate"].clone(), + ) + }) + .collect(); + assert_eq!( + got, + vec![ + (ctx.bank_account_id, json!("380.0000"), json!("2026-04-02")), + (b, json!("0"), Value::Null), + (c, Value::Null, Value::Null), + ], + "soldes, dates et ordre de la liste, au JSON près" + ); +} + +/// Story 25-6-a (#389) — les trois champs neufs traversent la frontière HTTP, en +/// chaînes décimales (jamais des nombres JSON). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn list_bank_accounts_exposes_the_last_statement(pool: MySqlPool) { + let ctx = setup_full(&pool, "Acme", "CH4431999123000889012", Role::Consultation).await; + let fy = insert_fiscal_year_2026(&pool, ctx.company_id).await; + let revenue = create_account( + &pool, + ctx.company_id, + ctx.user_id, + "3200", + "Ventes", + AccountType::Revenue, + ) + .await; + link(&pool, ctx.bank_account_id, ctx.asset_account_id).await; + insert_entry( + &pool, + ctx.company_id, + fy, + 1, + "2026-03-10", + ctx.asset_account_id, + revenue, + "500.00", + ) + .await; + sqlx::query( + "INSERT INTO bank_imports \ + (company_id, bank_account_id, filename, file_hash, source_format, period_from, period_to, \ + closing_balance, transaction_count, imported_by_user_id) \ + VALUES (?, ?, 'r.xml', REPEAT('c', 64), 'CAMT053', '2026-03-01', '2026-03-31', 480.00, 0, ?)", + ) + .bind(ctx.company_id) + .bind(ctx.bank_account_id) + .bind(ctx.user_id) + .execute(&pool) + .await + .unwrap(); + + let app = spawn_app(pool.clone()).await; + let body: Value = app + .client + .get(app.url("/api/v1/bank-accounts")) + .bearer_auth(&ctx.jwt) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let a = &body.as_array().expect("liste")[0]; + assert_eq!(a["statementClosingBalance"], json!("480.00")); + assert_eq!(a["statementDate"], json!("2026-03-31")); + assert_eq!(a["ledgerBalanceAtStatement"], json!("500.0000")); +} diff --git a/crates/kesh-db/src/repositories/bank_accounts.rs b/crates/kesh-db/src/repositories/bank_accounts.rs index c89287ba3..72f57dfbf 100644 --- a/crates/kesh-db/src/repositories/bank_accounts.rs +++ b/crates/kesh-db/src/repositories/bank_accounts.rs @@ -597,91 +597,204 @@ pub async fn acquire_company_sentinel_lock( Ok(()) } +/// Soldes d'un compte bancaire, tels que la liste les rend (Story v014-1, +/// Story 25-6-a #389). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct BankAccountBalances { + /// **Solde comptable** du compte de grand livre lié — `SUM(debit) − + /// SUM(credit)`, toutes dates. `None` sans compte lié ; `0` si le compte lié + /// n'a aucune ligne. + pub current_balance: Option, + /// `MAX(entry_date)` des lignes du compte lié. + pub last_transaction_date: Option, + /// Solde de clôture du **dernier relevé** importé qui en porte un (CAMT ; + /// un import CSV n'en a jamais) — `period_to` le plus récent, départagé par + /// `imported_at` puis `id`. + pub statement_closing_balance: Option, + /// `period_to` de ce relevé. + pub statement_date: Option, + /// Solde comptable du compte lié **à `statement_date` inclus**, corrigé des + /// dates de valeur (voir [`list_by_company_with_balances`]). `None` sans + /// relevé, sans compte lié, ou quand **plusieurs** comptes bancaires + /// partagent le compte de grand livre (son solde ne s'attribue à aucun). + pub ledger_balance_at_statement: Option, +} + +/// Agrégats d'un compte bancaire, lus en une requête (voir +/// [`list_by_company_with_balances`]). +#[derive(Debug, sqlx::FromRow)] +struct BalancesRow { + bank_account_id: i64, + balance: Option, + last_entry_date: Option, + closing_balance: Option, + period_to: Option, + ledger_at: Option, + booked_before_entered_after: Option, + booked_after_entered_before: Option, + sharing: Option, +} + /// Liste les comptes bancaires avec leurs soldes calculés + date dernière -/// transaction depuis `journal_entry_lines`. +/// transaction depuis `journal_entry_lines`, et le dernier relevé. /// /// Story v014-1 T5 (FINDING-10 Pass 3 Opus — périmètre du calcul) : /// - **Pas de filtre de status** : `journal_entries` n'a pas de colonne -/// `status` ; toute écriture insérée est par construction validée (la -/// double-partie est balanced et toutes les FK existent — il n'y a pas -/// de notion de draft v0.1). Donc toutes les `journal_entry_lines` -/// participent au solde. -/// - Pas de filtre `fiscal_year_id` : « solde depuis création » v0.1. -/// - Pas de rollup hiérarchique (uniquement sur `journal_account_id` exact — -/// pas sur les enfants `parent_id`). Tooltip helper text AC#27 recommande -/// de lier le bank_account au sous-compte spécifique. -/// - **F13 Pass 1 code review (AC#30)** : retourne aussi `last_transaction_date` -/// = `MAX(je.entry_date)` agrégé sur le `journal_account_id` du compte. -/// `None` si journal_account_id NULL OU aucune écriture sur ce compte. +/// `status` ; toute écriture insérée est par construction validée. Donc +/// toutes les `journal_entry_lines` participent au solde. +/// - Pas de filtre `fiscal_year_id` : « solde depuis création ». +/// - Pas de rollup hiérarchique (uniquement sur `journal_account_id` exact). +/// - `last_transaction_date` = `MAX(je.entry_date)` sur le compte lié. +/// +/// # Le dernier relevé et l'écart (Story 25-6-a, #389) /// -/// Retourne `Vec<(BankAccount, Option, Option)>` — le -/// solde et la date sont `None` si le bank_account n'a pas de -/// `journal_account_id` configuré (`L4`). +/// Le solde comptable n'est **pas** le solde bancaire. Pour les confronter, la +/// liste rend le solde de clôture du dernier relevé portant un solde, et le +/// solde comptable **à sa date**. ⚠️ Le relevé reflète les mouvements par date +/// de **comptabilisation bancaire** (`booking_date`), alors que les écritures de +/// rapprochement sont datées à la date de **valeur** : le solde à la date du +/// relevé est donc **corrigé** des transactions rapprochées dont les deux dates +/// tombent de part et d'autre de `period_to` — `+` le mouvement que leur +/// écriture porte sur le compte lié quand la banque l'a comptabilisé avant (ou +/// le jour même) et l'écriture après, `−` dans le cas inverse. On somme la +/// **ligne de l'écriture sur le compte lié actuel**, pas `amount` : une écriture +/// posée sur un ancien compte lié sort d'elle-même de la correction. Une +/// transaction **non rapprochée** n'y entre pas — son absence du grand livre est +/// l'écart à montrer. +/// +/// ⚠️ **Invariant supposé : une transaction, une écriture.** Les cinq chemins de +/// rapprochement (`kesh-api/src/routes/reconciliation.rs`) lient chacun une +/// écriture qu'ils viennent de créer ; aucune écriture n'est rapprochée de +/// plusieurs transactions. Si cela changeait, la correction ajouterait la ligne +/// entière de l'écriture là où seule la part d'une transaction franchit la date +/// du relevé (revue P1, lentille B). +/// +/// # Forme +/// +/// Une requête agrégée **par compte bancaire** ; chaque somme dans sa propre +/// table dérivée ou sous-requête corrélée. ⛔ Ne jamais joindre +/// `bank_transactions` dans le même `FROM` que les lignes du grand livre : le +/// produit multiplierait ces lignes et fausserait les sommes. pub async fn list_by_company_with_balances( pool: &MySqlPool, company_id: i64, include_archived: bool, -) -> Result< - Vec<( - BankAccount, - Option, - Option, - )>, - DbError, -> { +) -> Result, DbError> { let accounts = list_by_company(pool, company_id, include_archived).await?; if accounts.is_empty() { return Ok(Vec::new()); } - let account_ids: Vec = accounts - .iter() - .filter_map(|b| b.journal_account_id) - .collect(); - - if account_ids.is_empty() { - return Ok(accounts.into_iter().map(|b| (b, None, None)).collect()); - } - - let mut builder = sqlx::QueryBuilder::::new( - "SELECT jel.account_id, \ - COALESCE(SUM(jel.debit) - SUM(jel.credit), 0) AS balance, \ - MAX(je.entry_date) AS last_entry_date \ - FROM journal_entry_lines jel \ - INNER JOIN journal_entries je ON jel.entry_id = je.id \ - WHERE je.company_id = ", - ); - builder.push_bind(company_id); - builder.push(" AND jel.account_id IN ("); - let mut sep = builder.separated(", "); - for id in &account_ids { - sep.push_bind(*id); - } - builder.push(") GROUP BY jel.account_id"); - - let rows: Vec<(i64, rust_decimal::Decimal, Option)> = builder - .build_query_as() - .fetch_all(pool) - .await - .map_err(map_db_error)?; + let rows: Vec = sqlx::query_as( + "SELECT ba.id AS bank_account_id, \ + agg.balance, agg.last_entry_date, \ + st.closing_balance, st.period_to, \ + CASE WHEN st.period_to IS NULL OR ba.journal_account_id IS NULL THEN NULL ELSE \ + (SELECT COALESCE(SUM(jel.debit) - SUM(jel.credit), 0) \ + FROM journal_entry_lines jel \ + INNER JOIN journal_entries je ON jel.entry_id = je.id \ + WHERE je.company_id = ba.company_id \ + AND jel.account_id = ba.journal_account_id \ + AND je.entry_date <= st.period_to) END AS ledger_at, \ + CASE WHEN st.period_to IS NULL OR ba.journal_account_id IS NULL THEN NULL ELSE \ + (SELECT COALESCE(SUM(jel.debit) - SUM(jel.credit), 0) \ + FROM journal_entry_lines jel \ + INNER JOIN journal_entries je ON jel.entry_id = je.id \ + WHERE je.company_id = ba.company_id \ + AND jel.account_id = ba.journal_account_id \ + AND je.entry_date > st.period_to \ + AND je.id IN (SELECT bt.matched_entry_id FROM bank_transactions bt \ + WHERE bt.bank_account_id = ba.id AND bt.company_id = ba.company_id \ + AND bt.status = 'reconciled' AND bt.matched_entry_id IS NOT NULL \ + AND bt.booking_date <= st.period_to)) END \ + AS booked_before_entered_after, \ + CASE WHEN st.period_to IS NULL OR ba.journal_account_id IS NULL THEN NULL ELSE \ + (SELECT COALESCE(SUM(jel.debit) - SUM(jel.credit), 0) \ + FROM journal_entry_lines jel \ + INNER JOIN journal_entries je ON jel.entry_id = je.id \ + WHERE je.company_id = ba.company_id \ + AND jel.account_id = ba.journal_account_id \ + AND je.entry_date <= st.period_to \ + AND je.id IN (SELECT bt.matched_entry_id FROM bank_transactions bt \ + WHERE bt.bank_account_id = ba.id AND bt.company_id = ba.company_id \ + AND bt.status = 'reconciled' AND bt.matched_entry_id IS NOT NULL \ + AND bt.booking_date > st.period_to)) END \ + AS booked_after_entered_before, \ + CAST(shared.n AS SIGNED) AS sharing \ + FROM bank_accounts ba \ + LEFT JOIN ( \ + SELECT jel.account_id, \ + COALESCE(SUM(jel.debit) - SUM(jel.credit), 0) AS balance, \ + MAX(je.entry_date) AS last_entry_date \ + FROM journal_entry_lines jel \ + INNER JOIN journal_entries je ON jel.entry_id = je.id \ + WHERE je.company_id = ? \ + AND jel.account_id IN (SELECT journal_account_id FROM bank_accounts \ + WHERE company_id = ? AND journal_account_id IS NOT NULL) \ + GROUP BY jel.account_id \ + ) agg ON agg.account_id = ba.journal_account_id \ + LEFT JOIN ( \ + SELECT bank_account_id, closing_balance, period_to, \ + ROW_NUMBER() OVER (PARTITION BY bank_account_id \ + ORDER BY period_to DESC, imported_at DESC, id DESC) AS rn \ + FROM bank_imports \ + WHERE company_id = ? AND closing_balance IS NOT NULL \ + ) st ON st.bank_account_id = ba.id AND st.rn = 1 \ + LEFT JOIN ( \ + SELECT journal_account_id, COUNT(*) AS n \ + FROM bank_accounts \ + WHERE company_id = ? AND journal_account_id IS NOT NULL \ + GROUP BY journal_account_id \ + ) shared ON shared.journal_account_id = ba.journal_account_id \ + WHERE ba.company_id = ?", + ) + .bind(company_id) // agg : je.company_id + .bind(company_id) // agg : bornée aux comptes liés de la société + .bind(company_id) // st + .bind(company_id) // shared + .bind(company_id) // ba.company_id + .fetch_all(pool) + .await + .map_err(map_db_error)?; - use std::collections::HashMap; - let agg_by_account: HashMap)> = rows - .into_iter() - .map(|(aid, bal, dt)| (aid, (bal, dt))) - .collect(); + let by_bank_account: std::collections::HashMap = + rows.into_iter().map(|r| (r.bank_account_id, r)).collect(); Ok(accounts .into_iter() - .map(|b| match b.journal_account_id { - Some(aid) => match agg_by_account.get(&aid) { - Some((balance, date)) => (b, Some(*balance), *date), - // journal_account_id existe mais aucune ligne → solde = 0.00, - // date = None. - None => (b, Some(rust_decimal::Decimal::ZERO), None), - }, - None => (b, None, None), + .map(|b| { + let balances = match (b.journal_account_id, by_bank_account.get(&b.id)) { + (Some(_), Some(r)) => { + // Le partage se compte sur TOUS les comptes de la société, + // archivés compris : la réponse ne dépend pas d'`include_archived`. + let shared = r.sharing.unwrap_or(1) > 1; + let ledger_balance_at_statement = match ( + r.ledger_at, + r.booked_before_entered_after, + r.booked_after_entered_before, + ) { + (Some(at), Some(plus), Some(minus)) if !shared => Some(at + plus - minus), + _ => None, + }; + BankAccountBalances { + // Compte lié sans aucune ligne → 0 (comportement v014-1). + current_balance: Some(r.balance.unwrap_or(rust_decimal::Decimal::ZERO)), + last_transaction_date: r.last_entry_date, + statement_closing_balance: r.closing_balance, + statement_date: r.period_to, + ledger_balance_at_statement, + } + } + // Sans compte lié : ni solde comptable ni écart ; le relevé reste. + (None, Some(r)) => BankAccountBalances { + statement_closing_balance: r.closing_balance, + statement_date: r.period_to, + ..Default::default() + }, + (_, None) => BankAccountBalances::default(), + }; + (b, balances) }) .collect()) } diff --git a/crates/kesh-db/tests/bank_account_statement_gap.rs b/crates/kesh-db/tests/bank_account_statement_gap.rs new file mode 100644 index 000000000..68a506547 --- /dev/null +++ b/crates/kesh-db/tests/bank_account_statement_gap.rs @@ -0,0 +1,495 @@ +//! Le dernier relevé et le solde comptable à sa date — Story 25-6-a (#389). +//! +//! Le solde comptable n'est pas le solde bancaire : la liste des comptes +//! bancaires rend le solde de clôture du dernier relevé et le solde comptable à +//! sa date, **corrigé des dates de valeur** (le relevé compte par date de +//! comptabilisation bancaire, les écritures de rapprochement par date de +//! valeur). Chaque test nomme la mutation qu'il tue. +//! +//! Pré-requis : MariaDB démarré. + +use chrono::NaiveDate; +use kesh_db::entities::NewBankAccount; +use kesh_db::repositories::bank_accounts::{self, BankAccountBalances}; +use kesh_db::test_fixtures::{SeededCompany, seed_accounting_company}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use sqlx::MySqlPool; + +fn ymd(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +async fn bank_account(pool: &MySqlPool, seeded: &SeededCompany, iban: &str) -> i64 { + bank_accounts::create( + pool, + NewBankAccount { + company_id: seeded.company_id, + bank_name: "Banque".into(), + iban: iban.into(), + qr_iban: None, + is_primary: false, + }, + ) + .await + .expect("compte bancaire") + .id +} + +async fn link(pool: &MySqlPool, bank_account_id: i64, account_id: i64) { + sqlx::query("UPDATE bank_accounts SET journal_account_id = ? WHERE id = ?") + .bind(account_id) + .bind(bank_account_id) + .execute(pool) + .await + .unwrap(); +} + +/// Écriture équilibrée à deux lignes, en SQL brut (numéro unique par test). +async fn entry( + pool: &MySqlPool, + seeded: &SeededCompany, + date: NaiveDate, + debit: i64, + credit: i64, + amount: Decimal, +) -> i64 { + let n: i64 = sqlx::query_scalar( + "SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries WHERE company_id = ?", + ) + .bind(seeded.company_id) + .fetch_one(pool) + .await + .unwrap(); + let id = sqlx::query( + "INSERT INTO journal_entries (company_id, fiscal_year_id, entry_number, entry_date, journal, description) \ + VALUES (?, ?, ?, ?, 'Banque', 'test')", + ) + .bind(seeded.company_id) + .bind(seeded.fiscal_year_id) + .bind(n) + .bind(date) + .execute(pool) + .await + .unwrap() + .last_insert_id() as i64; + for (order, account, d, c) in [ + (1, debit, amount, Decimal::ZERO), + (2, credit, Decimal::ZERO, amount), + ] { + sqlx::query( + "INSERT INTO journal_entry_lines (entry_id, account_id, line_order, debit, credit) VALUES (?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(account) + .bind(order) + .bind(d) + .bind(c) + .execute(pool) + .await + .unwrap(); + } + id +} + +/// Un import ; `closing` `None` = un import sans solde (CSV). Renvoie son id. +async fn import( + pool: &MySqlPool, + seeded: &SeededCompany, + bank_account_id: i64, + period_to: NaiveDate, + closing: Option, +) -> i64 { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM bank_imports") + .fetch_one(pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO bank_imports \ + (company_id, bank_account_id, filename, file_hash, source_format, period_from, period_to, \ + closing_balance, transaction_count, imported_by_user_id) \ + VALUES (?, ?, 'r.xml', ?, 'CAMT053', '2026-01-01', ?, ?, 0, ?)", + ) + .bind(seeded.company_id) + .bind(bank_account_id) + .bind(format!("{n:064}")) + .bind(period_to) + .bind(closing) + .bind(seeded.admin_user_id) + .execute(pool) + .await + .unwrap() + .last_insert_id() as i64 +} + +/// Une transaction bancaire ; `matched` = l'écriture qui la rapproche. +async fn tx( + pool: &MySqlPool, + seeded: &SeededCompany, + bank_account_id: i64, + import_id: i64, + booking: NaiveDate, + amount: Decimal, + matched: Option, +) { + sqlx::query( + "INSERT INTO bank_transactions \ + (company_id, import_id, bank_account_id, booking_date, amount, currency, details, status, matched_entry_id) \ + VALUES (?, ?, ?, ?, ?, 'CHF', 'tx', ?, ?)", + ) + .bind(seeded.company_id) + .bind(import_id) + .bind(bank_account_id) + .bind(booking) + .bind(amount) + .bind(if matched.is_some() { "reconciled" } else { "pending" }) + .bind(matched) + .execute(pool) + .await + .unwrap(); +} + +async fn balances(pool: &MySqlPool, seeded: &SeededCompany, id: i64) -> BankAccountBalances { + bank_accounts::list_by_company_with_balances(pool, seeded.company_id, true) + .await + .unwrap() + .into_iter() + .find(|(b, _)| b.id == id) + .expect("compte présent") + .1 +} + +const IBAN_A: &str = "CH9300762011623852957"; +const IBAN_B: &str = "CH5604835012345678009"; + +/// Un compte lié à 1100 (Banque CI), un compte de produits pour la contrepartie. +async fn linked(pool: &MySqlPool) -> (SeededCompany, i64, i64, i64) { + let seeded = seed_accounting_company(pool).await.unwrap(); + let ba = bank_account(pool, &seeded, IBAN_A).await; + let bank = seeded.accounts["1100"]; + link(pool, ba, bank).await; + let sales = seeded.accounts["3000"]; + (seeded, ba, bank, sales) +} + +/// ⛔ Le dernier relevé est le plus récent **en date**, pas en ordre d'import ; +/// à date égale, le dernier importé. Mutation tuée : un tri par `imported_at`. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_dernier_releve_est_le_plus_recent_en_date(pool: MySqlPool) { + let (seeded, ba, _, _) = linked(&pool).await; + import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(300))).await; + import(&pool, &seeded, ba, ymd(2026, 2, 28), Some(dec!(200))).await; // importé APRÈS, plus ancien + let b = balances(&pool, &seeded, ba).await; + assert_eq!(b.statement_date, Some(ymd(2026, 3, 31))); + assert_eq!(b.statement_closing_balance, Some(dec!(300))); + + import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(310))).await; // même date, importé après + assert_eq!( + balances(&pool, &seeded, ba).await.statement_closing_balance, + Some(dec!(310)) + ); +} + +/// Un import sans solde (CSV) plus récent ne masque pas le relevé CAMT. +/// Mutation tuée : le filtre `closing_balance IS NOT NULL` retiré. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_import_sans_solde_ne_masque_pas_le_releve(pool: MySqlPool) { + let (seeded, ba, _, _) = linked(&pool).await; + import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(300))).await; + import(&pool, &seeded, ba, ymd(2026, 4, 30), None).await; + assert_eq!( + balances(&pool, &seeded, ba).await.statement_date, + Some(ymd(2026, 3, 31)) + ); +} + +/// ⛔ Le solde comptable À LA DATE du relevé : l'écriture du jour même y entre, +/// celle du lendemain non ; un relevé daté après la dernière écriture rend le +/// solde courant. Mutation tuée : `<=` → `<`, ou la borne retirée. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_solde_comptable_a_la_date_du_releve(pool: MySqlPool) { + let (seeded, ba, bank, sales) = linked(&pool).await; + entry(&pool, &seeded, ymd(2026, 3, 31), bank, sales, dec!(100)).await; + entry(&pool, &seeded, ymd(2026, 4, 1), bank, sales, dec!(50)).await; + import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(100))).await; + let b = balances(&pool, &seeded, ba).await; + assert_eq!(b.ledger_balance_at_statement, Some(dec!(100))); + assert_eq!(b.current_balance, Some(dec!(150))); + + import(&pool, &seeded, ba, ymd(2026, 5, 31), Some(dec!(150))).await; + assert_eq!( + balances(&pool, &seeded, ba) + .await + .ledger_balance_at_statement, + Some(dec!(150)) + ); +} + +/// ⛔ La date de valeur : un crédit comptabilisé par la banque le jour du relevé +/// et rapproché par une écriture du lendemain — et le cas inverse — ne +/// produisent AUCUN écart. Mutation tuée : la correction retirée. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn la_date_de_valeur_ne_cree_pas_d_ecart(pool: MySqlPool) { + let (seeded, ba, bank, sales) = linked(&pool).await; + let base = entry(&pool, &seeded, ymd(2026, 3, 10), bank, sales, dec!(800)).await; + let late = entry(&pool, &seeded, ymd(2026, 4, 1), bank, sales, dec!(200)).await; + let early = entry(&pool, &seeded, ymd(2026, 3, 31), bank, sales, dec!(70)).await; + let imp = import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(1000))).await; + tx( + &pool, + &seeded, + ba, + imp, + ymd(2026, 3, 10), + dec!(800), + Some(base), + ) + .await; + // Comptabilisé par la banque le 31, écriture le 1er : la banque l'a, le + // grand livre au 31 non. + tx( + &pool, + &seeded, + ba, + imp, + ymd(2026, 3, 31), + dec!(200), + Some(late), + ) + .await; + // Comptabilisé le 1er, écriture le 31 : le grand livre l'a, la banque non. + tx( + &pool, + &seeded, + ba, + imp, + ymd(2026, 4, 1), + dec!(70), + Some(early), + ) + .await; + + let b = balances(&pool, &seeded, ba).await; + // Grand livre au 31 = 800 + 70 ; + 200 − 70 = 1000 = le relevé. + assert_eq!(b.ledger_balance_at_statement, Some(dec!(1000))); + assert_eq!(b.statement_closing_balance, Some(dec!(1000))); +} + +/// Une transaction NON rapprochée (rapprochement annulé : `pending`, lien remis +/// à NULL) n'est pas corrigée : son absence du grand livre est l'écart. Ce test +/// ne tue PAS la mutation « `status` ignoré » — le lien NULL suffit à +/// l'écarter ; c'est le test suivant qui la tue. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn une_transaction_non_rapprochee_reste_un_ecart(pool: MySqlPool) { + let (seeded, ba, bank, sales) = linked(&pool).await; + let e = entry(&pool, &seeded, ymd(2026, 4, 2), bank, sales, dec!(200)).await; + let imp = import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(200))).await; + // Rapprochement annulé : `pending`, `matched_entry_id` remis à NULL. + tx(&pool, &seeded, ba, imp, ymd(2026, 3, 31), dec!(200), None).await; + let _ = e; + assert_eq!( + balances(&pool, &seeded, ba) + .await + .ledger_balance_at_statement, + Some(Decimal::ZERO) + ); +} + +/// ⛔ Une transaction `pending` qui porterait encore un lien vers une écriture +/// (état incohérent, posé en SQL brut) n'entre pas dans la correction : seul +/// `status = 'reconciled'` compte. Mutation tuée : le filtre `status` retiré de +/// la sous-requête `booked_before_entered_after` (le test suivant tue l'autre). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn seule_une_transaction_rapprochee_corrige(pool: MySqlPool) { + let (seeded, ba, bank, sales) = linked(&pool).await; + let late = entry(&pool, &seeded, ymd(2026, 4, 1), bank, sales, dec!(200)).await; + let imp = import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(200))).await; + tx( + &pool, + &seeded, + ba, + imp, + ymd(2026, 3, 31), + dec!(200), + Some(late), + ) + .await; + sqlx::query("UPDATE bank_transactions SET status = 'pending' WHERE matched_entry_id = ?") + .bind(late) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + balances(&pool, &seeded, ba) + .await + .ledger_balance_at_statement, + Some(Decimal::ZERO) + ); +} + +/// ⛔ Le miroir : comptabilisée par la banque APRÈS le relevé, écriture antérieure, +/// `pending` mais liée. Mutation tuée : le filtre `status` retiré de la +/// sous-requête `booked_after_entered_before` — le filtre existe en deux +/// exemplaires, chacun a son test (revue P2). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn seule_une_transaction_rapprochee_corrige_le_miroir(pool: MySqlPool) { + let (seeded, ba, bank, sales) = linked(&pool).await; + let early = entry(&pool, &seeded, ymd(2026, 3, 31), bank, sales, dec!(70)).await; + let imp = import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(70))).await; + tx( + &pool, + &seeded, + ba, + imp, + ymd(2026, 4, 1), + dec!(70), + Some(early), + ) + .await; + sqlx::query("UPDATE bank_transactions SET status = 'pending' WHERE matched_entry_id = ?") + .bind(early) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + balances(&pool, &seeded, ba) + .await + .ledger_balance_at_statement, + Some(dec!(70)) + ); +} + +/// ⛔ Le lien changé après des rapprochements : les écritures portent sur +/// l'ANCIEN compte et sortent de la correction. Mutation tuée : sommer +/// `bank_transactions.amount` au lieu de la ligne de l'écriture. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_lien_change_sort_de_la_correction(pool: MySqlPool) { + let (seeded, ba, bank, sales) = linked(&pool).await; + let late = entry(&pool, &seeded, ymd(2026, 4, 1), bank, sales, dec!(200)).await; + let imp = import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(200))).await; + tx( + &pool, + &seeded, + ba, + imp, + ymd(2026, 3, 31), + dec!(200), + Some(late), + ) + .await; + // Relié à la caisse, qui ne porte aucune de ces écritures. + link(&pool, ba, seeded.accounts["1000"]).await; + assert_eq!( + balances(&pool, &seeded, ba) + .await + .ledger_balance_at_statement, + Some(Decimal::ZERO) + ); +} + +/// Un compte de PASSIF lié (ligne de crédit tirée de 1000) : solde comptable +/// et relevé de même signe — `débit − crédit` est la convention du relevé. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_compte_de_passif_lie_a_le_signe_du_releve(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let ba = bank_account(&pool, &seeded, IBAN_A).await; + let credit_line = seeded.accounts["2000"]; // Capital CI, de type Liability + link(&pool, ba, credit_line).await; + entry( + &pool, + &seeded, + ymd(2026, 3, 5), + seeded.accounts["4000"], + credit_line, + dec!(1000), + ) + .await; + import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(-1000))).await; + let b = balances(&pool, &seeded, ba).await; + assert_eq!(b.ledger_balance_at_statement, Some(dec!(-1000))); + assert_eq!(b.statement_closing_balance, Some(dec!(-1000))); +} + +/// ⛔ Deux comptes bancaires sur le MÊME compte de grand livre — l'un archivé : +/// le solde ne s'attribue à aucun, pas d'écart. Mutation tuée : le partage +/// compté sur les seuls comptes actifs, ou pas compté du tout. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_compte_de_grand_livre_partage_n_a_pas_d_ecart(pool: MySqlPool) { + let (seeded, ba, bank, sales) = linked(&pool).await; + let other = bank_account(&pool, &seeded, IBAN_B).await; + link(&pool, other, bank).await; + sqlx::query("UPDATE bank_accounts SET archived = TRUE WHERE id = ?") + .bind(other) + .execute(&pool) + .await + .unwrap(); + entry(&pool, &seeded, ymd(2026, 3, 5), bank, sales, dec!(100)).await; + import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(100))).await; + let actives = bank_accounts::list_by_company_with_balances(&pool, seeded.company_id, false) + .await + .unwrap(); + let mine = &actives.iter().find(|(b, _)| b.id == ba).expect("présent").1; + assert_eq!(mine.ledger_balance_at_statement, None); + assert_eq!( + mine.current_balance, + Some(dec!(100)), + "le solde comptable reste" + ); +} + +/// Sans compte lié : ni solde comptable ni écart, le relevé reste. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn sans_compte_lie_le_releve_reste(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.unwrap(); + let ba = bank_account(&pool, &seeded, IBAN_A).await; + import(&pool, &seeded, ba, ymd(2026, 3, 31), Some(dec!(42))).await; + let b = balances(&pool, &seeded, ba).await; + assert_eq!(b.current_balance, None); + assert_eq!(b.ledger_balance_at_statement, None); + assert_eq!(b.statement_closing_balance, Some(dec!(42))); +} + +/// Isolation : le relevé et les écritures d'une autre société n'entrent pas. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn une_autre_societe_n_entre_pas(pool: MySqlPool) { + let (seeded, ba, bank, sales) = linked(&pool).await; + entry(&pool, &seeded, ymd(2026, 3, 5), bank, sales, dec!(100)).await; + // Une seconde société, son compte bancaire et son relevé — en SQL brut, la + // fixture ne se pose qu'une fois par base. + let other_company = sqlx::query( + "INSERT INTO companies (name, address, org_type, accounting_language, instance_language) \ + VALUES ('Autre SA', 'Rue 2', 'Pme', 'FR', 'FR')", + ) + .execute(&pool) + .await + .unwrap() + .last_insert_id() as i64; + let oba = bank_accounts::create( + &pool, + NewBankAccount { + company_id: other_company, + bank_name: "Autre".into(), + iban: IBAN_B.into(), + qr_iban: None, + is_primary: false, + }, + ) + .await + .unwrap() + .id; + sqlx::query( + "INSERT INTO bank_imports \ + (company_id, bank_account_id, filename, file_hash, source_format, period_from, period_to, \ + closing_balance, transaction_count, imported_by_user_id) \ + VALUES (?, ?, 'o.xml', REPEAT('f', 64), 'CAMT053', '2026-01-01', '2026-03-31', 999, 0, ?)", + ) + .bind(other_company) + .bind(oba) + .bind(seeded.admin_user_id) + .execute(&pool) + .await + .unwrap(); + let b = balances(&pool, &seeded, ba).await; + assert_eq!(b.statement_closing_balance, None); + assert_eq!(b.current_balance, Some(dec!(100))); +} diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 18dcf17a7..3deb818bb 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -124,7 +124,7 @@ homepage-entries-empty-guided = Noch keine Buchungen. Erfassen Sie Ihre erste Bu homepage-entries-action = Buchung erfassen homepage-invoices-title = Offene Rechnungen homepage-invoices-empty = Keine offenen Rechnungen. -homepage-invoices-empty-guided = Keine offenen Rechnungen. Erstellen Sie Ihre erste Rechnung. +homepage-invoices-empty-guided = Keine offenen Rechnungen. Erstellen Sie eine Rechnung. homepage-invoices-action = Rechnung erstellen homepage-reminders-count = { $n } Rechnung(en) zu mahnen homepage-bank-title = Bankkonten @@ -1003,7 +1003,7 @@ bank-accounts-actions-show-archived = Archivierte anzeigen bank-accounts-actions-hide-archived = Archivierte ausblenden bank-accounts-actions-submit-create = Erstellen bank-accounts-actions-submit-update = Speichern -bank-accounts-labels-balance = Saldo +bank-accounts-labels-balance = Buchsaldo bank-accounts-labels-balance-unavailable = Saldo nicht verfügbar (mit Kontenrahmen verbinden) bank-accounts-labels-qr-iban = QR-IBAN (optional) bank-accounts-error-qr-iban-not-qr = Diese IBAN ist keine QR-IBAN. Wenn Ihnen Ihre Bank keine spezielle QR-IBAN für QR-Rechnungen ausgestellt hat, lassen Sie dieses Feld leer: Ihre normale IBAN genügt. @@ -1017,10 +1017,21 @@ bank-accounts-toast-create-success = Bankkonto erstellt. bank-accounts-toast-update-success = Bankkonto geändert. bank-accounts-toast-archive-success = Bankkonto archiviert. # Story v014-1 — Homepage widget bank accounts (F14) -homepage-bank-total-liquidity = Liquide Mittel insgesamt homepage-bank-total-partial = (nur verbundene Konten) homepage-bank-balance-unavailable = Saldo nicht verfügbar — mit Kontenrahmen verbinden homepage-bank-last-transaction = Letzte Transaktion +homepage-entries-unavailable = Buchungen derzeit nicht verfügbar. +homepage-invoices-unavailable = Rechnungen derzeit nicht verfügbar. +homepage-invoices-open = { $n } offene Rechnung(en) — { $amount } +homepage-invoices-overdue = davon { $n } überfällig — { $amount } +homepage-bank-ledger-balance = Buchsaldo +homepage-bank-ledger-help = Aus dem Hauptbuch berechnet, nicht von der Bank. +homepage-bank-total-ledger = Total (Buchsaldo) +homepage-bank-shared-ledger-note = (ein gemeinsam genutztes Hauptbuchkonto wird nur einmal gezählt) +homepage-bank-statement = Auszug vom { $date }: { $amount } +homepage-bank-gap = Differenz: { $amount } +homepage-bank-gap-unavailable = Differenz nicht berechenbar: gemeinsames Hauptbuchkonto +homepage-bank-unavailable = Bankkonten derzeit nicht verfügbar. settings-bank-manage = In Administration → Bankkonten verwalten settings-bank-manage-hint = Um ein Bankkonto hinzuzufügen, zu ändern oder zu archivieren, nutzen Sie die spezielle Seite Administration → Bankkonten. bank-accounts-toast-link-success = Bankkonto erfolgreich mit dem Kontorahmen verbunden. diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 9ccb420e9..d6980d572 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -124,7 +124,7 @@ homepage-entries-empty-guided = No entries yet. Start by recording your first jo homepage-entries-action = Record an entry homepage-invoices-title = Open invoices homepage-invoices-empty = No open invoices. -homepage-invoices-empty-guided = No open invoices. Create your first invoice to bill your clients. +homepage-invoices-empty-guided = No open invoices. Create an invoice to bill your clients. homepage-invoices-action = Create an invoice homepage-reminders-count = { $n } invoice(s) to follow up homepage-bank-title = Bank accounts @@ -1003,7 +1003,7 @@ bank-accounts-actions-show-archived = Show archived bank-accounts-actions-hide-archived = Hide archived bank-accounts-actions-submit-create = Create bank-accounts-actions-submit-update = Save -bank-accounts-labels-balance = Balance +bank-accounts-labels-balance = Book balance bank-accounts-labels-balance-unavailable = Balance unavailable (link to chart of accounts) bank-accounts-labels-qr-iban = QR-IBAN (optional) bank-accounts-error-qr-iban-not-qr = This IBAN is not a QR-IBAN. If your bank has not issued you a dedicated QR-IBAN for QR-bills, leave this field empty: your regular IBAN is sufficient. @@ -1017,10 +1017,21 @@ bank-accounts-toast-create-success = Bank account created. bank-accounts-toast-update-success = Bank account updated. bank-accounts-toast-archive-success = Bank account archived. # Story v014-1 — Homepage widget bank accounts (F14) -homepage-bank-total-liquidity = Total liquidity homepage-bank-total-partial = (linked accounts only) homepage-bank-balance-unavailable = Balance unavailable — link to chart of accounts homepage-bank-last-transaction = Last transaction +homepage-entries-unavailable = Entries are unavailable right now. +homepage-invoices-unavailable = Invoices are unavailable right now. +homepage-invoices-open = { $n } open invoice(s) — { $amount } +homepage-invoices-overdue = of which { $n } overdue — { $amount } +homepage-bank-ledger-balance = Book balance +homepage-bank-ledger-help = Computed from the general ledger, not from the bank. +homepage-bank-total-ledger = Total (book balance) +homepage-bank-shared-ledger-note = (a shared ledger account is counted once) +homepage-bank-statement = Statement of { $date }: { $amount } +homepage-bank-gap = Difference: { $amount } +homepage-bank-gap-unavailable = Difference not computable: shared ledger account +homepage-bank-unavailable = Bank accounts are unavailable right now. settings-bank-manage = Manage in Administration → Bank accounts settings-bank-manage-hint = To add, edit or archive a bank account, use the dedicated page Administration → Bank accounts. bank-accounts-toast-link-success = Bank account successfully linked to chart of accounts. diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 1da1b3e68..9c6239854 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -124,7 +124,7 @@ homepage-entries-empty-guided = Aucune écriture pour le moment. Commencez par s homepage-entries-action = Saisir une écriture homepage-invoices-title = Factures ouvertes homepage-invoices-empty = Aucune facture ouverte. -homepage-invoices-empty-guided = Aucune facture ouverte. Créez votre première facture pour facturer vos clients. +homepage-invoices-empty-guided = Aucune facture ouverte. Créez une facture pour facturer vos clients. homepage-invoices-action = Créer une facture homepage-reminders-count = { $n } facture(s) à rappeler homepage-bank-title = Comptes bancaires @@ -1056,7 +1056,7 @@ bank-accounts-actions-show-archived = Afficher les archivés bank-accounts-actions-hide-archived = Masquer les archivés bank-accounts-actions-submit-create = Créer bank-accounts-actions-submit-update = Enregistrer -bank-accounts-labels-balance = Solde +bank-accounts-labels-balance = Solde comptable bank-accounts-labels-balance-unavailable = Solde non disponible (lier au plan comptable) bank-accounts-labels-qr-iban = QR-IBAN (optionnel) bank-accounts-error-qr-iban-not-qr = Cet IBAN n'est pas un QR-IBAN. Si votre banque ne vous a pas fourni de QR-IBAN dédié aux QR-factures, laissez ce champ vide : votre IBAN normal suffit. @@ -1071,10 +1071,21 @@ bank-accounts-toast-update-success = Compte bancaire modifié. bank-accounts-toast-archive-success = Compte bancaire archivé. # Story v014-1 — Homepage widget bank accounts (F14 Pass 1 code review) -homepage-bank-total-liquidity = Total liquidités homepage-bank-total-partial = (comptes liés uniquement) homepage-bank-balance-unavailable = Solde non disponible — lier au plan comptable homepage-bank-last-transaction = Dernière transaction +homepage-entries-unavailable = Écritures indisponibles pour le moment. +homepage-invoices-unavailable = Factures indisponibles pour le moment. +homepage-invoices-open = { $n } facture(s) ouverte(s) — { $amount } +homepage-invoices-overdue = dont { $n } échue(s) — { $amount } +homepage-bank-ledger-balance = Solde comptable +homepage-bank-ledger-help = Calculé depuis le grand livre, pas depuis la banque. +homepage-bank-total-ledger = Total (solde comptable) +homepage-bank-shared-ledger-note = (un compte du grand livre partagé n'est compté qu'une fois) +homepage-bank-statement = Relevé du { $date } : { $amount } +homepage-bank-gap = Écart : { $amount } +homepage-bank-gap-unavailable = Écart non calculable : compte du grand livre partagé +homepage-bank-unavailable = Comptes bancaires indisponibles pour le moment. settings-bank-manage = Gérer dans Administration → Comptes bancaires settings-bank-manage-hint = Pour ajouter, modifier ou archiver un compte bancaire, utilisez la page dédiée Administration → Comptes bancaires. bank-accounts-toast-link-success = Compte bancaire lié avec succès au plan comptable. diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 4491a46e6..fb1c9837a 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -124,7 +124,7 @@ homepage-entries-empty-guided = Nessuna registrazione per il momento. Iniziate i homepage-entries-action = Inserire una registrazione homepage-invoices-title = Fatture aperte homepage-invoices-empty = Nessuna fattura aperta. -homepage-invoices-empty-guided = Nessuna fattura aperta. Create la vostra prima fattura per fatturare ai vostri clienti. +homepage-invoices-empty-guided = Nessuna fattura aperta. Create una fattura per fatturare ai vostri clienti. homepage-invoices-action = Creare una fattura homepage-reminders-count = { $n } fattura/e da sollecitare homepage-bank-title = Conti bancari @@ -1003,7 +1003,7 @@ bank-accounts-actions-show-archived = Mostra archiviati bank-accounts-actions-hide-archived = Nascondi archiviati bank-accounts-actions-submit-create = Crea bank-accounts-actions-submit-update = Salva -bank-accounts-labels-balance = Saldo +bank-accounts-labels-balance = Saldo contabile bank-accounts-labels-balance-unavailable = Saldo non disponibile (collegare al piano dei conti) bank-accounts-labels-qr-iban = QR-IBAN (opzionale) bank-accounts-error-qr-iban-not-qr = Questo IBAN non è un QR-IBAN. Se la vostra banca non vi ha fornito un QR-IBAN dedicato alle fatture QR, lasciate vuoto questo campo: il vostro IBAN normale è sufficiente. @@ -1017,10 +1017,21 @@ bank-accounts-toast-create-success = Conto bancario creato. bank-accounts-toast-update-success = Conto bancario modificato. bank-accounts-toast-archive-success = Conto bancario archiviato. # Story v014-1 — Homepage widget bank accounts (F14) -homepage-bank-total-liquidity = Totale liquidità homepage-bank-total-partial = (solo conti collegati) homepage-bank-balance-unavailable = Saldo non disponibile — collegare al piano dei conti homepage-bank-last-transaction = Ultima transazione +homepage-entries-unavailable = Registrazioni al momento non disponibili. +homepage-invoices-unavailable = Fatture al momento non disponibili. +homepage-invoices-open = { $n } fattura/e aperta/e — { $amount } +homepage-invoices-overdue = di cui { $n } scaduta/e — { $amount } +homepage-bank-ledger-balance = Saldo contabile +homepage-bank-ledger-help = Calcolato dal libro mastro, non dalla banca. +homepage-bank-total-ledger = Totale (saldo contabile) +homepage-bank-shared-ledger-note = (un conto di mastro condiviso è contato una sola volta) +homepage-bank-statement = Estratto del { $date }: { $amount } +homepage-bank-gap = Differenza: { $amount } +homepage-bank-gap-unavailable = Differenza non calcolabile: conto di mastro condiviso +homepage-bank-unavailable = Conti bancari al momento non disponibili. settings-bank-manage = Gestisci in Amministrazione → Conti bancari settings-bank-manage-hint = Per aggiungere, modificare o archiviare un conto bancario, utilizza la pagina dedicata Amministrazione → Conti bancari. bank-accounts-toast-link-success = Conto bancario collegato con successo al piano dei conti. diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index 77a65c233..b6bf730c0 100644 Binary files a/docs/manual/fr/user-manual.pdf and b/docs/manual/fr/user-manual.pdf differ diff --git a/docs/manual/fr/user-manual.tex b/docs/manual/fr/user-manual.tex index 50deb098b..001208afd 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -204,7 +204,7 @@ \subsection{Chemin B — Mode production} À la finalisation, Kesh met en place votre plan comptable (Sterchi PME par défaut) et votre premier exercice. Vous êtes alors prêt à saisir vos premières écritures. \begin{keshnote} -Vous pourrez à tout moment ajouter ou modifier vos comptes bancaires après l'onboarding (\emph{Paramètres} → \emph{Comptes bancaires}) et adapter votre plan comptable (cf. section \emph{Plan comptable}). +Vous pourrez à tout moment ajouter ou modifier vos comptes bancaires après l'onboarding (\emph{Administration} → \emph{Comptes bancaires}) et adapter votre plan comptable (cf. section \emph{Plan comptable}). \end{keshnote} \begin{keshtip} @@ -215,17 +215,59 @@ \subsection{Chemin B — Mode production} \section{Tableau de bord} Le tableau de bord est votre page d'accueil après connexion. Il présente trois -tuiles de synthèse avec un accès rapide à chaque section : +tuiles de synthèse : \keshscreenshot{dashboard.png}{Tableau de bord : dernières écritures, factures ouvertes et comptes bancaires.} % TODO capture: dashboard avec donnees demo \begin{itemize} - \item \textbf{Dernières écritures} — accès rapide au journal. - \item \textbf{Factures ouvertes} — accès rapide à la facturation. - \item \textbf{Comptes bancaires} — soldes calculés lorsque des comptes - bancaires sont configurés. + \item \textbf{Dernières écritures} — les cinq plus récentes (date, numéro, libellé, + montant), chacune menant à son détail. + \item \textbf{Factures ouvertes} — le nombre de factures validées non soldées et leur + \textbf{reste dû} total, dont la part échue. S'y ajoute, pour un comptable ou un + administrateur, le nombre de factures à rappeler. + \item \textbf{Comptes bancaires} — pour chaque compte, son \textbf{solde comptable} et, + si un relevé a été importé, le solde de ce relevé et l'écart entre les deux + (détails ci-dessous). \end{itemize} +Un rôle \emph{Consultation} voit les mêmes chiffres, sans les boutons de saisie. Si une +tuile ne peut pas se charger, elle le dit — elle n'affiche jamais « Aucune écriture » sur +une comptabilité qui en contient. + +\subsection{Solde comptable, relevé et écart} + +Le chiffre affiché sous chaque compte bancaire est le \textbf{solde comptable} : il vient +du compte du grand livre lié au compte bancaire (\emph{Administration} → \emph{Comptes +bancaires}), \textbf{pas de la banque}. Il ne reflète un encaissement qu'une fois celui-ci +comptabilisé, par un rapprochement ou un règlement. Le total ne compte qu'une fois un +compte du grand livre partagé par plusieurs comptes bancaires. + +Quand un relevé a été importé, la tuile affiche aussi \emph{Relevé du \dots} : le solde de +clôture du \textbf{dernier relevé} importé, à sa date. Seuls les relevés \textbf{CAMT.053} +portent un solde de clôture — un import CSV n'en a pas (\S\ref{sec:import-bancaire}). Si +le solde comptable à cette date diffère du relevé, la tuile affiche l'\textbf{écart} +(solde comptable − relevé) : typiquement un mouvement bancaire pas encore rapproché. + +L'écart tient compte des \textbf{dates de valeur} : la banque arrête son relevé par date de +comptabilisation, Kesh date les écritures de rapprochement à la date de valeur. Un +mouvement comptabilisé par la banque le dernier jour du relevé, mais rapproché à une date +de valeur postérieure, ne crée donc pas d'écart. Trois limites : +\begin{itemize} + \item seule une écriture \textbf{issue d'un rapprochement} est ainsi corrigée : une + écriture saisie à la main pour un mouvement bancaire, à une autre date, apparaît dans + l'écart — rien ne la relie au relevé ; + \item quand plusieurs comptes bancaires partagent un même compte du grand livre, + l'écart n'est pas calculable — la tuile l'indique : le solde de ce compte ne + s'attribue à aucun d'eux ; + \item après l'\textbf{annulation d'un rapprochement}, un mouvement comptabilisé par la + banque après la date du relevé mais dont l'écriture d'origine la précédait peut laisser + un écart fictif — l'écriture de contre-passation est datée du jour de l'annulation. +\end{itemize} + +Un compte bancaire sans compte du grand livre lié n'a pas de solde comptable ; la tuile +affiche tout de même son dernier relevé. Si les comptes bancaires ne peuvent pas être +chargés, la tuile le dit au lieu de disparaître. + \begin{keshnote} Les indicateurs plus riches (graphique de chiffre d'affaires, liste des transactions à rapprocher, tâches en attente, personnalisation des tuiles) sont @@ -1280,9 +1322,11 @@ \subsection{Importer des factures depuis un dossier} \end{keshnote} %%───────────────────────────────────────────────────────────────────────────── -\section{Import bancaire} +\section{Import bancaire}\label{sec:import-bancaire} -L'import bancaire est l'étape clé pour automatiser votre comptabilité. Kesh supporte deux formats : +L'import bancaire est l'étape clé pour automatiser votre comptabilité. Kesh supporte deux formats. +Seul le CAMT.053 porte le \textbf{solde de clôture} du relevé, que le tableau de bord confronte +au solde comptable ; un import CSV n'en a pas : \subsection{Import CAMT.053 (ISO 20022)} @@ -1945,7 +1989,7 @@ \subsection{Mon QR Bill ne s'affiche pas correctement, que faire ?} \begin{itemize} \item Vérifiez que votre compte bancaire est configuré avec un IBAN suisse valide. - \item Si vous utilisez un QR-IBAN, assurez-vous qu'il est marqué comme tel dans \emph{Paramètres} → \emph{Comptes bancaires}. + \item Si vous utilisez un QR-IBAN, assurez-vous qu'il est marqué comme tel dans \emph{Administration} → \emph{Comptes bancaires}. \item Testez le PDF généré avec une application e-banking suisse (PostFinance, Twint Pro, Raiffeisen) : le QR code doit être scanné en moins d'une seconde. \end{itemize} diff --git a/frontend/src/lib/features/bank-accounts/BankAccountJournalLinkForm.test.ts b/frontend/src/lib/features/bank-accounts/BankAccountJournalLinkForm.test.ts index 4da338113..73aaba392 100644 --- a/frontend/src/lib/features/bank-accounts/BankAccountJournalLinkForm.test.ts +++ b/frontend/src/lib/features/bank-accounts/BankAccountJournalLinkForm.test.ts @@ -53,6 +53,9 @@ function makeBankAccount(journalAccountId: number | null): BankAccountSummary { version: 3, archived: false, currentBalance: null, + statementClosingBalance: null, + statementDate: null, + ledgerBalanceAtStatement: null, lastTransactionDate: null, }; } diff --git a/frontend/src/lib/features/bank-accounts/bank-accounts.api.test.ts b/frontend/src/lib/features/bank-accounts/bank-accounts.api.test.ts index 6cb0138b6..49b23bb58 100644 --- a/frontend/src/lib/features/bank-accounts/bank-accounts.api.test.ts +++ b/frontend/src/lib/features/bank-accounts/bank-accounts.api.test.ts @@ -38,6 +38,9 @@ describe('bank-accounts.api', () => { version: 4, archived: false, currentBalance: null, + statementClosingBalance: null, + statementDate: null, + ledgerBalanceAtStatement: null, lastTransactionDate: null, }), headers: new Headers(), @@ -81,3 +84,70 @@ describe('bank-accounts.api', () => { expect(body).toEqual({ journalAccountId: null, version: 4 }); }); }); + +// Story 25-6-a (#389) — les trois champs du relevé traversent le mappage TS ; une +// faute de frappe dans une clé `Raw` les rendrait toujours `null`, tous gates verts. +describe('bank-accounts.api — champs du dernier relevé (25-6-a)', () => { + beforeEach(() => { + authState.clearSession(); + authState.login({ userId: '1', username: 'test', role: 'Admin', expiresIn: 900 }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + authState.clearSession(); + }); + + function stubJson(body: unknown) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(body), + headers: new Headers(), + } as Response), + ); + } + + it('listBankAccounts rend les chaînes du relevé telles quelles', async () => { + stubJson([ + { + id: 1, + bankName: 'UBS', + iban: 'CH4431999123000889012', + qrIban: null, + isPrimary: true, + journalAccountId: 10, + version: 1, + archived: false, + currentBalance: '800.0000', + lastTransactionDate: '2026-03-10', + statementClosingBalance: '1000.00', + statementDate: '2026-03-31', + ledgerBalanceAtStatement: '1000.0000', + }, + ]); + const [a] = await listBankAccounts(); + expect(a.currentBalance).toBe('800.0000'); + expect(a.statementClosingBalance).toBe('1000.00'); + expect(a.statementDate).toBe('2026-03-31'); + expect(a.ledgerBalanceAtStatement).toBe('1000.0000'); + }); + + it('une réponse de mutation sans ces champs les rend à null', async () => { + stubJson({ + id: 1, + bankName: 'UBS', + iban: 'CH4431999123000889012', + qrIban: null, + isPrimary: true, + journalAccountId: 10, + version: 2, + archived: false, + }); + const a = await updateBankAccountJournalLink(1, 10, 1); + expect(a.statementClosingBalance).toBeNull(); + expect(a.statementDate).toBeNull(); + expect(a.ledgerBalanceAtStatement).toBeNull(); + }); +}); diff --git a/frontend/src/lib/features/bank-accounts/bank-accounts.api.ts b/frontend/src/lib/features/bank-accounts/bank-accounts.api.ts index 146fbb98f..aab1b1d6b 100644 --- a/frontend/src/lib/features/bank-accounts/bank-accounts.api.ts +++ b/frontend/src/lib/features/bank-accounts/bank-accounts.api.ts @@ -4,9 +4,10 @@ // Story v014-1 — étendu avec CRUD complet post-onboarding : // - `createBankAccount`, `updateBankAccount`, `archiveBankAccount`. // - `listBankAccounts(includeArchived?)` avec param optionnel pour toggle UI. -// - `BankAccountSummary` étendu avec `currentBalance: number | null` (Decimal -// sérialisé en string par serde, converti en `number` côté TS) et -// `archived: boolean`. +// - `BankAccountSummary` étendu avec `currentBalance` et `archived: boolean`. +// +// Story 25-6-a (#389) — les montants restent des chaînes décimales (`big.js` +// pour tout calcul) ; trois champs du dernier relevé. import { apiClient } from '$lib/shared/utils/api-client'; @@ -21,27 +22,35 @@ export interface BankAccountSummary { /** Story v014-1 — true si soft-deleted (archivé). */ archived: boolean; /** - * Story v014-1 T5 — solde calculé serveur-side depuis journal_entry_lines. - * `null` si `journal_account_id` n'est pas configuré sur ce compte (L4 - * limitation v0.1 — lien plan comptable obligatoire pour calcul du solde). + * Story v014-1 T5 — **solde comptable** calculé serveur-side depuis + * journal_entry_lines (pas le solde bancaire). `null` si `journal_account_id` + * n'est pas configuré sur ce compte. * - * Décimal sérialisé en string par serde (feature `serde-str`), converti - * en `number` ici. La précision CHF (2 décimales en pratique) ne pose pas - * de problème via Number ; si v0.2 nécessite plus de précision, migrer - * vers `decimal.js`. + * Story 25-6-a (#389) — reste une **chaîne décimale** (jamais un `Number`) : + * tout calcul passe par `big.js`, tout affichage par `formatChfBalance`. */ - currentBalance: number | null; + currentBalance: string | null; /** * Story v014-1 F13 Pass 1 code review (AC#30) — date `MAX(je.entry_date)` * agrégée sur le `journal_account_id` lié. Format ISO `YYYY-MM-DD`. * `null` si journal_account_id NULL ou aucune écriture. */ lastTransactionDate: string | null; + /** Story 25-6-a — solde de clôture du dernier relevé importé (chaîne décimale). */ + statementClosingBalance: string | null; + /** Story 25-6-a — date de ce relevé (`YYYY-MM-DD`). */ + statementDate: string | null; + /** + * Story 25-6-a — solde comptable à `statementDate`, corrigé des dates de + * valeur. `null` sans relevé, sans compte lié, ou si plusieurs comptes + * bancaires partagent le compte de grand livre. + */ + ledgerBalanceAtStatement: string | null; } /** - * Type backend raw : `currentBalance` est sérialisé en string par serde - * (Decimal feature `serde-str`). Convertir en number via `Number(...)`. + * Type backend raw : les montants sont sérialisés en chaînes par serde (Decimal + * feature `serde-str`) et le restent. */ interface BankAccountSummaryRaw { id: number; @@ -54,6 +63,10 @@ interface BankAccountSummaryRaw { archived: boolean; currentBalance: string | null; lastTransactionDate: string | null; + // Absents des réponses de mutation (POST/PUT/PATCH/DELETE rendent un compte nu). + statementClosingBalance?: string | null; + statementDate?: string | null; + ledgerBalanceAtStatement?: string | null; } function parseBankAccount(raw: BankAccountSummaryRaw): BankAccountSummary { @@ -66,8 +79,11 @@ function parseBankAccount(raw: BankAccountSummaryRaw): BankAccountSummary { journalAccountId: raw.journalAccountId, version: raw.version, archived: raw.archived, - currentBalance: raw.currentBalance == null ? null : Number(raw.currentBalance), + currentBalance: raw.currentBalance ?? null, lastTransactionDate: raw.lastTransactionDate, + statementClosingBalance: raw.statementClosingBalance ?? null, + statementDate: raw.statementDate ?? null, + ledgerBalanceAtStatement: raw.ledgerBalanceAtStatement ?? null, }; } diff --git a/frontend/src/lib/features/bank-accounts/format.ts b/frontend/src/lib/features/bank-accounts/format.ts index 3ba96b1a2..9b633e26c 100644 --- a/frontend/src/lib/features/bank-accounts/format.ts +++ b/frontend/src/lib/features/bank-accounts/format.ts @@ -1,4 +1,5 @@ // Story v014-1 — utilitaires d'affichage partagés pour les bank_accounts. +import Big from 'big.js'; // F17 Pass 1 code review : factoriser `shortIban` entre `+page.svelte` // homepage et `bank-accounts/+page.svelte` pour éviter la divergence de // seuil (4 vs 8 caractères). @@ -17,11 +18,22 @@ export function shortIban(iban: string): string { /** * Formate un solde CHF avec 2 décimales (locale `fr-CH`). * `null` → message i18n « Solde non disponible » fourni par le caller. + * + * Story 25-6-a (#389) — prend une **chaîne décimale ou un `Big`**, jamais un + * `number` : l'arrondi au centime se fait en `big.js` (demi loin de zéro, + * l'arrondi du dépôt) ; seul le résultat, déjà au centime, devient un `Number` + * pour `Intl` — exact jusqu'à ~9·10¹³ CHF. */ -export function formatChfBalance(balance: number): string { +export function formatChfBalance(balance: string | Big): string { return new Intl.NumberFormat('fr-CH', { style: 'currency', currency: 'CHF', minimumFractionDigits: 2, - }).format(balance); + }).format(toCentimeNumber(balance)); +} + +/** Arrondi au centime ; un résultat nul rend `0`, jamais `-0` (« -0.00 CHF »). */ +function toCentimeNumber(balance: string | Big): number { + const rounded = new Big(balance).round(2, Big.roundHalfUp); + return rounded.eq(0) ? 0 : rounded.toNumber(); } diff --git a/frontend/src/lib/features/homepage/BankAccountsCard.svelte b/frontend/src/lib/features/homepage/BankAccountsCard.svelte new file mode 100644 index 000000000..12b01dc96 --- /dev/null +++ b/frontend/src/lib/features/homepage/BankAccountsCard.svelte @@ -0,0 +1,115 @@ + + +
+

+ {i18nMsg('homepage-bank-title', 'Comptes bancaires')} +

+ {#if state === 'error'} +

+ {i18nMsg('homepage-bank-unavailable', 'Comptes bancaires indisponibles pour le moment.')} +

+ {:else} +

+ {i18nMsg('homepage-bank-ledger-help', 'Calculé depuis le grand livre, pas depuis la banque.')} +

+
+ {#each accounts as account (account.id)} + {@const gap = statementGap(account)} +
+
+

{account.bankName}

+

{shortIban(account.iban)}

+
+
+ {#if account.currentBalance !== null} +

+ {i18nMsg('homepage-bank-ledger-balance', 'Solde comptable')} +

+

+ {formatChfBalance(account.currentBalance)} +

+ {#if account.lastTransactionDate} +

+ {i18nMsg('homepage-bank-last-transaction', 'Dernière transaction')} : {formatSwissDate( + account.lastTransactionDate, + )} +

+ {/if} + {:else} +

+ + {i18nMsg('homepage-bank-balance-unavailable', 'Solde non disponible — lier au plan comptable')} + +

+ {/if} + {#if account.statementClosingBalance !== null && account.statementDate !== null} +

+ {i18nMsg('homepage-bank-statement', 'Relevé du { $date } : { $amount }', { + date: formatSwissDate(account.statementDate), + amount: formatChfBalance(account.statementClosingBalance), + })} +

+ {/if} + {#if gap === null && account.statementClosingBalance !== null && account.currentBalance !== null && account.ledgerBalanceAtStatement === null} + +

+ {i18nMsg( + 'homepage-bank-gap-unavailable', + 'Écart non calculable : compte du grand livre partagé', + )} +

+ {/if} + {#if gap !== null} +

+ {i18nMsg('homepage-bank-gap', 'Écart : { $amount }', { + amount: formatChfBalance(gap), + })} +

+ {/if} +
+
+ {/each} + {#if total.any} +
+

+ {i18nMsg('homepage-bank-total-ledger', 'Total (solde comptable)')} + {#if total.partial} + + {i18nMsg('homepage-bank-total-partial', '(comptes liés uniquement)')} + + {/if} + {#if total.shared} + + {i18nMsg( + 'homepage-bank-shared-ledger-note', + "(un compte du grand livre partagé n'est compté qu'une fois)", + )} + + {/if} +

+

+ {formatChfBalance(total.total)} +

+
+ {/if} +
+ {/if} +
diff --git a/frontend/src/lib/features/homepage/HomepageCards.test.ts b/frontend/src/lib/features/homepage/HomepageCards.test.ts new file mode 100644 index 000000000..fc17d9784 --- /dev/null +++ b/frontend/src/lib/features/homepage/HomepageCards.test.ts @@ -0,0 +1,221 @@ +// Story 25-6-a (#388, #389) — les trois tuiles de l'accueil, isolément. +// i18nMsg est mocké pour renvoyer le fallback interpolé. +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; + +vi.mock('$lib/shared/utils/i18n.svelte', () => ({ + i18nMsg: (_key: string, fallback: string, args?: Record) => + fallback.replace(/\{ \$(\w+) \}/g, (_m, k) => String(args?.[k] ?? '')), +})); + +import RecentEntriesCard from './RecentEntriesCard.svelte'; +import OpenInvoicesCard from './OpenInvoicesCard.svelte'; +import BankAccountsCard from './BankAccountsCard.svelte'; +import type { JournalEntryResponse } from '$lib/features/journal-entries/journal-entries.types'; +import type { BankAccountSummary } from '$lib/features/bank-accounts/bank-accounts.api'; + +const entry = { + id: 7, + entryNumber: 12, + entryDate: '2026-03-10', + description: 'Vente comptoir', + lines: [ + { debit: '250.00', credit: '0' }, + { debit: '0', credit: '250.00' }, + ], +} as unknown as JournalEntryResponse; + +describe('RecentEntriesCard', () => { + it('liste les écritures avec un lien vers chacune', () => { + render(RecentEntriesCard, { state: 'ready', entries: [entry], canManage: true, isGuided: false }); + const row = screen.getByTestId('homepage-entry-row'); + expect(row.textContent).toContain('Vente comptoir'); + expect(row.textContent).toContain('250.00'); + expect(row.querySelector('a')?.getAttribute('href')).toBe('/journal-entries/7'); + }); + + it('vide : le texte vide ; vide guidé : la variante guidée', () => { + const { unmount } = render(RecentEntriesCard, { + state: 'ready', + entries: [], + canManage: true, + isGuided: false, + }); + expect(screen.getByText('Aucune écriture.')).toBeTruthy(); + unmount(); + render(RecentEntriesCard, { state: 'ready', entries: [], canManage: true, isGuided: true }); + expect(screen.getByText(/première écriture/)).toBeTruthy(); + }); + + it('un échec se dit, et prime sur la variante guidée', () => { + render(RecentEntriesCard, { state: 'error', entries: [], canManage: true, isGuided: true }); + expect(screen.getByTestId('homepage-entries-unavailable')).toBeTruthy(); + expect(screen.queryByText(/Aucune écriture/)).toBeNull(); + }); + + it('Consultation : ni bouton de saisie, ni injonction guidée', () => { + render(RecentEntriesCard, { state: 'ready', entries: [], canManage: false, isGuided: true }); + expect(screen.queryByText('Saisir une écriture')).toBeNull(); + expect(screen.getByText('Aucune écriture.')).toBeTruthy(); + }); +}); + +const summary = { unpaidCount: 3, unpaidTotal: '1250.5000', overdueCount: 1, overdueTotal: '400.00' }; + +describe('OpenInvoicesCard', () => { + it('chiffre les factures ouvertes pour un rôle Consultation, sans bouton', () => { + render(OpenInvoicesCard, { + state: 'ready', + summary, + canManage: false, + isGuided: false, + reminderCount: 0, + }); + const count = screen.getByTestId('homepage-invoices-open-count'); + expect(count.textContent).toMatch(/3 facture\(s\) ouverte\(s\) — 1.250\.50/); + expect(count.getAttribute('data-amount')).toBe('1250.5000'); + expect(screen.getByTestId('homepage-invoices-overdue').textContent).toMatch(/dont 1 échue\(s\) — 400\.00/); + expect(screen.queryByText('Créer une facture')).toBeNull(); + }); + + it('« dont M échues » absent à zéro', () => { + render(OpenInvoicesCard, { + state: 'ready', + summary: { ...summary, overdueCount: 0, overdueTotal: '0' }, + canManage: true, + isGuided: false, + reminderCount: 0, + }); + expect(screen.queryByTestId('homepage-invoices-overdue')).toBeNull(); + }); + + it('vide guidé : sans « première » ; Consultation : texte non guidé', () => { + const empty = { unpaidCount: 0, unpaidTotal: '0', overdueCount: 0, overdueTotal: '0' }; + const { unmount } = render(OpenInvoicesCard, { + state: 'ready', + summary: empty, + canManage: true, + isGuided: true, + reminderCount: 0, + }); + expect(screen.getByText(/Créez une facture/).textContent).not.toMatch(/première/); + unmount(); + render(OpenInvoicesCard, { + state: 'ready', + summary: empty, + canManage: false, + isGuided: true, + reminderCount: 0, + }); + expect(screen.getByText('Aucune facture ouverte.')).toBeTruthy(); + }); + + it('un échec se dit', () => { + render(OpenInvoicesCard, { + state: 'error', + summary: null, + canManage: true, + isGuided: false, + reminderCount: 0, + }); + expect(screen.getByTestId('homepage-invoices-unavailable')).toBeTruthy(); + }); +}); + +function account(over: Partial = {}): BankAccountSummary { + return { + id: 1, + bankName: 'UBS', + iban: 'CH4431999123000889012', + qrIban: null, + isPrimary: true, + journalAccountId: 10, + version: 1, + archived: false, + currentBalance: '800.0000', + lastTransactionDate: '2026-03-10', + statementClosingBalance: null, + statementDate: null, + ledgerBalanceAtStatement: null, + ...over, + }; +} + +describe('BankAccountsCard', () => { + it('nomme le solde « solde comptable » et le total « Total (solde comptable) »', () => { + render(BankAccountsCard, { accounts: [account()] }); + expect(screen.getByText('Solde comptable')).toBeTruthy(); + expect(screen.getByText(/Total \(solde comptable\)/)).toBeTruthy(); + expect(screen.queryByText(/liquidités/)).toBeNull(); + }); + + it('affiche le relevé, et l’écart seulement s’il est non nul', () => { + const { unmount } = render(BankAccountsCard, { + accounts: [ + account({ + statementClosingBalance: '1000.00', + statementDate: '2026-03-31', + ledgerBalanceAtStatement: '800.0000', + }), + ], + }); + expect(screen.getByTestId('homepage-bank-statement-1').textContent).toMatch(/Relevé du 31\.03\.2026/); + expect(screen.getByTestId('homepage-bank-gap-1').textContent).toMatch(/Écart/); + unmount(); + render(BankAccountsCard, { + accounts: [ + account({ + statementClosingBalance: '800.00', + statementDate: '2026-03-31', + ledgerBalanceAtStatement: '800.0000', + }), + ], + }); + expect(screen.queryByTestId('homepage-bank-gap-1')).toBeNull(); + }); + + it('compte une fois un compte de grand livre partagé, avec sa note', () => { + render(BankAccountsCard, { + accounts: [account({ id: 1 }), account({ id: 2, iban: 'CH9300762011623852957' })], + }); + expect(screen.getByTestId('homepage-bank-total').textContent).toMatch(/800\.00/); + expect(screen.getByTestId('homepage-bank-total-shared')).toBeTruthy(); + }); +}); + +describe('BankAccountsCard — revue P1', () => { + it('un échec se dit (la tuile ne disparaît plus en silence)', () => { + render(BankAccountsCard, { state: 'error', accounts: [] }); + expect(screen.getByTestId('homepage-bank-unavailable')).toBeTruthy(); + }); + + it('compte du grand livre partagé : l’écart est dit non calculable, pas absent', () => { + render(BankAccountsCard, { + accounts: [ + account({ + statementClosingBalance: '900.00', + statementDate: '2026-03-31', + ledgerBalanceAtStatement: null, + }), + ], + }); + expect(screen.getByTestId('homepage-bank-gap-unavailable-1')).toBeTruthy(); + expect(screen.queryByTestId('homepage-bank-gap-1')).toBeNull(); + }); + + it('compte NON lié avec relevé : ni écart ni « non calculable » (ce n’est pas un partage)', () => { + render(BankAccountsCard, { + accounts: [ + account({ + journalAccountId: null, + currentBalance: null, + statementClosingBalance: '900.00', + statementDate: '2026-03-31', + ledgerBalanceAtStatement: null, + }), + ], + }); + expect(screen.getByTestId('homepage-bank-statement-1')).toBeTruthy(); + expect(screen.queryByTestId('homepage-bank-gap-unavailable-1')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/features/homepage/OpenInvoicesCard.svelte b/frontend/src/lib/features/homepage/OpenInvoicesCard.svelte new file mode 100644 index 000000000..87ba3ba2f --- /dev/null +++ b/frontend/src/lib/features/homepage/OpenInvoicesCard.svelte @@ -0,0 +1,82 @@ + + +
+

+ {i18nMsg('homepage-invoices-title', 'Factures ouvertes')} +

+ {#if state === 'error'} +

+ {i18nMsg('homepage-invoices-unavailable', 'Factures indisponibles pour le moment.')} +

+ {:else if state === 'ready' && summary && summary.unpaidCount > 0} +

+ {i18nMsg('homepage-invoices-open', '{ $n } facture(s) ouverte(s) — { $amount }', { + n: summary.unpaidCount, + amount: amount(summary.unpaidTotal), + })} +

+ {#if summary.overdueCount > 0} +

+ {i18nMsg('homepage-invoices-overdue', 'dont { $n } échue(s) — { $amount }', { + n: summary.overdueCount, + amount: amount(summary.overdueTotal), + })} +

+ {/if} + {:else if state === 'ready'} +

+ {#if isGuided && canManage} + {i18nMsg( + 'homepage-invoices-empty-guided', + 'Aucune facture ouverte. Créez une facture pour facturer vos clients.', + )} + {:else} + {i18nMsg('homepage-invoices-empty', 'Aucune facture ouverte.')} + {/if} +

+ {/if} + {#if canManage && reminderCount > 0} + +

+ + + {i18nMsg('homepage-reminders-count', '{ $n } facture(s) à rappeler', { + n: reminderCount, + })} + + +

+ {/if} + {#if canManage} + + {/if} +
diff --git a/frontend/src/lib/features/homepage/RecentEntriesCard.svelte b/frontend/src/lib/features/homepage/RecentEntriesCard.svelte new file mode 100644 index 000000000..d057d564c --- /dev/null +++ b/frontend/src/lib/features/homepage/RecentEntriesCard.svelte @@ -0,0 +1,60 @@ + + +
+

+ {i18nMsg('homepage-entries-title', 'Dernières écritures')} +

+ {#if state === 'error'} +

+ {i18nMsg('homepage-entries-unavailable', 'Écritures indisponibles pour le moment.')} +

+ {:else if state === 'ready' && entries.length > 0} + + {:else if state === 'ready'} +

+ {#if isGuided && canManage} + {i18nMsg( + 'homepage-entries-empty-guided', + 'Aucune écriture pour le moment. Commencez par saisir votre première écriture comptable.', + )} + {:else} + {i18nMsg('homepage-entries-empty', 'Aucune écriture.')} + {/if} +

+ {/if} + {#if canManage} + + {/if} +
diff --git a/frontend/src/lib/features/homepage/homepage.test.ts b/frontend/src/lib/features/homepage/homepage.test.ts new file mode 100644 index 000000000..f2dae5c81 --- /dev/null +++ b/frontend/src/lib/features/homepage/homepage.test.ts @@ -0,0 +1,105 @@ +// Story 25-6-a (#388, #389) — les calculs de l'accueil. +import { describe, it, expect } from 'vitest'; +import type { BankAccountSummary } from '$lib/features/bank-accounts/bank-accounts.api'; +import type { JournalEntryResponse } from '$lib/features/journal-entries/journal-entries.types'; +import { entryAmount, ledgerTotal, statementGap } from './homepage'; +import { formatChfBalance } from '$lib/features/bank-accounts/format'; + +function account(over: Partial = {}): BankAccountSummary { + return { + id: 1, + bankName: 'UBS', + iban: 'CH4431999123000889012', + qrIban: null, + isPrimary: true, + journalAccountId: 10, + version: 1, + archived: false, + currentBalance: '0', + lastTransactionDate: null, + statementClosingBalance: null, + statementDate: null, + ledgerBalanceAtStatement: null, + ...over, + }; +} + +describe('ledgerTotal', () => { + it('somme en big.js — 0.1 + 0.2 vaut 0.3, pas 0.30000000000000004', () => { + const t = ledgerTotal([ + account({ id: 1, journalAccountId: 10, currentBalance: '0.1' }), + account({ id: 2, journalAccountId: 11, currentBalance: '0.2' }), + ]); + expect(t.total.toString()).toBe('0.3'); + }); + + it('ne compte qu’une fois un compte de grand livre partagé, et le signale', () => { + const t = ledgerTotal([ + account({ id: 1, journalAccountId: 10, currentBalance: '500.0000' }), + account({ id: 2, journalAccountId: 10, currentBalance: '500.0000' }), + ]); + expect(t.total.toString()).toBe('500'); + expect(t.shared).toBe(true); + }); + + it('signale un total partiel quand un compte n’est pas lié', () => { + const t = ledgerTotal([ + account({ id: 1, currentBalance: '100' }), + account({ id: 2, journalAccountId: null, currentBalance: null }), + ]); + expect(t.partial).toBe(true); + expect(t.any).toBe(true); + }); +}); + +describe('statementGap', () => { + it('rend l’écart solde comptable − relevé', () => { + const g = statementGap( + account({ ledgerBalanceAtStatement: '800.0000', statementClosingBalance: '1000.00' }), + ); + expect(g?.toString()).toBe('-200'); + }); + + it('un écart sous le centime n’en est pas un', () => { + expect( + statementGap(account({ ledgerBalanceAtStatement: '100.0049', statementClosingBalance: '100.00' })), + ).toBeNull(); + }); + + it('arrondit à mi-chemin loin de zéro, pas à l’arrondi bancaire', () => { + // Arrondi bancaire : 100.005 → 100.00, d'où un faux écart de −0.01. + expect( + statementGap(account({ ledgerBalanceAtStatement: '100.005', statementClosingBalance: '100.01' })), + ).toBeNull(); + expect( + statementGap(account({ ledgerBalanceAtStatement: '-100.005', statementClosingBalance: '-100.01' })), + ).toBeNull(); + }); + + it('sans relevé ou sans solde à sa date, pas d’écart', () => { + expect(statementGap(account({ statementClosingBalance: '10.00' }))).toBeNull(); + }); +}); + +describe('entryAmount et formatChfBalance', () => { + it('le montant d’une écriture est la somme de ses débits', () => { + const e = { + lines: [ + { debit: '120.50', credit: '0' }, + { debit: '0', credit: '120.50' }, + ], + } as unknown as JournalEntryResponse; + expect(entryAmount(e).toString()).toBe('120.5'); + }); + + it('formatChfBalance accepte une chaîne à quatre décimales', () => { + expect(formatChfBalance('1234.5650')).toMatch(/1.234\.57/); + }); +}); + +describe('formatChfBalance — zéro négatif', () => { + it('un montant négatif sous le centime s’affiche 0.00, pas -0.00', () => { + expect(formatChfBalance('-0.004')).not.toMatch(/-/); + expect(formatChfBalance('-0.0000')).not.toMatch(/-/); + }); +}); diff --git a/frontend/src/lib/features/homepage/homepage.ts b/frontend/src/lib/features/homepage/homepage.ts new file mode 100644 index 000000000..1fe4ade91 --- /dev/null +++ b/frontend/src/lib/features/homepage/homepage.ts @@ -0,0 +1,71 @@ +// Story 25-6-a (#388, #389) — calculs de la page d'accueil, purs et testables. +// +// ⚠️ Tout montant reste une chaîne décimale jusqu'ici et passe par `big.js` : +// jamais de `Number` sur un montant (l'ancien total des liquidités sommait des +// `Number`). + +import Big from 'big.js'; +import type { BankAccountSummary } from '$lib/features/bank-accounts/bank-accounts.api'; +import type { JournalEntryResponse } from '$lib/features/journal-entries/journal-entries.types'; + +/** Montant d'une écriture : la somme de ses débits (= celle de ses crédits). */ +export function entryAmount(entry: JournalEntryResponse): Big { + return entry.lines.reduce((acc, l) => acc.plus(new Big(l.debit)), new Big(0)); +} + +/** Total des soldes comptables de la tuile « Comptes bancaires ». */ +export interface LedgerTotal { + total: Big; + /** Au moins un compte bancaire sans compte lié (donc sans solde). */ + partial: boolean; + /** Au moins deux comptes bancaires liés au même compte de grand livre. */ + shared: boolean; + /** Au moins un solde est connu. */ + any: boolean; +} + +/** + * Somme les soldes comptables en ne comptant **chaque compte de grand livre + * qu'une fois** : deux comptes bancaires liés au même compte ont, par + * construction, le même solde (la somme porte sur le même compte) — les + * additionner le compterait deux fois. + */ +export function ledgerTotal(accounts: BankAccountSummary[]): LedgerTotal { + const seen = new Set(); + let total = new Big(0); + let shared = false; + let any = false; + for (const a of accounts) { + if (a.currentBalance === null || a.journalAccountId === null) continue; + any = true; + if (seen.has(a.journalAccountId)) { + shared = true; + continue; + } + seen.add(a.journalAccountId); + total = total.plus(new Big(a.currentBalance)); + } + const partial = any && accounts.some((a) => a.currentBalance === null); + return { total, partial, shared, any }; +} + +/** Arrondi du dépôt : au centime, demi loin de zéro. */ +function toCentime(v: string): Big { + return new Big(v).round(2, Big.roundHalfUp); +} + +/** + * Écart entre le solde comptable à la date du relevé et le relevé, **au + * centime** (solde comptable − relevé). `null` quand il ne se calcule pas + * (pas de relevé, pas de compte lié, compte de grand livre partagé) **ou quand + * il est nul** — seul un écart réel s'affiche. + */ +export function statementGap(account: BankAccountSummary): Big | null { + if (account.ledgerBalanceAtStatement === null || account.statementClosingBalance === null) { + return null; + } + const gap = toCentime(account.ledgerBalanceAtStatement).minus( + toCentime(account.statementClosingBalance), + ); + return gap.eq(0) ? null : gap; +} diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts index eb8e271ef..56787693d 100644 --- a/frontend/src/lib/shared/i18n-keys.test.ts +++ b/frontend/src/lib/shared/i18n-keys.test.ts @@ -356,9 +356,23 @@ const ATTENDU = { // quatre catalogues), l'en-tête « Solde » remplacé par « Ouverture » et // « Clôture » (+1, deux clés neuves `reports-column-opening` / `-closing`), et // le libellé de la ligne calculée, qui réemploie les deux clés du bilan (+2). - sitesTotal: 1758, - sitesNonResolus: 34, - relais: 7, + // + // Story 25-6-a (#388, #389) : **1758 → 1765**, recompté par ce test et par + // `grep -o "i18nMsg(\|msg('"` aux deux bornes. La page d'accueil + // `routes/(app)/+page.svelte` pesait **17** sites — ses 15 appels par son relais + // `msg()`, plus la déclaration et le corps du relais —, elle en pèse **24** : 1 dans + // la page et 23 dans les trois tuiles extraites (`features/homepage/` + // `RecentEntriesCard` 5, `OpenInvoicesCard` 8, `BankAccountsCard` 10), qui appellent + // `i18nMsg` directement. Le relais disparaît : `relais` **7 → 6**, + // `sitesNonResolus` **34 → 32** (sa déclaration et son corps). + // + // Story 25-6-a, revue de code P1 : **1765 → 1767** — `BankAccountsCard` passe de 10 à + // 12 appels (`grep -o "i18nMsg("`) : l'état d'échec de la tuile + // (`homepage-bank-unavailable`) et l'écart non calculable d'un compte du grand + // livre partagé (`homepage-bank-gap-unavailable`), deux clés neuves × 4 locales. + sitesTotal: 1767, + sitesNonResolus: 32, + relais: 6, sitesGabarit: 10, litterauxMin: 1050, clesDepuisTsMin: 5 @@ -434,8 +448,9 @@ const ANGLE_MORT_CLE_EN_COLONNE = 'routes/(app)/settings/vat-rates/+page.svelte: * ⚠️ **QUATRIÈME angle mort, écrit ici faute de pouvoir le fermer** : `findRelays` * travaille **par fichier**. Un relais qui serait *importé* d'un module partagé ne * serait recensé nulle part — et, contrairement aux autres trous, **aucun compteur ne - * bougerait**. La règle DRY du dépôt pousse vers cette extraction : les sept relais - * actuels sont sept copies de la même fonction de trois lignes. À traiter en 23-2. + * bougerait**. La règle DRY du dépôt pousse vers cette extraction : les six relais + * actuels sont six copies de la même fonction de trois lignes (sept jusqu'à la 25-6-a, + * qui a retiré celui de la page d'accueil). À traiter en 23-2. */ // ⚠️ La carte des codes d'erreur d'import est LUE depuis la production, plus recopiée : // tant qu'elle vivait dans le composant, une entrée ajoutée passait tous les gates au vert @@ -688,7 +703,7 @@ describe('garde i18n — les clés demandées existent au catalogue', () => { ).toEqual([]); }); - it('les 7 relais locaux sont recensés — cardinalité assertée', () => { + it('les 6 relais locaux sont recensés — cardinalité assertée', () => { // ⚠️ **Ce garde-fou manquait au premier jet, alors que la tâche le déclarait fait.** // Sans lui, un huitième relais ajouté sans que le test le sache rendrait TOUTES // ses clés invisibles, en silence — le défaut que le recensement des relais avait diff --git a/frontend/src/routes/(app)/+page.svelte b/frontend/src/routes/(app)/+page.svelte index 2e52478b0..6d409d490 100644 --- a/frontend/src/routes/(app)/+page.svelte +++ b/frontend/src/routes/(app)/+page.svelte @@ -1,72 +1,84 @@ @@ -74,110 +86,17 @@

- {msg('homepage-title', 'Tableau de bord')} + {i18nMsg('homepage-title', 'Tableau de bord')}

- -
-

- {msg('homepage-entries-title', 'Dernières écritures')} -

- {#if isGuided} -

- {msg('homepage-entries-empty-guided', 'Aucune écriture pour le moment. Commencez par saisir votre première écriture comptable.')} -

- {:else} -

- {msg('homepage-entries-empty', 'Aucune écriture.')} -

- {/if} - -
- - -
-

- {msg('homepage-invoices-title', 'Factures ouvertes')} -

- {#if canManage && reminderLoaded && reminderCount > 0} - -

- - - {msg('homepage-reminders-count', '{ $n } facture(s) à rappeler', { - n: reminderCount, - })} - - -

- {:else if isGuided} -

- {msg('homepage-invoices-empty-guided', 'Aucune facture ouverte. Créez votre première facture pour facturer vos clients.')} -

- {:else} -

- {msg('homepage-invoices-empty', 'Aucune facture ouverte.')} -

- {/if} - -
- - - {#if bankLoaded && bankAccounts.length > 0} -
-

- {msg('homepage-bank-title', 'Comptes bancaires')} -

-
- {#each bankAccounts as account (account.id)} -
-
-

{account.bankName}

-

{shortIban(account.iban)}

-
-
- {#if account.currentBalance !== null} -

- {formatBalance(account.currentBalance)} -

- {#if account.lastTransactionDate} -

- {msg('homepage-bank-last-transaction', 'Dernière transaction')} : {account.lastTransactionDate} -

- {/if} - {:else} -

- - {msg('homepage-bank-balance-unavailable', 'Solde non disponible — lier au plan comptable')} - -

- {/if} -
-
- {/each} - {#if hasAnyBalance} -
-

- {msg('homepage-bank-total-liquidity', 'Total liquidités')} - {#if totalIsPartial} - - {msg('homepage-bank-total-partial', '(comptes liés uniquement)')} - - {/if} -

-

- {formatBalance(totalLiquidity)} -

-
- {/if} -
-
+ + + + {#if bankState === 'error'} + + {:else if bankState === 'ready' && bankAccounts.length > 0} + {/if}
diff --git a/frontend/src/routes/(app)/bank-accounts/+page.svelte b/frontend/src/routes/(app)/bank-accounts/+page.svelte index be0b8f5e6..b10c434c7 100644 --- a/frontend/src/routes/(app)/bank-accounts/+page.svelte +++ b/frontend/src/routes/(app)/bank-accounts/+page.svelte @@ -93,7 +93,7 @@ return `${acc.number} — ${acc.name}`; } - function formatBalance(balance: number | null): string { + function formatBalance(balance: string | null): string { if (balance === null) { return i18nMsg( 'bank-accounts-labels-balance-unavailable', @@ -371,7 +371,7 @@ {i18nMsg('bank-accounts-labels-bank-name', 'Banque')} {i18nMsg('bank-accounts-labels-iban', 'IBAN')} - {i18nMsg('bank-accounts-labels-balance', 'Solde')} + {i18nMsg('bank-accounts-labels-balance', 'Solde comptable')} {i18nMsg('bank-accounts-labels-journal-account-id', 'Compte comptable lié')} diff --git a/frontend/src/routes/(app)/homepage-page.test.ts b/frontend/src/routes/(app)/homepage-page.test.ts new file mode 100644 index 000000000..d6d2c6ef1 --- /dev/null +++ b/frontend/src/routes/(app)/homepage-page.test.ts @@ -0,0 +1,36 @@ +// Story 25-6-a (#388, #389), revue de code P2 — le câblage de la page d'accueil : +// un rejet de l'API des comptes bancaires mène à la tuile en échec, pas à une +// tuile cachée (le défaut même de #388). Les quatre sources sont mockées. +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; + +vi.mock('$lib/shared/utils/i18n.svelte', () => ({ + i18nMsg: (_key: string, fallback: string) => fallback, +})); +vi.mock('$lib/features/bank-accounts/bank-accounts.api', () => ({ + listBankAccounts: vi.fn().mockRejectedValue(new Error('réseau')), +})); +vi.mock('$lib/features/journal-entries/journal-entries.api', () => ({ + fetchJournalEntries: vi.fn().mockResolvedValue({ items: [], total: 0, offset: 0, limit: 5 }), +})); +vi.mock('$lib/features/invoices/invoices.api', () => ({ + listDueDates: vi.fn().mockResolvedValue({ + items: [], + total: 0, + offset: 0, + limit: 1, + summary: { unpaidCount: 0, unpaidTotal: '0', overdueCount: 0, overdueTotal: '0' }, + }), +})); +vi.mock('$lib/features/reminders/reminders.api', () => ({ + listReminders: vi.fn().mockResolvedValue({ groups: [] }), +})); + +import Page from './+page.svelte'; + +describe('page d’accueil — câblage des échecs', () => { + it('un rejet de listBankAccounts affiche la tuile bancaire en échec', async () => { + render(Page); + await waitFor(() => expect(screen.getByTestId('homepage-bank-unavailable')).toBeTruthy()); + }); +}); diff --git a/frontend/tests/e2e/helpers/api-fixtures.ts b/frontend/tests/e2e/helpers/api-fixtures.ts index b89190f9d..e8dcad119 100644 --- a/frontend/tests/e2e/helpers/api-fixtures.ts +++ b/frontend/tests/e2e/helpers/api-fixtures.ts @@ -131,3 +131,40 @@ export async function createAndValidateInvoiceViaApi( await disposeContextSafe(ctx); } } + +/** + * Story 25-6-a (#388) — poste une écriture équilibrée à deux lignes (compte de + * classe 10 au débit, de classe 3 au crédit), datée du jour : la plus récente, + * donc en tête de la tuile « Dernières écritures ». Renvoie son id. + */ +export async function createJournalEntryViaApi( + page: Page, + description: string, + amount = '321.40', +): Promise { + const ctx = await authedApiContext(page); + try { + const accRes = await ctx.get('/api/v1/accounts?includeArchived=false'); + expect(accRes.ok(), `list accounts: ${accRes.status()}`).toBeTruthy(); + const accounts: Array<{ id: number; number: string }> = await accRes.json(); + const debit = accounts.find((a) => /^10[0-9]{2}$/.test(a.number)) ?? accounts[0]; + const credit = accounts.find((a) => /^3[0-9]{3}$/.test(a.number)) ?? accounts[1]; + const res = await ctx.post('/api/v1/journal-entries', { + data: { + // Date LOCALE (Zurich), pas UTC : entre minuit et 2 h, la date UTC est + // la veille — et le 1er janvier, un autre exercice. + entryDate: new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Zurich' }), + journal: 'OD', + description, + lines: [ + { accountId: debit.id, debit: amount, credit: '0.00' }, + { accountId: credit.id, debit: '0.00', credit: amount }, + ], + }, + }); + expect(res.ok(), `create entry: ${res.status()}`).toBeTruthy(); + return ((await res.json()) as { id: number }).id; + } finally { + await disposeContextSafe(ctx); + } +} diff --git a/frontend/tests/e2e/homepage-dashboard.spec.ts b/frontend/tests/e2e/homepage-dashboard.spec.ts new file mode 100644 index 000000000..f1bda5c32 --- /dev/null +++ b/frontend/tests/e2e/homepage-dashboard.spec.ts @@ -0,0 +1,106 @@ +/** + * Story 25-6-a (#388, #389) — le tableau de bord dit vrai : les dernières + * écritures branchées, les factures ouvertes chiffrées pour TOUS les rôles. + * + * Seul un E2E voit les valeurs traverser la frontière HTTP. Sélecteurs + * `data-testid` uniquement — jamais un libellé traduit (garde + * `e2e-selecteurs-traduits`, #326). + * + * Pré-requis : MariaDB up + KESH_TEST_MODE=true (cf. docs/testing.md). + */ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { seedTestState, clearAuthStorage, authedApiContext, disposeContextSafe } from './helpers/test-state'; +import { + createAndValidateInvoiceViaApi, + createContactWithAddressViaApi, + createJournalEntryViaApi, + ensurePrimaryBankAccountViaApi, +} from './helpers/api-fixtures'; + +test.beforeAll(async () => { + await seedTestState('with-company'); +}); + +test.afterEach(async ({ page }) => { + await clearAuthStorage(page); +}); + +async function login(page: Page, username = 'admin', password = 'admin123') { + await page.goto('/login'); + await page.fill('#username', username); + await page.fill('#password', password); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/'); +} + +function uniq(prefix: string): string { + return `${prefix} ${Date.now()}-${Math.floor(Math.random() * 1e6)}`; +} + +test.describe('Tableau de bord — les tuiles disent vrai (25-6-a)', () => { + test('une écriture postée apparaît dans « Dernières écritures »', async ({ page }) => { + await login(page); + const label = uniq('Écriture accueil'); + const id = await createJournalEntryViaApi(page, label); + await page.goto('/'); + const row = page.getByTestId('homepage-entry-row').filter({ hasText: label }); + await expect(row).toHaveCount(1); + await expect(row.locator(`a[href="/journal-entries/${id}"]`)).toHaveCount(1); + }); + + test('une facture validée apparaît chiffrée dans « Factures ouvertes »', async ({ page }) => { + await login(page); + await ensurePrimaryBankAccountViaApi(page); + const contact = await createContactWithAddressViaApi(page, uniq('Accueil SA')); + await createAndValidateInvoiceViaApi(page, contact); + await page.goto('/'); + const count = page.getByTestId('homepage-invoices-open-count'); + await expect(count).toBeVisible(); + const amount = await count.getAttribute('data-amount'); + expect(Number.parseFloat(amount ?? '0'), 'reste dû total').toBeGreaterThan(0); + }); + + test('un rôle Consultation voit les factures ouvertes chiffrées, sans bouton d’action', async ({ + page, + }) => { + await login(page); + const contact = await createContactWithAddressViaApi(page, uniq('Accueil Consult SA')); + await createAndValidateInvoiceViaApi(page, contact); + const username = `consult-tiles-${Date.now()}`; + const ctx = await authedApiContext(page); + try { + const res = await ctx.post('/api/v1/users', { + data: { username, password: 'MotDePasse12345', role: 'Consultation' }, + }); + expect(res.ok(), `create user failed: ${res.status()}`).toBeTruthy(); + } finally { + await disposeContextSafe(ctx); + } + await clearAuthStorage(page); + await login(page, username, 'MotDePasse12345'); + + await expect(page.getByTestId('homepage-invoices-open-count')).toBeVisible(); + // Présence avant absence : la tuile des écritures est chargée (liste ou + // texte vide) avant d'affirmer qu'elle n'a pas de bouton. + const entriesCard = page.getByTestId('homepage-card-recent-entries'); + await expect(entriesCard.locator('ul, p').first()).toBeVisible(); + const invoicesCard = page.getByTestId('homepage-card-open-invoices'); + await expect(invoicesCard.locator('a[href="/invoices"]')).toHaveCount(0); + await expect(entriesCard.locator('a[href="/journal-entries"]')).toHaveCount(0); + }); + + test('axe-core sans violations sur l’accueil peuplé', async ({ page }) => { + await login(page); + await ensurePrimaryBankAccountViaApi(page); + await createJournalEntryViaApi(page, uniq('Écriture axe')); + const contact = await createContactWithAddressViaApi(page, uniq('Axe SA')); + await createAndValidateInvoiceViaApi(page, contact); + await page.goto('/'); + await expect(page.getByTestId('homepage-entry-row').first()).toBeVisible(); + await expect(page.getByTestId('homepage-invoices-open-count')).toBeVisible(); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }); +});