fix(import): read amounts with an anchored parser and a real debit/credit rule #336
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#336
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-325-amount-parsing"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Link 4 of the ten-link import-format stack. Based on
issue-324-persist-import-format, not onmain.Three ways an imported amount could be silently wrong, all of them passing validation.
The debit/credit rule
useImportWizard.ts:509wasamount = isNaN(credit) ? -(isNaN(debit) ? 0 : debit) : credit— the credit always won. Many banks write0,00in the unused column rather than leaving it empty, andisNaN(0)is false, so every debit of such a file imported as 0,00 and the expense simply vanished with no error anywhere.It is
credit - debiton magnitudes now. That needs no special case for a0,00cell (zero is the identity of the subtraction) andMath.absimplements the documented convention even when an export negates its debits. A row unreadable in both columns is an error rather than a free 0,00 transaction.The anchored parser
parseFrenchAmountended onparseFloat, which returns the longest valid prefix instead of rejecting. Measured before the fix:50,00-1 234,56 CR100,00 CAD(50,00)2024 MontantA factor-100 error that passes
isNaN, so those rows counted as VALID everywhere downstream — which would have defeated the signed preview of #329, the safety net of the whole chantier.Why NaN and not the rescued magnitude for
CR/CAD, where link 1's test titles said "should be 1234.56" / "should be 100" (its own block header said NaN, and so do the issue and the plan):CR/DBcarry a direction, not noise. Returning a magnitude for both would trade a loud failure for a silent sign error — the exact bug class this chantier exists to remove. The absolute-amount + D/C-indicator shape is refused upstream by design (#328)."100,00 CAD"and"2025 Montant"are structurally identical (digits, space, letters). Whitelisting a trailing word to rescue the first re-blindsdetectHeaderon the second — the defect link 1 measured.Parentheses
(50,00)and a trailing sign50,00-are supported as the two legitimate accounting forms.The
?? 0fallbacksThey read column 0 — usually the date — when the mapping was incomplete. An unmapped amount column is an explicit row error now, reported ahead of any per-row problem, since it is a format error affecting every row identically.
Column-level separator
1.234is 1234 in a French column and 1.234 in an English one, and no rule applied to that cell alone can tell.detectDecimalSeparatorarbitrates from the decisive siblings of the column;parseFrenchAmounttakes the verdict as an option;detectAmountSeparatorsruns it once per file over the columns the format declares. Detection deliberately stays out of it — it runs before a column is known to be an amount column at all — so the verdict is applied where the value actually becomes a transaction.mapRowextractedThe rule leaves the hook as a pure
mapRow(raw, format, options?)inimportFormat.ts. The prescribed two-argument call works as specified; the optional third carries what only the caller knows (rowIndex,sourceFilename, the column separators). This is what lets the corpus tests run the real rule — the hand-writtenmapCorpusRowmirror and the static guard pinning fiveparseFilesInternalexpressions are both deleted, as link 1 and link 3 instructed — and what stops #328's score and #329's preview each re-implementing it.Global hardening, including holdings
The parser is shared by 11 call sites: 8 in
csvAutoDetect.ts, 3 in the #245 holdings CSV import. A price cell150,25 CADused to store 15025; it is refused now andbuildDetailedLinesraises on the empty price. An unreadable quantity was worse — coerced to 0, so a zero-value position saved in silence. The draft keeps the offending text instead and the existing typedsnapshot_priced_quantity_requiredfires; one unreadable lot taints the merged quantity rather than being summed as 0. Covered by two new tests.i18n
Row errors were raw English literals rendered straight into the preview table. Since this adds a user-visible string ("amount column not mapped"), all four become
import.rowErrors.*keys in both locales. The report table also carries raw exception messages, so both render sites resolve throughisRowErrorKeyand never feedt()anything that is not ours.Test churn
Per link 1's handoff — update the expectation, drop the marker, never delete the test:
#325:parseFloat prefix scan,unsupported accounting forms,separator arbitrated per cell(amountParser.test.ts);unused column filled with 0,00(csvAutoDetect.test.ts).#328:header cell starting with digits. Its own comment reads "#328 adds a lexical signal todetectHeader, and #325 anchors the parser so a numeric prefix no longer reads as a number. Either fix closes this." The anchored parser landed first; the block records that and leaves #328 its lexical signal for header cells carrying a bare number.debit-credit-reversedandabsolute-indicator(#328),all-positive(#329), and link 3'suseImportWizardsource guards inimportFormat.test.ts(no callback was renamed or reordered).989 vitest (963 before),
tsc+vite buildclean,cargo checkclean. No DB migration. CHANGELOG anddocs/stay centralized in the last link of the stack, asspec-plan-import-csv-format.mdspecifies.Resolves #325
Generated autonomously by /autopilot run of 2026-08-13
Revue adversariale — PR #336
Verdict : REQUEST_CHANGES — 1 blocage, 6 suggestions.
Résumé
Le fond de la PR est juste et bien argumenté. J'ai rejoué le parseur ancré et
mapRowhors du dépôt (bundle esbuild des fichiers de la branche) plutôt que de lire le diff : les trois erreurs facteur-100 annoncées sont fermées ("50,00-"→ -50,"1 234,56 CR"→ NaN,"100,00 CAD"→ NaN), les parenthèses comptables et le signe traînant marchent,credit − debitsur des magnitudes rend le cas0,00trivial, et l'extraction demapRowen fonction pure est le bon geste — les tests corpus tournent enfin la vraie règle.Sur la décision contestée (
CR/CAD→ NaN plutôt que la magnitude sauvée), je tranche pour le worker. Les deux arguments tiennent à la vérification :CR/DBporte une direction. Sauver la magnitude échangerait un échec bruyant contre une erreur de signe silencieuse — la classe de bug que ce chantier existe pour retirer."100,00 CAD"et"2025 Montant"sont structurellement identiques, et c'est mesurable : le même ancrage qui refuse le premier fait passer la fixtureheader-numeric-labeldehasHeader: falseàtrue. Une whitelist de mot traînant ré-aveugleraitdetectHeader.Et le refus est découvrable : ligne surlignée en rouge dans
FilePreviewTable, message traduit, ligne brute affichée à côté,errorCountpuis la liste complète dansImportReportPanel. Les titres de test du maillon 1 avaient tort, l'en-tête du même bloc avait raison ; la PR le documente correctement.Vérifié sans reproche par ailleurs : le flux holdings dégrade proprement (le texte fautif reste dans la cellule, le total live saute la ligne au lieu d'afficher
NaN,buildDetailedLineslève unBalanceServiceErrortypé attrapé parsave()et traduit dans les deux locales) ; les gardes statiques du maillon 3 dansimportFormat.test.tssont intactes ; la garde supprimée est remplacée par quatre assertions négatives plus fortes ; 4 clés i18n présentes en FR et EN ; aucunskip/only; aucune migration touchée ; CI frontend verte.Un seul blocage, mais il porte sur l'invariant central de la PR.
Blocages
1.
src/utils/importFormat.ts:303-308— une cellule illisible à côté du remplissage0,00importe 0,00 sans aucune erreur.En mode
debit_credit, la règle n'échoue que si les deux cellules sont illisibles :Quand une seule des deux est illisible, elle est traitée en silence comme absente. Or la colonne inutilisée porte
0,00— c'est précisément la forme de fichier que cette PR existe pour corriger — donc la sœur parse toujours, et la ligne sort àamount = 0,parsednon nul, comptée VALIDE.Rejoué sur la fixture
unused-column-zero.csvde la PR, seul changement : un suffixe de devise sur la colonne utilisée (une forme que cette PR vient elle-même de rendre illisible).Six transactions réelles entrent au grand livre à 0,00 $, sans une seule erreur. C'est le symptôme exact décrit dans le corps de la PR — « every debit of such a file imported as 0,00 and the expense simply vanished with no error anywhere » — sur la même forme de fichier, par une autre porte. Le docstring de
mapRow(importFormat.ts:253) énonce d'ailleurs le bon principe, plus large que le code : « an amount nobody could read must not enter the ledger as a free transaction ».Cette PR amplifie le trou au lieu de le subir. L'arbitrage par colonne qu'elle introduit fabrique des NaN qui n'existaient pas :
La même ligne avec une cellule vide au lieu de
0,00échoue correctement (ERR import.rowErrors.invalidAmount). Seul le remplissage0,00masque la panne. Autres déclencheurs mesurés, tous à 0 erreur : marqueur de note84,32*, indicateur84,32 DB, code de devise.Correctif : une cellule mappée non vide qui ne parse pas est une erreur, quelle que soit sa sœur. Une cellule vide reste « absente », donc le correctif
0,00de la PR et les fixturesdebit-credit/unused-column-zeropassent inchangés.Test de régression à ajouter — il échoue sur la branche actuelle :
Le bloc
mapRow — debit/credit is a subtractionne couvre aujourd'hui que["", ""]et["n/a", "-"]: les deux cas où les deux cellules tombent. Le cas mixte n'est testé nulle part, ce qui explique que la CI soit verte.Suggestions (non bloquantes)
1.
importFormat.ts:340— arbitrer débit et crédit ensemble.Les deux colonnes sont la même grandeur, écrite par la même banque, dans la même notation ; elles reçoivent pourtant deux verdicts indépendants. Une colonne débit entièrement ambiguë (
1.234,2.500) ne reçoit aucun verdict alors que la colonne crédit voisine prouve la convention française — le débit se lit alors 1,234 au lieu de 1234, silencieusement. Mutualiser les cellules des deux colonnes en un seul vote ferme ce cas.2.
amountParser.ts:102vs:106— asymétrie du nombre de décimales.La branche virgule est plafonnée à 2 décimales (
decimalRe(",", 2)), la branche point ne l'est pas (decimalRe(".")). Conséquence :"1,2345"→ NaN mais"1.2345"→ 1.2345. Même plafond dansdetectDecimalSeparator(:186), donc une colonne française à 3+ décimales ne vote pour rien et chacune de ses cellules est refusée. Ça mord sur l'import de titres (#245) : quantités de parts de fonds et de crypto sont couramment à 3-8 décimales. Le comportement précédent était pire (silencieusement faux), donc pas de régression, mais le plafond mériterait d'être symétrique.3. Fichier à convention réellement mixte.
Le verdict majoritaire s'applique en silence et les cellules minoritaires deviennent des erreurs de ligne isolées ; rien ne dit à l'utilisateur qu'un verdict de colonne a été rendu ni lequel. Une mention dans l'aperçu (« colonne lue en notation française ») rendrait la sévérité lisible. Ex. : 3 cellules
84,32+ 1 cellule1,234.56→ la dernière refusée sans explication.4.
useSnapshotEditor.ts:634— libellé du refus de quantité.Le code réutilisé est
snapshot_priced_quantity_required, rendu « La quantité est obligatoire pour les comptes cotés. » alors que le cas est « quantité présente mais illisible ». Le texte fautif reste visible dans la cellule, donc c'est actionnable, mais un code dédié serait plus clair.5. Documentation du refus.
Un relevé dont les montants portent un suffixe de devise devient entièrement non importable, sans échappatoire dans l'UI. C'est le bon défaut, mais il faut que le CHANGELOG et le guide utilisateur du dernier maillon le disent explicitement — et un suivi mériterait d'être ouvert pour une option de colonne « suffixe à ignorer », ou pour le rattachement au support d'indicateur D/C de #328.
6. Ligne fantôme.
debit="0,00",credit=""importe une transaction à 0,00 sans erreur. Préexistant et hors périmètre du blocage ci-dessus, mais c'est une ligne qui n'a aucune raison d'entrer.Revue adversariale — parseur et
mapRowrejoués hors dépôt sur les fichiers de la branche, pas seulement lus.Blocage levé — corrigé sur la tête de pile
Une cellule mappée non vide qui ne parse pas fait désormais échouer la ligne, quelle que soit sa sœur. Une cellule vide garde son sens (« cette colonne ne s'applique pas à cette ligne ») et contribue zéro.
Le correctif est le commit
484c4besurissue-332-docs-adr-changelog, la tête de la pile, plutôt que sur cette branche : la chaîne se merge en--ff-onlyd'un seul tenant, et corriger ici aurait imposé un rebase en cascade de toute la descendance. L'attribution reste lisible — le commit porteRefs #325.Vérifié par mutation : annuler le correctif fait rougir un test dédié. Un garde qui ne peut pas échouer ne garde rien.
Suite complète verte : 1181 vitest, build tsc + vite,
cargo check.Mergée dans
mainen fast-forward avec le reste de la pile (tip37b832e).Forgejo ne détecte pas un merge local comme merged — la PR est donc fermée à la main, et l'issue liée s'est fermée automatiquement via son
Resolves #N.Tip cumulé validé avant push : 1181 vitest, 111 tests Rust, build tsc + vite. La CI ne tourne pas sur push
main, cette validation locale était donc le seul filet.Pull request closed