diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8049521f..25986289c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,10 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind
### Fixed
+- **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.
+
- **L'export de souveraineté ne portait que la moitié de votre comptabilité.** Il promettait de quoi « migrer vers un autre logiciel » et laissait dehors **les factures fournisseurs, les avoirs, les règlements, les projets, les ordres de virement, les personnes de contact, les pièces reçues et le journal d'audit** — onze tables comptables sur les trente que compte désormais l'archive.
⚠️ **Un cas était particulièrement trompeur** : les écritures exportaient bien un identifiant de projet, mais la table des projets manquait — l'identifiant ne renvoyait donc à rien. *Un fichier qui a l'air complet est pire qu'un fichier manifestement incomplet.*
@@ -48,6 +52,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind
### Changed
+- **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*.
- **Supprimer une facture validée n'est plus possible — il faut la « dévalider » d'abord.** Jusqu'ici, le bouton *Supprimer* d'une facture validée effaçait la facture **et son écriture comptable** d'un seul geste. La destruction d'une écriture était donc un **effet de bord du mot « supprimer »** : rien, dans le vocabulaire de l'écran, ne disait qu'on touchait aux livres.
diff --git a/_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md b/_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md
new file mode 100644
index 000000000..ecd5c3264
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md
@@ -0,0 +1,376 @@
+# Story 25.5-b : La balance des comptes porte un solde d'ouverture — et concorde enfin avec le bilan
+
+Status: done
+
+**Issue : [#385]**, que cette story **ferme** : la PR porte `closes #385` dans le **titre ET le corps**. Seconde moitié de
+la 25-5, découpée le 2026-09-23 (la 25-5-a a fermé #386). Branche `story/25-5-b-balance-solde-ouverture`, partie de
+`main` (`27c61def`) : la story ne dépend pas de la chaîne 25-4, qui ne touche pas `trial_balance.rs`.
+
+## Story
+
+En tant que **personne qui tient ses livres dans Kesh, ou qui les révise**,
+je veux que **la balance des comptes montre pour chaque compte son solde d'ouverture, ses mouvements et son solde de
+clôture**,
+afin que **le solde de clôture d'un compte de bilan soit le même nombre que celui du bilan** — un compte, un nombre.
+
+## Les faits, vérifiés dans le code
+
+- **La balance actuelle est une balance des mouvements.** `crates/kesh-report/src/trial_balance.rs:60-104` filtre les
+ lignes sur `je.fiscal_year_id = ?` **et** `je.entry_date BETWEEN start AND end`. `balance` est le **net des mouvements**
+ de la période, signé par type (`Asset`/`Expense` : débit − crédit ; sinon crédit − débit).
+- **Le bilan est cumulatif depuis l'origine** (`balance_sheet.rs:1-30`) : comptes de bilan = Σ de toutes les écritures
+ `entry_date ≤ fin`, tous exercices confondus ; plus deux **lignes calculées** : `retained_earnings` (P&L
+ `entry_date < fy_start`) et `equity_result` (P&L `[fy_start, fin]`).
+- ⚠️ **L'écran avoue l'écart** : `TrialBalanceView.svelte:26-32` affiche `reports-trial-balance-period-note` — « Le total
+ par compte n'est pas comparable au solde cumulé du même compte au bilan ». Cette note devient **fausse** avec la story.
+- **Le moteur existe — dans le grand livre** (Story 24-1). `general_ledger.rs:11-35` pose **la règle d'ouverture** :
+ `Asset`/`Liability` = cumul `entry_date < from`, tous exercices ; `Revenue`/`Expense` = cumul depuis le **début de
+ l'exercice contenant `from`** (0 si `from` est ce premier jour). Implémentée par `opening_balance` (`:220-275`), avec
+ `is_debit_natured` / `signed` (`:182-193`) et `fiscal_year_start_containing` (`:195-218`) — **privés au module**, et
+ **requête par compte**.
+- **Un test de concordance existe déjà** entre grand livre et balance (`crates/kesh-report/tests/general_ledger.rs:150-205`) :
+ il ne compare que `total_debit` / `total_credit` — les mouvements, la seule chose que la balance porte aujourd'hui.
+- **Règle d'inclusion actuelle** (`trial_balance.rs:3-6`) : comptes actifs toujours ; archivés **seulement s'ils ont une
+ écriture dans la période**. Un compte archivé portant un solde d'ouverture mais sans mouvement **disparaîtrait** —
+ alors que le bilan le compte.
+- **Rendus** : CSV `csv.rs:249-296` (`NumeroCompte;NomCompte;TotalDebit;TotalCredit;Solde`, ligne `Total`) ; PDF
+ `pdf.rs:699-850` (5 colonnes, A4 portrait, nom tronqué à 30 caractères, positions `MARGIN_LEFT_MM + 20/90/125/160`).
+ Les libellés `ledger_opening`, `ledger_closing` et `retained_result_label` existent déjà dans `SectionLabels`
+ (`pdf.rs:74-140`) ; côté écran, `reports-ledger-opening`, `reports-ledger-closing` et
+ `reports-retained-earnings-calculated` / `-loss` existent déjà dans les 4 locales. ⚠️ Les libellés « Solde
+ d'ouverture » / « Solde de clôture » sont **trop longs** pour les colonnes de la balance (AC 7) : seuls
+ `retained_result_label` et `reports-retained-earnings-*` sont réutilisés.
+- **Consommateurs de `TrialBalance` / `TrialBalanceRow`** (construction littérale à compléter) : `csv.rs` (tests
+ `:820`, `:1128`), `pdf.rs` (`fixture_tb`, `:1810`), `benches/export.rs:100-125`. Appels de `generate` :
+ `kesh-api/src/routes/reports.rs:399`, `:991` ; tests `kesh-db/tests/report_aggregates.rs` (4 sites),
+ `kesh-api/tests/reports_e2e.rs`, `reports_export_e2e.rs`, `kesh-report/tests/general_ledger.rs`.
+- **Le manuel est faux, et pas seulement sur l'ouverture** : `user-manual.tex:1557-1559` — « Liste tous les comptes avec
+ leurs soldes débiteurs et créditeurs. Filtres : période, **niveau de détail (comptes principaux uniquement, ou tous
+ sous-comptes)** ». Aucun filtre de niveau n'existe (`grep -n "detail\|niveau" reports/+page.svelte routes/reports.rs` :
+ rien). Glossaire `:2043` : « … à une date donnée ».
+
+## Acceptance Criteria
+
+**AC 1 — Le solde d'ouverture, selon la règle du grand livre.** `TrialBalanceRow` gagne `opening_balance` et
+`closing_balance` (`Decimal`, même convention de signe que `balance`). Ouverture : comptes de bilan (`Asset`,
+`Liability`) = Σ des lignes `entry_date < period.start_date`, **tous exercices confondus** ; comptes de résultat
+(`Revenue`, `Expense`) = Σ des lignes `entry_date ∈ [début de l'exercice de la période, period.start_date[` — 0 quand la
+période commence le premier jour de l'exercice. Le début de l'exercice est celui de `period.fiscal_year_id` (la
+`ReportPeriod` est déjà bornée à cet exercice). **Clôture = ouverture + net des mouvements** (`balance`).
+
+**AC 2 — Une seule règle, pas deux copies — et la forme qui le permet.** La règle type → borne vit **en Rust, une
+fois** : un module partagé de `kesh-report` (ex. `crate::opening`) porte `is_debit_natured`, `signed` et
+`opening_from(t, before_start: (debit, credit), since_fy_start: (debit, credit)) -> Decimal` (bilan : la première
+paire ; résultat : la seconde ; signé par type). La balance fait **une requête agrégée** (pas une requête par compte)
+qui rend, par compte, **trois paires de sommes brutes** — `entry_date < start`, `entry_date ∈ [fy_start, start[`, et les
+mouvements de la période — **sans** `CASE` sur le type ni sur le signe en SQL (le `CASE` de signe actuel,
+`trial_balance.rs:71-75`, disparaît au profit de `signed`). Le grand livre appelle `opening_from` lui aussi (sa
+requête d'ouverture rend les deux paires ; sa période est libre — quand aucun exercice ne couvre `from`,
+`fiscal_year_start_containing` rend `None` et il passe une paire **nulle** « depuis le début d'exercice », ce qui garde
+l'ouverture d'un compte de résultat à zéro comme aujourd'hui) ; ses tests restent verts sans retouche d'assertion. ⚠️ `TrialBalanceRow`
+dérive `sqlx::FromRow` (`trial_balance.rs:35`) : un champ calculé en Rust ferait échouer la lecture **à l'exécution**
+(`ColumnNotFound`), invisible à la compilation — lire dans une structure brute intermédiaire, puis construire la
+ligne.
+
+**AC 3 — Les mouvements ne changent pas.** `total_debit`, `total_credit` (par ligne et au total), `balance` (net des
+mouvements) et `balanced` gardent **exactement** leur sens et leurs valeurs actuels. `balance` est documenté au champ
+comme « net des mouvements de la période » — l'API publique (clés PAT) ne voit qu'**ajouter** des champs.
+
+**AC 4 — La ligne calculée du résultat reporté.** Sans écriture de clôture, le résultat des exercices antérieurs n'est
+porté par **aucun compte** : sans lui, ni la colonne d'ouverture ni celle de clôture ne s'équilibrent. `TrialBalance`
+gagne `retained_earnings: Decimal` — **même valeur** que `BalanceSheet::retained_earnings` pour l'exercice de la période
+(P&L `entry_date < fy_start`, signe crédit-positif), obtenue en appelant **la même fonction** :
+`fetch_retained_earnings` (`balance_sheet.rs:333`, privée) est rendue visible au crate (`pub(crate)`), pas recopiée. Il est rendu comme une **ligne
+à part**, après les comptes, libellée « Résultat reporté (calculé) » (« Perte reportée » si négatif, comme au bilan),
+ouverture = clôture = `retained_earnings`, mouvements vides. Ce n'est **pas** une `TrialBalanceRow` (pas
+d'`account_id`, pas de lien vers le grand livre). ⚠️ La bascule sur « Perte reportée » n'existe **qu'à l'écran**, comme
+au bilan (`BalanceSheetView.svelte:97-100`) ; le CSV et le PDF du bilan portent un libellé fixe (`csv.rs:140-144`,
+`pdf.rs:596-599`) — la balance fait de même.
+
+**AC 5 — Un contrôle, pas une tautologie.** `TrialBalance` gagne **un** indicateur `opening_balanced` (`bool`) :
+`Σ debit_sense(row.opening_balance) − retained_earnings = 0`, où
+`debit_sense(v) = if is_debit_natured(row.account_type) { v } else { −v }` — les soldes stockés sont **déjà signés par
+type** : les additionner sans re-signer les comptes à nature créditrice donnerait un faux ⚠️ sur des livres exacts
+(validation P1, calcul : 1000 − 600 ≠ 0 sur un jeu équilibré). Vérification chiffrée : ventes 1000 et charge 400 en
+exercice 1, balance de l'exercice 2 → ouvertures en sens débit 1000 (clients) − 400 (caisse) = 600, `retained_earnings`
+600, contrôle nul. **Pas de `closing_balanced`** (validation P2) : Σ debit_sense(clôture) = Σ debit_sense(ouverture) +
+(total débit − total crédit des mouvements), et des mouvements déséquilibrés font déjà échouer le rapport
+(`TrialBalanceUnbalanced`) — un second indicateur serait la copie du premier, une identité de construction. Le résultat reporté est calculé **indépendamment** (comme au bilan), donc
+l'égalité est une vérification réelle, pas une identité de construction. Un déséquilibre n'est **pas** une erreur (à la
+différence de `TrialBalanceUnbalanced` sur les mouvements) : il est **journalisé** en `tracing::warn!` — le niveau
+d'`equation_holds` au bilan (`balance_sheet.rs:237`) — et affiché (⚠️).
+
+**AC 6 — La règle d'inclusion.** Un compte est rendu s'il est **actif**, **ou** s'il a un mouvement dans la période,
+**ou** si son **solde d'ouverture est non nul**. Un compte archivé à solde d'ouverture non nul sans mouvement apparaît
+donc (marqueur `active: false`), sinon la clôture ne concorderait pas avec le bilan. Doc du module mise à jour.
+
+**AC 7 — Les rendus.**
+- **Écran** (`TrialBalanceView.svelte`) : colonnes N° | Intitulé | **Ouverture** | Débit | Crédit | **Clôture** — clés
+ **neuves et courtes** `reports-column-opening` (« Ouverture ») et `reports-column-closing` (« Clôture ») dans les 4
+ locales (les clés du grand livre rendent « Solde d'ouverture », trop long et non ancrable) ; la colonne « Solde » (net
+ des mouvements) disparaît de l'écran ; pied : totaux débit/crédit et ✓/⚠️ des mouvements comme aujourd'hui, plus
+ ✓/⚠️ d'`opening_balanced` sous la colonne Ouverture (AC 5) ; la ligne calculée (AC 4) avant le pied.
+ La note `reports-trial-balance-period-note` est **retirée** (clé supprimée des 4 locales) — elle deviendrait fausse.
+- **CSV** : `NumeroCompte;NomCompte;SoldeOuverture;TotalDebit;TotalCredit;SoldeCloture` — la colonne `Solde` est
+ **remplacée**, pas renommée en silence (CHANGELOG — ⚠️ changement incompatible pour qui lit ce CSV, dans une version
+ 0.x : **arbitré par Guy le 2026-10-03 : remplacer**). Ligne de résultat reporté sur le patron du bilan
+ (`csv.rs:139-145`) : numéro **vide**, libellé fixe « Résultat reporté (calculé) » dans la colonne du nom, ouverture et
+ clôture remplies, débit et crédit vides ; puis ligne `Total` (débit, crédit ; ouverture et clôture vides).
+- **PDF** : six colonnes en A4 portrait. La mise en page à cinq colonnes occupe **déjà** toute la largeur
+ (20 + 70 + 3 × 35 = 195 mm = 210 − 15, validation P1) : positions imposées `MARGIN_LEFT_MM +` **0** (N°), **20**
+ (intitulé), **75** (ouverture), **105** (débit), **135** (crédit), **165** (clôture) — colonnes de montant de 30 mm
+ (« -1'234'567.89 » y tient), intitulé de 55 mm tronqué à **22** caractères (au lieu de 30, constante nommée).
+ En-têtes : deux champs **neufs** de `SectionLabels`, `col_opening` = « Ouverture » et `col_closing` = « Clôture »
+ (défauts fr de `PdfContext::fr_ch_default`) — « Solde d'ouverture » en Helvetica-Bold 10 pt mesure 29,85 mm et
+ toucherait « Débit », « Solde de clôture » finirait à 2,5 mm du bord (validation P2). Ligne `retained_result_label`. Garde « vide » inchangée
+ (`rows.is_empty()`).
+- `isReportEmpty('trial-balance', …)` (`reports.api.ts:169-172`) : inchangé — les comptes actifs sont toujours rendus.
+
+**AC 8 — L'API et les types.** `openingBalance`, `closingBalance` sur chaque ligne ; `retainedEarnings`,
+`openingBalanced` au niveau du rapport — dans la réponse de `GET /api/v1/reports/trial-balance` et dans
+`reports.types.ts`. Libellés neufs éventuels dans les 4 locales, `sitesTotal` recompté depuis la source.
+
+**AC 9 — La concordance, prouvée en appelant les autres rapports.** Le motif d'échec redouté est **muet** :
+`clôture = ouverture + mouvements` resterait vrai, le rapport serait intérieurement cohérent et extérieurement faux.
+Les tests appellent donc **réellement** `generate_balance_sheet`, `generate_income_statement` et le grand livre :
+- deux exercices, des écritures sur les deux (dont un compte de bilan, un de produits, un de charges) ; balance de
+ l'exercice 2 → **clôture de chaque compte de bilan = son solde au bilan** à la fin de la période ; **clôture de chaque
+ compte de résultat = son montant au compte de résultat** ; `retained_earnings` = celui du bilan ; ouverture du compte
+ de produits **= 0** au premier jour de l'exercice 2 (et non le cumul de l'exercice 1) ;
+- une période **en cours d'exercice** (mars–juin de l'exercice 2) : ouverture du compte de produits = cumul janvier–
+ février ; clôture = celle du grand livre sur la même période (`section.closing`) ;
+- **la frontière** : dans la période mars–juin, une écriture datée **le 1er mars** (comptée dans les mouvements,
+ absente de l'ouverture) et une datée **la veille du début de période** (dans l'ouverture, absente des mouvements) —
+ la mutation `<` → `<=` compterait la première deux fois, et seules les comparaisons au bilan et au grand livre la
+ voient (celle au grand livre seulement si la borne n'est pas dans le module partagé) ;
+- une période **d'un seul jour** (`start = end`), une écriture ce jour-là et une la veille ;
+- le **premier exercice** d'une société (aucune écriture antérieure) : `retained_earnings = 0`, ouvertures nulles,
+ contrôle vrai ; une **société sans aucune écriture** : tout à zéro, contrôle vrai ; un **exercice 2 ouvert et encore
+ vide** après un exercice 1 mouvementé — la première vue réelle d'une année neuve : ouvertures = clôtures de
+ l'exercice 1 pour les comptes de bilan, nulles pour les comptes de résultat, mouvements nuls, contrôle vrai ;
+- des **pertes cumulées** (charges > produits en exercice 1) : `retained_earnings < 0`, contrôle vrai ; le libellé
+ « Perte reportée » est vérifié **dans le Vitest de la vue** (seul rendu qui bascule, AC 4) ;
+- `opening_balanced` vrai dans ces scénarios — et **faux** quand l'égalité est réellement cassée : une ligne
+ d'écriture **déséquilibrée insérée en SQL brut** (hors `create_in_tx`) datée dans l'exercice 1 — sur un compte de
+ bilan **et**, dans un second cas, sur un compte de résultat (l'écart passe alors par `retained_earnings`) → balance
+ de l'exercice 2 rendue (pas d'erreur : les mouvements de la période restent équilibrés), `opening_balanced` à
+ `false`. Sans ce test, le contrôle reproduit l'angle mort d'`equation_holds`, jamais testé à
+ `false` dans le dépôt (validation P1) ;
+- un test **unitaire** de `debit_sense` (compte à nature créditrice re-signé) ;
+- un compte **archivé** à solde d'ouverture non nul, sans mouvement → présent, clôture = bilan ; et un compte de
+ **résultat** archivé, mouvementé en janvier, balance de mars–juin → présent (ouverture non nulle), `opening_balanced`
+ vrai (une règle d'inclusion limitée aux comptes de bilan le ferait disparaître et le contrôle rougirait) ;
+- non-régression : mouvements, `balance`, `balanced` identiques à avant sur le jeu existant (`report_aggregates.rs`) ;
+- le test existant `concordance_compte_de_resultat` (`general_ledger.rs:135-205`) étendu à `opening` / `closing` **et
+ passé à une période en cours d'exercice** — sur l'exercice entier, les deux ouvertures valent 0 par construction et
+ une mutation « ouverture toujours nulle » survivrait.
+- Rendus : CSV (en-tête à six colonnes, **valeurs** d'ouverture et de clôture dans l'ordre des colonnes, ligne de
+ résultat reporté, ligne `Total`) ; PDF (rendu non vide avec la ligne calculée ; la troncature à 22 caractères testée
+ sur un intitulé de 38) ; **Vitest** de la vue (`TrialBalanceView.test.ts`, à créer : colonnes, ligne calculée, ✓/⚠️, note absente) ;
+ test d'API sur la forme de la réponse (`reports_e2e.rs`) ; **E2E** : la balance affiche les colonnes Ouverture /
+ Clôture (`reports.spec.ts`, sélecteurs ancrés).
+
+Chacun aurait échoué avant le patch ; chaque test de concordance nomme la mutation qu'il tue (ex. « ouverture des
+comptes de résultat cumulée depuis l'origine », « règle d'inclusion sans le solde d'ouverture »).
+
+**AC 10 — Le manuel et le CHANGELOG.** `user-manual.tex` § *Balance des comptes* (`:1557-1559`) réécrit : les quatre
+colonnes, la règle d'ouverture (bilan depuis l'origine, résultat depuis le début de l'exercice), la ligne calculée,
+la concordance avec le bilan et le grand livre ; la phrase sur le **filtre de niveau de détail**, qui n'existe pas,
+**retirée**. Le grand livre (`:1561-1565`, « là où la balance dit la même chose autrement ») relu. Le manuel dit aussi : (a) les
+soldes de départ sont une écriture datée du premier jour du premier exercice (`:615`) — ils apparaissent donc en
+**mouvements** de cet exercice, ouverture nulle ; (b) Ouverture et Clôture sont **signées selon la nature du compte** :
+leur somme visible n'est pas nulle (1000 − 400 + 600 = 1200 dans l'exemple de l'AC 5) alors que l'écran affiche ✓ —
+le contrôle porte sur les soldes en sens débit. Glossaire `:2043`
+corrigé (« à une date donnée » → sur une période, ouverture et clôture). PDF régénéré, contrôlé **aplati**
+(`pdftotext … | tr '\n' ' ' | tr -s ' '`). CHANGELOG, section `## [0.12.1] — Non publié` (il n'y a pas de section `[Unreleased]`) : `Changed` (colonnes de l'écran, du PDF et du CSV —
+`Solde` remplacé) et `Fixed` (#385). README et site relus (`grep -rn "balance" README.md website/`).
+
+## Tasks / Subtasks
+
+- [x] **T1 — le module partagé** (AC 2) : extraire `is_debit_natured`, `signed`, la borne basse de l'ouverture ; le grand
+ livre l'appelle ; ses tests verts sans retouche d'assertion.
+- [x] **T2 — la balance** (AC 1, 3, 4, 5, 6) : requête agrégée, champs neufs, ligne calculée, contrôle, inclusion.
+- [x] **T3 — les rendus** CSV et PDF (AC 7), fixtures et bench complétés.
+- [x] **T4 — l'écran, les types, l'i18n** (AC 7, 8) : vue, `reports.types.ts`, clé de note retirée et deux clés de
+ colonne ajoutées dans les 4 locales, `sitesTotal`.
+- [x] **T5 — tests** (AC 9).
+- [x] **T6 — manuel, CHANGELOG** (AC 10).
+- [x] **T7 — gates** : backend complet, frontend complet, **E2E complet**.
+
+## Dev Notes
+
+### Ce qu'il ne faut pas faire
+
+- **Ne pas** recopier `opening_balance` du grand livre dans `trial_balance.rs` (DRY, AC 2) — ni l'appeler compte par
+ compte (une requête par compte sur un plan de 300 comptes).
+- **Ne pas** changer le sens de `balance` : l'API est publique (clés PAT, Story 15-x). On **ajoute**.
+- **Ne pas** fusionner la ligne calculée avec un compte physique de rôle `RetainedEarnings` (collision D1 de la 14-3c,
+ `balance_sheet.rs:40-55`) : le compte physique reste une ligne de compte, la ligne calculée reste à part.
+- **Ne pas** borner l'ouverture des comptes de bilan par `fiscal_year_id` : c'est exactement le défaut de #385.
+- **Ne pas** introduire de filtre de niveau de détail pour rendre le manuel vrai : c'est le manuel qu'on corrige.
+- La « requête agrégée unique » (AC 2) n'est tenue par **aucun test** (pas de compteur de requêtes dans le dépôt) :
+ c'est un contrôle de **revue de code**, assumé comme tel.
+
+### Limites assumées (à écrire au manuel et en doc de module)
+
+- **Signe lu sur le type courant** du compte : retyper un compte mouvementé re-signe tout son historique — limite
+ préexistante du grand livre (#274, #382), héritée telle quelle.
+- **Pas d'écriture de clôture** : la remise à zéro des comptes de résultat n'existe que comme borne basse du `SUM` ;
+ c'est ce qui rend les tests de concordance (AC 9) indispensables.
+
+### Modules
+
+`kesh-report` (balance, module partagé, rendus, deux libellés PDF), `frontend` (vue, types, test), `kesh-i18n` (clé
+retirée, deux clés de colonne), + `docs`, `CHANGELOG`. `kesh-api` : aucun changement de code attendu (la route sérialise la structure ;
+les libellés PDF existent) — tests seulement. **Trois modules de code**, sous le seuil de la § *Règle de splitting
+préventif*.
+
+### References
+
+- Issue [#385] ; audit du 2026-08-26 (comptable § 5.2, DAF § 1.3).
+- `crates/kesh-report/src/general_ledger.rs:1-60`, `:182-275` — la règle et son implémentation.
+- `crates/kesh-report/src/balance_sheet.rs:1-120`, `:224-228`, `:333` — le calcul de `retained_earnings`.
+- Story 24-1 (grand livre) — `_bmad-output/implementation-artifacts/24-1-grand-livre.md`.
+
+## Dev Agent Record
+
+### Agent Model Used
+
+Claude Opus 5.5 (`claude-opus-5-5`).
+
+### Debug Log References
+
+- Les dix tests de concordance ont passé **du premier coup** : aucun n'a donc été vu rouge avant le code. D'où les
+ mutations ci-dessous, qui tiennent lieu de phase rouge.
+- Deux garde-fous du frontend ont rougi au gate, et ils avaient raison :
+ - `e2e-selecteurs-traduits` : l'E2E ancrait l'en-tête « Clôture » sur son libellé traduit. Remplacé par les
+ `data-testid` `tb-col-opening` et `tb-col-closing`, et l'absence de « Solde » par un compte de six en-têtes.
+ - `i18n-libelle-en-dur` : la déclaration neuve `retainedLabel` est **nommée** dans le relevé (44 → 45,
+ `conforme` 38 → 39). Ses deux branches délèguent à `i18nMsg`.
+- Manuel : les glyphes ✓ et ⚠️ manquent dans la police (`Missing character`). Remplacés par des mots. Les dix
+ `Missing character` restants sont antérieurs à la story.
+- `cargo fmt` a reformaté le fichier de test neuf.
+
+### Completion Notes List
+
+- **T1, le module partagé** — `crate::opening` porte `is_debit_natured`, `is_bilan`, `signed`, `debit_sense` et
+ `opening_from`. Le grand livre lit désormais les deux paires de sommes en **une** requête par compte. Quand aucun
+ exercice ne couvre `from`, la borne basse vaut `from` lui-même et la paire est nulle. Ses tests passent sans
+ retouche d'assertion ; ses deux tests unitaires de signe et de nature ont migré dans `opening.rs`.
+- **T2, la balance** — une requête agrégée rend trois paires de sommes brutes par compte, sans aucun `CASE` sur le
+ type ni sur le signe. Elle est lue dans une structure brute `RawRow`, la ligne étant construite en Rust.
+ - Règle d'inclusion : actif, ou mouvementé, ou porteur d'un solde d'ouverture.
+ - `retained_earnings` vient de `balance_sheet::fetch_retained_earnings`, passé en `pub(crate)` et non recopié.
+ - `opening_balanced` est calculé par `debit_sense` ; un écart est signalé par `tracing::warn!`.
+ - `balance`, `total_debit`, `total_credit` et `balanced` sont inchangés.
+ - Les trois tests unitaires tautologiques de `trial_balance.rs` (qui comparaient des constantes) sont remplacés
+ par trois tests de `is_opening_balanced`.
+- **T3, les rendus** — CSV à six colonnes, avec la ligne du résultat reporté sur le patron du bilan. PDF à six
+ colonnes aux positions imposées, avec l'intitulé tronqué à 22 caractères et deux libellés neufs, `col_opening` et
+ `col_closing`. Le libellé de la ligne calculée n'est pas tronqué : environ 46 mm pour 55 mm disponibles.
+- **T4, l'écran** — les colonnes Ouverture et Clôture, la ligne calculée (« Perte reportée » si elle est négative)
+ et le contrôle ✓/⚠️ d'ouverture, distinct de celui des mouvements. La note retirée a perdu sa clé dans les 4
+ locales ; deux clés neuves sont ajoutées. `sitesTotal` passe de 1756 à 1758.
+- **T5, les tests** — **périmètre : de `HEAD` = `42623926` (fiche validée, aucun code) à l'arbre de travail.**
+ - Rust, **+14 nets** :
+ - `trial_balance_opening.rs` : 10 tests neufs ;
+ - `opening.rs` : 4 tests, dont 2 migrés du grand livre (`general_ledger.rs` passe de 4 à 2) ;
+ - `pdf.rs` : +2 ;
+ - `trial_balance.rs` : 3 tests remplacés ;
+ - `csv.rs` : un test réécrit, qui porte maintenant les valeurs ;
+ - `concordance_compte_de_resultat` étendu à une période en cours d'exercice ;
+ - `reports_e2e.rs` : un test étendu à la forme de la réponse.
+ - Vitest : **+6** dans `TrialBalanceView.test.ts`, neuf. E2E : `reports.spec.ts` étendu.
+- **Gates** (T7) : base remise à zéro, `scripts/test-fast.sh` **2545/2545, 4 ignorés** (fmt, clippy, nextest) ;
+ frontend `check` 0 erreur, `lint-i18n-ownership` PASS, `test:unit` **845/845**, `build` OK ; **E2E complet** sur
+ `kesh_e2e` migrée à neuf (run de 10:07 UTC) : **224 passés, 12 échoués, 19 ignorés** — les 10 attendus d'un run matinal (KF-029 ×7, KF-045 ×2
+ `invoices.spec.ts:415/439`, KF-046 `sidebar-navigation:75`) et la paire de pollution documentée `products:166` +
+ `product-revenue-account:133`, **vertes rejouées seules** ; `reports.spec.ts` vert, extension 25-5-b comprise.
+- ⚠️ **Deux tests PDF prouvent moins que l'AC 9 ne le demande** (revue P1, C-2) : `…_is_not_the_empty_render` compare
+ des longueurs, et le test de troncature appelle la fonction et la constante, pas le site d'appel du rendu. Le PDF ne
+ s'inspecte pas sans extraire son texte ; limite assumée, aucune mutation PDF n'est déclarée tuée par eux.
+- **Mutations tuées** :
+ - `<` → `<=` sur la borne d'ouverture : 2 tests tombent ;
+ - ouverture de résultat cumulée depuis l'origine : 5 tests ;
+ - bilan borné par l'exercice (le défaut de #385) : 5 tests ;
+ - ancienne règle d'inclusion : 1 test ;
+ - contrôle tautologique : 2 tests ;
+ - à l'écran, contrôle lu sur `balanced` : 1 test ;
+ - ouverture affichée à la place de la clôture : 1 test.
+- **T6, manuel et CHANGELOG** :
+ - § *Balance des comptes* réécrite. La phrase sur le filtre de niveau de détail, qui n'existait pas, est retirée.
+ - Les deux points de la validation P2 sont écrits : les soldes de départ en mouvements, les montants signés.
+ - Le renvoi vers le grand livre est corrigé, ainsi que le glossaire ; le label `sec:soldes-depart` est posé.
+ - PDF régénéré et contrôlé aplati.
+ - CHANGELOG `[0.12.1]` : une entrée `Fixed` et une entrée `Changed`, cette dernière signalant l'incompatibilité CSV.
+ - README et site relus, toujours vrais.
+
+### File List
+
+- `CHANGELOG.md`
+- `crates/kesh-api/tests/reports_e2e.rs`
+- `crates/kesh-i18n/locales/{de-CH,en-CH,fr-CH,it-CH}/messages.ftl`
+- `crates/kesh-report/benches/export.rs`
+- `crates/kesh-report/src/{balance_sheet.rs,csv.rs,general_ledger.rs,lib.rs,pdf.rs,trial_balance.rs}`
+- `crates/kesh-report/src/opening.rs` (neuf)
+- `crates/kesh-report/tests/general_ledger.rs`
+- `crates/kesh-report/tests/trial_balance_opening.rs` (neuf)
+- `docs/manual/fr/user-manual.{tex,pdf}`
+- `frontend/src/lib/features/reports/{TrialBalanceView.svelte,reports.types.ts}`
+- `frontend/src/lib/features/reports/TrialBalanceView.test.ts` (neuf)
+- `frontend/src/lib/shared/{i18n-keys.test.ts,i18n-libelle-en-dur.test.ts}`
+- `frontend/tests/e2e/reports.spec.ts`
+
+## Change Log
+
+- **2026-10-03** — Arbitrage de Guy (« ok ») : la colonne CSV `Solde` est **remplacée**, comme livré.
+- **2026-10-03** — Revue de code P2 ciblée (Haiku, prompt `25-5-b-review-prompt-p2.md`) : **0 finding**, sorties des
+ quatre vérifications fournies. Le seul résidu cité (« montant au compte de résultat », AC 9) relu par
+ l'orchestrateur : il porte sur l'exercice entier, où l'égalité tient. **Boucle close** : P1 1 MED (Sonnet ×3) → P2 0
+ (Haiku). Gate complet après la remédiation : base remise à zéro, `scripts/test-fast.sh` **2547/2547, 4 ignorés** ;
+ frontend inchangé depuis son gate ; E2E rejoué entre-temps : 227 passés, 9 échoués — les 9 attendus d'un run
+ matinal —, 19 ignorés. Statut → `done`.
+- **2026-10-03** — Revue de code P1 (Sonnet ×3, prompt `25-5-b-review-prompt-p1.md`) : **0 CRITICAL/HIGH, 1 MED**, LOW.
+ - **C-1 (MED)** : le manuel et le CHANGELOG promettaient la concordance d'un compte de **résultat** avec le compte de
+ résultat **sur toute période**. C'est faux en cours d'exercice, où le compte de résultat ne porte que les mouvements
+ (mars–juin : 820 contre 570). Corrigé : clôture « depuis le début de l'exercice », égale au compte de résultat
+ quand la période commence au premier jour de l'exercice, au grand livre toujours. PDF régénéré, contrôlé aplati.
+ - **LOW corrigés** :
+ - le contrôle d'ouverture n'existait qu'à l'écran ; le PDF l'écrit désormais sous les totaux quand il tombe (A-1,
+ nouveau libellé `opening_unbalanced`, test, mutation tuée) ;
+ - un compte archivé aux mouvements compensés n'était exercé par aucun test (C-3, test neuf, mutation « inclusion
+ sur le montant net » tuée) ;
+ - les ignorés manquaient aux gates déclarés (C-4) ;
+ - la limite des deux tests PDF est écrite (C-2) ;
+ - deux limites sont écrites en doc de module : l'invariant date ↔ exercice, tenu par `create_in_tx`
+ (`DateOutsideFiscalYear`), vérifié (A-4, B-2) ; et la lecture de tout l'historique (A-5, B-1).
+ - **Écartés** :
+ - « Perte reportée » réservé à l'écran, voulu par l'AC 4 (A-2) ;
+ - la convention de signe, écrite au manuel (A-3) ;
+ - la ligne du résultat reporté affichée à 0, voulue (B-3).
+ - Gate ciblé : `fmt`, `clippy kesh-report`, `binary(trial_balance_opening)` + tests `trial_balance` 21/21. **Gate complet
+ au push.**
+- **2026-10-03** — Implémentée (dev-story) : T1–T7, gates ci-dessus. Statut → `review`.
+- **2026-10-03** — Validation P3 ciblée (Haiku, prompt `25-5-b-validate-prompt-p3.md`) : **0 finding**, sorties des quatre
+ vérifications fournies. Le point 2 (compatibilité de l'AC 2 avec le grand livre), établi par raisonnement, **repris
+ par l'orchestrateur** : la période libre du grand livre (`LedgerPeriod`) impose de passer une paire nulle quand aucun
+ exercice ne couvre `from` — écrit à l'AC 2 (LOW). **Boucle close** : P1 1H/4M (Sonnet ×3) → P2 4M/9L (Opus) → P3 0
+ (Haiku). ⚠️ Arbitrage de Guy attendu sur le CSV (remplacer `Solde` ou ajouter deux colonnes en fin de ligne) — défaut
+ retenu : remplacer.
+- **2026-10-03** — Validation P2 (Opus, prompt `25-5-b-validate-prompt-p2.md`) : **0 CRITICAL/HIGH, 4 MED, 9 LOW**,
+ retenus. `closing_balanced` retiré — copie d'`opening_balanced` par construction (M1) ; la forme de l'AC 2 prescrite
+ (sommes brutes en SQL, règle en Rust partagée avec le grand livre ; piège `FromRow`) (M2) ; libellés courts
+ « Ouverture » / « Clôture » — les en-têtes du grand livre débordent au PDF (calcul AFM) et ne s'ancrent pas en E2E
+ (M3) ; « Perte reportée » à l'écran seulement, comme au bilan (M4) ; test de concordance du grand livre en cours
+ d'exercice, « société sans écriture » et exercice 2 vide, compte de résultat archivé, `warn!`, CHANGELOG `[0.12.1]`
+ et incompatibilité CSV à arbitrer, « la veille », colonne du libellé CSV, deux phrases au manuel (L1–L8). Trend :
+ P1 1H/4M → P2 4M.
+- **2026-10-03** — Validation P1 (Sonnet ×3, prompt `25-5-b-validate-prompt-p1.md`) : **1 HIGH, 4 MED**, LOW. Retenus :
+ la frontière `entry_date = start` testée, et la veille (C1, HIGH) ; la formule du sens débit écrite et chiffrée (A) ;
+ le contrôle testé à `false` par une ligne déséquilibrée insérée en SQL brut (C2) ; premier exercice (C3) ; positions
+ des six colonnes du PDF chiffrées — la largeur est déjà saturée à cinq (B) ; période d'un jour, exercice vide, pertes
+ cumulées, valeurs CSV, troncature, requête unique laissée à la revue, deux références décalées (LOW). Règle, ligne
+ calculée et contrôle **confirmés par le calcul** sur trois jeux (lentille A, mutation #385 détectée : −1000 ≠ 0).
+- **2026-10-03** — Créée (Guy : « pousse et continue »).
+
+[#385]: https://github.com/guycorbaz/kesh/issues/385
diff --git a/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p1.md b/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p1.md
new file mode 100644
index 000000000..7d9095475
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p1.md
@@ -0,0 +1,45 @@
+# Prompt — revue de code P1, Story 25-5-b (la balance porte un solde d'ouverture)
+
+*Versionné le 2026-10-03. Trois lentilles (Sonnet), contexte frais chacune.*
+
+Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Diff à revoir : `git diff 42623926 46edd970`**
+— un seul commit d'implémentation. Fiche : `_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md`
+(AC 1–10, Dev Notes, Dev Agent Record). Issue : `gh issue view 385`. Règles : `CLAUDE.md`.
+
+## Lentilles
+
+- **A — Blind hunter** : le diff **seul**, sans la fiche. SQL de `trial_balance.rs` (liaisons dans le bon ordre —
+ compte-les une à une contre les `?` ; `SUM(CASE … END)` sans `ELSE` → `NULL` couvert par `COALESCE` ? ; `CAST … AS
+ SIGNED` lu en `i64` ; la jointure et le filtre de société), la requête d'ouverture réécrite du grand livre, `Decimal`,
+ rendus CSV/PDF, réactivité Svelte 5 et `big.js`.
+- **B — Edge-case hunter** : le diff et le code environnant.
+ - Le grand livre : sa nouvelle requête d'ouverture rend-elle **exactement** l'ancien résultat dans tous les cas — compte
+ de bilan, compte de résultat, `from` hors de tout exercice, `from` = premier jour d'un exercice, exercices avec trous ?
+ - La balance : `fy_start` lu par `find_by_id_in_company` — exercice d'une autre société ? Une écriture d'une **autre
+ société** sur un compte de cette société (impossible ?) ; une écriture datée hors de son exercice ; un compte retypé.
+ - La règle d'inclusion : un compte archivé dont l'ouverture est nulle mais qui a des lignes **de montant net nul** dans
+ la période ; `period_lines` compte-t-il les bonnes lignes ?
+ - `opening_balanced` : faux positif possible sur des livres exacts (arrondi `Decimal`, échelle 4 décimales) ?
+ - L'écran : `retainedEarnings` absent ou illisible, rapport vide, ligne calculée quand il n'y a aucun compte.
+ - La performance : la sous-requête lit toutes les lignes jusqu'à `end` — index utilisés (`idx_jel_account`) ?
+- **C — Acceptance auditor** : chaque AC tenu ? Recompte depuis la source (`sitesTotal`, relevé des libellés en dur, clés
+ dans les 4 locales, décomptes de tests du Dev Agent Record aux deux bornes `42623926` / `46edd970`). **Pars du
+ symptôme** : `grep -rn "\.balance\b\|totalDebit\|reports-column-balance" frontend/src crates/kesh-report crates/kesh-api/src`
+ — un consommateur de la balance qui suppose encore l'ancienne forme ? Les tests prouvent-ils ce qu'ils disent (chaque
+ mutation déclarée est-elle tuée 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 — § *Balance des
+ comptes*, § *Grand livre*, glossaire, § *Reprise de comptabilité* ? Le CHANGELOG ? Le Dev Agent Record n'affirme-t-il que
+ ce qui a tourné ?
+
+## 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-5-b-review-prompt-p2.md b/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p2.md
new file mode 100644
index 000000000..c58358e2c
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p2.md
@@ -0,0 +1,33 @@
+# Prompt — revue de code P2 ciblée, Story 25-5-b
+
+*Versionné le 2026-10-03. **Une lentille** (Haiku), contexte frais — passe ciblée sur la remédiation de P1 (P1 Sonnet ×3 :
+1 MED, LOW).*
+
+Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Diff à relire, un seul commit, aplati :
+`git show 84505eed`.** Fiche : `_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md`.
+
+## Lentille — regression hunter
+
+1. `crates/kesh-report/src/pdf.rs` : le libellé `opening_unbalanced` est-il ajouté à `SectionLabels` **et** à ses défauts,
+ sans autre construction littérale de `SectionLabels` ailleurs (`grep -rn "SectionLabels {" crates`) ? L'avertissement
+ est-il dessiné après `ensure_space_for_row` ? Tient-il sur une ligne (Helvetica-Bold 10 pt, ~0,55 em par caractère,
+ largeur utile 180 mm) ? Le test l'exerce-t-il dans les deux sens ?
+2. `crates/kesh-report/tests/trial_balance_opening.rs`, test `un_compte_archive_aux_mouvements_compenses_figure` : les deux
+ écritures tombent-elles dans l'exercice et la période ? L'assertion distingue-t-elle « présent à zéro » d'« absent » ?
+3. Manuel et CHANGELOG : la phrase corrigée dit-elle juste ? Lis `crates/kesh-report/src/income_statement.rs` (filtre des
+ mouvements) et vérifie : sur une période qui ne commence pas au premier jour de l'exercice, compte de résultat ≠ clôture
+ de la balance ; sur l'exercice entier, égalité. Reste-t-il **ailleurs** la promesse fausse ? `grep -rn "montant au compte
+ de résultat" docs CHANGELOG.md _bmad-output/implementation-artifacts/25-5-b-*.md crates`.
+4. La doc de module de `trial_balance.rs` : `DateOutsideFiscalYear` existe-t-il bien dans `journal_entries::create_in_tx`
+ (`grep -n "DateOutsideFiscalYear" crates/kesh-db/src/repositories/journal_entries.rs`) ?
+
+## Ce que tu rends
+
+Findings avec sévérité, endroit, **preuve obligatoire : la commande ET sa sortie** — un finding sans sortie sera rejeté,
+un « 0 finding » sans les sorties 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-5-b-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p1.md
new file mode 100644
index 000000000..e426cb8df
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p1.md
@@ -0,0 +1,48 @@
+# Prompt — validation P1, Story 25-5-b (la balance porte un solde d'ouverture)
+
+*Versionné le 2026-10-03. Trois lentilles (Sonnet), contexte frais chacune.*
+
+Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Fiche à valider :**
+`_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md` (commit `ac40f66b`). Issue : `gh issue view 385`.
+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 — Exactitude comptable.** La règle d'ouverture (bilan depuis l'origine, résultat depuis le début de l'exercice)
+ est-elle juste pour une balance suisse ? La ligne calculée du résultat reporté rend-elle **réellement** les colonnes
+ d'ouverture et de clôture équilibrées — refais le calcul sur deux exercices (vente et charge en exercice 1, vente en
+ exercice 2, balance de l'exercice 2 entière puis d'une période mars–juin) : Σ des ouvertures en sens débit −
+ `retained_earnings` = 0 ? Et la clôture ? Le résultat **de l'exercice en cours avant la période** (janvier–février)
+ est-il compté une fois, pas deux (dans les ouvertures des comptes de résultat ET dans `retained_earnings`) ? Le
+ contrôle de l'AC 5 est-il une vraie vérification ou une identité ? La concordance avec le bilan tient-elle quand la
+ période ne finit pas au dernier jour de l'exercice ? Un compte physique de rôle `RetainedEarnings` (report d'ouverture
+ d'un migrant) est-il compté deux fois avec la ligne calculée ?
+- **B — Faisabilité dans le code.** Chaque référence `fichier:ligne` de la fiche existe-t-elle et dit-elle ce que la fiche
+ affirme (`grep -nF`, `sed -n`) ? L'extraction de l'AC 2 est-elle possible sans changer le comportement du grand livre
+ (lis `general_ledger.rs` en entier) ? `fetch_retained_earnings` prend-il la bonne date ? La requête agrégée unique
+ est-elle écrivable (règle d'inclusion sur un solde d'ouverture non nul : `HAVING` ou sous-requête ?) ? Les six colonnes
+ tiennent-elles en A4 portrait avec la police et les positions actuelles (`pdf.rs`) ? Tous les consommateurs de
+ `TrialBalance`/`TrialBalanceRow` et de la clé `reports-trial-balance-period-note` sont-ils listés (`grep -rn`) ? Le
+ périmètre (trois modules de code) est-il exact ?
+- **C — Complétude et tests.** Chaque AC est-il testable, et le test prévu aurait-il échoué avant le patch ? Manque-t-il
+ un cas (période d'un seul jour ; premier exercice, `retained_earnings` = 0 ; exercice sans écriture ; compte de bilan
+ sans aucun mouvement ni ouverture ; écriture datée le jour même du début de période) ? Le manuel : relis
+ `docs/manual/fr/user-manual.tex` § *Balance des comptes*, § *Grand livre*, glossaire **et le PDF aplati**
+ (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers
+ `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`) : la fiche liste-t-elle
+ **tous** les sites que le patch rendra faux ? Pars du symptôme : `grep -rn "mouvement de la période\|balance de vérification\|Balance des comptes" docs website README.md crates/kesh-i18n frontend/src`.
+ CHANGELOG, README, site.
+
+## 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é pour la lentille A), correction proposée. ⛔ 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`,
+`gh issue view`, `pdftotext` vers le scratchpad.
diff --git a/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p2.md
new file mode 100644
index 000000000..f7a91a9a1
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p2.md
@@ -0,0 +1,42 @@
+# Prompt — validation P2, Story 25-5-b (la balance porte un solde d'ouverture)
+
+*Versionné le 2026-10-03. Une passe (Opus), contexte frais, les trois lentilles. Trend : P1 1H/4M (Sonnet ×3).*
+
+Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Fiche :**
+`_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md` (tête `b6c1d5f4`). Remédiation de P1 :
+`git diff 7e529131 b6c1d5f4`. Issue : `gh issue view 385`. 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** (le diff) : chaque ajout est-il juste ? En particulier :
+ - la formule `debit_sense` et la vérification chiffrée de l'AC 5 — refais le calcul ;
+ - le test du contrôle à `false` : une ligne déséquilibrée insérée en SQL brut dans l'exercice 1 casse-t-elle
+ **réellement** les deux égalités (ouverture ET clôture) sans faire échouer le rapport ? `fetch_retained_earnings`
+ lit-il cette ligne (selon le type du compte choisi, elle tombe dans le résultat reporté ou dans une ouverture —
+ l'écart survit-il ?) ; `TrialBalanceUnbalanced` (mouvements de la période) reste-t-il muet ?
+ - les positions des six colonnes du PDF (`pdf.rs`, police Helvetica 10 pt, `MARGIN_LEFT_MM`, `PAGE_WIDTH_MM`,
+ `truncate_with_ellipsis`) : chevauchement ? la colonne de clôture déborde-t-elle de la page ?
+ - les scénarios ajoutés à l'AC 9 contredisent-ils un autre AC ?
+2. **Ce que P1 n'a pas regardé** — cohérence interne de la fiche (AC, tâches, Dev Notes, *Modules*) ; la règle d'ouverture
+ pour une période **dont le début n'est pas le début de l'exercice** combinée à la règle d'inclusion (AC 6) ; un compte de
+ **résultat** archivé à ouverture non nulle en cours d'exercice ; la ligne calculée au CSV (libellé fixe fr — cohérent
+ avec les autres lignes CSV de `csv.rs` ?) ; la doc de l'API publique (`docs/` — `grep -rn "trial-balance" docs`) et le
+ manuel admin s'ils décrivent la réponse ; le test de concordance existant `general_ledger.rs:150-205` : étendu à
+ `opening`/`closing`, son scénario (exercice entier) prouve-t-il quelque chose sur l'ouverture ?
+3. **Le manuel** — `docs/manual/fr/user-manual.tex` (§ *Balance des comptes*, § *Grand livre*, glossaire) **et le PDF
+ aplati** (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers
+ `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`) : la fiche nomme-t-elle
+ tous les sites que le patch rendra faux ?
+
+## 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 ; 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 le scratchpad.
diff --git a/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p3.md
new file mode 100644
index 000000000..053297ec3
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p3.md
@@ -0,0 +1,36 @@
+# Prompt — validation P3 ciblée, Story 25-5-b
+
+*Versionné le 2026-10-03. **Une lentille** (Haiku), contexte frais — passe ciblée sur la remédiation de P2. Trend :
+P1 1H/4M (Sonnet ×3) → P2 4M/9L (Opus).*
+
+Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Diff à relire, aplati, un seul
+commit : `git diff 6fc79cf5 013b2ae7`** (la fiche `_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.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. **Cohérence interne après le patch** : `closing_balanced` a été retiré — reste-t-il une phrase (AC 5, 7, 8, 9, tâches,
+ Dev Notes) qui suppose deux indicateurs ou un ✓/⚠️ sous la colonne Clôture ? `grep -nF "closing" ` et lis chaque
+ occurrence.
+2. **L'AC 2 réécrit** : la forme prescrite (trois paires de sommes brutes, `opening_from` en Rust) est-elle compatible
+ avec le grand livre tel qu'il est ? Lis `crates/kesh-report/src/general_ledger.rs:194-275` : sa requête d'ouverture
+ peut-elle rendre les deux paires sans changer son résultat ? `fiscal_year_start_containing` y renvoie `None` quand
+ aucun exercice ne couvre `from` — la forme prescrite conserve-t-elle ce cas ?
+3. **Les libellés** : les clés `reports-column-opening` / `reports-column-closing` existent-elles **déjà** dans
+ `crates/kesh-i18n/locales/fr-CH/messages.ftl` (collision) ? `grep -n "reports-column-" …`. Les champs `col_opening` /
+ `col_closing` existent-ils déjà dans `SectionLabels` (`crates/kesh-report/src/pdf.rs`) ?
+4. **Les références ajoutées** existent-elles et disent-elles ce que la fiche affirme : `balance_sheet.rs:237`,
+ `BalanceSheetView.svelte:97-100`, `csv.rs:139-145`, `csv.rs:140-144`, `pdf.rs:596-599`, `general_ledger.rs:135-205`,
+ `user-manual.tex:615`, `CHANGELOG.md` (section `## [0.12.1] — Non publié`) ?
+
+## Ce que tu rends
+
+Findings avec sévérité, endroit, **preuve obligatoire : la commande ET sa sortie** — un finding sans sortie de commande
+sera rejeté, un « 0 finding » sans les sorties 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 a4f7f76fb..7f684779b 100644
--- a/_bmad-output/implementation-artifacts/sprint-status.yaml
+++ b/_bmad-output/implementation-artifacts/sprint-status.yaml
@@ -368,7 +368,7 @@ development_status:
25-4-d-solder-le-reste: backlog # [#384] ramenee de la 25-6 le 2026-09-27 (Guy) — perte sur debiteur, escompte, frais : clore une facture partiellement reglee ; la part TVA reduit la TVA due.
25-4-propager-le-residuel: split # 2026-09-26 DECOUPEE (six modules > 5) en 25-4-a / 25-4-b / 25-4-c, puis 25-4-d (#384, 2026-09-27) ; #455 et #456 rattachees. # [#416] [#420] — la 24-2 a introduit le reglement PARTIEL ; le residuel n'est propage ni a la balance agee ni a l'echeancier, et **le score de rapprochement compare au TTC et non au residuel** : le virement du solde d'une facture partiellement reglee **score 0**.
25-5-a-export-souverainete: done # 2026-09-26 **MERGEE (PR #452, #386 fermee) SANS REVUE DE CODE** ; revue a posteriori CLOSE en 3 passes sur `story/25-5-a-revue-code` (4 HIGH P1, 1 HIGH orchestrateur P2 — colonnes manquantes, garde de colonnes —, P3 ciblee test seul) ; gates 2466/2466, E2E 221/8/19 sans regression ; reports #465, #466. # 2026-09-23 **SPECIFIEE et VALIDEE en 1 passe** (0 CRITICAL, 4 HIGH, 3 MEDIUM, 3 LOW, tous corriges). [#386] — issue du DECOUPAGE de la 25-5 (arbitrage de Guy) : #385 (Balance vs Bilan) et #386 n'ont en commun que le mot « etat », et touchent des modules disjoints. ⛔ **Trois faits qui corrigent l'issue** : 21 tables absentes et non 10 ; le trou s'est CREUSE depuis son ecriture (`invoice_settlements`, Epic 24) ; et SIX repositories sur huit n'ont aucune lecture exhaustive — le vrai cout, absent de l'issue. Arbitrage de Guy : l'export CSV ne porte que les donnees COMPTABLES (11 dedans), les backups portent tout ; 10 exclusions AVEC motif ecrit, dont 3 tables de SECRETS. Les justificatifs (fichiers) partent en story separee sur la SAUVEGARDE. ⛔ La vraie livraison est la GARDE D'EXHAUSTIVITE (patron D4-ter + P7), non les 11 exports : trois epics ont rouvert le trou en silence. ⚠️ Pieges releves par la validation : le nombre 19 code en dur a SEPT endroits dont deux `debug_assert_eq!` qui PANIQUENT ; `audit_log` n'a AUCUN dump exhaustif (ses fonctions sont bornees, la route rejette au-dela de 10 000 lignes) ; `csv_sanitize` existe et n'est pas employee par cet export ; deux sites de manuel DEJA faux (un dossier `reports/` invente, un « 38 » pour 39).
- 25-5-b-balance-solde-ouverture: backlog # [#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-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*.
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
diff --git a/crates/kesh-api/tests/reports_e2e.rs b/crates/kesh-api/tests/reports_e2e.rs
index 58ef83ad6..88fdc3ec9 100644
--- a/crates/kesh-api/tests/reports_e2e.rs
+++ b/crates/kesh-api/tests/reports_e2e.rs
@@ -590,6 +590,13 @@ async fn trial_balance_total_debit_equals_total_credit(pool: MySqlPool) {
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["balanced"], true);
+ // Story 25-5-b — les champs neufs traversent la frontière HTTP.
+ assert_eq!(body["openingBalanced"], true);
+ assert!(body["retainedEarnings"].is_string(), "{body}");
+ let first = &body["rows"].as_array().expect("rows")[0];
+ for key in ["openingBalance", "closingBalance", "balance"] {
+ assert!(first[key].is_string(), "{key} absent : {first}");
+ }
}
// ============================================================
diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl
index 5f5cf114e..18dcf17a7 100644
--- a/crates/kesh-i18n/locales/de-CH/messages.ftl
+++ b/crates/kesh-i18n/locales/de-CH/messages.ftl
@@ -1096,6 +1096,8 @@ reports-column-account-name = Bezeichnung
reports-column-debit = Soll
reports-column-credit = Haben
reports-column-balance = Saldo
+reports-column-opening = Eröffnung
+reports-column-closing = Abschluss
reports-column-entry-date = Datum
reports-column-description = Buchungstext
@@ -1139,7 +1141,6 @@ reports-equity-result-loss = Verlust der Periode
reports-retained-earnings = Gewinnvortrag
reports-retained-earnings-calculated = Gewinnvortrag (berechnet)
reports-retained-earnings-loss = Verlustvortrag
-reports-trial-balance-period-note = Die Rohbilanz zeigt die Bewegung der Periode (pro Geschäftsjahr). Die Summe pro Konto ist nicht mit dem kumulierten Saldo desselben Kontos in der Bilanz vergleichbar (Saldovortrag seit Beginn).
# Alerts + badges UI (2 — code review Pass 1 i18n leaks)
reports-equation-warning = ⚠️ Bilanzgleichung ungültig (Quelldaten prüfen).
diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl
index b08af60ee..9ccb420e9 100644
--- a/crates/kesh-i18n/locales/en-CH/messages.ftl
+++ b/crates/kesh-i18n/locales/en-CH/messages.ftl
@@ -1096,6 +1096,8 @@ reports-column-account-name = Account Name
reports-column-debit = Debit
reports-column-credit = Credit
reports-column-balance = Balance
+reports-column-opening = Opening
+reports-column-closing = Closing
reports-column-entry-date = Date
reports-column-description = Description
@@ -1139,7 +1141,6 @@ reports-equity-result-loss = Period loss
reports-retained-earnings = Retained earnings
reports-retained-earnings-calculated = Retained earnings (calculated)
reports-retained-earnings-loss = Accumulated loss
-reports-trial-balance-period-note = The trial balance shows the movement of the period (per fiscal year). The per-account total is not comparable to the cumulative balance of the same account in the balance sheet (carry-forward from inception).
# Alerts + badges UI (2 — code review Pass 1 i18n leaks)
reports-equation-warning = ⚠️ Balance sheet equation unbalanced (verify source data).
diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl
index 0e17c9cfd..1da1b3e68 100644
--- a/crates/kesh-i18n/locales/fr-CH/messages.ftl
+++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl
@@ -1151,6 +1151,8 @@ reports-column-account-name = Intitulé
reports-column-debit = Débit
reports-column-credit = Crédit
reports-column-balance = Solde
+reports-column-opening = Ouverture
+reports-column-closing = Clôture
reports-column-entry-date = Date
reports-column-description = Libellé
@@ -1199,7 +1201,6 @@ reports-equity-result-loss = Perte de l'exercice
reports-retained-earnings = Résultat reporté
reports-retained-earnings-calculated = Résultat reporté (calculé)
reports-retained-earnings-loss = Perte reportée
-reports-trial-balance-period-note = La balance de vérification affiche le mouvement de la période (par exercice). Le total par compte n'est pas comparable au solde cumulé du même compte au bilan (report à-nouveau depuis l'origine).
# Alertes + badges UI (2 — code review Pass 1 i18n leaks)
reports-equation-warning = ⚠️ Équation bilan déséquilibrée (vérifier données source).
diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl
index 55c658f3e..4491a46e6 100644
--- a/crates/kesh-i18n/locales/it-CH/messages.ftl
+++ b/crates/kesh-i18n/locales/it-CH/messages.ftl
@@ -1096,6 +1096,8 @@ reports-column-account-name = Denominazione
reports-column-debit = Dare
reports-column-credit = Avere
reports-column-balance = Saldo
+reports-column-opening = Apertura
+reports-column-closing = Chiusura
reports-column-entry-date = Data
reports-column-description = Causale
@@ -1139,7 +1141,6 @@ reports-equity-result-loss = Perdita del periodo
reports-retained-earnings = Risultato riportato
reports-retained-earnings-calculated = Risultato riportato (calcolato)
reports-retained-earnings-loss = Perdita riportata
-reports-trial-balance-period-note = Il bilancio di verifica mostra il movimento del periodo (per esercizio). Il totale per conto non è comparabile con il saldo cumulato dello stesso conto nel bilancio (riporto a nuovo dall'origine).
# Alerts + badges UI (2 — code review Pass 1 i18n leaks)
reports-equation-warning = ⚠️ Equazione di bilancio non bilanciata (verificare i dati sorgente).
diff --git a/crates/kesh-report/benches/export.rs b/crates/kesh-report/benches/export.rs
index b5f188b96..c459deaf9 100644
--- a/crates/kesh-report/benches/export.rs
+++ b/crates/kesh-report/benches/export.rs
@@ -109,9 +109,11 @@ fn make_trial_balance(n: usize) -> TrialBalance {
AccountType::Liability
},
active: true,
+ opening_balance: Decimal::from(250),
total_debit: Decimal::from(100),
total_credit: Decimal::from(100),
balance: Decimal::from(0),
+ closing_balance: Decimal::from(250),
})
.collect();
let total_debit: Decimal = rows.iter().map(|r| r.total_debit).sum();
@@ -122,6 +124,8 @@ fn make_trial_balance(n: usize) -> TrialBalance {
total_debit,
total_credit,
balanced: true,
+ retained_earnings: Decimal::ZERO,
+ opening_balanced: true,
}
}
diff --git a/crates/kesh-report/src/balance_sheet.rs b/crates/kesh-report/src/balance_sheet.rs
index 22562af7b..6c82b3242 100644
--- a/crates/kesh-report/src/balance_sheet.rs
+++ b/crates/kesh-report/src/balance_sheet.rs
@@ -330,7 +330,7 @@ async fn fetch_cumulative_section(
///
/// **Couture (AC-G)** : même borne date `entry_date < before` que
/// `fetch_cumulative_section` ; un snapshot de clôture stockera aussi ce report.
-async fn fetch_retained_earnings(
+pub(crate) async fn fetch_retained_earnings(
pool: &MySqlPool,
company_id: i64,
before: NaiveDate,
diff --git a/crates/kesh-report/src/csv.rs b/crates/kesh-report/src/csv.rs
index 075070de1..32b7140c0 100644
--- a/crates/kesh-report/src/csv.rs
+++ b/crates/kesh-report/src/csv.rs
@@ -249,8 +249,11 @@ pub fn render_income_statement_csv(
/// Génère le CSV de la balance des comptes (AC #14).
///
-/// Colonnes : `NumeroCompte;NomCompte;TotalDebit;TotalCredit;Solde`. Ligne
-/// finale totaux débit/crédit avec colonnes solde vide.
+/// Colonnes : `NumeroCompte;NomCompte;SoldeOuverture;TotalDebit;TotalCredit;SoldeCloture`
+/// — Story 25-5-b (#385) : la colonne `Solde` (net des mouvements, pris pour un
+/// solde de compte) est **remplacée** par l'ouverture et la clôture. Après les
+/// comptes, la ligne calculée du résultat reporté (numéro vide, comme au bilan),
+/// puis la ligne `Total` (mouvements ; ouverture et clôture vides).
pub fn render_trial_balance_csv(
tb: &TrialBalance,
mut writer: W,
@@ -261,9 +264,10 @@ pub fn render_trial_balance_csv(
wtr.write_record([
"NumeroCompte",
"NomCompte",
+ "SoldeOuverture",
"TotalDebit",
"TotalCredit",
- "Solde",
+ "SoldeCloture",
])
.map_err(map_csv_err)?;
@@ -276,16 +280,30 @@ pub fn render_trial_balance_csv(
wtr.write_record([
&row.account_number,
&row.account_name,
+ &format_amount_iso(row.opening_balance),
&format_amount_iso(row.total_debit),
&format_amount_iso(row.total_credit),
- &format_amount_iso(row.balance),
+ &format_amount_iso(row.closing_balance),
])
.map_err(map_csv_err)?;
}
+ // Ligne calculée : libellé fixe, comme au CSV du bilan.
+ let retained = format_amount_iso(tb.retained_earnings);
+ wtr.write_record([
+ "",
+ "Résultat reporté (calculé)",
+ &retained,
+ "",
+ "",
+ &retained,
+ ])
+ .map_err(map_csv_err)?;
+
wtr.write_record([
"Total",
"",
+ "",
&format_amount_iso(tb.total_debit),
&format_amount_iso(tb.total_credit),
"",
@@ -824,6 +842,8 @@ mod tests {
total_debit: Decimal::ZERO,
total_credit: Decimal::ZERO,
balanced: true,
+ retained_earnings: Decimal::ZERO,
+ opening_balanced: true,
};
let mut buf = Vec::new();
render_trial_balance_csv(&tb, &mut buf).unwrap();
@@ -1134,24 +1154,35 @@ mod tests {
account_name: "Caisse".into(),
account_type: AccountType::Asset,
active: true,
+ opening_balance: dec!(1000),
total_debit: dec!(200),
total_credit: dec!(50),
balance: dec!(150),
+ closing_balance: dec!(1150),
}],
total_debit: dec!(200),
total_credit: dec!(50),
balanced: true,
+ retained_earnings: dec!(-600),
+ opening_balanced: true,
};
let mut buf = Vec::new();
render_trial_balance_csv(&tb, &mut buf).unwrap();
let body = String::from_utf8(buf[3..].to_vec()).unwrap();
let lines: Vec<&str> = body.split("\r\n").filter(|l| !l.is_empty()).collect();
- assert_eq!(lines.len(), 3, "1 header + 1 row + 1 total = 3 lines");
+ assert_eq!(
+ lines.len(),
+ 4,
+ "1 header + 1 row + résultat reporté + 1 total = 4 lines"
+ );
assert_eq!(
lines[0],
- "NumeroCompte;NomCompte;TotalDebit;TotalCredit;Solde"
+ "NumeroCompte;NomCompte;SoldeOuverture;TotalDebit;TotalCredit;SoldeCloture"
);
- assert!(lines[2].starts_with("Total;"));
+ // Story 25-5-b — l'ouverture et la clôture dans l'ordre des colonnes.
+ assert_eq!(lines[1], "1000;Caisse;1000.00;200.00;50.00;1150.00");
+ assert_eq!(lines[2], ";Résultat reporté (calculé);-600.00;;;-600.00");
+ assert_eq!(lines[3], "Total;;;200.00;50.00;");
}
// Story 21-7 — CSV balance âgée.
diff --git a/crates/kesh-report/src/general_ledger.rs b/crates/kesh-report/src/general_ledger.rs
index 7825f95ec..3c6edca8f 100644
--- a/crates/kesh-report/src/general_ledger.rs
+++ b/crates/kesh-report/src/general_ledger.rs
@@ -10,6 +10,9 @@
//!
//! # La règle du solde d'ouverture — le cœur du module
//!
+//! Elle vit dans [`crate::opening`], **partagée avec la balance des comptes**
+//! (Story 25-5-b) : une seule règle, que les deux rapports appliquent.
+//!
//! Elle **diffère selon le type de compte**, et c'est ce qu'un lecteur pressé
//! prendra de travers :
//!
@@ -53,6 +56,7 @@ use serde::Serialize;
use sqlx::MySqlPool;
use crate::errors::ReportError;
+use crate::opening::{is_bilan, is_debit_natured, opening_from, signed};
/// Plafond de lignes rendues en une fois, aligné sur le patron du dépôt.
///
@@ -178,20 +182,6 @@ struct RawAccount {
active: bool,
}
-/// `true` si le compte a le débit pour côté naturel.
-fn is_debit_natured(t: AccountType) -> bool {
- matches!(t, AccountType::Asset | AccountType::Expense)
-}
-
-/// Applique la convention de signe du dépôt — celle de la balance et du bilan.
-fn signed(t: AccountType, debit: Decimal, credit: Decimal) -> Decimal {
- if is_debit_natured(t) {
- debit - credit
- } else {
- credit - debit
- }
-}
-
/// Début de l'exercice contenant `date`, s'il en existe un.
///
/// Sert **uniquement** aux comptes de résultat : c'est la borne basse de leur
@@ -217,10 +207,13 @@ async fn fiscal_year_start_containing(
Ok(row.map(|r| r.0))
}
-/// Solde d'ouverture d'un compte au `from` — **la règle centrale du module**.
+/// Solde d'ouverture d'un compte au `from` — **la règle centrale du module**,
+/// appliquée par [`opening_from`] (module partagé avec la balance des comptes).
///
-/// Comptes de bilan : cumul depuis l'origine. Comptes de résultat : cumul depuis
-/// le début de leur exercice. Voir la documentation du module pour le pourquoi.
+/// Une requête lit les deux paires de sommes brutes : avant `from`, et depuis le
+/// début de l'exercice contenant `from`. Quand aucun exercice ne couvre `from`, la
+/// borne basse vaut `from` lui-même : la seconde paire est nulle, et l'ouverture
+/// d'un compte de résultat aussi.
async fn opening_balance(
pool: &MySqlPool,
company_id: i64,
@@ -228,50 +221,28 @@ async fn opening_balance(
account_type: AccountType,
from: NaiveDate,
) -> Result {
- // Borne basse : aucune pour un compte de bilan ; le début de l'exercice pour
- // un compte de résultat. `None` ⇒ ouverture nulle, la borne haute étant
- // exclusive et égale à la borne basse.
- let lower: Option = match account_type {
- AccountType::Asset | AccountType::Liability => None,
- AccountType::Revenue | AccountType::Expense => {
- match fiscal_year_start_containing(pool, company_id, from).await? {
- Some(start) => Some(start),
- // Aucun exercice ne couvre `from` : rien à reporter.
- None => return Ok(Decimal::ZERO),
- }
- }
- };
-
- let sql = match lower {
- Some(_) => {
- "SELECT COALESCE(SUM(jel.debit), 0) AS d, COALESCE(SUM(jel.credit), 0) AS c \
- FROM journal_entry_lines jel \
- INNER JOIN journal_entries je ON je.id = jel.entry_id \
- WHERE jel.account_id = ? AND je.company_id = ? \
- AND je.entry_date >= ? AND je.entry_date < ?"
- }
- None => {
- "SELECT COALESCE(SUM(jel.debit), 0) AS d, COALESCE(SUM(jel.credit), 0) AS c \
- FROM journal_entry_lines jel \
- INNER JOIN journal_entries je ON je.id = jel.entry_id \
- WHERE jel.account_id = ? AND je.company_id = ? \
- AND je.entry_date < ?"
- }
- };
-
- let mut q = sqlx::query_as::<_, (Decimal, Decimal)>(sql)
- .bind(account_id)
- .bind(company_id);
- if let Some(lo) = lower {
- q = q.bind(lo);
- }
- let (d, c) = q
- .bind(from)
- .fetch_one(pool)
- .await
- .map_err(kesh_db::errors::map_db_error)?;
+ let fy_start = fiscal_year_start_containing(pool, company_id, from)
+ .await?
+ .unwrap_or(from);
+
+ let (bd, bc, sd, sc) = sqlx::query_as::<_, (Decimal, Decimal, Decimal, Decimal)>(
+ "SELECT COALESCE(SUM(jel.debit), 0), COALESCE(SUM(jel.credit), 0), \
+ COALESCE(SUM(CASE WHEN je.entry_date >= ? THEN jel.debit ELSE 0 END), 0), \
+ COALESCE(SUM(CASE WHEN je.entry_date >= ? THEN jel.credit ELSE 0 END), 0) \
+ FROM journal_entry_lines jel \
+ INNER JOIN journal_entries je ON je.id = jel.entry_id \
+ WHERE jel.account_id = ? AND je.company_id = ? AND je.entry_date < ?",
+ )
+ .bind(fy_start)
+ .bind(fy_start)
+ .bind(account_id)
+ .bind(company_id)
+ .bind(from)
+ .fetch_one(pool)
+ .await
+ .map_err(kesh_db::errors::map_db_error)?;
- Ok(signed(account_type, d, c))
+ Ok(opening_from(account_type, (bd, bc), (sd, sc)))
}
/// Comptes à rendre : ceux qui ont un mouvement sur la période **ou** un solde
@@ -560,39 +531,10 @@ pub async fn generate(
})
}
-/// `true` pour un compte de bilan — dont le solde se reporte d'un exercice à
-/// l'autre.
-fn is_bilan(t: AccountType) -> bool {
- matches!(t, AccountType::Asset | AccountType::Liability)
-}
-
#[cfg(test)]
mod tests {
use super::*;
- fn d(v: i64) -> Decimal {
- Decimal::from(v)
- }
-
- #[test]
- fn signe_suit_la_nature_du_compte() {
- // Convention du dépôt, reprise telle quelle de la balance et du bilan.
- assert_eq!(signed(AccountType::Asset, d(100), d(30)), d(70));
- assert_eq!(signed(AccountType::Expense, d(100), d(30)), d(70));
- assert_eq!(signed(AccountType::Liability, d(30), d(100)), d(70));
- assert_eq!(signed(AccountType::Revenue, d(30), d(100)), d(70));
- }
-
- #[test]
- fn seuls_les_comptes_de_bilan_reportent_leur_solde() {
- // C'est ce qui commande la borne basse du solde d'ouverture, et la
- // remise à zéro du solde progressif au passage d'exercice.
- assert!(is_bilan(AccountType::Asset));
- assert!(is_bilan(AccountType::Liability));
- assert!(!is_bilan(AccountType::Revenue));
- assert!(!is_bilan(AccountType::Expense));
- }
-
#[test]
fn periode_inversee_est_refusee() {
let from = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
diff --git a/crates/kesh-report/src/lib.rs b/crates/kesh-report/src/lib.rs
index 6760d3584..9f7b32f19 100644
--- a/crates/kesh-report/src/lib.rs
+++ b/crates/kesh-report/src/lib.rs
@@ -20,6 +20,7 @@ pub mod errors;
pub mod general_ledger;
pub mod income_statement;
pub mod journal_report;
+pub mod opening;
pub mod pdf;
pub mod period;
pub mod project_report;
diff --git a/crates/kesh-report/src/opening.rs b/crates/kesh-report/src/opening.rs
new file mode 100644
index 000000000..19c3ec249
--- /dev/null
+++ b/crates/kesh-report/src/opening.rs
@@ -0,0 +1,136 @@
+//! La règle du solde d'ouverture, partagée par le grand livre et la balance des
+//! comptes (Story 25-5-b, #385).
+//!
+//! # La règle
+//!
+//! Elle **diffère selon le type de compte** :
+//!
+//! - `Asset` / `Liability` (comptes de bilan) : cumul sur `entry_date < début`,
+//! **tous exercices confondus** — même patron que le bilan.
+//! - `Revenue` / `Expense` (comptes de résultat) : cumul depuis le **début de
+//! l'exercice** contenant le début de la période seulement. Si la période
+//! commence le premier jour d'un exercice, l'ouverture vaut 0.
+//!
+//! Un compte de bilan reporte son solde d'un exercice à l'autre ; un compte de
+//! résultat est soldé au bouclement et repart de zéro.
+//!
+//! ⚠️ **Kesh ne passe aucune écriture de clôture** : cette remise à zéro n'existe
+//! que comme borne basse d'une somme. Les appelants lisent donc, en SQL, **deux
+//! paires de sommes brutes** — avant le début, et depuis le début de l'exercice —
+//! et c'est [`opening_from`] qui choisit, **ici seulement**. Recopier ce choix en
+//! `CASE` SQL dans un rapport en ferait une seconde règle, qui divergerait en
+//! silence.
+//!
+//! # Limite connue
+//!
+//! Le signe est lu sur le `account_type` **courant** du compte : retyper un
+//! compte mouvementé re-signe tout son historique (issues #274 et #382).
+
+use kesh_db::entities::AccountType;
+use rust_decimal::Decimal;
+
+/// Une paire de sommes brutes `(débit, crédit)`, telle que la lit SQL.
+pub type DebitCredit = (Decimal, Decimal);
+
+/// `true` si le compte a le débit pour côté naturel.
+pub fn is_debit_natured(t: AccountType) -> bool {
+ matches!(t, AccountType::Asset | AccountType::Expense)
+}
+
+/// `true` pour un compte de bilan — dont le solde se reporte d'un exercice à
+/// l'autre.
+pub fn is_bilan(t: AccountType) -> bool {
+ matches!(t, AccountType::Asset | AccountType::Liability)
+}
+
+/// Applique la convention de signe du dépôt — celle de la balance, du bilan et
+/// du grand livre : positif du côté naturel du compte.
+pub fn signed(t: AccountType, debit: Decimal, credit: Decimal) -> Decimal {
+ if is_debit_natured(t) {
+ debit - credit
+ } else {
+ credit - debit
+ }
+}
+
+/// Ramène un solde **signé par type** en sens débit (débit − crédit), quel que
+/// soit le compte. C'est la grandeur qui s'additionne d'un compte à l'autre :
+/// additionner des soldes signés par type sans les re-signer mélange les deux
+/// conventions.
+pub fn debit_sense(t: AccountType, signed_balance: Decimal) -> Decimal {
+ if is_debit_natured(t) {
+ signed_balance
+ } else {
+ -signed_balance
+ }
+}
+
+/// Solde d'ouverture, signé par type, depuis les deux paires de sommes brutes :
+/// `before_start` (`entry_date < début`) pour un compte de bilan,
+/// `since_fy_start` (`entry_date ∈ [début de l'exercice, début[`) pour un compte
+/// de résultat.
+///
+/// Un appelant dont la période n'est couverte par aucun exercice passe une paire
+/// `since_fy_start` **nulle** : l'ouverture d'un compte de résultat vaut alors 0.
+pub fn opening_from(
+ t: AccountType,
+ before_start: DebitCredit,
+ since_fy_start: DebitCredit,
+) -> Decimal {
+ let (debit, credit) = if is_bilan(t) {
+ before_start
+ } else {
+ since_fy_start
+ };
+ signed(t, debit, credit)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn d(v: i64) -> Decimal {
+ Decimal::from(v)
+ }
+
+ #[test]
+ fn signe_suit_la_nature_du_compte() {
+ assert_eq!(signed(AccountType::Asset, d(100), d(30)), d(70));
+ assert_eq!(signed(AccountType::Expense, d(100), d(30)), d(70));
+ assert_eq!(signed(AccountType::Liability, d(30), d(100)), d(70));
+ assert_eq!(signed(AccountType::Revenue, d(30), d(100)), d(70));
+ }
+
+ #[test]
+ fn seuls_les_comptes_de_bilan_reportent_leur_solde() {
+ assert!(is_bilan(AccountType::Asset));
+ assert!(is_bilan(AccountType::Liability));
+ assert!(!is_bilan(AccountType::Revenue));
+ assert!(!is_bilan(AccountType::Expense));
+ }
+
+ /// Un compte à nature créditrice est re-signé : un passif de 70 (crédit) vaut
+ /// −70 en sens débit. Sans ce re-signe, le contrôle d'ouverture de la balance
+ /// rougirait sur des livres exacts.
+ #[test]
+ fn le_sens_debit_re_signe_les_comptes_a_nature_creditrice() {
+ assert_eq!(debit_sense(AccountType::Asset, d(70)), d(70));
+ assert_eq!(debit_sense(AccountType::Expense, d(70)), d(70));
+ assert_eq!(debit_sense(AccountType::Liability, d(70)), d(-70));
+ assert_eq!(debit_sense(AccountType::Revenue, d(70)), d(-70));
+ }
+
+ /// Le bilan prend la paire « avant le début », le résultat la paire « depuis
+ /// le début de l'exercice » — jamais l'inverse.
+ #[test]
+ fn l_ouverture_choisit_la_paire_selon_le_type() {
+ let before = (d(1000), d(0));
+ let since = (d(0), d(200));
+ assert_eq!(opening_from(AccountType::Asset, before, since), d(1000));
+ assert_eq!(opening_from(AccountType::Revenue, before, since), d(200));
+ assert_eq!(
+ opening_from(AccountType::Revenue, before, (d(0), d(0))),
+ d(0)
+ );
+ }
+}
diff --git a/crates/kesh-report/src/pdf.rs b/crates/kesh-report/src/pdf.rs
index d12912adc..f42abc32a 100644
--- a/crates/kesh-report/src/pdf.rs
+++ b/crates/kesh-report/src/pdf.rs
@@ -3,7 +3,7 @@
//! 4 fonctions publiques pures (input DTO → `Result, ReportError>`) :
//! - [`render_balance_sheet_pdf`] — Bilan (Actifs / Passifs / résultat de l'exercice).
//! - [`render_income_statement_pdf`] — Compte de résultat (Produits / Charges / résultat net).
-//! - [`render_trial_balance_pdf`] — Balance des comptes (5 colonnes : N°, Compte, Débit, Crédit, Solde).
+//! - [`render_trial_balance_pdf`] — Balance des comptes (6 colonnes : N°, Compte, Ouverture, Débit, Crédit, Clôture).
//! - [`render_journal_report_pdf`] — Journaux (5 sections fixes, filtre optionnel).
//!
//! Architecture (Pass 1 AA-M1 + Decision §swiss-amount-format) :
@@ -114,6 +114,14 @@ pub struct SectionLabels {
pub col_debit: String,
pub col_credit: String,
pub col_balance: String,
+ /// En-tête court de la colonne d'ouverture de la balance (Story 25-5-b) —
+ /// « Solde d'ouverture » ne tient pas dans une colonne de 30 mm.
+ pub col_opening: String,
+ /// En-tête court de la colonne de clôture de la balance (Story 25-5-b).
+ pub col_closing: String,
+ /// Avertissement de la balance quand les ouvertures ne s'équilibrent pas avec
+ /// le résultat reporté (Story 25-5-b) — l'écran a son ⚠️, le PDF ce texte.
+ pub opening_unbalanced: String,
pub col_entry_date: String,
pub col_description: String,
// Header
@@ -168,6 +176,12 @@ impl SectionLabels {
col_debit: "Débit".into(),
col_credit: "Crédit".into(),
col_balance: "Solde".into(),
+ col_opening: "Ouverture".into(),
+ col_closing: "Clôture".into(),
+ // ≈ 145 mm en Helvetica-Bold 10 pt : tient sur une ligne (`write_line` ne
+ // renvoie pas à la ligne).
+ opening_unbalanced:
+ "Contrôle d'ouverture en écart : une écriture antérieure est déséquilibrée.".into(),
col_entry_date: "Date".into(),
col_description: "Libellé".into(),
header_period: "Période".into(),
@@ -696,7 +710,35 @@ pub fn render_income_statement_pdf(
builder.finalize()
}
-/// Génère le PDF de la balance des comptes — 5 colonnes (AC #5).
+/// Positions des six colonnes de la balance (Story 25-5-b), en mm depuis la marge
+/// gauche. A4 portrait est déjà saturé à cinq colonnes (20 + 70 + 3 × 35 = 195 mm) :
+/// quatre colonnes de montant de 30 mm (« -12'345'678.90 » ≈ 23 mm en Helvetica
+/// 10 pt) laissent 55 mm à l'intitulé.
+const TB_COL_X: [f32; 6] = [0.0, 20.0, 75.0, 105.0, 135.0, 165.0];
+
+/// Longueur maximale de l'intitulé d'un compte dans la balance PDF : 22
+/// caractères tiennent dans 55 mm, même en capitales.
+const TB_NAME_MAX_CHARS: usize = 22;
+
+/// Dessine une ligne de la balance : six cellules, `None` = cellule vide.
+fn draw_tb_row(builder: &mut PdfBuilder, cells: [Option<&str>; 6], bold: bool) {
+ let y = builder.cursor_y;
+ let font = if bold {
+ builder.font_bold.clone()
+ } else {
+ builder.font.clone()
+ };
+ let layer = builder.current_layer();
+ for (cell, x) in cells.iter().zip(TB_COL_X) {
+ if let Some(text) = cell {
+ layer.use_text(*text, FONT_SIZE_PT, Mm(MARGIN_LEFT_MM + x), Mm(y), &font);
+ }
+ }
+}
+
+/// Génère le PDF de la balance des comptes — 6 colonnes : N°, Intitulé,
+/// Ouverture, Débit, Crédit, Clôture (Story 25-5-b, #385), puis la ligne calculée
+/// du résultat reporté et les totaux des mouvements.
pub fn render_trial_balance_pdf(
tb: &TrialBalance,
ctx: &PdfContext,
@@ -715,133 +757,95 @@ pub fn render_trial_balance_pdf(
return builder.finalize();
}
- // En-têtes de colonnes (5 colonnes : N°, Compte, Débit, Crédit, Solde)
- let col_num_x = MARGIN_LEFT_MM;
- let col_name_x = MARGIN_LEFT_MM + 20.0;
- let col_debit_x = MARGIN_LEFT_MM + 90.0;
- let col_credit_x = MARGIN_LEFT_MM + 125.0;
- let col_balance_x = MARGIN_LEFT_MM + 160.0;
-
- {
- let y = builder.cursor_y;
- let layer = builder.current_layer();
- layer.use_text(
- &ctx.section_labels.col_account_number,
- FONT_SIZE_PT,
- Mm(col_num_x),
- Mm(y),
- &builder.font_bold,
- );
- layer.use_text(
- &ctx.section_labels.col_account_name,
- FONT_SIZE_PT,
- Mm(col_name_x),
- Mm(y),
- &builder.font_bold,
- );
- layer.use_text(
- &ctx.section_labels.col_debit,
- FONT_SIZE_PT,
- Mm(col_debit_x),
- Mm(y),
- &builder.font_bold,
- );
- layer.use_text(
- &ctx.section_labels.col_credit,
- FONT_SIZE_PT,
- Mm(col_credit_x),
- Mm(y),
- &builder.font_bold,
- );
- layer.use_text(
- &ctx.section_labels.col_balance,
- FONT_SIZE_PT,
- Mm(col_balance_x),
- Mm(y),
- &builder.font_bold,
- );
- }
+ let l = &ctx.section_labels;
+ draw_tb_row(
+ &mut builder,
+ [
+ Some(&l.col_account_number),
+ Some(&l.col_account_name),
+ Some(&l.col_opening),
+ Some(&l.col_debit),
+ Some(&l.col_credit),
+ Some(&l.col_closing),
+ ],
+ true,
+ );
builder.cursor_y -= LINE_HEIGHT_MM * 1.5;
- // Lignes
for row in &tb.rows {
builder.ensure_space_for_row();
- let y = builder.cursor_y;
- let layer = builder.current_layer();
- layer.use_text(
- &row.account_number,
- FONT_SIZE_PT,
- Mm(col_num_x),
- Mm(y),
- &builder.font,
- );
- // Pass 1 code-review H4 (BH1-H4) : la colonne `name` (largeur 70mm) peut
- // déborder sur la colonne `débit` pour des libellés CH GAAP > 30 chars
- // (e.g. « Provisions pour dépréciation d'actifs »). Truncate à 30 chars
- // avec ellipsis Unicode pour préserver la lisibilité des colonnes
- // débit/crédit/solde alignées. Refactor multi-ligne → v0.2 si feedback.
- let display_name = truncate_with_ellipsis(&row.account_name, 30);
- layer.use_text(
- &display_name,
- FONT_SIZE_PT,
- Mm(col_name_x),
- Mm(y),
- &builder.font,
- );
- layer.use_text(
+ // Pass 1 code-review H4 (BH1-H4) : un intitulé trop long déborderait sur la
+ // colonne suivante — tronqué avec ellipsis Unicode.
+ let name = truncate_with_ellipsis(&row.account_name, TB_NAME_MAX_CHARS);
+ let (opening, debit, credit, closing) = (
+ format_swiss_amount(row.opening_balance),
format_swiss_amount(row.total_debit),
- FONT_SIZE_PT,
- Mm(col_debit_x),
- Mm(y),
- &builder.font,
- );
- layer.use_text(
format_swiss_amount(row.total_credit),
- FONT_SIZE_PT,
- Mm(col_credit_x),
- Mm(y),
- &builder.font,
+ format_swiss_amount(row.closing_balance),
);
- layer.use_text(
- format_swiss_amount(row.balance),
- FONT_SIZE_PT,
- Mm(col_balance_x),
- Mm(y),
- &builder.font,
+ draw_tb_row(
+ &mut builder,
+ [
+ Some(&row.account_number),
+ Some(&name),
+ Some(&opening),
+ Some(&debit),
+ Some(&credit),
+ Some(&closing),
+ ],
+ false,
);
builder.cursor_y -= LINE_HEIGHT_MM;
}
- // Totaux
+ // Ligne calculée du résultat reporté : ouverture = clôture, sans mouvement.
+ builder.ensure_space_for_row();
+ let retained = format_swiss_amount(tb.retained_earnings);
+ // Libellé fixe, non tronqué : « Résultat reporté (calculé) » ≈ 46 mm en casse
+ // mixte, dans les 55 mm de la colonne.
+ draw_tb_row(
+ &mut builder,
+ [
+ None,
+ Some(&l.retained_result_label),
+ Some(&retained),
+ None,
+ None,
+ Some(&retained),
+ ],
+ false,
+ );
+ builder.cursor_y -= LINE_HEIGHT_MM;
+
+ // Totaux des mouvements.
builder.cursor_y -= LINE_HEIGHT_MM * 0.5;
builder.ensure_space_for_row();
- {
- let y = builder.cursor_y;
- let layer = builder.current_layer();
- layer.use_text(
- &ctx.section_labels.grand_total,
- FONT_SIZE_PT,
- Mm(col_num_x),
- Mm(y),
- &builder.font_bold,
- );
- layer.use_text(
- format_swiss_amount(tb.total_debit),
- FONT_SIZE_PT,
- Mm(col_debit_x),
- Mm(y),
- &builder.font_bold,
- );
- layer.use_text(
- format_swiss_amount(tb.total_credit),
- FONT_SIZE_PT,
- Mm(col_credit_x),
- Mm(y),
- &builder.font_bold,
- );
- }
+ let (total_debit, total_credit) = (
+ format_swiss_amount(tb.total_debit),
+ format_swiss_amount(tb.total_credit),
+ );
+ draw_tb_row(
+ &mut builder,
+ [
+ Some(&l.grand_total),
+ None,
+ None,
+ Some(&total_debit),
+ Some(&total_credit),
+ None,
+ ],
+ true,
+ );
builder.cursor_y -= LINE_HEIGHT_MM;
+ // Le contrôle d'ouverture : silencieux s'il tient, écrit s'il tombe — un PDF
+ // remis à un réviseur ne doit pas perdre le signal que l'écran affiche.
+ if !tb.opening_balanced {
+ builder.cursor_y -= LINE_HEIGHT_MM * 0.5;
+ builder.ensure_space_for_row();
+ builder.write_line(&l.opening_unbalanced, FONT_SIZE_PT, true, 0.0);
+ }
+
builder.finalize()
}
@@ -1819,14 +1823,18 @@ mod tests {
account_name: "Caisse".into(),
account_type: AccountType::Asset,
active: true,
+ opening_balance: dec!(500),
total_debit: dec!(2000),
total_credit: dec!(1000),
balance: dec!(1000),
+ closing_balance: dec!(1500),
}]
},
total_debit: if empty { Decimal::ZERO } else { dec!(2000) },
total_credit: if empty { Decimal::ZERO } else { dec!(1000) },
balanced: true,
+ retained_earnings: if empty { Decimal::ZERO } else { dec!(500) },
+ opening_balanced: true,
}
}
@@ -1986,6 +1994,37 @@ mod tests {
assert!(bytes.starts_with(b"%PDF-1."));
}
+ #[test]
+ fn trial_balance_pdf_with_retained_line_is_not_the_empty_render() {
+ // Story 25-5-b — la ligne calculée et les six colonnes : le rendu peuplé
+ // diffère du rendu vide (garde `rows.is_empty()` inchangée).
+ let ctx = PdfContext::fr_ch_default("CI Test Company");
+ let full = render_trial_balance_pdf(&fixture_tb(false), &ctx).unwrap();
+ let empty = render_trial_balance_pdf(&fixture_tb(true), &ctx).unwrap();
+ assert!(full.starts_with(b"%PDF-1."));
+ assert!(full.len() > empty.len());
+ }
+
+ #[test]
+ fn trial_balance_pdf_writes_the_opening_warning_only_when_unbalanced() {
+ // Story 25-5-b, revue P1 — le contrôle d'ouverture n'existait qu'à l'écran.
+ let ctx = PdfContext::fr_ch_default("CI Test Company");
+ let ok = render_trial_balance_pdf(&fixture_tb(false), &ctx).unwrap();
+ let mut broken = fixture_tb(false);
+ broken.opening_balanced = false;
+ let warned = render_trial_balance_pdf(&broken, &ctx).unwrap();
+ assert!(warned.len() > ok.len(), "l'avertissement doit être écrit");
+ }
+
+ #[test]
+ fn trial_balance_names_are_truncated_to_fit_six_columns() {
+ // Story 25-5-b — 55 mm pour l'intitulé : 22 caractères, ellipsis comprise.
+ let long = "Provisions pour dépréciation d'actifs"; // 37 caractères
+ let truncated = truncate_with_ellipsis(long, TB_NAME_MAX_CHARS);
+ assert_eq!(truncated.chars().count(), 22);
+ assert!(truncated.ends_with('…'));
+ }
+
#[test]
fn journal_report_pdf_starts_with_pdf_signature() {
let ctx = PdfContext::fr_ch_default("CI Test Company");
diff --git a/crates/kesh-report/src/trial_balance.rs b/crates/kesh-report/src/trial_balance.rs
index 23a7b58a0..3c5d6d864 100644
--- a/crates/kesh-report/src/trial_balance.rs
+++ b/crates/kesh-report/src/trial_balance.rs
@@ -1,20 +1,61 @@
-//! Balance des comptes : 1 ligne par compte avec total débit / total crédit / solde.
+//! Balance des comptes : pour chaque compte, **solde d'ouverture, mouvements de la
+//! période, solde de clôture** — et une ligne calculée du résultat reporté.
//!
-//! Règle d'inclusion (Pass 1 ECH-03) :
-//! - Comptes **actifs** : inclus dans tous les cas (même sans écriture, balance=0)
-//! - Comptes **archivés avec écritures dans la période** : inclus (marqueur `active: false`)
-//! - Comptes **archivés sans écriture dans la période** : exclus
+//! # Pourquoi l'ouverture (Story 25-5-b, #385)
//!
-//! Invariant : `total_debit == total_credit` (cohérence partie double, vérifiée à
-//! l'INSERT des écritures dans `kesh-db::repositories::journal_entries::create_in_tx:201`).
-//! Si déséquilibré → `ReportError::TrialBalanceUnbalanced` + log error! (defense in depth).
+//! Jusqu'à la 25-5-b, la balance ne portait que les mouvements de la période :
+//! le compte « clients » de la balance 2026 ne valait pas celui du bilan 2026,
+//! qui est cumulatif depuis l'origine. Désormais `closing_balance` d'un compte de
+//! bilan **est** son solde au bilan, et celui d'un compte de résultat son montant
+//! au compte de résultat. La règle d'ouverture vit dans [`crate::opening`],
+//! partagée avec le grand livre.
+//!
+//! # Règle d'inclusion
+//!
+//! Un compte est rendu s'il est **actif**, **ou** s'il a un mouvement dans la
+//! période, **ou** si son **solde d'ouverture est non nul** — un compte archivé
+//! qui porte encore un solde doit apparaître, sinon la clôture ne concorderait
+//! plus avec le bilan. Un compte rendu archivé porte `active: false`.
+//!
+//! # La ligne calculée du résultat reporté
+//!
+//! Kesh ne passe aucune écriture de clôture : le résultat des exercices
+//! antérieurs n'est porté par **aucun compte**. Sans lui, ni la colonne
+//! d'ouverture ni celle de clôture ne s'équilibrent. [`TrialBalance::retained_earnings`]
+//! le porte — la même valeur que le bilan, par la même fonction.
+//!
+//! # Deux contrôles
+//!
+//! - **Mouvements** : `total_debit == total_credit` (partie double, vérifiée à
+//! l'INSERT des écritures dans `kesh-db::repositories::journal_entries::create_in_tx`).
+//! Si déséquilibré → `ReportError::TrialBalanceUnbalanced` + log error! (defense
+//! in depth).
+//! - **Ouverture** : `opening_balanced` (voir le champ). Un déséquilibre n'est pas
+//! une erreur : il est journalisé et affiché, comme `equation_holds` au bilan.
+//! Il n'y a pas de contrôle de clôture : la clôture vaut l'ouverture plus des
+//! mouvements équilibrés, ce serait la copie du premier.
+//!
+//! # Limites connues
+//!
+//! - Le signe est lu sur le `account_type` **courant** du compte (issues #274, #382).
+//! - Les mouvements filtrent `fiscal_year_id` **et** la date, l'ouverture la date
+//! seule : la concordance avec le bilan suppose que toute écriture tombe dans les
+//! bornes de son exercice — ce que `journal_entries::create_in_tx` impose
+//! (`DateOutsideFiscalYear`), et qu'aucune mise à jour ne peut plus défaire
+//! (Story 24-4b).
+//! - La requête lit **tout l'historique** de la société jusqu'à la fin de la
+//! période — le prix d'une ouverture cumulée depuis l'origine, comme au bilan.
+//! Sans mesure à ce jour ; à surveiller si l'historique grossit.
use kesh_db::entities::AccountType;
+use kesh_db::repositories::fiscal_years;
use rust_decimal::Decimal;
use serde::Serialize;
use sqlx::MySqlPool;
+use crate::balance_sheet::fetch_retained_earnings;
use crate::errors::ReportError;
+use crate::opening::{debit_sense, opening_from, signed};
use crate::period::ReportPeriod;
/// Balance des comptes complète.
@@ -23,28 +64,64 @@ use crate::period::ReportPeriod;
pub struct TrialBalance {
pub period: ReportPeriod,
pub rows: Vec,
+ /// Total des mouvements débit de la période.
pub total_debit: Decimal,
+ /// Total des mouvements crédit de la période.
pub total_credit: Decimal,
+ /// Mouvements équilibrés — toujours vrai dans un rapport rendu (sinon
+ /// `TrialBalanceUnbalanced`).
pub balanced: bool,
+ /// Résultat reporté **calculé** : cumul des résultats des exercices
+ /// strictement antérieurs à celui de la période (P&L `entry_date < fy_start`),
+ /// signe crédit-positif — négatif = pertes cumulées. Même valeur que
+ /// `BalanceSheet::retained_earnings`. Ouverture = clôture, sans mouvement.
+ pub retained_earnings: Decimal,
+ /// `Σ debit_sense(opening_balance) − retained_earnings == 0`. Le résultat
+ /// reporté étant calculé **indépendamment**, c'est une vérification réelle :
+ /// une ligne d'écriture déséquilibrée antérieure à la période la fait tomber.
+ pub opening_balanced: bool,
}
-/// Ligne de la balance des comptes.
-///
-/// `account_number` (camelCase) mappe la colonne DB `accounts.number`
-/// (Pass 4 AA4-01).
-#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
+/// Ligne de la balance des comptes. Tous les soldes suivent la convention du
+/// dépôt : positifs du côté naturel du compte (`Asset`/`Expense` : débit).
+#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrialBalanceRow {
pub account_id: i64,
- #[sqlx(rename = "number")]
+ /// `accounts.number`.
pub account_number: String,
- #[sqlx(rename = "name")]
+ /// `accounts.name`.
pub account_name: String,
pub account_type: AccountType,
pub active: bool,
+ /// Solde au début de la période — règle de [`crate::opening`].
+ pub opening_balance: Decimal,
+ /// Mouvements débit de la période.
pub total_debit: Decimal,
+ /// Mouvements crédit de la période.
pub total_credit: Decimal,
+ /// **Net des mouvements** de la période (pas un solde de compte).
pub balance: Decimal,
+ /// `opening_balance + balance` — pour un compte de bilan, son solde au bilan.
+ pub closing_balance: Decimal,
+}
+
+/// Ligne brute, telle que la lit SQL : trois paires de sommes, aucun choix de
+/// règle ni de signe (ils sont en Rust, dans [`crate::opening`]).
+#[derive(Debug, sqlx::FromRow)]
+struct RawRow {
+ account_id: i64,
+ number: String,
+ name: String,
+ account_type: AccountType,
+ active: bool,
+ before_debit: Decimal,
+ before_credit: Decimal,
+ since_fy_debit: Decimal,
+ since_fy_credit: Decimal,
+ period_debit: Decimal,
+ period_credit: Decimal,
+ period_lines: i64,
}
/// Génère la balance des comptes pour la période donnée.
@@ -53,12 +130,16 @@ pub async fn generate(
company_id: i64,
period: &ReportPeriod,
) -> Result {
- // Règle d'inclusion (Pass 1 ECH-03) :
- // (active=true OR EXISTS écritures dans la période)
- //
- // CORRECTION (Pass 4 dev) : la subquery `filtered_jel` filtre les lignes par
- // fiscal_year + période AVANT le LEFT JOIN sur accounts, sinon SUM agrège
- // les lignes de TOUS les fy (bug constaté avec fy2 sans écritures).
+ let fy = fiscal_years::find_by_id_in_company(pool, company_id, period.fiscal_year_id)
+ .await?
+ .ok_or(ReportError::FiscalYearNotFound {
+ fiscal_year_id: period.fiscal_year_id,
+ })?;
+ let fy_start = fy.start_date;
+
+ // Une requête agrégée : par compte, les sommes avant le début, depuis le début
+ // de l'exercice, et sur la période. Les mouvements gardent leur filtre
+ // d'origine (exercice ET dates).
let sql = "
SELECT
a.id AS account_id,
@@ -66,52 +147,83 @@ pub async fn generate(
a.name,
a.account_type,
a.active,
- COALESCE(SUM(filtered_jel.debit), 0) AS total_debit,
- COALESCE(SUM(filtered_jel.credit), 0) AS total_credit,
- CASE
- WHEN a.account_type IN ('Asset', 'Expense')
- THEN COALESCE(SUM(filtered_jel.debit), 0) - COALESCE(SUM(filtered_jel.credit), 0)
- ELSE COALESCE(SUM(filtered_jel.credit), 0) - COALESCE(SUM(filtered_jel.debit), 0)
- END AS balance
+ COALESCE(SUM(CASE WHEN x.entry_date < ? THEN x.debit END), 0) AS before_debit,
+ COALESCE(SUM(CASE WHEN x.entry_date < ? THEN x.credit END), 0) AS before_credit,
+ COALESCE(SUM(CASE WHEN x.entry_date >= ? AND x.entry_date < ? THEN x.debit END), 0)
+ AS since_fy_debit,
+ COALESCE(SUM(CASE WHEN x.entry_date >= ? AND x.entry_date < ? THEN x.credit END), 0)
+ AS since_fy_credit,
+ COALESCE(SUM(CASE WHEN x.fiscal_year_id = ? AND x.entry_date BETWEEN ? AND ?
+ THEN x.debit END), 0) AS period_debit,
+ COALESCE(SUM(CASE WHEN x.fiscal_year_id = ? AND x.entry_date BETWEEN ? AND ?
+ THEN x.credit END), 0) AS period_credit,
+ CAST(COALESCE(SUM(CASE WHEN x.fiscal_year_id = ? AND x.entry_date BETWEEN ? AND ?
+ THEN 1 ELSE 0 END), 0) AS SIGNED) AS period_lines
FROM accounts a
LEFT JOIN (
- SELECT jel.account_id, jel.debit, jel.credit
+ SELECT jel.account_id, jel.debit, jel.credit, je.entry_date, je.fiscal_year_id
FROM journal_entry_lines jel
INNER JOIN journal_entries je ON je.id = jel.entry_id
WHERE je.company_id = ?
- AND je.fiscal_year_id = ?
- AND je.entry_date BETWEEN ? AND ?
- ) AS filtered_jel ON filtered_jel.account_id = a.id
+ AND je.entry_date <= ?
+ ) AS x ON x.account_id = a.id
WHERE a.company_id = ?
- AND (
- a.active = TRUE
- OR EXISTS (
- SELECT 1 FROM journal_entry_lines jel2
- INNER JOIN journal_entries je2 ON je2.id = jel2.entry_id
- WHERE jel2.account_id = a.id
- AND je2.company_id = ?
- AND je2.fiscal_year_id = ?
- AND je2.entry_date BETWEEN ? AND ?
- )
- )
GROUP BY a.id, a.number, a.name, a.account_type, a.active
ORDER BY a.number ASC
";
- let rows = sqlx::query_as::<_, TrialBalanceRow>(sql)
- .bind(company_id) // filtered_jel.je.company_id
+ let (start, end) = (period.start_date, period.end_date);
+ let raw = sqlx::query_as::<_, RawRow>(sql)
+ .bind(start) // before_debit
+ .bind(start) // before_credit
+ .bind(fy_start)
+ .bind(start) // since_fy_debit
+ .bind(fy_start)
+ .bind(start) // since_fy_credit
.bind(period.fiscal_year_id)
- .bind(period.start_date)
- .bind(period.end_date)
- .bind(company_id) // a.company_id (WHERE)
- .bind(company_id) // je2.company_id (EXISTS subquery)
+ .bind(start)
+ .bind(end) // period_debit
.bind(period.fiscal_year_id)
- .bind(period.start_date)
- .bind(period.end_date)
+ .bind(start)
+ .bind(end) // period_credit
+ .bind(period.fiscal_year_id)
+ .bind(start)
+ .bind(end) // period_lines
+ .bind(company_id) // x: je.company_id
+ .bind(end) // x: borne haute
+ .bind(company_id) // a.company_id
.fetch_all(pool)
.await
.map_err(kesh_db::errors::map_db_error)?;
+ let rows: Vec = raw
+ .into_iter()
+ .filter_map(|r| {
+ let opening = opening_from(
+ r.account_type,
+ (r.before_debit, r.before_credit),
+ (r.since_fy_debit, r.since_fy_credit),
+ );
+ // Règle d'inclusion : actif, ou mouvementé, ou porteur d'un solde.
+ if !r.active && r.period_lines == 0 && opening.is_zero() {
+ return None;
+ }
+ let balance = signed(r.account_type, r.period_debit, r.period_credit);
+ Some(TrialBalanceRow {
+ account_id: r.account_id,
+ account_number: r.number,
+ account_name: r.name,
+ account_type: r.account_type,
+ active: r.active,
+ opening_balance: opening,
+ total_debit: r.period_debit,
+ total_credit: r.period_credit,
+ balance,
+ closing_balance: opening + balance,
+ })
+ })
+ .collect();
+
let total_debit: Decimal = rows.iter().map(|r| r.total_debit).sum();
let total_credit: Decimal = rows.iter().map(|r| r.total_credit).sum();
let balanced = total_debit == total_credit;
@@ -130,38 +242,92 @@ pub async fn generate(
});
}
+ let retained_earnings = fetch_retained_earnings(pool, company_id, fy_start).await?;
+ let opening_balanced = is_opening_balanced(&rows, retained_earnings);
+ if !opening_balanced {
+ tracing::warn!(
+ company_id,
+ fiscal_year_id = period.fiscal_year_id,
+ retained_earnings = %retained_earnings,
+ "trial_balance : les soldes d'ouverture ne s'équilibrent pas avec le résultat reporté"
+ );
+ }
+
Ok(TrialBalance {
period: period.clone(),
rows,
total_debit,
total_credit,
balanced,
+ retained_earnings,
+ opening_balanced,
})
}
+/// `Σ debit_sense(opening_balance) − retained_earnings == 0` : les soldes signés
+/// par type sont ramenés en sens débit avant d'être additionnés.
+fn is_opening_balanced(rows: &[TrialBalanceRow], retained_earnings: Decimal) -> bool {
+ let sum: Decimal = rows
+ .iter()
+ .map(|r| debit_sense(r.account_type, r.opening_balance))
+ .sum();
+ sum - retained_earnings == Decimal::ZERO
+}
+
#[cfg(test)]
mod tests {
+ use super::*;
use rust_decimal_macros::dec;
+ fn row(account_type: AccountType, opening: Decimal) -> TrialBalanceRow {
+ TrialBalanceRow {
+ account_id: 0,
+ account_number: String::new(),
+ account_name: String::new(),
+ account_type,
+ active: true,
+ opening_balance: opening,
+ total_debit: Decimal::ZERO,
+ total_credit: Decimal::ZERO,
+ balance: Decimal::ZERO,
+ closing_balance: opening,
+ }
+ }
+
+ /// Ventes 1000 et charge 400 en exercice 1 (clients 1000, caisse −400, tous
+ /// deux à l'actif) : ouvertures en sens débit 600 = résultat reporté 600.
#[test]
- fn balanced_when_debit_equals_credit() {
- let total_debit = dec!(1500);
- let total_credit = dec!(1500);
- assert!(total_debit == total_credit);
+ fn l_ouverture_s_equilibre_avec_le_resultat_reporte() {
+ let rows = [
+ row(AccountType::Asset, dec!(1000)),
+ row(AccountType::Asset, dec!(-400)),
+ ];
+ assert!(is_opening_balanced(&rows, dec!(600)));
+ assert!(!is_opening_balanced(&rows, dec!(500)));
}
+ /// Un passif créditeur de 5000 (report d'un migrant) face à une banque de
+ /// 5000 : sans le re-signe en sens débit, la somme vaudrait 10000 et le
+ /// contrôle rougirait sur des livres exacts.
#[test]
- fn unbalanced_when_debit_diverges() {
- let total_debit = dec!(1500);
- let total_credit = dec!(1499);
- assert!(total_debit != total_credit);
+ fn les_comptes_a_nature_creditrice_sont_re_signes() {
+ let rows = [
+ row(AccountType::Asset, dec!(5000)),
+ row(AccountType::Liability, dec!(5000)),
+ ];
+ assert!(is_opening_balanced(&rows, Decimal::ZERO));
}
+ /// Exercice en cours : un produit de 200 avant la période (janvier–février)
+ /// est porté par l'ouverture du compte de produits, PAS par le résultat
+ /// reporté — compté une fois.
#[test]
- fn zero_balance_for_inactive_accounts_without_entries() {
- // L'invariant : si compte archivé sans écriture, exclus de la balance.
- // Si compte actif sans écriture, balance = 0 (SUM NULL → COALESCE 0).
- let active_no_entries_balance = rust_decimal::Decimal::ZERO;
- assert_eq!(active_no_entries_balance, rust_decimal::Decimal::ZERO);
+ fn le_resultat_de_l_exercice_avant_la_periode_est_compte_une_fois() {
+ let rows = [
+ row(AccountType::Asset, dec!(1200)),
+ row(AccountType::Asset, dec!(-400)),
+ row(AccountType::Revenue, dec!(200)),
+ ];
+ assert!(is_opening_balanced(&rows, dec!(600)));
}
}
diff --git a/crates/kesh-report/tests/general_ledger.rs b/crates/kesh-report/tests/general_ledger.rs
index 8b0e1308c..29c051882 100644
--- a/crates/kesh-report/tests/general_ledger.rs
+++ b/crates/kesh-report/tests/general_ledger.rs
@@ -204,6 +204,47 @@ async fn concordance_compte_de_resultat(pool: MySqlPool) {
section.closing, is_row.balance,
"la clôture doit égaler le montant du compte de résultat"
);
+
+ // Story 25-5-b — en cours d'exercice (à partir du 1er mars), la vente de
+ // février fait l'ouverture du compte de ventes ; le grand livre et la balance
+ // doivent la porter de la même façon, et clore au même nombre. Sur
+ // l'exercice entier, les deux ouvertures vaudraient 0 par construction.
+ let (from, to) = (ymd(2026, 3, 1), ymd(2026, 12, 31));
+ let mid_ledger = generate(
+ &pool,
+ seeded.company_id,
+ &LedgerPeriod::new(from, to).expect("période"),
+ &LedgerOptions::default(),
+ )
+ .await
+ .expect("grand livre en cours d'exercice");
+ let mid_section = mid_ledger
+ .sections
+ .iter()
+ .find(|s| s.account_id == ventes)
+ .expect("le compte de ventes doit figurer");
+ let mid_rp = ReportPeriod {
+ fiscal_year_id: seeded.fiscal_year_id,
+ start_date: from,
+ end_date: to,
+ };
+ let mid_tb = generate_trial_balance(&pool, seeded.company_id, &mid_rp)
+ .await
+ .expect("balance en cours d'exercice");
+ let mid_row = mid_tb
+ .rows
+ .iter()
+ .find(|r| r.account_id == ventes)
+ .expect("le compte doit figurer à la balance");
+ assert_eq!(mid_section.opening, dec!(1000.00));
+ assert_eq!(
+ mid_section.opening, mid_row.opening_balance,
+ "l'ouverture doit concorder entre grand livre et balance"
+ );
+ assert_eq!(
+ mid_section.closing, mid_row.closing_balance,
+ "la clôture doit concorder entre grand livre et balance"
+ );
}
/// La partie double, vue depuis le grand livre : c'est le test le moins cher du
diff --git a/crates/kesh-report/tests/trial_balance_opening.rs b/crates/kesh-report/tests/trial_balance_opening.rs
new file mode 100644
index 000000000..d4cd0c565
--- /dev/null
+++ b/crates/kesh-report/tests/trial_balance_opening.rs
@@ -0,0 +1,599 @@
+//! La balance des comptes porte un solde d'ouverture — Story 25-5-b (#385).
+//!
+//! ⚠️ **Les tests de CONCORDANCE sont la raison d'être de ce fichier.** Le défaut
+//! redouté est muet : si l'ouverture est prise de travers, `clôture = ouverture +
+//! mouvements` reste vrai et le rapport est intérieurement cohérent, extérieurement
+//! faux. Ils appellent donc réellement le bilan, le compte de résultat et le grand
+//! livre, et chacun nomme la mutation qu'il tue.
+//!
+//! Pré-requis : MariaDB démarré.
+
+use chrono::NaiveDate;
+use kesh_db::entities::journal_entry::{Journal, NewJournalEntry, NewJournalEntryLine};
+use kesh_db::repositories::journal_entries;
+use kesh_db::test_fixtures::{SeededCompany, seed_accounting_company};
+use kesh_report::general_ledger::{LedgerOptions, LedgerPeriod};
+use kesh_report::period::ReportPeriod;
+use kesh_report::trial_balance::TrialBalance;
+use kesh_report::{generate_balance_sheet, generate_general_ledger, generate_trial_balance};
+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).expect("date valide")
+}
+
+/// Poste une écriture équilibrée à deux lignes dans l'exercice `fy`. Renvoie
+/// l'id de l'écriture.
+#[allow(clippy::too_many_arguments)]
+async fn post(
+ pool: &MySqlPool,
+ seeded: &SeededCompany,
+ fy: i64,
+ date: NaiveDate,
+ debit_account: &str,
+ credit_account: &str,
+ amount: Decimal,
+) -> i64 {
+ journal_entries::create(
+ pool,
+ fy,
+ seeded.admin_user_id,
+ NewJournalEntry {
+ company_id: seeded.company_id,
+ entry_date: date,
+ journal: Journal::OD,
+ description: "écriture".into(),
+ project_id: None,
+ lines: vec![
+ NewJournalEntryLine {
+ account_id: seeded.accounts[debit_account],
+ debit: amount,
+ credit: Decimal::ZERO,
+ project_id: None,
+ },
+ NewJournalEntryLine {
+ account_id: seeded.accounts[credit_account],
+ debit: Decimal::ZERO,
+ credit: amount,
+ project_id: None,
+ },
+ ],
+ },
+ )
+ .await
+ .expect("écriture postée")
+ .entry
+ .id
+}
+
+/// Deux exercices, 2025 et 2026. ⚠️ La fixture seede UN exercice 2020-2030 : il
+/// faut le rétrécir, sinon 2025 et 2026 sont le même exercice et le report ne
+/// prouve plus rien.
+async fn two_years(pool: &MySqlPool, seeded: &SeededCompany) -> (i64, i64) {
+ sqlx::query("UPDATE fiscal_years SET end_date = '2025-12-31' WHERE id = ?")
+ .bind(seeded.fiscal_year_id)
+ .execute(pool)
+ .await
+ .expect("exercice seedé rétréci à 2025");
+ let fy2026: i64 = sqlx::query_scalar(
+ "INSERT INTO fiscal_years (company_id, name, start_date, end_date, status, created_at, updated_at) \
+ VALUES (?, 'FY 2026', '2026-01-01', '2026-12-31', 'Open', NOW(3), NOW(3)) RETURNING id",
+ )
+ .bind(seeded.company_id)
+ .fetch_one(pool)
+ .await
+ .expect("exercice 2026 créé");
+ (seeded.fiscal_year_id, fy2026)
+}
+
+/// Le jeu de référence : ventes 1000 et charge 400 en 2025 ; en 2026, une vente
+/// de 200 en février, 50 la veille du 1er mars, 70 le 1er mars (la frontière),
+/// 500 en avril, 30 en août.
+async fn reference_books(pool: &MySqlPool, seeded: &SeededCompany) -> (i64, i64) {
+ let (fy25, fy26) = two_years(pool, seeded).await;
+ post(
+ pool,
+ seeded,
+ fy25,
+ ymd(2025, 3, 1),
+ "1100",
+ "3000",
+ dec!(1000),
+ )
+ .await;
+ post(
+ pool,
+ seeded,
+ fy25,
+ ymd(2025, 6, 1),
+ "4000",
+ "1000",
+ dec!(400),
+ )
+ .await;
+ post(
+ pool,
+ seeded,
+ fy26,
+ ymd(2026, 2, 1),
+ "1100",
+ "3000",
+ dec!(200),
+ )
+ .await;
+ let veille = ymd(2026, 3, 1).pred_opt().expect("veille");
+ post(pool, seeded, fy26, veille, "1000", "3000", dec!(50)).await;
+ post(
+ pool,
+ seeded,
+ fy26,
+ ymd(2026, 3, 1),
+ "1000",
+ "3000",
+ dec!(70),
+ )
+ .await;
+ post(
+ pool,
+ seeded,
+ fy26,
+ ymd(2026, 4, 15),
+ "1100",
+ "3000",
+ dec!(500),
+ )
+ .await;
+ post(
+ pool,
+ seeded,
+ fy26,
+ ymd(2026, 8, 1),
+ "1100",
+ "3000",
+ dec!(30),
+ )
+ .await;
+ (fy25, fy26)
+}
+
+fn period(fy: i64, start: NaiveDate, end: NaiveDate) -> ReportPeriod {
+ ReportPeriod {
+ fiscal_year_id: fy,
+ start_date: start,
+ end_date: end,
+ }
+}
+
+async fn balance(pool: &MySqlPool, seeded: &SeededCompany, p: &ReportPeriod) -> TrialBalance {
+ generate_trial_balance(pool, seeded.company_id, p)
+ .await
+ .expect("balance")
+}
+
+/// `(ouverture, débit, crédit, clôture)` d'un compte, par numéro.
+fn row(tb: &TrialBalance, number: &str) -> (Decimal, Decimal, Decimal, Decimal) {
+ let r = tb
+ .rows
+ .iter()
+ .find(|r| r.account_number == number)
+ .unwrap_or_else(|| panic!("le compte {number} doit figurer à la balance"));
+ (
+ r.opening_balance,
+ r.total_debit,
+ r.total_credit,
+ r.closing_balance,
+ )
+}
+
+/// ⛔ Toute la balance de l'exercice 2 concorde avec le bilan (comptes de bilan)
+/// et le compte de résultat (comptes de résultat). Mutation tuée : « ouverture
+/// bornée par l'exercice » (le défaut de #385 — la caisse et la banque perdraient
+/// leur report) et « ouverture des comptes de résultat cumulée depuis l'origine »
+/// (les ventes porteraient les 1000 de 2025).
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn la_balance_de_l_exercice_concorde_avec_le_bilan_et_le_resultat(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let (_, fy26) = reference_books(&pool, &seeded).await;
+ let p = period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31));
+ let tb = balance(&pool, &seeded, &p).await;
+
+ assert_eq!(
+ row(&tb, "3000").0,
+ Decimal::ZERO,
+ "les ventes repartent de zéro"
+ );
+ assert_eq!(row(&tb, "1100").0, dec!(1000), "la banque reporte 2025");
+ assert_eq!(row(&tb, "1000").0, dec!(-400), "la caisse reporte 2025");
+ assert_eq!(tb.retained_earnings, dec!(600));
+ assert!(tb.opening_balanced);
+
+ let bs = generate_balance_sheet(&pool, seeded.company_id, &p)
+ .await
+ .expect("bilan");
+ assert_eq!(tb.retained_earnings, bs.retained_earnings);
+ let bs_lines: Vec<_> = bs
+ .assets
+ .iter()
+ .chain(&bs.liabilities)
+ .chain(&bs.equity)
+ .collect();
+ let is = kesh_report::generate_income_statement(&pool, seeded.company_id, &p)
+ .await
+ .expect("compte de résultat");
+ let is_lines: Vec<_> = is.revenues.iter().chain(&is.expenses).collect();
+
+ for r in &tb.rows {
+ let expected = if kesh_report::opening::is_bilan(r.account_type) {
+ bs_lines
+ .iter()
+ .find(|b| b.account_id == r.account_id)
+ .map_or(Decimal::ZERO, |b| b.balance)
+ } else {
+ is_lines
+ .iter()
+ .find(|b| b.account_id == r.account_id)
+ .map_or(Decimal::ZERO, |b| b.balance)
+ };
+ assert_eq!(
+ r.closing_balance, expected,
+ "compte {} : la clôture doit égaler le bilan / le compte de résultat",
+ r.account_number
+ );
+ }
+}
+
+/// ⛔ Période en cours d'exercice (mars–juin), avec une écriture LE 1er mars et une
+/// la veille. Mutation tuée : `<` → `<=` sur la borne d'ouverture (la vente du 1er
+/// mars serait comptée deux fois — en ouverture et en mouvement).
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn en_cours_d_exercice_la_frontiere_n_est_comptee_qu_une_fois(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let (_, fy26) = reference_books(&pool, &seeded).await;
+ let (start, end) = (ymd(2026, 3, 1), ymd(2026, 6, 30));
+ let tb = balance(&pool, &seeded, &period(fy26, start, end)).await;
+
+ // Ventes : 200 + 50 (la veille) en ouverture ; 70 (le 1er mars) + 500 en mouvement.
+ assert_eq!(
+ row(&tb, "3000"),
+ (dec!(250), Decimal::ZERO, dec!(570), dec!(820))
+ );
+ // Caisse : −400 + 50 en ouverture ; 70 en mouvement.
+ assert_eq!(
+ row(&tb, "1000"),
+ (dec!(-350), dec!(70), Decimal::ZERO, dec!(-280))
+ );
+ assert!(tb.opening_balanced);
+
+ // La même clôture que le grand livre, compte par compte.
+ let ledger = generate_general_ledger(
+ &pool,
+ seeded.company_id,
+ &LedgerPeriod::new(start, end).expect("période"),
+ &LedgerOptions::default(),
+ )
+ .await
+ .expect("grand livre");
+ for s in &ledger.sections {
+ let r = tb
+ .rows
+ .iter()
+ .find(|r| r.account_id == s.account_id)
+ .expect("tout compte du grand livre figure à la balance");
+ assert_eq!(
+ r.opening_balance, s.opening,
+ "ouverture {}",
+ s.account_number
+ );
+ assert_eq!(r.closing_balance, s.closing, "clôture {}", s.account_number);
+ }
+
+ // Et la clôture d'un compte de bilan, celle du bilan arrêté au 30 juin.
+ let bs = generate_balance_sheet(&pool, seeded.company_id, &period(fy26, start, end))
+ .await
+ .expect("bilan");
+ let caisse = bs
+ .assets
+ .iter()
+ .find(|a| a.account_number == "1000")
+ .expect("caisse au bilan");
+ assert_eq!(row(&tb, "1000").3, caisse.balance);
+}
+
+/// Une période d'un seul jour : l'écriture du jour en mouvement, celle de la
+/// veille en ouverture.
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn une_periode_d_un_jour(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let (_, fy26) = reference_books(&pool, &seeded).await;
+ let day = ymd(2026, 3, 1);
+ let tb = balance(&pool, &seeded, &period(fy26, day, day)).await;
+ assert_eq!(
+ row(&tb, "1000"),
+ (dec!(-350), dec!(70), Decimal::ZERO, dec!(-280))
+ );
+ assert!(tb.opening_balanced);
+}
+
+/// Le premier exercice d'une société : rien n'est reporté.
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn le_premier_exercice_n_a_rien_a_reporter(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let fy = seeded.fiscal_year_id;
+ post(
+ &pool,
+ &seeded,
+ fy,
+ ymd(2026, 3, 1),
+ "1100",
+ "3000",
+ dec!(1000),
+ )
+ .await;
+ let tb = balance(
+ &pool,
+ &seeded,
+ &period(fy, ymd(2020, 1, 1), ymd(2030, 12, 31)),
+ )
+ .await;
+ assert_eq!(tb.retained_earnings, Decimal::ZERO);
+ assert!(tb.rows.iter().all(|r| r.opening_balance.is_zero()));
+ assert_eq!(row(&tb, "1100").3, dec!(1000));
+ assert!(tb.opening_balanced);
+}
+
+/// Une société sans aucune écriture : tout à zéro, contrôle vrai.
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn une_societe_sans_ecriture(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let fy = seeded.fiscal_year_id;
+ let tb = balance(
+ &pool,
+ &seeded,
+ &period(fy, ymd(2020, 1, 1), ymd(2030, 12, 31)),
+ )
+ .await;
+ assert!(
+ !tb.rows.is_empty(),
+ "les comptes actifs sont toujours rendus"
+ );
+ assert!(
+ tb.rows
+ .iter()
+ .all(|r| r.opening_balance.is_zero() && r.closing_balance.is_zero())
+ );
+ assert_eq!(tb.retained_earnings, Decimal::ZERO);
+ assert!(tb.opening_balanced);
+}
+
+/// L'exercice 2 ouvert et encore vide — la première vue d'une année neuve :
+/// ouvertures = clôtures de 2025 pour le bilan, nulles pour le résultat.
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn un_exercice_neuf_encore_vide_montre_le_report(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let (fy25, fy26) = two_years(&pool, &seeded).await;
+ post(
+ &pool,
+ &seeded,
+ fy25,
+ ymd(2025, 3, 1),
+ "1100",
+ "3000",
+ dec!(1000),
+ )
+ .await;
+ post(
+ &pool,
+ &seeded,
+ fy25,
+ ymd(2025, 6, 1),
+ "4000",
+ "1000",
+ dec!(400),
+ )
+ .await;
+ let tb = balance(
+ &pool,
+ &seeded,
+ &period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)),
+ )
+ .await;
+ assert_eq!(
+ row(&tb, "1100"),
+ (dec!(1000), Decimal::ZERO, Decimal::ZERO, dec!(1000))
+ );
+ assert_eq!(row(&tb, "3000").0, Decimal::ZERO);
+ assert_eq!(row(&tb, "4000").0, Decimal::ZERO);
+ assert_eq!(tb.retained_earnings, dec!(600));
+ assert!(tb.opening_balanced);
+}
+
+/// Des pertes cumulées : résultat reporté négatif, contrôle vrai.
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn des_pertes_cumulees(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let (fy25, fy26) = two_years(&pool, &seeded).await;
+ post(
+ &pool,
+ &seeded,
+ fy25,
+ ymd(2025, 3, 1),
+ "1100",
+ "3000",
+ dec!(100),
+ )
+ .await;
+ post(
+ &pool,
+ &seeded,
+ fy25,
+ ymd(2025, 6, 1),
+ "4000",
+ "1100",
+ dec!(900),
+ )
+ .await;
+ let tb = balance(
+ &pool,
+ &seeded,
+ &period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)),
+ )
+ .await;
+ assert_eq!(tb.retained_earnings, dec!(-800));
+ assert!(tb.opening_balanced);
+}
+
+/// Insère une ligne **déséquilibrée** dans une écriture existante, hors
+/// `create_in_tx` (qui garantit la partie double).
+async fn break_entry(pool: &MySqlPool, entry_id: i64, account_id: i64) {
+ sqlx::query(
+ "INSERT INTO journal_entry_lines (entry_id, account_id, line_order, debit, credit) \
+ VALUES (?, ?, 99, 10, 0)",
+ )
+ .bind(entry_id)
+ .bind(account_id)
+ .execute(pool)
+ .await
+ .expect("ligne déséquilibrée insérée");
+}
+
+/// ⛔ Le contrôle d'ouverture TOMBE quand l'égalité est réellement cassée — sur un
+/// compte de bilan, puis sur un compte de résultat (l'écart passe alors par le
+/// résultat reporté). Le rapport est rendu : les mouvements de 2026 restent
+/// équilibrés. Mutation tuée : un contrôle tautologique (toujours vrai).
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn le_controle_d_ouverture_tombe_sur_un_compte_de_bilan(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let (fy25, fy26) = two_years(&pool, &seeded).await;
+ let e = post(
+ &pool,
+ &seeded,
+ fy25,
+ ymd(2025, 3, 1),
+ "1100",
+ "3000",
+ dec!(1000),
+ )
+ .await;
+ break_entry(&pool, e, seeded.accounts["1000"]).await;
+ let tb = balance(
+ &pool,
+ &seeded,
+ &period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)),
+ )
+ .await;
+ assert!(!tb.opening_balanced);
+}
+
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn le_controle_d_ouverture_tombe_sur_un_compte_de_resultat(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let (fy25, fy26) = two_years(&pool, &seeded).await;
+ let e = post(
+ &pool,
+ &seeded,
+ fy25,
+ ymd(2025, 3, 1),
+ "1100",
+ "3000",
+ dec!(1000),
+ )
+ .await;
+ break_entry(&pool, e, seeded.accounts["4000"]).await;
+ let tb = balance(
+ &pool,
+ &seeded,
+ &period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)),
+ )
+ .await;
+ assert!(!tb.opening_balanced);
+}
+
+async fn archive(pool: &MySqlPool, account_id: i64) {
+ sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?")
+ .bind(account_id)
+ .execute(pool)
+ .await
+ .expect("compte archivé");
+}
+
+/// Un compte archivé dont les lignes de la période **se compensent** (net nul),
+/// sans ouverture : il figure — il a été mouvementé. Mutation tuée : une règle
+/// d'inclusion sur le **montant** des mouvements plutôt que sur leur existence.
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn un_compte_archive_aux_mouvements_compenses_figure(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let fy = seeded.fiscal_year_id;
+ post(
+ &pool,
+ &seeded,
+ fy,
+ ymd(2026, 3, 1),
+ "1000",
+ "1100",
+ dec!(100),
+ )
+ .await;
+ post(
+ &pool,
+ &seeded,
+ fy,
+ ymd(2026, 3, 2),
+ "1100",
+ "1000",
+ dec!(100),
+ )
+ .await;
+ archive(&pool, seeded.accounts["1100"]).await;
+ let tb = balance(
+ &pool,
+ &seeded,
+ &period(fy, ymd(2026, 1, 1), ymd(2026, 12, 31)),
+ )
+ .await;
+ assert_eq!(
+ row(&tb, "1100"),
+ (Decimal::ZERO, dec!(100), dec!(100), Decimal::ZERO)
+ );
+}
+
+/// ⛔ Un compte archivé qui porte encore un solde figure à la balance — comptes de
+/// bilan et de résultat. Mutation tuée : la règle d'inclusion d'avant (archivé =
+/// seulement s'il a un mouvement dans la période) — le compte disparaîtrait, sa
+/// clôture ne concorderait plus avec le bilan et le contrôle rougirait.
+#[sqlx::test(migrations = "../kesh-db/test-schema")]
+async fn un_compte_archive_porteur_d_un_solde_figure(pool: MySqlPool) {
+ let seeded = seed_accounting_company(&pool).await.expect("seed");
+ let (_, fy26) = reference_books(&pool, &seeded).await;
+ archive(&pool, seeded.accounts["1100"]).await;
+ archive(&pool, seeded.accounts["3000"]).await;
+
+ // Exercice 2 entier : la banque archivée porte son report et ses mouvements.
+ let p = period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31));
+ let tb = balance(&pool, &seeded, &p).await;
+ let banque = tb
+ .rows
+ .iter()
+ .find(|r| r.account_number == "1100")
+ .expect("banque archivée présente");
+ assert!(!banque.active);
+ assert!(tb.opening_balanced);
+
+ // Juillet : aucun mouvement de la banque ni des ventes ; les deux portent une
+ // ouverture non nulle et doivent figurer.
+ let july = period(fy26, ymd(2026, 7, 1), ymd(2026, 7, 31));
+ let tb = balance(&pool, &seeded, &july).await;
+ assert_eq!(row(&tb, "1100").0, dec!(1700));
+ assert_eq!(row(&tb, "3000").0, dec!(820));
+ assert!(tb.opening_balanced);
+ let bs = generate_balance_sheet(&pool, seeded.company_id, &july)
+ .await
+ .expect("bilan");
+ let b = bs
+ .assets
+ .iter()
+ .find(|a| a.account_number == "1100")
+ .expect("banque au bilan");
+ assert_eq!(row(&tb, "1100").3, b.balance);
+}
diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf
index f68c17a33..77a65c233 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 fe5ed9fcb..50deb098b 100644
--- a/docs/manual/fr/user-manual.tex
+++ b/docs/manual/fr/user-manual.tex
@@ -603,7 +603,7 @@ \subsection{Réouverture d'un exercice clôturé}
On rouvre les exercices \textbf{du plus récent vers l'ancien} : si un exercice postérieur est encore clôturé, Kesh refuse la réouverture et indique lequel rouvrir d'abord. Effectuer cette opération avec parcimonie pour préserver l'intégrité légale.
\end{keshtip}
-\subsection{Reprise de comptabilité — soldes de départ}
+\subsection{Reprise de comptabilité — soldes de départ}\label{sec:soldes-depart}
Si vous migrez vers Kesh depuis un autre logiciel comptable, l'écran \emph{Administration} → \emph{Soldes de départ} (réservé aux rôles Comptable et Administrateur) vous permet de reprendre le bilan de clôture de votre ancienne comptabilité en une seule saisie :
@@ -1556,13 +1556,38 @@ \subsection{Compte de résultat}
\subsection{Balance des comptes}
-\emph{Mensuel} → \emph{Rapports} → \emph{Balance}. Liste tous les comptes avec leurs soldes débiteurs et créditeurs. Filtres : période, niveau de détail (comptes principaux uniquement, ou tous sous-comptes).
+\emph{Mensuel} → \emph{Rapports} → \emph{Balance}. Pour chaque compte, sur la période choisie : le \textbf{solde
+d'ouverture}, les \textbf{mouvements} débit et crédit, et le \textbf{solde de clôture} (ouverture + mouvements).
+
+\begin{itemize}
+ \item \textbf{L'ouverture suit la nature du compte.} Un compte de bilan (actif, passif) reporte son solde depuis
+ l'origine, tous exercices confondus ; un compte de produits ou de charges repart de zéro au début de
+ l'exercice — son ouverture ne compte que ce qui précède la période \emph{dans l'exercice}.
+ \item \textbf{La balance concorde avec le bilan et le grand livre.} La clôture d'un compte de bilan est son solde au
+ bilan à la fin de la période ; celle d'un compte de résultat, son montant \textbf{depuis le début de l'exercice} —
+ égal au compte de résultat quand la période commence au premier jour de l'exercice, puisque le compte de
+ résultat ne porte que les mouvements de la période choisie. Ouverture et clôture sont, dans tous les cas,
+ celles du grand livre (\S\ref{sec:grand-livre}) sur la même période.
+ \item \textbf{La ligne « Résultat reporté (calculé) ».} Kesh ne passe aucune écriture de clôture : le résultat des
+ exercices antérieurs n'est porté par aucun compte. Une ligne calculée le montre, sous les comptes — la même
+ valeur qu'au bilan, « Perte reportée » à l'écran si elle est négative.
+ \item \textbf{Les montants sont signés selon la nature du compte} — positifs du côté naturel (débit pour un actif ou
+ une charge, crédit pour un passif ou un produit). La somme visible des colonnes d'ouverture et de clôture n'est
+ donc pas nulle ; le \textbf{contrôle} affiché sous l'ouverture (une coche) la vérifie en ramenant chaque solde au sens débit, résultat
+ reporté compris. Un avertissement à sa place signale une écriture déséquilibrée antérieure à la période ; le PDF
+ l'écrit en toutes lettres sous les totaux.
+ \item \textbf{Premier exercice} : les soldes de départ saisis à la reprise sont une écriture datée du premier jour
+ de l'exercice (\S\ref{sec:soldes-depart}) — ils apparaissent donc en \emph{mouvements}, et l'ouverture vaut zéro.
+ \item Un compte \textbf{archivé} figure tant qu'il porte un solde ou un mouvement.
+\end{itemize}
+
+Seul filtre : la période.
\subsection{Grand livre}\label{sec:grand-livre}
\emph{Rapports} → \emph{Grand livre}. C'est l'\textbf{extrait d'un compte} : ce qui a fait son
-solde, ligne à ligne. Là où le bilan dit « clients : 84'320.15 » et où la balance dit la même
-chose autrement, le grand livre répond à la question qui suit toujours — \emph{de quoi ce solde
+solde, ligne à ligne. Là où le bilan dit « clients : 84'320.15 » et où la balance clôt ce compte au
+même nombre, le grand livre répond à la question qui suit toujours — \emph{de quoi ce solde
est-il fait ?}
Chaque compte y est encadré par trois lignes sans lesquelles ce n'est pas un extrait : le
@@ -2040,7 +2065,7 @@ \subsection{Glossaire comptable}
\item[Actif] Ce que possède l'entreprise (liquidités, créances, stocks, immobilisations). Classe 1 du plan comptable.
\item[Amortissement] Constatation comptable de la perte de valeur d'une immobilisation sur sa durée d'utilisation.
\item[Avoir (note de crédit)] Document qui annule tout ou partie d'une facture émise (l'inverse d'une facture).
- \item[Balance des comptes] Liste de tous les comptes avec leurs soldes débiteurs et créditeurs à une date donnée.
+ \item[Balance des comptes] Liste des comptes avec, sur une période, leur solde d'ouverture, leurs mouvements et leur solde de clôture.
\item[Bilan] État du patrimoine d'une entreprise à une date donnée (actifs = passifs + capitaux propres).
\item[Capitaux propres] Fonds appartenant aux propriétaires (capital, réserves, résultat reporté). Différence entre actifs et dettes.
\item[Charge] Coût supporté par l'entreprise (achats, salaires, loyer). Classes 4 à 6.
diff --git a/frontend/src/lib/features/reports/TrialBalanceView.svelte b/frontend/src/lib/features/reports/TrialBalanceView.svelte
index f49146447..d4375d614 100644
--- a/frontend/src/lib/features/reports/TrialBalanceView.svelte
+++ b/frontend/src/lib/features/reports/TrialBalanceView.svelte
@@ -1,5 +1,8 @@
@@ -24,25 +39,24 @@
{i18nMsg('reports-error-no-entries-in-period', 'Aucune écriture dans la période sélectionnée.')}
{:else}
-
- {i18nMsg(
- 'reports-trial-balance-period-note',
- 'La balance de vérification affiche le mouvement de la période (par exercice). Le total par compte n’est pas comparable au solde cumulé du même compte au bilan (report à-nouveau depuis l’origine).',
- )}
-
| {i18nMsg('reports-column-account-number', 'N°')} |
{i18nMsg('reports-column-account-name', 'Intitulé')} |
+ {i18nMsg('reports-column-opening', 'Ouverture')} |
{i18nMsg('reports-column-debit', 'Débit')} |
{i18nMsg('reports-column-credit', 'Crédit')} |
- {i18nMsg('reports-column-balance', 'Solde')} |
+ {i18nMsg('reports-column-closing', 'Clôture')} |
{#each dto.rows as r (r.accountId)}
-
+
|
{i18nMsg('reports-archived-label', 'archivé')}{/if}
|
+ {fmt(r.openingBalance)} |
{fmt(r.totalDebit)} |
{fmt(r.totalCredit)} |
- {fmt(r.balance)} |
+ {fmt(r.closingBalance)} |
{/each}
+
+ |
+ {retainedLabel} |
+ {fmt(dto.retainedEarnings)} |
+ |
+ |
+ {fmt(dto.retainedEarnings)} |
+
| {i18nMsg('reports-grand-total', 'Total général')} |
+
+ {dto.openingBalanced ? '✓' : '⚠️'}
+ |
{fmt(dto.totalDebit)} |
{fmt(dto.totalCredit)} |
-
+ |
{dto.balanced ? '✓' : '⚠️'}
|
diff --git a/frontend/src/lib/features/reports/TrialBalanceView.test.ts b/frontend/src/lib/features/reports/TrialBalanceView.test.ts
new file mode 100644
index 000000000..29af85c96
--- /dev/null
+++ b/frontend/src/lib/features/reports/TrialBalanceView.test.ts
@@ -0,0 +1,101 @@
+// Story 25-5-b (#385) — tests Vitest pour TrialBalanceView.svelte : ouverture,
+// mouvements, clôture, la ligne calculée du résultat reporté et le contrôle
+// d'ouverture. i18nMsg est mocké pour renvoyer le fallback.
+
+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) => fallback,
+}));
+
+import TrialBalanceView from "./TrialBalanceView.svelte";
+import type { TrialBalanceDto } from "./reports.types";
+
+function makeDto(overrides: Partial = {}): TrialBalanceDto {
+ return {
+ period: { fiscalYearId: 1, startDate: "2026-01-01", endDate: "2026-12-31" },
+ rows: [
+ {
+ accountId: 1,
+ accountNumber: "1100",
+ accountName: "Banque",
+ accountType: "Asset",
+ active: true,
+ openingBalance: "1000",
+ totalDebit: "500",
+ totalCredit: "0",
+ balance: "500",
+ closingBalance: "1500",
+ },
+ ],
+ totalDebit: "500",
+ totalCredit: "500",
+ balanced: true,
+ retainedEarnings: "600",
+ openingBalanced: true,
+ ...overrides,
+ };
+}
+
+describe("TrialBalanceView — Story 25-5-b", () => {
+ it("affiche les colonnes Ouverture et Clôture, sans « Solde » ni la note retirée", () => {
+ render(TrialBalanceView, { dto: makeDto() });
+ const headers = screen
+ .getAllByRole("columnheader")
+ .map((h) => h.textContent?.trim());
+ expect(headers).toEqual([
+ "N°",
+ "Intitulé",
+ "Ouverture",
+ "Débit",
+ "Crédit",
+ "Clôture",
+ ]);
+ expect(screen.queryByText(/mouvement de la période/)).toBeNull();
+ });
+
+ it("porte l’ouverture et la clôture de chaque compte, dans l’ordre des colonnes", () => {
+ render(TrialBalanceView, { dto: makeDto() });
+ const cells = Array.from(
+ screen.getByTestId("tb-row").querySelectorAll("td"),
+ ).map((c) => c.textContent?.replace(/\s+/g, " ").trim());
+ expect(cells[2]).toMatch(/1.?000\.00/);
+ expect(cells[5]).toMatch(/1.?500\.00/);
+ });
+
+ it("rend la ligne calculée du résultat reporté (ouverture = clôture)", () => {
+ render(TrialBalanceView, { dto: makeDto() });
+ const row = screen.getByTestId("tb-retained");
+ expect(row.textContent).toContain("Résultat reporté (calculé)");
+ const cells = Array.from(row.querySelectorAll("td")).map((c) =>
+ c.textContent?.trim(),
+ );
+ expect(cells[2]).toBe(cells[5]);
+ expect(cells[3]).toBe("");
+ });
+
+ it("bascule sur « Perte reportée » quand le résultat reporté est négatif", () => {
+ render(TrialBalanceView, { dto: makeDto({ retainedEarnings: "-800" }) });
+ expect(screen.getByTestId("tb-retained").textContent).toContain(
+ "Perte reportée",
+ );
+ });
+
+ it("signale une ouverture déséquilibrée, indépendamment des mouvements", () => {
+ render(TrialBalanceView, { dto: makeDto({ openingBalanced: false }) });
+ expect(screen.getByTestId("tb-opening-check").textContent?.trim()).toBe(
+ "⚠️",
+ );
+ expect(screen.getByTestId("tb-movements-check").textContent?.trim()).toBe(
+ "✓",
+ );
+ });
+
+ it("affiche ✓ quand l’ouverture s’équilibre", () => {
+ render(TrialBalanceView, { dto: makeDto() });
+ expect(screen.getByTestId("tb-opening-check").textContent?.trim()).toBe(
+ "✓",
+ );
+ });
+});
diff --git a/frontend/src/lib/features/reports/reports.types.ts b/frontend/src/lib/features/reports/reports.types.ts
index a32ef98c8..58635615b 100644
--- a/frontend/src/lib/features/reports/reports.types.ts
+++ b/frontend/src/lib/features/reports/reports.types.ts
@@ -56,9 +56,14 @@ export interface TrialBalanceRow {
accountName: string;
accountType: AccountType;
active: boolean;
+ /** Story 25-5-b — solde au début de la période, signé selon la nature du compte. */
+ openingBalance: string;
totalDebit: string;
totalCredit: string;
+ /** Net des mouvements de la période — pas un solde de compte. */
balance: string;
+ /** Story 25-5-b — ouverture + mouvements : pour un compte de bilan, son solde au bilan. */
+ closingBalance: string;
}
export interface TrialBalanceDto {
@@ -67,6 +72,10 @@ export interface TrialBalanceDto {
totalDebit: string;
totalCredit: string;
balanced: boolean;
+ /** Story 25-5-b — résultat reporté calculé (exercices antérieurs), crédit-positif. */
+ retainedEarnings: string;
+ /** Story 25-5-b — Σ des ouvertures en sens débit − résultat reporté = 0. */
+ openingBalanced: boolean;
}
export type Journal = 'Achats' | 'Ventes' | 'Banque' | 'Caisse' | 'OD';
diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts
index 723844b02..eb8e271ef 100644
--- a/frontend/src/lib/shared/i18n-keys.test.ts
+++ b/frontend/src/lib/shared/i18n-keys.test.ts
@@ -349,7 +349,14 @@ const ATTENDU = {
// (+4) — la section « Différences d'arrondi » : titre, aide, libellé du
// sélecteur (trois clés neuves `settings-invoicing-rounding-*` dans les quatre
// catalogues) et son option vide, qui réemploie `settings-invoicing-select-none`.
- sitesTotal: 1756,
+ //
+ // Story 25-5-b (#385) : **1756 → 1758**, recompté par ce test et par
+ // `grep -c "i18nMsg("` aux deux bornes de `reports/TrialBalanceView.svelte`
+ // (11 → 13) — la note « mouvement de la période » retirée (−1, clé supprimée des
+ // 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,
sitesGabarit: 10,
diff --git a/frontend/src/lib/shared/i18n-libelle-en-dur.test.ts b/frontend/src/lib/shared/i18n-libelle-en-dur.test.ts
index 3b07559da..6bc62fdeb 100644
--- a/frontend/src/lib/shared/i18n-libelle-en-dur.test.ts
+++ b/frontend/src/lib/shared/i18n-libelle-en-dur.test.ts
@@ -140,8 +140,15 @@ const SUFFIXES = ['Label', 'Text', 'Display'];
* à `i18nMsg` (`reconciliation-cancel-confirm-invoice` / `-entry`) : aucun littéral, donc
* `conforme` **37 → 38** ; `ecartee` ne bouge pas. *Identifiée par la même recherche des
* trois suffixes dans les fichiers de la story — la seule déclaration neuve qui en porte un.*
+ *
+ * ⚠️ **44 → 45, et la déclaration est NOMMÉE** : `retainedLabel`
+ * (`lib/features/reports/TrialBalanceView.svelte`), né avec la ligne calculée du résultat
+ * reporté de la Story 25-5-b (#385). Ses deux branches délèguent à `i18nMsg`
+ * (`reports-retained-earnings-loss` / `-calculated`) : aucun littéral, donc `conforme`
+ * **38 → 39** ; `ecartee` ne bouge pas. *Identifiée par la même recherche des trois
+ * suffixes dans les fichiers de la story — la seule déclaration neuve qui en porte un.*
*/
-const CANDIDATES_ATTENDUES = 44;
+const CANDIDATES_ATTENDUES = 45;
/** Les trois délimiteurs de littéral en JS/TS. */
const QUOTES = ["'", '"', '`'];
@@ -658,7 +665,7 @@ describe('libellés en dur — l’angle mort #255', () => {
else if (c.retours.length > 0) classes.ecartee += 1;
else classes.conforme += 1;
}
- expect(classes).toEqual({ nonAnalysee: 0, enViolation: 0, ecartee: 6, conforme: 38 });
+ expect(classes).toEqual({ nonAnalysee: 0, enViolation: 0, ecartee: 6, conforme: 39 });
// La somme est recalculée depuis les classes, jamais depuis le total qu'elle contrôle.
const somme = Object.values(classes).reduce((a, b) => a + b, 0);
expect(somme).toBe(CANDIDATES_ATTENDUES);
diff --git a/frontend/tests/e2e/reports.spec.ts b/frontend/tests/e2e/reports.spec.ts
index b4fbb95e8..ed27bb44d 100644
--- a/frontend/tests/e2e/reports.spec.ts
+++ b/frontend/tests/e2e/reports.spec.ts
@@ -399,6 +399,15 @@ test('grand livre : une écriture postée réapparaît dans l’extrait de son c
await page.getByRole('button', { name: /générer/i }).click();
const lien = page.getByRole('link', { name: debitNumber }).first();
await expect(lien).toBeVisible({ timeout: 5000 });
+ // Story 25-5-b (#385) — la balance porte l'ouverture et la clôture, et la
+ // ligne calculée du résultat reporté : les champs neufs traversent la frontière.
+ const panel = page.getByRole('tabpanel');
+ await expect(page.getByTestId('tb-col-opening')).toBeVisible();
+ await expect(page.getByTestId('tb-col-closing')).toBeVisible();
+ // Six colonnes : la colonne « Solde » (net des mouvements) a disparu.
+ await expect(panel.getByRole('columnheader')).toHaveCount(6);
+ await expect(page.getByTestId('tb-retained')).toBeVisible();
+ await expect(page.getByTestId('tb-opening-check')).toHaveText('✓');
await lien.click();
await expect(page).toHaveURL(/tab=general-ledger/);
await expect(page).toHaveURL(new RegExp(`accountId=`));