Compare commits

..

44 commits

Author SHA1 Message Date
le king fu
e5c188e2d8 state: close #314 — audit.yml scheduler was never broken
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:42:38 -04:00
le king fu
d6be676b22 state: sync after #321/#322 merge (advisories resolved, QA checklist landed)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:15:54 -04:00
le king fu
97d376b83c docs(qa): fix the Linux state sequence the checklist had backwards
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 9m14s
`downloadAndInstall` is one call that downloads AND installs
(updater.rs:723-729), and the `Finished` event is a no-op in
useUpdater.ts:119-121. `READY_TO_INSTALL` is therefore dispatched only
after `dpkg -i` returns, so the pkexec prompt opens while the card still
reads "Téléchargement en cours…".

The checklist told the tester to tick `readyToInstall` before the prompt.
That ordering neutralized the polkit precondition the page exists to
enforce: with no agent the app hangs in `downloading`, not in
`readyToInstall`, so a tester following the checklist files "the download
stalls" instead of "no polkit agent" — the exact false trace this page is
written to prevent.

Also states plainly that at `readyToInstall` the .deb is already on disk
and the button only calls `relaunch()`; the label misleads on this target.

Three smaller corrections from the same review:

- The update triggers are four manual sites, not two: UpdateCard idle /
  upToDate refresh / error retry, plus ErrorPage.tsx:82 — which only
  detects and offers no download path. The load-bearing claim, "no
  automatic check in the app", was already right.
- "Si ça casse" now spells out that DELETE and PUT hit different API
  prefixes (/api/v1/packages/ vs /api/packages/, release.yml:217-219,
  which carries a comment about exactly this). Replaying both against one
  URL 404s at the worst possible moment.
- The SKILL.md changelog entry moves back into date order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:51:51 -04:00
le king fu
f6418f79cd docs(release): add a real update-cycle QA checklist to step 9
Step 9 inspected latest.json and stopped there. That proves the file is well
formed, not that it installs: the TLS download, signature verification,
installer execution and relaunch are exercised by no test at all — cargo check
and cargo test only prove that code compiles. A regression there surfaces at
update time, on a user's machine, and automatic updates are a Base+ feature.

Written as a manual checklist rather than automation because it needs two real
desktop environments. The value is in the preconditions, which are what make
the difference between running the test and only appearing to:

- Test from a machine still on the PREVIOUS version. check() compares
  release.version > current strictly, so testing on the machine that just
  built the release shows "up to date" and the checklist gets ticked as
  "nothing to update" — the silent skip it exists to prevent, now with a
  paper trail claiming it passed.
- A Base+ key on each machine, with any activation.token from another machine
  removed. Auto-update is entitlement-gated: useUpdater dispatches NOT_ENTITLED
  before check() ever runs, and a token bound to a different machine_id
  silently resolves the edition back to Free.
- A live polkit agent on Linux, and record which prompt actually appeared —
  install_deb cascades pkexec -> zenity/kdialog -> terminal sudo, so without an
  agent the app appears to hang instead of failing.

The two target flows are written separately because they genuinely differ: on
Windows downloadAndInstall never returns (the process exits and NSIS takes
over, so readyToInstall/installing never render), while on Linux the app stays
alive through a polkit prompt and a restart control.

The doc states what the test does NOT prove: `tar` stays unexercised. Its
vulnerable path is only reached by install_appimage and the macOS .app.tar.gz
branch; our .deb goes through `pkexec dpkg -i` and our NSIS path launches the
.exe. That corrects #315's own premise.

The failure path is documented as an incident playbook rather than a
"rollback", because it is not one. Republishing the previous latest.json stops
propagation, but check()'s strict comparison means users already on the broken
version are never offered the older one — the real fix is a vN+1.

rpm is recorded as known-broken, not unverified, and tracked in #320.

Resolves #315

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:51:51 -04:00
le king fu
2d4caecae8 fix(deps): resolve the quick-xml advisories, unyank deep-link and spin
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 9m23s
The removal trigger #312 was written for had already fired — I filed the issue
without checking whether a newer plist existed. plist 1.10.0 ships quick-xml
0.41.0, which carries the fix, within tauri's existing bound:

    cargo update -p plist -> plist 1.8.0 -> 1.10.0
                             quick-xml 0.38.4 -> 0.41.0

So RUSTSEC-2026-0194 and -0195 are resolved rather than accepted, and leave
.cargo/audit.toml the day they entered it. rsa is now the only entry, and the
guard loops on that crate alone; its rationale comment is re-pointed
accordingly, since it was written entirely around quick-xml/plist.

Also bumps the two yanked crates (#313). tauri-plugin-deep-link 2.4.8 -> 2.4.9:
upstream's 2.4.9 is a single commit, "Fix broken iOS custom URL schemes", so
the defect behind the yank is iOS-only and never reached this desktop app —
v0.14.0 shipping 2.4.8 was not a user-facing problem, which is why neither
Security nor Fixed applies to it in the changelog. spin 0.9.8 -> 0.9.9; every
0.9.x up to 0.9.8 is yanked, which reads as a bulk yank rather than a defect.

The #310 changelog bullet is amended rather than contradicted: it sits in the
same unreleased section and would otherwise ship two opposing claims in the
same release notes. Two of its statements were wrong. It said three advisories
remained (now one), and it said tar sits on "real code paths in the shipped
app" — tar is compiled, but its vulnerable extraction path is only reached by
the AppImage and macOS installers this project does not bundle. The
rustls-webpki half stands: TLS runs on every update check.

ADR 0018's decision is untouched; an amendment header marks the passages that
are now historical, including the "override is impossible" alternative, which
plist 1.10.0 made false the same day.

cargo audit from the repo root: 0 vulnerabilities, warnings 23 -> 21 (the two
yanked ones). From src-tauri/ it reports 1 — that is the cwd sensitivity of
.cargo/audit.toml, not a regression. Guard: 2 checks + canary, exit 0.
cargo check + cargo test green (106 tests). Lock diff: 4 packages, 666 before
and after.

Resolves #312
Resolves #313

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:51:46 -04:00
le king fu
89149d06a9 state: sync after import CSV chantier (#323-#332 merged, migration v17)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:15:35 -04:00
le king fu
37b832e084 fix(import): stop a generic bank signature from claiming, and misreading, a richer file
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
Review finding on #330, two defects with one root cause.

Desjardins' fingerprint is date/description/montant/solde — four labels any
Canadian bank could emit — and MIN_SIGNATURE_LABELS = 4 did not deliver the
property it promised, because matching was by SUBSET. Any
Date;Description;Montant;Solde file was announced 'Format Desjardins reconnu'.
A variant built only from generic labels must now describe the header exactly;
one carrying a discriminating label (chequenumber, categorie, memo) keeps
subset matching, so extra columns stay fine once something identifies the bank.

Worse, a matched signature's single amount column short-circuited the
sparse-complementary scan instead of being arbitrated against it. A
Date;Description;Debit;Credit;Montant;Solde file reads correctly as debit/credit
before #330 and became one unsigned column after, importing every deposit as an
expense. The scan now runs first; a signature's amount column only wins when the
pair contains it — which is what RBC's genuine Cheque Number / CAD$ case needs,
and it still passes.

Refs #330

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:03:07 -04:00
le king fu
ef7de3cf9b fix(import): veto a labelled description column that behaves like an enum
Review finding on #327. detectDescriptionColumn returned the lexically
preferred column with no check on the data, unlike the date (replayed at 0.8)
and the amount (constrained to the shape candidates). The dictionary lists
'transaction' as a description keyword, and Tangerine exports
Date,Transaction,Name,Memo,Amount where Transaction holds DEBIT/CREDIT — so the
description moved off the merchant name and keyword categorisation died.

Cardinality tells free text from an enum: a description repeats almost nothing,
an enum repeats almost everything. Average length does not — Note and Libelle
are both short, so a length veto would reject legitimate columns.

This fixes the cause. #330 had rescued the case through the Tangerine signature
alone, leaving every unrecognised file with a Transaction column broken; that
test now asserts the correct mapping with and without a signature.

Refs #327

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:02:59 -04:00
le king fu
484c4beb47 fix(import): fail a row whose amount cell is unreadable beside a 0,00 sibling
Review finding on #325. The debit/credit rule tested isNaN(debit) && isNaN(credit),
which only caught the case where BOTH sides fell. The unused column carries 0,00
in exactly the files this rule exists to fix, so an unreadable debit beside a
0,00 credit computed 0 - 0 = 0 and imported silently — the very bug, one cell
over. Replayed on the PR's own unused-column-zero fixture with a currency
suffix: 6 transactions imported at 0,00 with no error row.

A mapped cell that is not empty but does not parse now fails the row whatever
its sibling holds. An EMPTY cell keeps meaning 'this column does not apply to
this row' and contributes zero, which is the normal shape of the format.

Refs #325

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:02:52 -04:00
le king fu
e2b8eb8b22 docs: architecture, ADR 0019, user guide and changelog for the import format
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m38s
Last link of the ten-link import-format stack. Links 1-9 deliberately wrote
no changelog and no documentation, to avoid a conflict at every level of a
linear pile; this link owes all of it.

- ADR 0019 records the structuring decision: the import format is a fully
  persisted value, never re-inferred. It documents the three independent paths
  by which it used to be lost (hardcoded restore, header drift, data export),
  and why a single codec with a completeness test closes the class rather than
  a composed type -- the two carriers are structurally incompatible
  (`has_header` is boolean on one and number on the other). `template_id` is
  recorded as a provenance label, never re-read as format.
- `docs/architecture.md` gains a dedicated "Import CSV" section covering the
  codec, the lexical detection layer and its separate dictionary module, the
  bank signatures, the now-mandatory preview step, and sources/templates in
  the SREF envelope. Migration v17 and its four CHECK-guarded columns are
  listed in the migrations table.
- Stale counts corrected against the tree, not by arithmetic: 16 -> 17
  migrations (both files), `src/components/import/` 13 -> 14, `src/utils/`
  4 -> 13. Tables (20) and indexes (24) were re-measured and are NOT stale --
  v17 is an ALTER TABLE only -- so they are left as they are, with the reason
  written down. ADR 0018, missing from the ADR table, is added.
- The user guide and the `docs.*` keys in both locales carry the same new
  import journey: automatic detection, confidence score, mandatory preview
  with its signed recap, sign inversion, recognised bank formats, drift panel,
  and the safe repair path.
- Both changelogs carry the same entries, translated, verified section by
  section including issue references. The `public/` copies sync automatically
  via `syncChangelogs()`.

1176 vitest green, build clean, cargo check clean. No DB migration.

Resolves #332

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:13:25 -04:00
le king fu
f377d760af fix(export): preserve import sources and templates across data export/import
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
Exporting then re-importing data destroyed every import configuration:
dataExportService serialised only categories, suppliers, keywords and
transactions, then ran DELETE FROM import_sources on restore and replaced
them with a synthetic 'Data Import' source. After restoring a backup, every
source had to be reconfigured by hand.

- Serialise import_sources and import_config_templates into the envelope,
  with an explicit format_version; a file without one is the earlier format
  and its missing arrays are treated as empty.
- Wrap wipe + restore in withTransaction, which the service had nowhere:
  a constraint violation mid-restore used to destroy financial history with
  no rollback.
- Restore templates BEFORE sources (template_id is a foreign key), upserting
  by name and remapping template_id through the resolved ids, so restoring
  into a profile that already has templates no longer hits UNIQUE(name).
- Whitelist amount_mode and sign_convention at the import boundary with a
  readable message rather than an SQLite constraint error.

Resolves #331

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 10:58:52 -04:00
le king fu
c9872fc36b feat(import): recognise known bank layouts and report format drift
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
Two failure modes, both anchored on the header row.

A KNOWN BANK IS NOW READ BY NAME. `bankSignatures.ts` declares the
documented export layout of Desjardins, RBC, Banque Nationale and
Tangerine as a set of normalized header labels plus a delimiter and a
preamble quirk. The table is evaluated BEFORE the generic dictionary and
an unknown file falls straight through to it, unchanged.

The signatures are not decorative. The generic dictionary matches
keywords as substrings, one role at a time, which reads two of these
four layouts wrong — both frozen as counterfactual test pairs, same
rows, header renamed:

  - Tangerine writes `Date,Transaction,Name,Memo,Amount`. `Transaction`
    is a description keyword, so every row of the file was labelled with
    its direction word instead of the merchant.
  - RBC writes its amount column `CAD$`, which no amount keyword
    matches, next to a nearly empty `Cheque Number`. Those two are
    sparse-complementary, so the shape scan paired them as debit/credit
    and the one row carrying a cheque number imported as -247.95 instead
    of -6.95.

A signature stays a set of PREFERENCES all the same: they are written
from documented layouts, without real statements, so every hint is
dropped the moment the data contradicts it. The single exception is the
amount mode, which outranks the sparse-complementary scan — nothing
inside an RBC file can tell that pair from a genuine one — and even that
is refused unless the declared columns are candidates the shape scan
proposed. Failing degrades to the generic path; it never breaks.

FORMAT DRIFT IS NOW REPORTED INSTEAD OF IMPORTED. Every successful
import records the normalized labels of its header row in
`import_sources.header_signature`, as a JSON array and not a hash: the
panel has to be able to name the columns that moved. On the next import,
a header that normalizes differently opens a `FormatDriftPanel` above
the preview — column by column, `Montant : 3 -> 4` — with the two
outcomes that exist: adopt the re-detected format, or keep the stored
one. A cosmetic rename (`Montant` -> `MONTANT ($)`) normalizes
identically and says nothing.

A source whose file has no header row keeps `header_signature` NULL and
drift detection is inoperative on it. Documented, not worked around:
a signature invented from the data would fire on every import.

THE REPAIR PATH IS NOW IN THE INTERFACE, in the drift panel and beside
the preview's sign flip. `findDuplicates` matches on date AND
description AND amount, so re-importing a file "now that it reads right"
does not correct the rows already written — it doubles them, and a
flipped sign produces mirror pairs that net to zero in every report. The
only safe path is deleting the faulty import from the history first.

The drift re-detection reuses `detectFormatForFile`, so there is still
exactly one detector; the static guard on its caller count moves from
two to three deliberately. Its score and bank badge are dropped straight
after: they measure the format the panel offers, not the one in use.

Resolves #330
2026-08-13 14:54:46 -04:00
le king fu
ce19efd476 feat(import): make the preview mandatory and show what the amounts mean
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
A confidence score reports how many rows were READ, never what they say:
the `all-positive` fixture scores a perfect 100 % while every credit is
imported as an expense, because each of those rows is perfectly readable.
Nothing between that score and the database looked at the signs — the
preview was an optional modal of 20 rows with no totals, and the final
confirmation listed the delimiter and the date format but neither the
amount mode nor the sign convention.

The `file-preview` step had been declared in `ImportWizardStep` since the
beginning and no dispatch ever aimed at it. It is a real step now,
traversed at every import and gated by nothing — in particular not by the
detection score, which is sign-blind by construction.

- `useImportWizard`: `parseAndPreview` parses and stops at the preview,
  replacing `parsePreview` and the `parseAndCheckDuplicates` that jumped
  straight to the duplicates ("skips preview step"); `checkDuplicates`,
  dead code until now, is the preview's next button, so the rows the user
  validated are the rows that get checked.
- `summarizeParsedRows`: the recap, pure and tested — outflows and their
  total, inflows and theirs, rows in error. Totals stay SIGNED, since
  magnitudes would hide the one thing the recap exists to expose. Computed
  over the whole file, never over the twenty rows displayed.
- `flipSignFormat` + "Inverser les signes": the correction lands on the
  CONFIGURATION, so it is persisted with the source and the next file from
  that bank reads right on its own. In debit/credit mode it swaps the two
  column indices rather than toggling a convention `mapRow` ignores there,
  where a toggle would have been inert.
- `ImportConfirmation` states the amount mode, the sign convention (in the
  mode that applies it) and the column mapping, named by header.
- `FilePreviewModal` removed: it was the redundant surface, and editing
  the table alone would have mutated a still-live copy of it.

The `all-positive` KNOWN DEFECT marker is dropped rather than deleted. Its
three original expectations still hold — an unsigned file carries no
direction and detection cannot invent one — and two cases were added: the
recap tell (six outflows, zero inflows) and the honest limit, that
flipping this particular file only produces its mirror image.

1088 vitest (1054 before), tsc and vite build clean. No DB migration.

Resolves #329
2026-08-13 14:31:49 -04:00
le king fu
bf608b9d67 feat(import): score the detected format and run detection on its own
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
Detection handed back a configuration it had never tested, and only ever
ran behind the magic-wand button. A source opened for the first time
therefore started on `defaultConfig` — `;`, `DD/MM/YYYY`, columns 0/1/2 —
plausible enough to import a whole file wrong rather than fail visibly.

`detectImportFormat` now REPLAYS what it just decided over every data row
of the file and returns the rate as a `DetectionScore`. The replay runs
`mapRow`, the same pure function `parseFilesInternal` runs at import time,
under the same column-level decimal arbitration: two mappers would be the
exact divergence this chantier removes — a score reading 100 % while the
import wrote different amounts. A test asserts the two agree row for row
on every fixture of the corpus.

The threshold is 90 %, and it only colours the banner. Below it the panel
warns and prints the detailed count ("132 of 150 rows read"); at or above
it the banner is neutral. Nothing is blocked, because a perfect score says
nothing about the SIGN of what was read — `all-positive` scores 100 %
while every credit imports as an expense — and the preview step (#329) is
the real net, traversed at every import.

Detection now also fires on its own, guarded on `!existing`: a source that
has never been configured. A source that HAS one is never re-detected, the
stored format wins. The condition is deliberately not `!restored` — a
stored format that fails to decode already reports its own error, and
detecting over it would replace that message with a silent guess.

The button and the automatic run share one `detectFormatForFile`, so the
button replays detection instead of running a second, drifting variant of
it. The score is cleared when its source changes, when the format is
edited by hand (compared through the codec, so a rename keeps it) and when
a template overwrites the format — a banner vouching for a configuration
nobody measured is the misinformation it exists to remove.

Finally, the sign-convention selector is hidden in debit/credit mode.
`mapRow` computes `credit - debit` on magnitudes there and never reads
`signConvention`, so the control changed nothing. Hidden, not reset: the
stored value is left untouched.

Tests: 1054 vitest (+20), build and cargo check green. No DB migration.
CHANGELOG and docs are centralized in link 10 of the stack per the plan.

Resolves #328
2026-08-13 14:15:23 -04:00
le king fu
6f64fc5c1a feat(import): detect transaction columns by header label
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
Detection reasoned on the shape of the data alone, so it could not tell a debit
column from a credit one: a file laid out `Date;Description;Credit;Debit` was
mapped by position and every sign of the import came out inverted — silently,
since the total is merely negated and no aggregate check notices.

A new `headerDictionary.ts` carries the FR/EN transaction dictionary (date,
description, amount, debit, credit, balance) plus the two matching helpers,
moved out of `csvAutoDetect.ts` whose holdings tables stay untouched: `montant`
is an exclusion token there and the primary amount keyword here, so the two
tables cannot be merged. Moving the generic helpers rather than exporting them
keeps the dependency one-way.

`csvAutoDetect` puts that layer in front of the shape heuristics. Labels resolve
the debit/credit order, the date, description and single-amount columns, and
give `detectHeader` a second signal for a header row carrying a bare number.
Every hint is a preference the data can veto — a labelled date column must still
parse, a labelled balance column is never excluded if it would leave nothing to
map — and a mute file (no header row, unknown labels) falls back to the shape
heuristics unchanged.

Files pairing unsigned amounts with an adjacent D/C indicator column are now
detected and REFUSED with a dedicated message, instead of being configured as
`positive_expense` and importing every deposit as an expense. Detection reports
that reason through `detectImportFormat`; `autoDetectConfig` keeps its previous
shape for the callers that only need the configuration.

Resolves #327
2026-08-13 13:58:02 -04:00
le king fu
7b6f063094 fix(import): read amounts with an anchored parser and a real debit/credit rule
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m44s
Three ways an imported amount could be silently wrong, all of them passing
validation, all of them fixed here.

The two-column rule was `isNaN(credit) ? -debit : credit`, so the credit always
won. Many banks write `0,00` in the unused column rather than leaving it empty,
and `isNaN(0)` is false -- every debit of such a file imported as 0,00 and the
expense simply vanished, with no error anywhere. The rule is `credit - debit` on
magnitudes now, which needs no special case for a `0,00` cell (zero is the
identity of the subtraction) and implements the documented convention even when
an export negates its debits. A row unreadable in BOTH columns is an error
instead of a free 0,00 transaction.

`parseFrenchAmount` ended on `parseFloat`, which returns the longest valid
PREFIX instead of rejecting. Measured before the fix: `"50,00-"` -> 5000,
`"1 234,56 CR"` -> 123456, `"100,00 CAD"` -> 10000. A factor-100 error, and it
passes `isNaN`, so those rows counted as VALID everywhere downstream -- which
would have defeated the signed preview (#329), the safety net of the whole
chantier. Validation is anchored over the whole normalized string now and any
residual character yields NaN.

NaN, not a rescued magnitude, for a trailing `CR`/`DB` or currency code. Two
reasons: `CR`/`DB` carry a DIRECTION, so returning a magnitude for both would
trade a loud failure for a silent SIGN error (the D/C-indicator shape is refused
upstream by design, #328); and `"100,00 CAD"` is structurally identical to
`"2025 Montant"`, so whitelisting a trailing word to rescue the first re-blinds
`detectHeader` on the second. Two accounting forms ARE legitimate and supported:
parentheses `(50,00)` and a trailing sign `50,00-`.

The `?? 0` fallbacks 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.

`1.234` is 1234 in a French column and 1.234 in an English one, and no rule
applied to that cell ALONE can tell. `detectDecimalSeparator` arbitrates from
the decisive siblings of the column and `parseFrenchAmount` takes the verdict as
an option. 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.

The rule itself moves out of the hook as a pure `mapRow(raw, format)` in
`importFormat.ts`. That is what lets the corpus tests run the REAL rule -- the
hand-written mirror in `csvAutoDetect.test.ts` and the static guard pinning five
`parseFilesInternal` expressions are both deleted -- and what stops the
detection score (#328) and the signed preview (#329) each re-implementing it.

Hardening is global: the parser is shared by 11 call sites, 8 in
`csvAutoDetect.ts` and 3 in the holdings CSV import (#245), where a price cell
`150,25 CAD` used to store 15025. It is refused now and `buildDetailedLines`
raises 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 `snapshot_priced_quantity_required` fires.

Row errors become i18n keys (`import.rowErrors.*`) rather than the raw English
literals rendered straight into the preview table, since this adds a
user-visible string. The report table also carries raw exception messages, so
both render sites resolve through `isRowErrorKey` and never feed `t()` anything
that is not ours.

Test churn, per link 1's handoff (update the expectation, drop the marker, never
delete the test): the three `#325` KNOWN DEFECT blocks in `amountParser.test.ts`
flip, plus `unused-column-zero` in `csvAutoDetect.test.ts`. One block tagged
`#328` flips too -- `header-numeric-label`, whose own comment reads "#328 adds a
lexical signal to detectHeader, and #325 anchors the parser [...] either fix
closes this". The anchored parser landed first. The other `#328` blocks
(`debit-credit-reversed`, `absolute-indicator`) and the `#329` block are
verified unchanged. CHANGELOG and docs stay centralized in the last link of the
stack, as the plan specifies.

989 vitest (963 before), tsc + vite build clean, cargo check clean. No DB
migration.

Resolves #325
2026-08-13 13:37:06 -04:00
le king fu
7a604e0e0d fix(import): persist the import format and restore it faithfully
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m48s
The root bug of this chantier. `import_sources` carried no `amount_mode` and no
`sign_convention` until v17, so restoring a configured source re-inferred the
mode from `mapping.debitAmount !== undefined` and wrote
`signConvention: "negative_expense"` outright (useImportWizard.ts:321-323). A
credit-card statement configured for positive expenses came back on the default
convention at its second import and `parseFilesInternal` negated every amount:
expenses landed as income, with no error shown anywhere. The format is a read
value now, not a guessed one.

Two types and a codec, not one composed type. The four carriers are
structurally incompatible -- `ImportSource.has_header` is declared boolean,
`ImportConfigTemplate.has_header` is a number, `SourceConfig` is camelCase on a
parsed mapping -- so the guarantee cannot come from a shared shape. It comes
from `src/utils/importFormat.ts` being the single conversion point between
`ImportFormatRow` (persisted: snake_case, mapping as JSON, `has_header`
normalized to 0/1) and `ImportFormat` (domain), and from its completeness test.

That test is enforced on two levels, and both were mutation-checked:
`FORMAT_FIELD_PAIRS` is typed `Record<keyof ImportFormat, keyof
ImportFormatRow>`, so a field added to the format fails to BUILD until it is
listed; the test then compares each codec's real output keys against that table,
so a field listed but not wired fails the TEST. Dropping `sign_convention` from
`formatToRow` -- the shape of the original bug -- fails 14 tests.

`formatFromRow` validates rather than falls back. The v17 CHECK admits
`absolute_indicator` so the third amount mode ships without another migration,
but the app cannot map one: falling through to the `single` branch would read
the wrong column for every row, and anything other than `positive_expense`
would silently mean `negative_expense`. It raises an `ImportFormatError`
carrying an i18n key, and the wizard opens on a fresh configuration so
"reconfigure this source" stays an action the user can actually take.

Also here:

- The config write moves from `checkDuplicatesInternal` to `executeImport`, so
  an import abandoned at the duplicate step leaves no configuration behind. It
  is the only write point in the hook and a guard test holds that.
- Switching amount mode prunes the abandoned mode's columns, so the mode owns
  the mapping rather than the reverse. The column the `<select>` merely displays
  is deliberately not materialized -- #325 turns an unmapped amount column into
  an explicit row error, and writing a 0 here would make it unreachable. The
  mode and the pruned mapping land in ONE state update: the panel's handlers
  each spread the same `config` prop, so two calls would see the same stale
  value.
- `template_id` is recorded and restored as provenance only, never re-read as
  format. `selectedTemplateId` is no longer blanked on every source selection.
  An acceptance test rewrites a template end to end and asserts the linked
  source reads identically, plus a non-vacuity check that the template really
  changed.
- Both template writers go through the codec too, so a new format field cannot
  reach one table and miss the other.

`parseFilesInternal` is untouched: link 1's static guard on its five pinned
expressions still passes. 39 new tests (963 vitest total, was 924), build clean,
`cargo check` clean, no migration.

Resolves #324
2026-08-13 13:16:52 -04:00
le king fu
bd1085c148 schema: add migration v17 carrying the full import format on import_sources
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 9m27s
`import_sources` carried only the mechanical CSV settings. The two fields that
decide how an amount is READ -- `amount_mode` and `sign_convention` -- lived
only on `import_config_templates`. That asymmetry is the root bug of this
chantier: restoring a saved source re-inferred the mode from the mapping and
hardcoded `signConvention: "negative_expense"` (useImportWizard.ts:321-323), so
a source configured with positive expenses silently flipped back on its second
import. After v17 both tables carry the same eight format fields.

v17 is strictly additive -- v1 to v16 are untouched, and the diff is pure
insertion. The four columns are defaulted or nullable so the ALTERs are safe on
a populated database:

- amount_mode / sign_convention carry a CHECK, same pattern as v15 on
  balance_accounts.kind. amount_mode admits 'absolute_indicator' from the start
  so the third amount mode ships without another migration, while the database
  still refuses a corrupted value today.
- header_signature stores the normalized header labels seen at the last
  successful import, for drift detection.
- template_id is a provenance tag only, never re-read as format: the eight
  source columns are authoritative, so editing a template changes no linked
  source. ON DELETE SET NULL keeps the source and its format when a template
  goes.

The backfill reproduces exactly the rule the wizard applied on the fly, so no
source changes behaviour on migration. It tests `column_mapping LIKE
'%debitAmount%'` rather than json_extract, so the migration depends on no JSON1
extension in the bundled SQLite. sign_convention is deliberately not
backfilled: its DEFAULT restores precisely the value the code hardcoded, the
only past convention that can be inferred.

The four columns are mirrored into consolidated_schema.sql. They are inert
there on the production path -- that script runs after every migration and only
uses CREATE TABLE IF NOT EXISTS, so new profiles receive them from v17 -- but
it stays the tested reference definition, and a parity test now compares it
against the v1->v17 chain column by column, DEFAULT by DEFAULT, CHECK by CHECK
and FK by FK. Both halves of that test were mutation-checked to confirm they
fail on drift.

5 new tests (111 Rust total): v17 on a populated v16 database with a child row,
the backfill against 5 mapping shapes, the CHECKs, the provenance-tag
semantics, and the consolidated parity.

Resolves #323
2026-08-13 12:56:28 -04:00
le king fu
c88ebd862e test: freeze CSV detection and amount parsing behaviour on a fixture corpus
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
First link of the import-format stack. `autoDetectConfig`, `detectAmountMode`,
`detectSingleAmount`, `preprocessQuotedCSV` and `parseFrenchAmount` had no test
at all on a suite of 871, while carrying every imported amount. This adds the
reference the rewrite (#323-#332) measures itself against, before any of it
moves.

Corpus — 11 synthetic files under `src/__fixtures__/csv/`, no real statement
data, covering shapes a single real statement never contains at once: signed
amount, debit/credit, debit/credit in reversed column order, unused column
filled with `0,00`, preamble before the header, header carrying a number,
header cell starting with digits, no header row, all-positive amounts,
whole-line-quoted (Desjardins style), absolute amount + D/C indicator.

Every expectation was derived by running the code, not by reading it. Four
cases are frozen as DEFECTIVE, each named `KNOWN DEFECT` with the right answer
in a comment and the issue that owes the fix:

- reversed debit/credit — the pair is assigned by column position, never by
  label, so every sign is inverted while the total merely negates (#328)
- unused column at `0,00` — the rule branches on `isNaN(credit)` and `"0,00"`
  parses to 0, so every debit imports as zero (#325)
- header cell starting with digits — `parseFloat` returns the numeric prefix,
  `detectHeader` reads the header as data (#328/#325)
- absolute amount + D/C indicator — the indicator column is ignored entirely
  and every credit imports as an expense (#328)

`parseFrenchAmount` is pinned form by form, including the prefix-scan defect
the spec flagged: `"100,00 CAD"` yields 10000 and passes `isNaN`. Per the
/review-spec revision, the assertions reach the holdings call sites too, which
surfaced the same x100 leak in the #245 holdings import — a price cell of
`"150,25 CAD"` stores the position at 15025.

The end-to-end tests replay the wizard's row-mapping rule from a mirror, since
it still lives inside a `useCallback` and the repo has no jsdom. A guard test
asserts the production expressions are still literally present, so the mirror
cannot drift; #325 extracts `mapRow` and must then delete it.

No production file is modified. 924 vitest green (was 871), build clean.

Resolves #326
2026-08-13 12:36:13 -04:00
le king fu
b30c9fa5c1 docs(spec): add import CSV format spec (decisions + reviewed plan)
The import wizard forgets its format between runs: import_sources carries
neither amount_mode nor sign_convention, so a source configured for positive
expenses silently flips every amount on its second import.

Force-added despite .gitignore so /autopilot workers can read them from a
worktree — same precedent as PR #295 (ADR 0016 shipped a dead Spec: line).

Plan reviewed by the 3-expert pass: 7 criticals integrated, 8 decisions
drained. Milestone planned-2026-08-12-import-csv-format (#323-#332).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:17:04 -04:00
le king fu
81804bb94c state: sync after #310 + #311 merge (deps advisories cleared) 2026-07-27 20:41:01 -04:00
le king fu
a14258b147 fix(deps): update postcss to 8.5.23, accept the react-router advisory
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
npm update postcss moves it 8.5.13 -> 8.5.23, clearing GHSA-r28c-9q8g-f849
(path traversal in previous-source-map auto-loading via a sourceMappingURL
comment, arbitrary .map disclosure, 7.5 high). No overrides entry needed,
unlike #241: vite declares postcss ^8.5.3 and 8.5.23 is published, so the
existing range already permitted the fix and only the lockfile carried a
stale resolution. nanoid 3.3.11 -> 3.3.16 comes along as postcss's own
dependency, within its declared range.

postcss IS the CSS pipeline, so a green build only proves compilation. The
emitted stylesheet was diffed across the bump and is byte-for-byte identical
(same content hash, same asset filename).

The remaining react-router advisory (GHSA-qwww-vcr4-c8h2, RSC Mode CSRF
bypass) is accepted rather than fixed. It targets React Server Components,
which a Tauri desktop app never runs — App.tsx mounts a client-only
BrowserRouter and src/ has no createStaticHandler, StaticRouter or server
rendering. There is also nothing to move forward to: react-router-dom is
frozen at 7.18.1 since v8 merged the package into react-router, so npm's
proposed "fix" is a downgrade to 7.11.0, and leaving the affected range
means migrating to react-router v8. Re-evaluation trigger tracked in #317.

Unlike the Rust side, no CI gate is involved: check-frontend.yml runs no
npm audit step, so nothing turns red. That expectation is now written down
in docs/architecture.md and CLAUDE.md so the two permanent high findings do
not read as a regression.

npm audit: 3 findings -> 2 (high 3 -> 2), postcss cleared.
npm ci + npm run build + 871 vitest green.

Resolves #311

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:00:11 -04:00
le king fu
e3dc794a09 ci: make the suppression guard log every check, not just failures
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
PR Check — Rust / rust (pull_request) Successful in 9m3s
The guard emitted nothing when it passed, so its success was indistinguishable
in the CI log from the step never running at all — the same silent-skip failure
mode it exists to catch, one level up. Confirmed on run 332: the job was green
and the log carried no trace of the step either way.

Each crate/target check and the canary now echo their result, followed by a
count and the exit code, so a reader can see the guard ran and what it proved.

Verified by extracting the run: block from the workflow and executing it
verbatim under bash -e: 4 checks, canary found, exit 0.
2026-07-27 20:00:09 -04:00
le king fu
f4b09b028e fix(deps): clear 6 reachable RustSec advisories, accept 3 unreachable ones
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m38s
PR Check — Rust / rust (pull_request) Successful in 8m40s
cargo update -p rustls-webpki -p tar moves rustls-webpki 0.103.9 -> 0.103.13
and tar 0.4.44 -> 0.4.46, both within the existing Cargo.toml bounds. They sit
under tauri-plugin-updater, which downloads and unpacks application updates, so
all six of their advisories were reachable in the shipped binary.

The remaining three can neither be fixed nor reached. quick-xml (2x 7.5 high)
is pulled by plist, which tauri only needs for Apple bundling: its per-target
trees are empty for both shipped targets and it appears solely under
x86_64-apple-darwin. Its fix is >= 0.41.0 while plist requires ^0.38, a
semver-incompatible boundary [patch.crates-io] cannot cross. rsa has no
published fix at all and is never compiled — its only parent is sqlx-mysql, an
artifact of sqlx's multi-backend graph on a SQLite project.

Leaving those three to red the daily gate forever would reproduce the signal
loss that #232 removed the `|| true` to fix, so they move into a versioned
.cargo/audit.toml. Entries are keyed by advisory ID, never by crate, so a new
advisory against the same crate still reds the gate; each carries its
reachability proof and its removal condition.

A blocking step in check-rust.yml re-proves that justification on every PR
touching src-tauri/ or .cargo/, and fails if a suppressed crate enters a
shipped target's graph — the scenario that would rot the list is itself a
src-tauri change. It separates cargo tree's exit status from its output (an
absent crate and a failed invocation both print nothing) and asserts a canary
crate is still found, so its silence proves something.

cargo audit: 9 vulnerabilities -> 0, warnings unchanged at 23
(cargo-audit 0.22.2, advisory-db 0bfde9d6 of 2026-07-27).
cargo check + cargo test green (106 tests); npm build + 871 vitest green.

Resolves #310

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:45:15 -04:00
le king fu
78e8be3f1a state: sync after #232 merge (CI split + caches removed) 2026-07-27 18:55:19 -04:00
le king fu
7779f7dc52 ci: ignore .claude/ in the frontend filter, drop stale check.yml ref
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m44s
PR Check — Rust / rust (pull_request) Successful in 9m6s
Follow-up on the review of #232:

- .claude/ is tracked (rules + skills) and cannot affect the frontend build,
  so a change confined to it no longer queues a 1m43s job for nothing.
- The release skill's pre-flight step named check.yml, which this PR deletes.
  The reasoning still holds — the check workflows only run on PRs, never on
  main, so a merged tip has never been seen by CI — only the filename moved.
  Its dated changelog entry is left alone.

Resolves #232
2026-07-24 21:02:12 -04:00
le king fu
263ebe1495 ci: split check.yml, drop dead caches, prebuild cargo-audit (#232)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 8m55s
The rust job cost 21m44s on every PR while only ~1 PR in 40 touches
src-tauri/, and the runner has capacity 1 — the frontend job queues behind
it, so every PR paid ~24.5 min of feedback.

Measured on run 326 (2026-07-21), 12m15s of that was pure waste:
- 6m54s tarring target/ and the cargo registry for saves that time out
  against the runner's unreachable cache server (#234). The restore times
  out into a miss too, so nothing was ever cached at either end.
- 4m41s recompiling cargo-audit from source on every run.
- ~40s on the two doomed restores.

Split check.yml into check-rust.yml (paths: src-tauri/**) and
check-frontend.yml (paths-ignore denylist), drop every actions/cache step
until #234 is fixed, and install cargo-audit as a prebuilt binary via
taiki-e/install-action. The audit step keeps continue-on-error — advisories
are informational and can land on unrelated crates — but loses the `|| true`
that also hid tooling failures; the install step is blocking.

The frontend filter is a denylist on purpose: that job costs ~2.5 min, so
running it needlessly is cheap while silently not running it is not. The
expensive job keeps a strict allowlist.

Neither workflow filters on `branches:` anymore. `branches: [main]` never
matched a PR stacked on another feature branch, which is what /autopilot
produces: PRs #305-#308 of the feature-gating milestone ran no CI at all.

Adds audit.yml for daily RustSec coverage, since check-rust.yml now only
runs on Rust PRs. It skips the Rust toolchain entirely — cargo-audit only
reads Cargo.lock — so it costs ~1-2 min rather than the ~22 a scheduled
check-rust would burn daily on a capacity-1 runner.

The GitHub mirror is left untouched (#233: it receives no PRs).

Expected: Rust PR ~9-10 min, frontend-only PR ~2.5 min instead of ~24.5.

Resolves #232
2026-07-24 20:26:35 -04:00
le king fu
edb0689b69 state: sync after feature-gating milestone (#297-#302 shipped)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 20:26:14 -04:00
le king fu
6de96174de fix(i18n): FR typo in docs.editions tier descriptions (#302)
"tout la Gratuite/Base" -> "tout de la Gratuite/Base", flagged as the
one user-facing correction in the /pr-review pass on PR #308.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 20:20:11 -04:00
le king fu
01da65c215 docs(gating): ADR 0017 + architecture + user guide + CHANGELOG
Document the edition-gating work (#297-#301):

- ADR 0017 (accepted): tier->features matrix, signed features[] override
  fail-closed in Free (CWE-863), UI-only enforcement as an assumed GPL
  soft-paywall (server-enforced price fetching stays the only hard gate),
  non-destructive downgrade, dev-override behind an explicit Cargo
  feature (CWE-489), rejected alternatives.
- architecture.md: new 'Gating par edition' section (entitlements matrix,
  LicenseContext, useEntitlement, RequireFeature/UpsellGate, NavLock,
  profileGate, Rust side), rewritten entitlements.rs section (auto-update
  now Base+, stale 'open to free' note removed), gated routes listed in
  the routing section, hooks table updated (useLicense removed in #297 ->
  useEntitlement/useIsPremium), ADR index + header refreshed.
- guide-utilisateur.md + docs.editions.* i18n keys (FR/EN) wired into
  DocsContent: new 'Editions' section with the Free/Base/Premium table,
  unlock flow and non-destructive locking tips.
- CHANGELOG.md + CHANGELOG.fr.md: one global [Unreleased] entry listing
  the modules now gated Base (Budget, Adjustments, advanced reports,
  multi-profile, auto-update) and Premium (Balance), the visible-but-
  locked upsell with disabled 'coming soon' purchase CTA, and the
  data-preserving behaviour.

Resolves #302

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:48:55 -04:00
le king fu
17833cf942 feat(gating): auto-update Base+, features[] override fail-closed, dev-override (Rust)
Re-gate auto-update to Base+Premium now that paid activation works
end-to-end (absorbs #271), and align the Rust entitlement layer with the
TS matrix shipped in #297:

- FEATURE_TIERS: auto-update -> [base, premium]; the 'temporarily open'
  carve-out and its test are gone (free_allows_auto_update_temporarily
  -> free_denied_auto_update). Dead rows web-sync, cloud-backup and
  advanced-reports are purged (no call-site anywhere; advanced-reports
  -> Premium contradicted the TS reports-advanced -> Base+ matrix).
  Only auto-update remains on the Rust side.
- features[] override, fail-closed in Free (CWE-863): new
  current_entitlements() resolves the edition AND the signed features[]
  through the same machine-binding path — every downgrade path returns
  ('free', []) so a copied license.key can never keep its signed
  features. check_entitlement combines them via the new pure
  is_entitled(): is_feature_allowed(feature, edition) ||
  features.contains(feature), with a defense-in-depth free short-circuit
  mirroring the TS isEntitled. current_edition() now delegates to
  current_entitlements() — single resolution path, no drift possible.
- dev-override: new Cargo feature (off by default, never in a release
  feature set — CWE-489: debug_assertions could be flipped on a custom
  release build and become a Premium backdoor). Only when compiled in,
  SR_DEV_EDITION forces the edition (free|base|premium) to test tiers
  locally. A feature-off test proves the env var has zero effect in
  normal builds; feature-on companions (env access serialized by a
  mutex) cover cargo test --features dev-override.

No Tauri command signature changes: check_entitlement keeps its
(feature: String) -> Result<bool, String> contract for useUpdater.ts
and ErrorPage.tsx.

Validation: cargo check + cargo test (106 passed, feature off) +
cargo test --features dev-override + npm test (871) + npm run build.

Resolves #301

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:35:16 -04:00
le king fu
b89074e6c6 feat(gating): multi-profile gate (Base+), non-destructive
A Free user keeps full access to their active profile; profiles beyond
it show a lock in ProfileSwitcher and open an upsell dialog instead of
switching. Creating a profile beyond the first is locked at the single
creation point, ProfileFormModal (reached from both ProfileSwitcher and
ProfileSelectionPage), with a race guard in handleSave covering the
license boot window. Both creation entries stay visible with a lock
(locked-not-hidden). Nothing is ever removed from profiles.json — an
upgrade to Base/Premium makes every profile reappear untouched.

- New pure predicates in src/shared/profileGate.ts
  (isProfileSwitchLocked, isProfileCreationLocked) + 10 vitest
- ProfileFormModal upsell panel reuses upsell.* keys WITHOUT UpsellGate:
  the modal also opens from ProfileSelectionPage, which renders outside
  BrowserRouter, where UpsellGate's useNavigate would throw
- UpsellGate gains an optional onNavigate callback so the
  ProfileSwitcher upsell dialog can close itself after navigation
- No lock while the license is loading (anti-flash, ready guard);
  zero new i18n keys; no DB migration

Resolves #300

Generated autonomously by /autopilot run of 2026-07-20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:24:23 -04:00
le king fu
553da0ce8c feat(gating): gate routes and Sidebar for budget, advanced reports, balance
Apply the tier gating to routes and navigation on top of the #298 UI
guard:

- App.tsx: pathless RequireFeature layout-routes grouping /balance,
  /balance/accounts, /balance/snapshot under "balance"; /reports/
  highlights|compare|category|cartes under "reports-advanced"; /budget
  under "budget"; /adjustments under "adjustments". The /reports hub and
  /reports/trends stay Free and ungated.
- NavItem gains an optional `feature?: FeatureKey`; set in NAV_ITEMS on
  budget, adjustments and balance only — NOT on reports (Free hub).
- Sidebar: local NavLock child component (hook at component top level)
  renders a lock badge only when the license is ready AND the feature is
  not allowed — no locked flash at boot; items stay clickable and lead
  to the upsell via the gated route. Tooltip/aria reuse nav.locked.
- ReportsPage hub: single useEntitlement("reports-advanced") call
  drives a `locked` badge on the 4 advanced tiles via a new additive
  HubReportNavCard `locked?` prop; the Trends tile is never locked.
- Pure contract test on NAV_ITEMS (gated trio present, reports/Free
  items ungated, exactly 3 of 9 gated).

No new i18n strings (nav.locked shipped with #298), no DB migration.
Changelog centralized in #302.

Resolves #299

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:12:51 -04:00
le king fu
554373e7d8 feat(gating): UI guard — RequireFeature + UpsellGate + i18n
All checks were successful
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m28s
Add the reusable gating guard components on top of the #297 foundation:

- UpsellGate: full locked screen (lock icon, tier title, per-feature
  description). Two CTAs: "Get <tier>" rendered VISIBLE but DISABLED with
  an "online purchase coming soon" note (per planning decision — #270 will
  activate it), and "I already have a key" navigating to /settings/users
  (LicenseCard).
- RequireFeature: renders a neutral loader while the license is not ready
  (no upsell flash at boot), then children or UpsellGate. Renders <Outlet/>
  when children are omitted so it also works as a layout route grouping
  all routes of one feature.
- requiredTierFor() pure helper in shared/entitlements.ts derives the
  minimum unlocking tier from matrix membership (not array order).
- i18n: upsell.* (title, per-feature descriptions, CTAs) + nav.locked in
  BOTH locales; tier labels reuse the existing license.editions.* keys.
- Tests: requiredTierFor mapping/minimality + upsell i18n coverage for
  every FeatureKey in fr and en (the components themselves are not
  testable without jsdom).

Resolves #298

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:05:04 -04:00
le king fu
b9e13b5bca fix(gating): keep key-validation errors out of the load lifecycle (#297)
All checks were successful
PR Check / rust (pull_request) Successful in 22m21s
PR Check / frontend (pull_request) Successful in 2m32s
A rejected submitKey dispatched the same ERROR action as a failed boot
load, so the CWE-703 retry backoff armed on it and the auto refresh
(LOAD_START) cleared the "invalid key" message ~1s after submit —
LicenseCard has no local error state, the context is the only source.

Split the state: load lifecycle (status/error, retried) vs validation
(validating/validationError, never retried). VALIDATE_ERROR leaves
status untouched, so a ready license stays ready on a typo'd key (no
`ready` regression for gating consumers) and a boot-error retry loop
keeps running through a failed validation. Reducer + initial state
exported for tests, covered by LicenseContext.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:21:18 -04:00
le king fu
fd7e053239 feat(gating): license provider + entitlements matrix + useEntitlement (#297)
All checks were successful
PR Check / rust (pull_request) Successful in 22m38s
PR Check / frontend (pull_request) Successful in 2m34s
Socle for tier-based feature gating (UI-only soft-paywall).

- LicenseContext: machine-level provider (createContext<T|null>, useReducer,
  throwing consumer hook), mounted above ProfileProvider in main.tsx so a
  profile switch (BrowserRouter key remount) does not reload the license.
  Loads edition + info once; exposes { status, edition, features, info, error,
  refresh, submitKey }. Boot-error recovery (CWE-703): neutral state + capped
  exponential-backoff retry, never the upsell.
- shared/entitlements.ts: FeatureKey (kebab-case), ENTITLEMENTS matrix, pure
  isEntitled() fail-closed in Free (CWE-863) — the features[] override is
  ignored before edition==="free" is checked.
- useEntitlement(f): { allowed, ready } (ready = status==="ready"), synchronous.
- useIsPremium + its test migrated onto the context (drops the per-call double
  invoke); LicenseCard consumes the context. useLicense.ts removed (fully
  replaced, no remaining consumers).
- services/entitlements.test.ts: matrix, features[] override, override ignored
  in Free, unknown-feature deny-all.

Resolves #297
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:05:10 -04:00
le king fu
0b408a8014 state: feature-gating milestone re-homed to planned-2026-07-19 (ready for autopilot)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:44:34 -04:00
le king fu
4f39fa3434 spec(gating): adjustments -> Base tier + STATE review sync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:44:22 -04:00
le king fu
a100ee287b docs(spec): apply /review-spec corrections to feature-gating plan (2 critical + 6 improvements)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:38:42 -04:00
le king fu
99ba147906 docs(spec): feature-gating decisions + plan + milestone spec-feature-gating (#297-#302)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:20:50 -04:00
le king fu
195a73596e state: sync after #259 merge (rebase into main)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:44:56 -04:00
le king fu
2314a64213 feat(categories): merge custom categories into the standard taxonomy (#259)
Custom categories with no standard match were shown as read-only text in
the migration wizard and force-parented under a catch-all bucket. They now
get the same inline target picker as seeded rows: picking a standard leaf
merges the custom category — its transactions, budgets, keywords and
suppliers are reassigned to the leaf — and deactivates it. Leaving a custom
unmapped keeps the previous behaviour and never blocks the wizard.

- Reducer: RESOLVE_ROW resolves rows in both plan.rows and plan.preserved;
  the Next-button guard still counts seeded rows only.
- Writer: the rewrite mapping now includes resolved preserved rows; the
  catch-all parent is created only when a custom is left unmerged; merged
  customs are deactivated instead of re-parented (shared isResolvedTarget
  helper across the three sites).
- UI: the preserved block renders MappingRow instead of plain text.
- i18n (FR/EN) + CHANGELOG (FR/EN).

Tests: reducer (resolve a preserved custom, guard unchanged, GO_NEXT still
proceeds) + writer (reassign to the chosen leaf, soft-delete, no empty
parent when all merged, orphan-free merge of a custom parent with an
unresolved child). 836 vitest green, tsc + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:43:47 +00:00
le king fu
2a4658bad9 state: close #260 (report-uniformity epic ratified) + #259 in PR #296
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:16:55 -04:00
le king fu
e4fe703578 state: sync after v0.14.0 (collapse multi-niveaux #288-291 shipped)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:59:47 -04:00
107 changed files with 12293 additions and 1234 deletions

53
.cargo/audit.toml Normal file
View file

@ -0,0 +1,53 @@
# cargo-audit configuration — accepted advisories (#310).
#
# Every ID listed here is suppressed on EVERY `cargo audit` run, including the
# daily blocking gate in .forgejo/workflows/audit.yml. So a green audit means
# "no advisory outside this list", not "no advisory at all". See
# docs/adr/0018-suppression-advisories-non-atteignables.md.
#
# Two rules govern the list:
#
# 1. An advisory may only be listed if its crate is absent from the
# dependency graph of EVERY shipped target (Windows and Linux), or if no
# fix has been published at all. A reachable advisory with an available
# fix gets fixed, never suppressed.
#
# 2. Entries are keyed by advisory ID, never by crate. A new advisory filed
# against a crate already listed here re-reds the gate on purpose — each
# one is reviewed on its own merits. Widening an entry to a whole crate
# would defeat the gate.
#
# check-rust.yml carries a guard that re-proves rule 1 on every PR touching
# src-tauri/ or this file. ADDING AN ENTRY HERE REQUIRES ADDING ITS CRATE TO
# THAT GUARD'S CRATE LIST — otherwise the new entry gets no anti-rot coverage.
#
# To re-verify an entry (or before removing one), run from the repo root:
#
# cargo tree --manifest-path src-tauri/Cargo.toml -i <crate> --target x86_64-unknown-linux-gnu
# cargo tree --manifest-path src-tauri/Cargo.toml -i <crate> --target x86_64-pc-windows-msvc
#
# Empty output on both targets means the entry is still justified. Any output
# means it is not: drop the entry and fix the advisory for real.
# The quick-xml pair (RUSTSEC-2026-0194 / -0195) used to live here and was
# removed on 2026-07-27 by #312: plist 1.10.0 ships quick-xml 0.41.0, which
# carries the fix, so the advisories were resolved rather than accepted. That is
# the intended lifecycle of an entry in this file — it leaves when a fix becomes
# reachable, not when someone remembers to look.
[advisories]
ignore = [
# rsa 0.9.10 — RUSTSEC-2023-0071 (Marvin attack: potential key recovery
# through timing side channels), 5.9 medium.
#
# Qualifies under both halves of rule 1. It has no fix at all — the
# advisory's patched list is empty, which is why suppression is the only
# option available. And it is unreachable: its sole parent in the lockfile
# is sqlx-mysql, an artifact of sqlx's multi-backend graph, while this
# project talks to SQLite through tauri-plugin-sql. `cargo tree -i rsa
# --target all` returns nothing at all.
#
# Removal trigger: a fixed rsa release, or sqlx dropping the crate from the
# graph.
"RUSTSEC-2023-0071",
]

View file

@ -2,7 +2,7 @@
name: release name: release
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push) description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
user-invocable: true user-invocable: true
updated: 2026-07-13 updated: 2026-07-27
--- ---
# /release — Release Simpl-Resultat # /release — Release Simpl-Resultat
@ -15,7 +15,7 @@ updated: 2026-07-13
## Workflow ## Workflow
0. **Pré-vol — revalider le tip localement.** `check.yml` ne tourne pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert. 0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert.
1. Determiner la nouvelle version (argument utilisateur ou demander) 1. Determiner la nouvelle version (argument utilisateur ou demander)
2. Bump version dans les 5 fichiers : 2. Bump version dans les 5 fichiers :
- `src-tauri/Cargo.toml` (ligne `version = "..."`) - `src-tauri/Cargo.toml` (ligne `version = "..."`)
@ -41,6 +41,13 @@ updated: 2026-07-13
9. **Post-CI — vérifier la release publiée.** Surveiller `release.yml` (outil `Monitor` sur le run), puis vérifier la release réellement attachée — `status=success` du workflow ne suffit pas : 9. **Post-CI — vérifier la release publiée.** Surveiller `release.yml` (outil `Monitor` sur le run), puis vérifier la release réellement attachée — `status=success` du workflow ne suffit pas :
- Les **7 artefacts** attendus : `.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, et `latest.json`. - Les **7 artefacts** attendus : `.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, et `latest.json`.
- Le contenu de `latest.json` (il pilote l'auto-update des installations existantes) : champ `version` correct, signatures non vides pour les deux plateformes, URLs pointant vers les bons binaires, notes extraites du CHANGELOG. - Le contenu de `latest.json` (il pilote l'auto-update des installations existantes) : champ `version` correct, signatures non vides pour les deux plateformes, URLs pointant vers les bons binaires, notes extraites du CHANGELOG.
10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md).
Inspecter `latest.json` prouve qu'il est **bien formé**, pas qu'il **installe**. Le reste de la chaîne — téléchargement TLS, vérification de signature, exécution de l'installeur, redémarrage — n'est exercé par aucun test : `cargo check`/`cargo test` prouvent que ce code compile, rien de plus. Une régression s'y voit chez l'utilisateur, au moment de la mise à jour.
Deux pièges qui font cocher la checklist sans rien vérifier, détaillés dans la doc : tester depuis une machine **déjà à la nouvelle version** (`check()` répond « à jour ») et tester sans **clé Base+** (l'auto-update est verrouillé par édition, l'app bascule en `notEntitled` avant même d'interroger le serveur).
Si la checklist n'est pas déroulée, **l'écrire avec la raison** sur la release plutôt que de laisser le silence.
## Regles ## Regles
@ -55,3 +62,4 @@ updated: 2026-07-13
- 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle) - 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle)
- 2026-07-01 — Documenter que le header FR est `## [Non publié]` (≠ `[Unreleased]`), pour éviter le faux diagnostic « changelog FR vide » lors de la migration. Source : session 5466da98. - 2026-07-01 — Documenter que le header FR est `## [Non publié]` (≠ `[Unreleased]`), pour éviter le faux diagnostic « changelog FR vide » lors de la migration. Source : session 5466da98.
- 2026-07-13 — Étape 0 (pré-vol) : revalider le tip localement avant de tagger — `check.yml` ne tourne pas sur `main`, le tip mergé n'a jamais été vu par le CI ; vérifier `.claude/worktrees/` vide (vitest récurse sinon). Étape 9 (post-CI) : vérifier la release publiée — 7 artefacts attendus + contenu de `latest.json` (pilote l'auto-update) ; `status=success` ne suffit pas. Règle : tagger publie vers l'extérieur (updater automatique) → confirmer avec Max avant de tagger. Source : session fdda84cb (release v0.13.0). - 2026-07-13 — Étape 0 (pré-vol) : revalider le tip localement avant de tagger — `check.yml` ne tourne pas sur `main`, le tip mergé n'a jamais été vu par le CI ; vérifier `.claude/worktrees/` vide (vitest récurse sinon). Étape 9 (post-CI) : vérifier la release publiée — 7 artefacts attendus + contenu de `latest.json` (pilote l'auto-update) ; `status=success` ne suffit pas. Règle : tagger publie vers l'extérieur (updater automatique) → confirmer avec Max avant de tagger. Source : session fdda84cb (release v0.13.0).
- 2026-07-27 — Étape 10 (post-CI) : dérouler un vrai cycle de mise à jour via `docs/qa-update-cycle.md` quand la release touche l'updater. Inspecter `latest.json` prouve qu'il est bien formé, pas qu'il installe : le téléchargement TLS, la vérification de signature, l'exécution de l'installeur et le redémarrage ne sont exercés par aucun test. Deux pièges qui font cocher sans vérifier — tester depuis la machine déjà à jour (`check()` répond « à jour ») et tester sans clé Base+ (auto-update verrouillé par édition, `notEntitled` avant toute requête). Source : session 50ac88d9 (#315, découvert en traitant les advisories `tar`/`rustls-webpki` de #310).

View file

@ -0,0 +1,64 @@
name: Security audit
# Daily RustSec coverage (#232).
#
# check-rust.yml only runs when src-tauri/ changes, which is roughly 1 PR in
# 40 — without this workflow a new advisory published against an unchanged
# dependency would go unnoticed for weeks.
#
# Runs without a Rust toolchain: cargo-audit only reads Cargo.lock, so the
# binary is invoked directly instead of as a `cargo` subcommand. That keeps
# this to ~1-2 min rather than the ~22 min a scheduled check-rust would cost
# every day on a capacity-1 runner.
#
# Note: PATH is deliberately NOT overridden at job level (unlike check-rust,
# which needs /root/.cargo/bin for the toolchain) so that the install dir
# taiki-e/install-action appends to $GITHUB_PATH stays effective.
#
# This job reads .cargo/audit.toml from the checkout root, which lists the
# advisories accepted for this project (#310). A green run therefore means "no
# advisory outside that list", not "no advisory at all" — the justification and
# the removal criteria for each entry live in that file, and the policy behind
# it in docs/adr/0018-suppression-advisories-non-atteignables.md.
on:
schedule:
# 06:00 UTC daily
- cron: '0 6 * * *'
workflow_dispatch:
concurrency:
group: ci-audit-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu
container: ubuntu:22.04
steps:
- name: Install Node.js 20
run: |
apt-get update
apt-get install -y --no-install-recommends \
curl ca-certificates git tar gzip
# Node.js is required by actions/checkout and taiki-e/install-action
# (JavaScript actions need `node` in the container PATH).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
node --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Install cargo-audit
uses: https://github.com/taiki-e/install-action@v2
with:
tool: cargo-audit
# Unlike the PR run in check-rust.yml, this one is meant to fail loudly:
# it IS the notification channel for a newly published advisory.
- name: cargo audit
run: cargo-audit audit --file src-tauri/Cargo.lock

View file

@ -0,0 +1,65 @@
name: PR Check — Frontend
# Frontend half of the former check.yml (split in #232).
#
# Filtered with paths-ignore rather than an allowlist: this job costs ~2.5 min,
# so running it when it was not strictly needed is cheap, while silently NOT
# running it is not. A root-level config file added later would drop out of an
# allowlist without anyone noticing; here it fails safe.
#
# No `branches:` filter — see check-rust.yml.
on:
pull_request:
paths-ignore:
- 'src-tauri/**'
- '.cargo/**'
- 'docs/**'
- 'reports/**'
- 'tasks/**'
- '.github/**'
- '.claude/**'
- '.forgejo/workflows/check-rust.yml'
- '.forgejo/workflows/audit.yml'
- '.forgejo/workflows/release.yml'
- '*.md'
- 'LICENSE'
# Distinct group from the rust workflow — see check-rust.yml.
concurrency:
group: ci-frontend-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
frontend:
runs-on: ubuntu
container: ubuntu:22.04
steps:
- name: Install Node.js 20
run: |
apt-get update
apt-get install -y --no-install-recommends curl ca-certificates git
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
node --version
npm --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
# No npm cache step here, deliberately — same reason as the cargo caches
# in check-rust.yml: the restore misses and the save times out against
# the runner's cache server (#234), which cost ~23s per run for nothing.
# Restore it together with rust-cache once #234 is fixed.
- name: Install dependencies
run: npm ci
- name: Build (tsc + vite)
run: npm run build
- name: Tests (vitest)
run: npm test

View file

@ -0,0 +1,153 @@
name: PR Check — Rust
# Rust half of the former check.yml (split in #232).
#
# Only runs when Rust actually changes. On this repo roughly 1 PR in 40 touches
# src-tauri/, and the runner has capacity 1 — jobs queue instead of running in
# parallel, so every minute spent here is a minute the next PR waits.
#
# No `branches:` filter on purpose. `branches: [main]` never matched a PR
# stacked on top of another feature branch, which is what /autopilot produces:
# 4 of the 5 PRs in the feature-gating milestone ran no CI at all.
on:
pull_request:
paths:
- 'src-tauri/**'
- '.forgejo/workflows/check-rust.yml'
# Whole directory, not just audit.toml: a later .cargo/config.toml
# (rustflags, linker, target dir) would change the Rust build, and an
# exact-path entry would let it skip Rust CI unnoticed.
- '.cargo/**'
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
# Distinct from the frontend group: a shared group would make the two
# workflows cancel each other on a PR that touches both.
concurrency:
group: ci-rust-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
rust:
runs-on: ubuntu
container: ubuntu:22.04
env:
PATH: /root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
CARGO_TERM_COLOR: always
# Nothing persists between runs (see the caching note below), so
# incremental artifacts get written and never reused — pure overhead.
# Test debug info is dead weight here for the same reason.
CARGO_INCREMENTAL: 0
CARGO_PROFILE_TEST_DEBUG: 0
steps:
- name: Install system dependencies, Node.js and Rust
run: |
apt-get update
apt-get install -y --no-install-recommends \
curl wget git ca-certificates build-essential pkg-config \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \
libdbus-1-dev
# Node.js is required by actions/checkout and taiki-e/install-action
# (they are JavaScript actions and need `node` in the container PATH).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
node --version
rustc --version
cargo --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
# No actions/cache step here, deliberately. The job container cannot
# reach the runner's cache server (#234): the restore times out into a
# miss AND the save times out, so the cache cost ~7 min per run and
# returned nothing. Bring caching back through Swatinem/rust-cache once
# #234 is fixed — not before, it shares the same backend.
- name: cargo check
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
# Anti-rot guard for the suppressions in .cargo/audit.toml (#310). Each
# entry there is justified by its crate being absent from every shipped
# target's graph — a property of today's resolved graph, not a permanent
# one. rsa is currently reachable from nothing: its only parent in the
# lockfile is sqlx-mysql, which this SQLite project never compiles. If a
# dependency change ever pulls it into a shipped target, the suppression
# would silently hide a live advisory and the daily audit would stay
# green: the inverse of the permanent red #310 exists to kill.
#
# Such a change would itself touch src-tauri, which is exactly what
# triggers this workflow. Runs after cargo check so the registry index is
# already warm, and --locked so cargo tree cannot rewrite the lockfile the
# audit was taken against.
#
# CRATES must mirror the crates named in .cargo/audit.toml. Adding an
# entry there without adding its crate here leaves it unguarded.
#
# Every check echoes its result, including the passing ones. A guard that
# is silent on success cannot be told apart in the log from a guard that
# never ran — which is the same silent-skip failure mode this step exists
# to catch, one level up.
- name: Verify suppressed advisories are still unreachable
run: |
set -u
CRATES="rsa"
TARGETS="x86_64-unknown-linux-gnu x86_64-pc-windows-msvc"
rc=0
checks=0
for crate in $CRATES; do
for target in $TARGETS; do
# An absent crate exits 0 with empty stdout ("nothing to print"
# goes to stderr). A non-zero exit means cargo tree itself failed
# — treat that as a failure rather than as proof of absence.
out=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \
-i "$crate" --target "$target" 2>/dev/null) || {
echo "cargo tree failed for $crate / $target — cannot verify the suppression"
rc=1
continue
}
if [ -n "$out" ]; then
echo "FAIL: $crate is now compiled for $target — its .cargo/audit.toml suppression is no longer justified (see #310)."
rc=1
else
echo "ok: $crate absent from $target"
fi
checks=$((checks + 1))
done
done
# Canary: a crate known to be present. If this stops being found, the
# loop above is broken and its silence means nothing.
canary=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \
-i tar --target x86_64-unknown-linux-gnu 2>/dev/null) || true
if [ -z "$canary" ]; then
echo "FAIL: canary 'tar' was not found although it is a known dependency. The guard is not proving anything."
rc=1
else
echo "ok: canary 'tar' found for x86_64-unknown-linux-gnu"
fi
echo "Suppression guard: $checks checks, exit $rc"
exit $rc
- name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
# Prebuilt binary. `cargo install --locked cargo-audit` recompiled the
# tool from source on every single run (~4m40s). No continue-on-error:
# a tooling failure should fail the job rather than be swallowed.
- name: Install cargo-audit
uses: https://github.com/taiki-e/install-action@v2
with:
tool: cargo-audit
# Advisories are informational — they can land on unrelated crates and
# would otherwise stall unrelated work — so the step is non-blocking.
# It no longer hides real failures behind `|| true` though. Daily
# RustSec coverage outside Rust PRs lives in audit.yml.
- name: cargo audit
continue-on-error: true
run: cargo audit --file src-tauri/Cargo.lock

View file

@ -1,115 +0,0 @@
name: PR Check
# Validates Rust + frontend on every PR opened against main.
# Goal: catch compile errors, type errors, and failing tests BEFORE merge,
# instead of waiting for the release tag (which is when release.yml runs).
#
# Trigger is `pull_request` only — the previous `push` trigger duplicated
# every run when a branch was pushed and immediately opened as a PR (#171).
# Trade-off: branches pushed without an open PR don't get CI feedback. Open
# a draft PR if you want feedback before requesting review.
on:
pull_request:
branches:
- main
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
runs-on: ubuntu
container: ubuntu:22.04
env:
PATH: /root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
CARGO_TERM_COLOR: always
steps:
- name: Install system dependencies, Node.js and Rust
run: |
apt-get update
apt-get install -y --no-install-recommends \
curl wget git ca-certificates build-essential pkg-config \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \
libdbus-1-dev
# Node.js is required by actions/checkout and actions/cache (they
# are JavaScript actions and need `node` in the container PATH).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
node --version
rustc --version
cargo --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Cache cargo registry and git
uses: https://github.com/actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
- name: Cache cargo build target
uses: https://github.com/actions/cache@v4
with:
path: src-tauri/target
key: ${{ runner.os }}-cargo-target-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-target-
- name: cargo check
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
- name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
# Informational audit of transitive dependencies. Failure does not
# block the CI (advisories can appear on unrelated crates and stall
# unrelated work); surface them in the job log so we see them on
# every PR run and can react in a follow-up.
- name: cargo audit
continue-on-error: true
run: |
cargo install --locked cargo-audit || true
cargo audit --file src-tauri/Cargo.lock || true
frontend:
runs-on: ubuntu
container: ubuntu:22.04
steps:
- name: Install Node.js 20
run: |
apt-get update
apt-get install -y --no-install-recommends curl ca-certificates git
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
node --version
npm --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Cache npm cache
uses: https://github.com/actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci
- name: Build (tsc + vite)
run: npm run build
- name: Tests (vitest)
run: npm test

View file

@ -2,6 +2,35 @@
## [Non publié] ## [Non publié]
### Ajouté
- Import : ouvrir une source jamais configurée **détecte désormais son format tout seul** — délimiteur, encodage, format de date, et quelle colonne porte la date, la description et le montant. Les colonnes sont reconnues par leur libellé d'en-tête en français comme en anglais (`Montant`/`Amount`, `Débit`/`Withdrawal`, `Crédit`/`Deposit`…), et les formats d'export de **Desjardins, RBC, Banque Nationale et Tangerine** sont reconnus par leur nom. La détection rejoue ensuite sa propre décision sur toutes les lignes du fichier et affiche un **score de confiance** (« 147 des 150 lignes lues ») : au-dessus de 90 % le bandeau est neutre, en dessous il avertit et renvoie vers le mapping des colonnes et l'aperçu. Rien n'est jamais bloqué par le score — il dit ce qui a pu être lu, pas si le sens est bon. Le bouton baguette reste disponible pour relancer la détection à la demande, et une source qui a déjà un format enregistré n'est jamais re-détectée (#327, #328).
- Import : l'**aperçu des données est désormais une étape obligatoire de chaque import**, et non plus une fenêtre optionnelle. Il affiche un récapitulatif signé de ce que le fichier va réellement écrire : combien de sorties et pour quel total, combien d'entrées et pour quel total, et combien de lignes n'ont pas pu être lues. Si les entrées et les sorties sont inversées, un bouton **« Inverser les signes »** les corrige — et comme il corrige la configuration de la source plutôt que le seul aperçu, le prochain fichier de cette banque se lira correctement de lui-même. L'écran de confirmation énonce maintenant aussi le mode de montant, la convention de signe et le mapping des colonnes nommé par en-tête, pour les vérifier avant que l'import ne parte (#329).
- Import : quand une banque change la disposition de son export, l'assistant le **signale avant d'importer**. L'en-tête de chaque import réussi est mémorisé sur la source, et un fichier dont l'en-tête ne correspond plus ouvre un panneau listant les colonnes qui ont bougé, apparu ou disparu (« Montant : colonne 3 → 4 »), avec les deux seuls choix qui existent : adopter le format re-détecté, ou conserver celui enregistré. Un renommage purement cosmétique (`Montant` → `MONTANT ($)`) est reconnu comme le même en-tête et ne dit rien. Le panneau de dérive comme le bouton d'inversion portent en plus une note expliquant la **façon sûre de réparer un import déjà écrit de travers** : le supprimer d'abord dans l'historique des imports, puis rejouer le fichier — le réimporter par-dessus doublerait les lignes, les doublons étant repérés sur la date, la description et le montant ensemble (#330).
- Import : vos **sources et modèles d'import font désormais partie de la sauvegarde chiffrée**. Exporter puis réimporter vos données effaçait toute configuration d'import et la remplaçait par une unique source synthétique « Data Import » : restaurer une sauvegarde obligeait à reconfigurer chaque banque à la main. Les anciens fichiers de sauvegarde, qui ne les contiennent pas, sont restaurés exactement comme avant (#331).
- Migration des catégories : les catégories personnalisées sans correspondance standard peuvent désormais être **fusionnées** dans une catégorie standard, au lieu d'être seulement mises de côté. Chaque catégorie personnalisée du bloc « Catégories personnalisées » reçoit le même sélecteur de cible que les lignes du seed — choisissez une feuille standard et ses transactions, budgets, mots-clés et fournisseurs y sont réassignés, puis la catégorie personnalisée est retirée. Laisser une catégorie personnalisée non mappée conserve le comportement précédent (regroupée sous « Catégories personnalisées (migration) ») et ne bloque jamais la migration (#259).
### Modifié
- Import : le format d'une source — y compris le **mode de montant** (montant unique, ou colonnes débit et crédit séparées) et la **convention de signe** (dépenses écrites négatives ou positives) — est désormais enregistré en entier sur la source elle-même et restitué exactement tel que vous l'avez réglé. Jusqu'ici ces deux réglages n'étaient conservés que sur les modèles, et rouvrir une source configurée re-déduisait le mode puis remettait silencieusement la convention sur « dépenses négatives » : une source que vous aviez configurée en dépenses positives revenait sur le défaut à son deuxième import. Reconfigurer une source ne change pas, mais une source configurée une fois n'est plus jamais devinée. Le sélecteur de convention de signe est maintenant masqué en mode débit/crédit, où il ne s'appliquait pas (votre valeur enregistrée reste intacte), et votre configuration n'est écrite qu'au moment où l'import part réellement — abandonner l'assistant à l'étape des doublons ne laisse plus de configuration à moitié enregistrée (#323, #324, #328).
- L'application déverrouille désormais ses modules par édition — **Gratuite**, **Base** ou **Premium**, résolue depuis votre clé de licence. La Gratuite conserve le Tableau de bord, l'Import CSV, les Transactions, les Catégories, le rapport Tendances (et le hub Rapports), l'export/import chiffré et le journal des modifications, avec un seul profil. La **Base** débloque en plus le Budget, les Ajustements, les quatre rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes), les profils multiples et les mises à jour automatiques. La **Premium** débloque en plus le module Bilan complet (patrimoine, détail par titre, cours du marché). Les modules verrouillés restent visibles — un cadenas apparaît dans la barre latérale et sur les tuiles de rapports — et les ouvrir affiche un écran de déverrouillage avec un raccourci « J'ai déjà une clé » vers la carte de licence ; le bouton d'achat en ligne « Obtenir Base / Premium » est affiché mais désactivé pour l'instant (« bientôt disponible »). Le verrouillage n'est jamais destructif : quelle que soit l'édition, vos données sont conservées intactes et tout réapparaît dès qu'une clé valide est entrée — en Gratuite votre profil actif reste toujours accessible, seuls la création d'un profil supplémentaire ou le passage à un autre profil sont verrouillés (#297, #298, #299, #300, #301).
### Corrigé
- Import : un relevé dont les colonnes débit et crédit sont écrites dans cet ordre (`Date;Description;Crédit;Débit`) voyait **tous ses signes inversés** — les dépenses importées en revenus et les revenus en dépenses. Les deux colonnes étaient distinguées par leur position, jamais par leur libellé. Elles sont désormais identifiées par leur en-tête, l'ordre dans lequel elles apparaissent n'a donc plus d'importance (#327).
- Import : chez les nombreuses banques qui écrivent `0,00` dans la colonne inutilisée au lieu de la laisser vide, **chaque dépense s'importait à 0,00** et disparaissait purement et simplement du grand livre, sans aucune erreur. Les montants sont maintenant calculés comme crédit moins débit, ce qui ne demande aucun cas particulier pour une cellule à zéro. Une ligne illisible dans les deux colonnes est signalée comme erreur au lieu d'être écrite en transaction gratuite à 0,00, et une colonne de montant laissée non mappée est signalée d'emblée plutôt que de retomber sur la lecture de la colonne de date (#325).
- Import : les montants portant un code de devise ou un marqueur comptable en suffixe étaient lus **cent fois trop grands**`100,00 CAD` s'importait à 10 000,00 et passait toutes les validations, seuls les chiffres de tête étant lus et le reste ignoré. Une telle valeur est désormais rejetée comme illisible et signalée sur sa ligne. Les formes comptables sont en revanche prises en charge : `(50,00)` et `50,00-` valent tous deux 50,00. La même correction s'applique à l'import CSV de titres dans un snapshot de bilan, où un prix écrit `150,25 CAD` était enregistré à 15 025 et une quantité illisible sauvegardée en silence comme une position de valeur nulle (#325).
- Import : dans un fichier où une colonne utilise la virgule décimale et une autre le point, `1.234` pouvait être lu 1234 à un endroit et 1,234 à l'autre. Le séparateur décimal est maintenant tranché par colonne, à partir des valeurs de cette colonne (#325).
- Import : un relevé à **montants positifs accompagnés d'une colonne d'indicateur « D » / « C »** était indiscernable d'un fichier tout-positif, donc configuré en « dépenses positives », et **chaque dépôt s'importait en dépense**. Ce format est désormais détecté et refusé avec une explication, au lieu d'être importé à l'envers. Réexportez le relevé avec des montants signés, ou avec des colonnes débit et crédit séparées (#327).
- Import : une ligne d'en-tête dont la première cellule commence par des chiffres (une colonne intitulée `2025`, par exemple) était prise pour une ligne de données, et l'en-tête s'importait en transaction. Les lignes d'en-tête sont maintenant reconnues par leurs libellés autant que par leur forme (#325, #327).
- Export/import de données : la restauration d'une sauvegarde n'était pas enveloppée dans une transaction — une violation de contrainte en cours de route abandonnait la restauration alors que les données existantes avaient déjà été effacées, détruisant l'historique financier sans retour arrière. L'effacement et la restauration réussissent ou échouent désormais d'un bloc. Restaurer dans un profil qui possédait déjà des modèles d'import n'échoue plus non plus sur une collision de nom (#331).
### Sécurité
- Mise à jour de deux dépendances Rust situées sur le chemin de la mise à jour automatique, corrigeant six advisories RustSec : `rustls-webpki` 0.103.9 → 0.103.13 (traitement des contraintes de nom de certificat et analyse des listes de révocation, dont une panique atteignable) et `tar` 0.4.44 → 0.4.46 (`chmod` de répertoires arbitraires en suivant des liens symboliques pendant l'extraction, et en-têtes de taille PAX mal pris en compte). Les deux sont tirées par `tauri-plugin-updater` : celle qui gère TLS travaille à chaque vérification et à chaque téléchargement de mise à jour, tandis que celle qui décompresse n'est atteinte que par des formats d'installeur que cette application ne livre pas — elle a été mise à jour quand même, plutôt que contournée par un raisonnement. Aucun changement de comportement. Une autre advisory reste signalée sur le fichier de verrouillage et ne s'applique pas au produit livré : `rsa` n'est jamais compilé, son seul parent étant un pilote MySQL qu'une application SQLite ne construit jamais. Elle est consignée comme acceptée, avec sa justification et les conditions de son retrait, plutôt que de laisser l'audit de sécurité quotidien rouge en permanence. Les deux advisories visant `quick-xml` ont été acceptées de la même façon pendant quelques heures, puis résolues pour de bon : `plist` 1.10.0 livrait déjà le `quick-xml` 0.41.0 corrigé (#310, #312).
- Mise à jour de la dépendance de build `postcss` (8.5.13 → 8.5.23), corrigeant GHSA-r28c-9q8g-f849 (traversée de chemin lors du chargement automatique d'une source map précédente depuis un commentaire `sourceMappingURL`, menant à la divulgation de fichiers `.map` arbitraires). Outillage de build uniquement, aucun changement runtime ni de comportement — le CSS généré est identique octet pour octet. Une advisory reste signalée sur `react-router` et est volontairement laissée en l'état : elle vise le mode React Server Components, qu'une application de bureau locale n'exécute jamais, et aucun correctif n'existe vers lequel avancer puisque `react-router-dom` est figé en 7.18.1 (sortir de la plage concernée demanderait une migration vers react-router v8, pas un changement de version) (#311).
## [0.14.0] - 2026-07-18 ## [0.14.0] - 2026-07-18
### Modifié ### Modifié

View file

@ -2,6 +2,35 @@
## [Unreleased] ## [Unreleased]
### Added
- Import: opening a source you have never configured now **detects its format on its own** — delimiter, encoding, date format, and which column holds the date, the description and the amount. Columns are recognised by their header label in French and in English (`Montant`/`Amount`, `Débit`/`Withdrawal`, `Crédit`/`Deposit`…), and the exported layouts of **Desjardins, RBC, National Bank and Tangerine** are recognised by name. Detection then replays its own decision over every row of the file and reports a **confidence score** ("147 of 150 rows read"): above 90 % the banner is neutral, below it the banner warns and points you at the column mapping and the preview. Nothing is ever blocked by the score — it says what could be read, not whether the meaning is right. The magic-wand button is still there to re-run detection on demand, and a source that already has a saved format is never re-detected (#327, #328).
- Import: the data **preview is now a mandatory step of every import**, no longer an optional pop-up, and it shows a signed recap of what the file will actually write: how many outflows and for what total, how many inflows and for what total, and how many rows could not be read. If inflows and outflows are swapped, an **"Flip the signs"** button fixes them — and because it corrects the source's configuration rather than just this preview, the next file from that bank reads correctly on its own. The confirmation screen now also states the amount mode, the sign convention and the column mapping named by header, so you can check them before the import runs (#329).
- Import: when a bank changes the layout of its export, the wizard now **says so before importing**. The header of every successful import is recorded on the source, and a file whose header no longer matches opens a panel listing the columns that moved, appeared or disappeared ("Amount: column 3 → 4"), with the two choices that exist: adopt the newly detected format, or keep the stored one. A purely cosmetic rename (`Montant` → `MONTANT ($)`) is recognised as the same header and says nothing. Both the drift panel and the sign-flip button now also carry a note explaining the **safe way to repair an import that was already written wrong**: delete it from the import history first, then replay the file — re-importing on top of it would double the rows, since duplicates are matched on date, description and amount together (#330).
- Import: your **import sources and templates are now included in the encrypted backup**. Exporting and re-importing your data used to wipe every import configuration and replace it with a single synthetic "Data Import" source, so restoring a backup meant reconfiguring every bank by hand. Older backup files, which do not carry them, are still restored exactly as before (#331).
- Category migration: custom categories that have no standard match can now be **merged** into a standard category instead of only being set aside. Each custom category in the "Custom categories" block gets the same target picker as the seeded rows — choose a standard leaf and its transactions, budgets, keywords and suppliers are reassigned to it, then the custom category is removed. Leaving a custom category unmapped keeps the previous behaviour (grouped under "Custom categories (migration)") and never blocks the migration (#259).
### Changed
- Import: the format of a source — including the **amount mode** (single amount, or separate debit and credit columns) and the **sign convention** (expenses written negative or positive) — is now stored in full on the source itself and restored exactly as you set it. Until now those two settings were only kept on templates, and reopening a configured source re-derived the mode and silently reset the convention to "expenses negative"; a source you had configured for positive expenses came back on the default at its second import. Reconfiguring a source is unchanged, but a source configured once is never guessed at again. The sign-convention selector is now hidden in debit/credit mode, where it never applied (your stored value is left untouched), and your configuration is written only once the import actually runs — abandoning the wizard at the duplicate-check step no longer leaves a half-saved configuration behind (#323, #324, #328).
- The application now unlocks its modules per edition — **Free**, **Base** or **Premium**, resolved from your license key. Free keeps the Dashboard, CSV Import, Transactions, Categories, the Trends report (and the Reports hub), encrypted export/import and the changelog, with a single profile. **Base** additionally unlocks Budget, Adjustments, the four advanced reports (Highlights, Compare, Category analysis, Cards), multiple profiles and automatic updates. **Premium** additionally unlocks the full Balance module (net worth, per-security detail, market prices). Locked modules stay visible — a lock badge shows in the sidebar and on the report tiles — and opening one shows an unlock screen with an "I already have a key" shortcut to the license card; the online "Get Base / Premium" purchase button is shown but disabled for now ("coming soon"). Locking is never destructive: whatever the edition, your data is kept untouched and everything reappears as soon as a valid key is entered — on Free your active profile always stays accessible, only creating or switching to another profile is locked (#297, #298, #299, #300, #301).
### Fixed
- Import: a statement whose debit and credit columns are written in that order (`Date;Description;Credit;Debit`) used to have **every single sign inverted** — expenses imported as income and income as expenses. The two columns were told apart by their position, never by their label. They are now identified by their header, so the order they appear in no longer matters (#327).
- Import: on the many banks that write `0,00` in the unused column instead of leaving it empty, **every expense used to import as 0,00** and simply vanished from your ledger, with no error anywhere. Amounts are now computed as credit minus debit, which needs no special case for a zero cell. A row that cannot be read in either column is reported as an error instead of being silently written as a free 0,00 transaction, and an amount column left unmapped is now reported up front rather than falling back to reading the date column (#325).
- Import: amounts carrying a trailing currency code or accounting marker were read **a hundred times too large**`100,00 CAD` imported as 10 000,00 and passed every validation, since only the leading digits were read and the rest was discarded. Such a value is now rejected as unreadable and reported on its row. Accounting forms are handled properly instead: `(50,00)` and `50,00-` both read as 50,00. The same fix applies to the CSV import of securities into a balance snapshot, where a price written `150,25 CAD` used to be stored as 15 025 and an unreadable quantity was silently saved as a zero-value position (#325).
- Import: in a file where one column uses a comma for decimals and another a period, `1.234` could be read as 1234 in one place and 1,234 in the other. The decimal separator is now decided per column, from that column's own values (#325).
- Import: a statement with **positive amounts plus a separate "D" / "C" indicator column** used to be indistinguishable from an all-positive file, so it was configured as "expenses positive" and **every deposit imported as an expense**. This layout is now detected and refused with an explanation, instead of being imported backwards. Re-export the statement with signed amounts, or with separate debit and credit columns (#327).
- Import: a header row whose first cell starts with digits (a column titled `2025`, for example) was mistaken for a data row, and the header was imported as a transaction. Header rows are now recognised by their labels as well as their shape (#325, #327).
- Data export/import: restoring a backup was not wrapped in a transaction — a constraint failure partway through would abandon the restore after the existing data had already been wiped, destroying your financial history with no rollback. The wipe and the restore now succeed or fail as a whole. Restoring into a profile that already had import templates also no longer fails on a name collision (#331).
### Security
- Updated two Rust dependencies sitting on the automatic-update path, clearing six RustSec advisories: `rustls-webpki` 0.103.9 → 0.103.13 (certificate name-constraint handling and certificate-revocation-list parsing, including a reachable panic) and `tar` 0.4.44 → 0.4.46 (arbitrary directory `chmod` by following symlinks during extraction, and mishandled PAX size headers). Both are pulled in by `tauri-plugin-updater`: the TLS one runs on every update check and download, while the archive one is only reached by installer formats this app does not ship — it was updated anyway rather than reasoned around. No behaviour change. One further advisory is still reported against the lockfile and does not apply to the shipped product: `rsa` is never compiled at all, its only parent being a MySQL driver that a SQLite application never builds. It is recorded as accepted, with its justification and the conditions for removing it, instead of leaving the daily security audit permanently red. The two advisories against `quick-xml` were accepted the same way for a few hours, then resolved outright: `plist` 1.10.0 turned out to ship the fixed `quick-xml` 0.41.0 (#310, #312).
- Updated the build-time dependency `postcss` (8.5.13 → 8.5.23), clearing GHSA-r28c-9q8g-f849 (path traversal while auto-loading a previous source map from a `sourceMappingURL` comment, leading to arbitrary `.map` file disclosure). Build tooling only, no runtime or behaviour change — the generated CSS is byte-for-byte identical. One advisory remains reported against `react-router`, and is deliberately left as is: it targets the React Server Components mode, which a local desktop app never runs, and no fix exists to move forward to since `react-router-dom` is frozen at 7.18.1 (leaving the affected range would mean migrating to react-router v8, not bumping a version) (#311).
## [0.14.0] - 2026-07-18 ## [0.14.0] - 2026-07-18
### Changed ### Changed

View file

@ -43,7 +43,7 @@ src/
│ ├── budget/ # Budget │ ├── budget/ # Budget
│ ├── categories/ # Catégories hiérarchiques │ ├── categories/ # Catégories hiérarchiques
│ ├── dashboard/ # Tableau de bord │ ├── dashboard/ # Tableau de bord
│ ├── import/ # Wizard d'import (13 composants) │ ├── import/ # Wizard d'import (14 composants)
│ ├── layout/ # AppShell, Sidebar │ ├── layout/ # AppShell, Sidebar
│ ├── profile/ # Profils (PIN, formulaire, switcher) │ ├── profile/ # Profils (PIN, formulaire, switcher)
│ ├── reports/ # Graphiques et rapports │ ├── reports/ # Graphiques et rapports
@ -70,7 +70,7 @@ src-tauri/
│ │ ├── schema.sql # Schéma initial (v1) │ │ ├── schema.sql # Schéma initial (v1)
│ │ ├── seed_categories.sql # Seed catégories (v2) │ │ ├── seed_categories.sql # Seed catégories (v2)
│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils) │ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils)
│ ├── lib.rs # Point d'entrée, 7 migrations inline, plugins │ ├── lib.rs # Point d'entrée, 17 migrations inline, plugins
│ └── main.rs │ └── main.rs
└── Cargo.toml └── Cargo.toml
``` ```
@ -86,7 +86,7 @@ src-tauri/
## Fonctionnalités principales ## Fonctionnalités principales
- **Import CSV** : wizard multi-étapes, détection auto de l'encodage/délimiteur, templates de config, déduplication par fichier - **Import CSV** : wizard multi-étapes, détection auto (encodage, délimiteur, colonnes par libellé FR/EN, formats de banques connus) avec score de confiance, **aperçu obligatoire à récap signé** avant tout import, templates de config, déduplication par fichier. Le format d'import est **persisté en entier sur la source et jamais re-deviné** — [ADR 0019](docs/adr/0019-format-import-persiste.md)
- **Catégorisation** : automatique (mots-clés avec priorité) et manuelle, drag-and-drop pour réorganiser - **Catégorisation** : automatique (mots-clés avec priorité) et manuelle, drag-and-drop pour réorganiser
- **Transactions** : filtrage, tri, split sur plusieurs catégories, notes - **Transactions** : filtrage, tri, split sur plusieurs catégories, notes
- **Budget** : grille 12 mois, templates réutilisables, budget vs réel - **Budget** : grille 12 mois, templates réutilisables, budget vs réel
@ -118,8 +118,8 @@ src-tauri/
## Base de données ## Base de données
- **20 tables** SQLite, **24 index** (voir `docs/architecture.md` pour le détail). Le module Bilan en représente 7 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`, puis `balance_securities` + `balance_snapshot_holdings` ajoutées en Étape 2 — détail par titre) et 9 index - **20 tables** SQLite, **24 index** (voir `docs/architecture.md` pour le détail). Le module Bilan en représente 7 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`, puis `balance_securities` + `balance_snapshot_holdings` ajoutées en Étape 2 — détail par titre) et 9 index. Ces deux comptes sont inchangés depuis v13 : v14-v16 ajoutent 2 tables + 2 index (déjà comptés), v17 est un `ALTER TABLE` pur
- **16 migrations inline** dans `lib.rs` (v1→v16, via `tauri_plugin_sql::Migration`). Étape 2 (détail par titre) : v14 (`balance_securities` + `balance_snapshot_holdings` + 2 index), v15 (`balance_accounts.kind` + `detailed_since` + backfill), v16 (conversion des comptes cotés existants en détaillés 1-position). Voir [ADR 0015](docs/adr/0015-balance-detail-par-titre.md) - **17 migrations inline** dans `lib.rs` (v1→v17, via `tauri_plugin_sql::Migration`). Étape 2 du Bilan (détail par titre) : v14 (`balance_securities` + `balance_snapshot_holdings` + 2 index), v15 (`balance_accounts.kind` + `detailed_since` + backfill), v16 (conversion des comptes cotés existants en détaillés 1-position) — voir [ADR 0015](docs/adr/0015-balance-detail-par-titre.md). Import : v17 (`import_sources.amount_mode` + `sign_convention`, les deux avec `CHECK`, + `header_signature` + `template_id`) — voir [ADR 0019](docs/adr/0019-format-import-persiste.md)
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils - **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle - Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
@ -159,9 +159,15 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
## CI/CD ## CI/CD
- **`check.yml`** (Forgejo Actions + miroir GitHub) — déclenché sur chaque push de branche (sauf `main`) et chaque PR vers `main`. Lance `cargo check`, `cargo test`, `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. Workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** — les jobs se suivent, ils ne tournent pas en parallèle.
- **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, `cargo test` et un `cargo audit` informatif. Doit être vert avant tout merge.
- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown. Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` remonte 2 high en permanence — une seule advisory `react-router` (mode RSC, inatteignable dans une app de bureau sans serveur), acceptée et suivie en [#317](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/317).
- **`audit.yml`** — audit RustSec quotidien (06:00 UTC) + `workflow_dispatch`, pour couvrir les avis de sécurité entre deux PR Rust. Échec bloquant. **Un run vert signifie « zéro advisory hors de la liste acceptée »**, pas « zéro advisory » : cette liste est dans `.cargo/audit.toml` (preuve de non-atteignabilité et condition de retrait par entrée), encadrée par l'[ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md). Ne jamais y ajouter une advisory atteignable ni élargir une entrée à un crate entier.
- **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques. - **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques.
Aucun workflow `check-*` ne filtre sur `branches:` : une PR stackée sur une autre branche de feature déclenche donc bien la CI. Le cache Actions est retiré partout tant que [#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234) (connectivité du serveur de cache) n'est pas réglé — restore et save échouent tous les deux. Le miroir `.github/workflows/` est dormant (aucune PR côté GitHub).
--- ---
## Ressources clés ## Ressources clés

View file

@ -1,6 +1,8 @@
# STATE — Simpl'Résultat # STATE — Simpl'Résultat
> Derniere MAJ : 2026-07-12 (**#259 recadrée** après `/analyze` : son corps décrivait un flux inexistant du Bilan, le vrai sujet est le **mapping manuel des catégories custom** en migration de taxonomie → réécrite `status:ready`, **hors epic #260** ; l'epic #260 n'a donc plus de reliquat. Précédemment 2026-07-11 : **M2 « rapports-parite » #277-#279 shippée** sur `main` `a982f9e` — /pr-review ×3 [APPROVE #285/#286, REQUEST_CHANGES #287 → point I7 « filtrage Cartes » confirmé + fix `9ee5ad3`], merge local pile de 3 + union CHANGELOG, tip cumulé validé **811 vitest** + 98 Rust, réconciliation Forgejo complète. **30 commits non taggés depuis `v0.12.0`** — release en attente) > Derniere MAJ : 2026-08-15 (**PRs #321 et #322 reviewées, corrigées et mergées ff-only** (`main` `97d376b`) — les deux dormaient depuis 18 jours, chacune avec une revue du 2026-08-14 non traitée. **#321 APPROVE** : `plist 1.10.0` remonte `quick-xml` à `0.41.0`, la version corrigée, dans la borne existante de `tauri` → RUSTSEC-2026-0194/-0195 **résolues, plus acceptées**, `.cargo/audit.toml` réduit à `rsa` seul, `deep-link` dé-yanké. Point **inversé** par la revue de re-vérification : les 7 réaiguillages `windows-sys` **remontent** à `0.61.2`, soit l'état exact du tag `v0.14.0` (vérifié crate par crate) — c'était `main` qui portait la configuration jamais compilée sous Windows, la PR la supprime au lieu de l'ajouter. **#322 REQUEST_CHANGES puis corrigée avant merge** : la checklist QA inversait la séquence d'états Linux — `downloadAndInstall` télécharge **et** installe dans le même appel (`updater.rs:723-729`, `Finished` no-op en `useUpdater.ts:119-121`), donc l'invite pkexec s'ouvre pendant « Téléchargement en cours… » et `readyToInstall` n'arrive qu'**après** `dpkg -i` ; l'ordre des cases **neutralisait le prérequis polkit** que la page existe pour imposer (sans agent l'app se fige en `downloading`, le testeur rapporte « le téléchargement bloque »). #312/#313/#315 fermées. **#314 est caduque** : `audit.yml` tire tous les jours à 06:00 UTC, **19 runs `schedule` verts** depuis le 2026-07-28 — l'issue a été écrite le 07-27 au soir, avant que le premier tir programmé ait pu exister. CI runs 370/371 vertes, garde-fou d'audit tracé (`Suppression guard: 2 checks, exit 0`), **111 Rust**, 0 vulnérabilité. Candidate **v0.15.0**. Rappels infra : `main` est **protégée** (whitelist push `maximus`) + remote ancré `https://maximus@…`)
>
> Précédente MAJ : 2026-08-14 (**Chantier import CSV livré — milestone `planned-2026-08-12-import-csv-format` 10/10, pile de 10 PRs #333-#342 mergée ff-only** (`main` `37b832e`). Le symptôme rapporté par Max — « l'app oublie le format, y compris les colonnes montant positif/négatif » — n'était pas une faiblesse d'heuristique mais un **trou de persistance** : `import_sources` ne portait ni `amount_mode` ni `sign_convention` (ils n'existaient que sur `import_config_templates`), donc `useImportWizard.ts:323` réécrivait `signConvention: "negative_expense"` **en dur** à chaque restauration et une source réglée en montants positifs inversait tous ses montants au 2e import, sans erreur. **Migration v17** (4 colonnes + `CHECK`, backfill `LIKE '%debitAmount%'` reproduisant la règle runtime → aucune source ne change de comportement) ; codec unique `formatToRow`/`formatFromRow` à test de complétude ; `credit debit` sur magnitudes ; `parseFrenchAmount` **ancré** ; détection par libellé d'en-tête ; score de confiance (seuil 90 %) ; **aperçu obligatoire à récap signé** ; signatures de banques + dérive de format ; sources et modèles enfin sauvegardés dans l'export SREF. **871 → 1181 vitest**, **106 → 111 Rust**. 20 tables / 24 index **inchangés** (v17 est un ALTER pur). ADR 0019. Candidate **v0.15.0**. Rappels infra : `main` est **protégée** (whitelist push `maximus`) + shadowing d'identité dans `~/.git-credentials` → remote ancré `https://maximus@…`)
## Position actuelle ## Position actuelle
@ -8,7 +10,7 @@ v0.9.1 shippée (2026-05-10). Milestones `spec-refonte-rapports`, `spec-refonte-
Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. **#228 mergé** (PR #229, fix-forward : garde d'abort v16 scopée aux comptes convertibles via `JOIN balance_categories` + `c.asset_type IS NOT NULL` dans les 3 copies — Migration v16 / V16_SQL / V16_CORRUPT — + test régression ; CI vert car #229 ciblait `main`) → milestone `overnight-2026-06-05-bilan-detail-titres` complète **10/10 et fermée**. **v0.10.0 shippée** (2026-06-29 : Étapes 1+2 du bilan, migrations v12→v16) **puis hotfix v0.10.1** (2026-06-30, PR #230, déployé) — corrige un « database is locked » introduit en 0.10.0 (apparaissait après l'abandon d'un snapshot en cours) : tout l'accès DB est désormais sérialisé via `withTransaction` dans `db.ts` (tauri-plugin-sql = pool sqlx multi-connexions sans primitive de transaction JS → `BEGIN`/`COMMIT` en `db.execute` séparés pouvaient strander une transaction d'écriture = verrou zombie), appliqué aux 5 sites transactionnels ; + console de log live-update + API `logInfo/logWarn/logError`. 20 tables / 24 index, 16 migrations (v1→v16). Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. **#228 mergé** (PR #229, fix-forward : garde d'abort v16 scopée aux comptes convertibles via `JOIN balance_categories` + `c.asset_type IS NOT NULL` dans les 3 copies — Migration v16 / V16_SQL / V16_CORRUPT — + test régression ; CI vert car #229 ciblait `main`) → milestone `overnight-2026-06-05-bilan-detail-titres` complète **10/10 et fermée**. **v0.10.0 shippée** (2026-06-29 : Étapes 1+2 du bilan, migrations v12→v16) **puis hotfix v0.10.1** (2026-06-30, PR #230, déployé) — corrige un « database is locked » introduit en 0.10.0 (apparaissait après l'abandon d'un snapshot en cours) : tout l'accès DB est désormais sérialisé via `withTransaction` dans `db.ts` (tauri-plugin-sql = pool sqlx multi-connexions sans primitive de transaction JS → `BEGIN`/`COMMIT` en `db.execute` séparés pouvaient strander une transaction d'écriture = verrou zombie), appliqué aux 5 sites transactionnels ; + console de log live-update + API `logInfo/logWarn/logError`. 20 tables / 24 index, 16 migrations (v1→v16).
**Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows). **Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). **Ce « 0 » n'est plus l'état courant** : des advisories publiées depuis l'ont ramené à 3, traitées en #311 le 2026-07-27 → **2 restantes, acceptées** (`react-router`, cf. entrée du jour). Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows).
**v0.11.0 shippée** (2026-07-05, tag `v0.11.0` → CI release Windows/Linux + JSON updater) — milestone `overnight-2026-07-05-bilan-rapports-ux` complétée **5/5 et fermée** via un run `/autopilot` (5 workers séquentiels, PRs #248-#252 toutes `/pr-review` APPROVE, puis merge local de la pile + réconciliation Forgejo). Contenu : rapport comparable réel-vs-réel **hiérarchique** avec sous-totaux (#247) ; **netting des transferts** ciblé (catégories type `transfer` → SUM signé ~0, autres types inchangés au byte) dans le compare (#243) ; landing Bilan en **tuiles** `HubReportNavCard` + guard empty-state découplé + gestion comptes accessible partout (#244) ; **import CSV de titres** dans un snapshot détaillé (front pur, `autoDetectHoldingColumns`, prix flexible) (#245) ; cible de **migration catégories éditable** sur chaque ligne + type-ahead `CategoryCombobox` feuilles-seulement (#246). Aucune migration DB (v1→v16 inchangées), 20 tables / 24 index, 16 migrations. Point de suivi non bloquant : nets de transfert signés traversent du code UI compare pensé pour des magnitudes positives (mord seulement les transferts déséquilibrés à une jambe). **v0.11.0 shippée** (2026-07-05, tag `v0.11.0` → CI release Windows/Linux + JSON updater) — milestone `overnight-2026-07-05-bilan-rapports-ux` complétée **5/5 et fermée** via un run `/autopilot` (5 workers séquentiels, PRs #248-#252 toutes `/pr-review` APPROVE, puis merge local de la pile + réconciliation Forgejo). Contenu : rapport comparable réel-vs-réel **hiérarchique** avec sous-totaux (#247) ; **netting des transferts** ciblé (catégories type `transfer` → SUM signé ~0, autres types inchangés au byte) dans le compare (#243) ; landing Bilan en **tuiles** `HubReportNavCard` + guard empty-state découplé + gestion comptes accessible partout (#244) ; **import CSV de titres** dans un snapshot détaillé (front pur, `autoDetectHoldingColumns`, prix flexible) (#245) ; cible de **migration catégories éditable** sur chaque ligne + type-ahead `CategoryCombobox` feuilles-seulement (#246). Aucune migration DB (v1→v16 inchangées), 20 tables / 24 index, 16 migrations. Point de suivi non bloquant : nets de transfert signés traversent du code UI compare pensé pour des magnitudes positives (mord seulement les transferts déséquilibrés à une jambe).
@ -16,20 +18,21 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
## Decisions recentes ## Decisions recentes
- 2026-08-15 : **#321 + #322 mergées ff-only (`main` `97d376b`) — deux PRs de 18 jours, deux revues du 08-14 jamais traitées, et une revue qui se corrige elle-même.** Passes de re-vérification lancées depuis le main loop (jamais en sous-agent, [[feedback-pr-review-subagent-forks]]) et séquencées plutôt que parallélisées — deux revues concurrentes partagent le même chemin de scratch et s'écrasent entre le Write et le POST (gotcha du chantier import). **Les deux passes ont retracé les claims depuis la source au lieu de relayer, et les deux ont trouvé une erreur dans la revue qu'elles re-vérifiaient.** (1) Sur #321, la revue du 08-14 lisait les 7 réaiguillages `windows-sys` comme une dérive de re-résolution et demandait un build Windows avant de tagger : **le sens est l'inverse**#310 les avait fait *descendre* de `0.61.2` vers 0.48/0.52/0.59, cette PR les *remonte* à `0.61.2`, l'état exact du tag `v0.14.0` (`git show v0.14.0:src-tauri/Cargo.lock`, crate par crate). v0.14.0 taggée le 07-18, #310 mergée le 07-27 → le build NSIS de v0.14.0 a réellement compilé contre `0.61.2`. **La configuration non prouvée sous Windows était celle de `main`** ; la PR réduit le risque. (2) Sur #322, la revue du 08-14 annonçait « trois » déclencheurs et mappait `UpdateCard.tsx:201` à la page d'erreur — faux dans les deux sens : `:201` est le retry de l'état `error` de la carte, et `ErrorPage.tsx:82` est un **4e** site distinct qui **détecte seulement** (`handleCheckUpdate` s'arrête à `setUpdateStatus("available")`). Appliquer « écrire trois » aurait échangé une inexactitude contre une autre. **Le blocage de #322 était réel et non traité** : la checklist demandait de cocher `readyToInstall` **avant** l'invite d'authentification, alors que `download_and_install` fait `download(...).await?` puis `install(bytes)` dans le même appel (`updater.rs:723-729`), que `Finished` est un no-op côté UI (`useUpdater.ts:119-121`) et que `READY_TO_INSTALL` n'est dispatché qu'après le retour de `dpkg -i` — l'invite pkexec s'ouvre donc **par-dessus « Téléchargement en cours… »**. Conséquence exacte de l'ordre des cases : sans agent polkit l'app se fige en `downloading`, le testeur guette un blocage après `readyToInstall`, et rapporte « le téléchargement bloque » au lieu de « pas d'agent polkit » — **la page produisait la fausse trace qu'elle existe pour éliminer**. Corrigé en `97d376b` avec 3 mineurs (les 4 déclencheurs, les préfixes `/api/v1/packages/` (DELETE) vs `/api/packages/` (PUT) que `release.yml:217-219` commente déjà, ordre du changelog `SKILL.md`) + titre de PR (« step 9 » → « as step 10 »). Merge : rebase des 2 branches (15 commits de retard) en pile linéaire, **preuve que le rebase préserve `.cargo/`/`.forgejo/`/`Cargo.lock` au byte près** vs le commit validé par la CI run 338, puis CI re-jouée quand même (runs 370/371 verts, garde-fou **exécuté et tracé**, `cargo audit` 0 vulnérabilité / 22 warnings autorisés) et `cargo test` **111 passed** — le « 106 » de la description de #321 était périmé, le chantier import ayant ajouté 5 tests. Issues #312/#313/#315 auto-fermées par `Resolves`, PRs fermées à la main (Forgejo ne détecte pas un merge local comme *merged*), branches supprimées. **#314 trouvée caduque au passage** : `audit.yml` tire bien tous les jours à 06:00 UTC — 19 runs `schedule` verts du 07-28 au 08-15 — et son `workflow_dispatch` de référence datait du 07-27 à 23:56Z, soit ~6 h avant le premier tir programmé possible. L'issue a été écrite avant que le scheduler ait pu s'exprimer, pas après l'avoir vu échouer. (ref #312, #313, #314, #315, PRs #321/#322)
- 2026-08-14 : **Import CSV — chantier complet livré (10 issues, migration v17)**. Cycle intégral en une session : revue d'implémentation → `/spec``/review-spec``/plan-run``/autopilot``/pr-review` ×10 → merge ff-only. **Le diagnostic a tenu, mais trois choses ont été trouvées en cours de route que personne n'avait vues.** (1) Au re-ancrage Phase 3b : `dataExportService` ne sérialise ni `import_sources` ni `import_config_templates` et fait `DELETE FROM import_sources` à la restauration — **restaurer une sauvegarde détruisait toutes les configurations d'import**, 3e voie de perte du format, indépendante du bug racine (→ #331, + `withTransaction` que le service n'avait nulle part). (2) Un worker a **mesuré** que l'exemple « Solde 2024 » de l'issue n'est pas défectueux (`parseFloat` s'arrête sur le `S`) et a ajouté la fixture qui l'est vraiment. (3) Un autre a mesuré que chez RBC `CAD$` et un `Cheque Number` presque vide **sont** sparse-complementary : le générique les apparie et une ligne de chèque s'importe à 234,05 au lieu de 6,95 — d'où l'override par signature. **Correction de la revue initiale** : `parseFrenchAmount("50,00-")` ne rend pas 50 mais **5000**`"100,00 CAD"` → 10000, `"1 234,56 CR"` → 123456 : erreur de **facteur 100** qui passait `isNaN` et comptait donc la ligne comme VALIDE. Le ×100 atteignait aussi l'import de titres #245. Durcissement global assumé : un relevé à suffixe de devise produit désormais des **lignes en erreur** plutôt que des valeurs fausses. **`/pr-review` a bloqué 3 des 10 PRs, tous fondés** : (a) #336 — la règle débit/crédit testait `isNaN(debit) && isNaN(credit)`, donc une cellule illisible à côté du remplissage `0,00` calculait 0 0 = 0 et importait en silence, **le bug même que la PR fermait, une cellule plus loin** (rejoué sur sa propre fixture : 6 transactions à 0,00 $, 0 erreur) ; (b) #337`detectDescriptionColumn` retournait la colonne préférée **sans veto par les données**, et le mot-clé `transaction` capturait la colonne DEBIT/CREDIT de Tangerine, tuant la catégorisation ; (c) #340**régression réelle** : la signature Desjardins est 4 libellés génériques appariés par sous-ensemble, et une signature court-circuitait le scan sparse-complementary → un fichier `Date;Description;Débit;Crédit;Montant;Solde` lu correctement AVANT devenait `positive_expense` APRÈS, chaque dépôt en dépense. Corrigés en 3 commits sur la tête de pile (`484c4be`, `ef7de3c`, `37b832e`), **chacun vérifié par mutation** — et cette vérification a révélé qu'un de mes propres tests **passait la mutation** (le fix sur les signatures génériques empêchait la branche gardée d'être atteinte) : il a fallu un fichier reconnu par une signature *légitime* pour l'exercer. Leçon : un garde qui ne peut pas échouer ne garde rien, y compris quand c'est soi qui l'écrit. Incident run : le worker #331 tué par une limite de session pendant sa validation — travail récupéré depuis son worktree et revalidé de zéro (le rationale de sa PR est une reconstruction, signalé comme tel). Gotcha parallélisation : deux agents de revue partageaient `scratchpad/review.md`, l'un a écrasé l'autre entre Write et POST → **donner un chemin de scratch unique par agent**. Suivis ouverts : refus du format à indicateur borné à `amountCol ± 1` ; dérives de comptes préexistantes dans `CLAUDE.md`/`architecture.md` (mesurées et documentées, non corrigées). (ref #323-#332, PRs #333-#342)
- 2026-07-27 : **#310 + #311 mergées — `cargo audit` 9→0, `npm audit` 3→2 acceptées** (`main` `a14258b`, ff-only ; PRs #316#318 stackées, CI verte des deux côtés). Parties d'un `/analyse-vulnerabilite` : rapport Défenseur **écarté comme source** (VPS injoignable — SSH Tailscale en attente d'auth navigateur, aucun lien imprimé ; rapport local du 2026-05-06, 82 jours) → vérité live = `cargo audit 0.22.2` + `npm audit` sur `main`, advisory-db `0bfde9d6`. **Le triage d'origine de #310 révisé sur un point** : `quick-xml` (2× 7.5 high) n'est **compilé sur aucune des deux cibles livrées**`cargo tree -i` vide sur `x86_64-pc-windows-msvc` **et** `x86_64-unknown-linux-gnu`, présent seulement sur `x86_64-apple-darwin` via `plist`←`tauri` (dép. conditionnelle Apple). Il n'y avait donc **rien à attendre de Tauri**, contrairement au « probablement bloqué en amont » du corps. Méthode retenue : tracer **par triple explicite**, jamais `--target all` (qui répond « présent » pour des deps Apple jamais compilées ici) — mémoire [[reference-cargo-audit-reachability-par-cible]]. 6 advisories atteignables corrigées par `cargo update -p rustls-webpki -p tar` (0.103.9→**0.103.13** couvrant les 4, 0.4.44→**0.4.46**) sans toucher `Cargo.toml` ; dérive de lock expliquée et bornée (7 pointeurs `windows-sys` repointés vers des versions **déjà présentes**, 666 paquets avant/après, deps Windows-only → no-op sur Linux ; `--precise` donne le même résultat, c'est la re-résolution de cargo 1.94.1). Les 3 restantes → `.cargo/audit.toml` versionné, lu par les deux workflows sans qu'aucun ne le sache. **Le plan checker a levé un MAJOR fondé** : une liste de suppressions sans déclencheur de retrait **inverse** le problème de l'alarme (l'audit resterait vert si `quick-xml` redevenait atteignable) — d'où 3 règles en **ADR 0018** : admission sur preuve par cible livrée ou absence de correctif ; clé **par ID d'advisory jamais par crate** (une nouvelle advisory sur le même crate repasse au rouge) ; **garde-fou bloquant** dans `check-rust.yml`, placé après `cargo check` (index chaud) avec `--locked`, testant le **code de sortie séparément de la sortie** (un crate absent et un `cargo tree` en panne impriment tous deux du vide) + **canari** `tar`. Le pendant — suppression devenue *inutile* — est #312. **Leçon du 1er run CI** : le garde était **muet en cas de succès**, donc indiscernable dans les logs d'une étape non exécutée — exactement le mode d'échec silencieux que la PR combat (même famille que le « glob `src-tauri/**` non prouvé » de #232) → il logge maintenant chaque vérification (`Suppression guard: 4 checks, exit 0`, visible run 336). Preuve que `.cargo/audit.toml` est bien lu en conteneur : le `workflow_dispatch` de `audit.yml` sur la branche est **vert**, alors que le bump seul n'aurait éliminé que 6 des 9. **npm** (#311) : `postcss` 8.5.13→**8.5.23 sans override** — `vite` déclare `^8.5.3`, seul le lock était périmé (≠ cas #241 où le parent pinnait) ; `nanoid` suit dans sa borne ; **CSS émis identique octet pour octet** (postcss *est* le pipeline CSS, un build vert n'aurait prouvé que la compilation) ; `react-router` **accepté** — advisory mode **RSC**, or `App.tsx:109` monte un `BrowserRouter` client-only, et `react-router-dom` est **figé à 7.18.1** (v8 a fusionné le paquet dans `react-router`) donc le « fix » npm est un **downgrade** en 7.11.0 → sortir de la plage = migration, pas bump (#317, titre portant `react-router` pour la dédup par sous-chaîne de `/analyse-vulnerabilite`). Asymétrie à connaître : **aucun `npm audit` en CI**, donc rien ne rougit côté front — écrit dans `architecture.md` + `CLAUDE.md` pour que les 2 high permanents ne se lisent pas comme une régression. Gotchas Forgejo relevés : logs de job **uniquement via l'URL web** (l'API v1 répond 404 partout) et `head_branch` = `#<PR>` sur les runs `pull_request` → [[reference-forgejo-ci-logs-et-head-branch]]. 106 Rust + 871 vitest + builds verts. Aucune migration DB (v1→v16). (ref #310, #311, PRs #316/#318, #312-#315, #317)
- 2026-07-27 : **#232 mergée — CI split, caches morts retirés, `cargo-audit` pré-buildé** (`main` `7779f7d`, PR #309 rebase, milestone `spec-ci-build-optimization` **3/4**). `/analyze` a confirmé la prémisse mais **révisé le cadrage sur 3 points mesurés** : (1) les 2 jobs ne tournent **pas en parallèle** — runner à capacité 1, `frontend` démarre à la seconde où `rust` finit sur tous les runs de l'historique → chaque PR payait rust+frontend ≈ 24,5 min ; (2) **3 des 40 derniers commits first-parent** touchent `src-tauri/`, dont 2 `chore: release` (push sur `main`, ne déclenche pas la CI) → le path-filter est le **gain dominant**, pas un effet de bord ; (3) le risque « required check skippé » est **nul** (`enable_status_check: false` sur la protection de `main`, vérifié API). Chrono re-mesuré du run 326 : **12m15s de gaspillage sur 21m44** — save `target/` 6m11 + save registry 43s (`reserveCache failed`, cause #234) + `cargo install cargo-audit` 4m41 + restores ~40s ; **nouveau vs baseline #231** : le restore **timeout aussi** désormais (`getCacheEntry failed`), le 30 juin n'avait qu'un MISS propre. Résultat : **rust 21m44 → 8m55** (59 %), **frontend 2m28 → 1m43**, **PR frontend-only 24m12 → 1m43** (93 %). **Trou de couverture fermé** : `branches: [main]` ne matchait pas une PR stackée → **#305-#308 (pile feature-gating) n'ont eu aucune CI**, seules #303/#304 (base `main`) ont tourné ; plus aucun filtre `branches:`. 2 écarts assumés vs le corps d'origine, tranchés sur mesure : cache npm retiré aussi (23s/run) et **denylist** pour le frontend (job à 2,5 min → le faire tourner pour rien est bon marché, ne PAS le faire tourner silencieusement ne l'est pas ; le job cher garde un allowlist strict). `/pr-review` **APPROVE** — a récupéré les logs CI plutôt que juger le diff, et levé que l'override `PATH` job-level aurait pu masquer le binaire d'`install-action` (vérifié : audit bien exécuté) ; 2 des 6 suggestions appliquées (`.claude/**` au denylist, ref `check.yml` périmée dans le skill `release`). **Skip prouvé empiriquement** : le 2e push (ni `src-tauri/**` ni `check-rust.yml`) n'a **pas** déclenché `check-rust`. **Reste non prouvé — le glob `src-tauri/**` lui-même** (seule l'entrée chemin-exact a matché) : mode d'échec **silencieux** sur la PR Rust (1/40, ni `cargo check` ni `cargo test`) → #310 (`cargo update` touchant `Cargo.lock` seul) sera le test isolé, à surveiller. Le retrait du `|| true` a **découvert 9 advisories RUSTSEC réelles** (préexistantes, seulement masquées) → **#310** ouverte : `rustls-webpki` ×4 + `tar` ×2 atteignables via `tauri-plugin-updater`/`reqwest` (correctifs **patch**), `quick-xml` ×2 bloqué en amont par `plist`←`tauri` (bump majeur), `rsa` sans correctif publié mais seul parent `sqlx-mysql` — absent de tout arbre de compilation (`cargo tree -i rsa --target all` vide, projet SQLite) donc vraisemblablement **inatteignable**. Conséquence : `audit.yml` (quotidien, bloquant par choix) sera **rouge dès son 1er run** tant que #310 n'est pas traitée — alarme permanente = alarme ignorée, donc à lander tôt. Aucune migration DB (v1→v16). (ref #232, PR #309, #310)
- 2026-07-21 : **Milestone `planned-2026-07-19-feature-gating` livrée 6/6 et fermée — le gating par tier est sur `main`** ([Unreleased], candidate v0.15.0). Reprise du run `/autopilot` interrompu le 19 au soir (mort après la PR #303 ; worker #298 tué sans commit — 2 worktrees + branche vide nettoyés). Séquence : `/pr-review` #303 **REQUEST_CHANGES** (le retry backoff CWE-703 s'armait aussi sur un échec de `submitKey` → l'auto-refresh effaçait le message « clé invalide » ~1 s après ; et `status: "error"` persistant aurait laissé `ready=false` à jamais pour les futurs RequireFeature) → fix `b9e13b5` : validation de clé **orthogonale** au lifecycle de chargement (`validationError` dédié, `status` intouché, reducer exporté + 6 tests) → **APPROVE** → merge API. Relance `/autopilot` en session : **5 workers séquentiels en pile linéaire** (#302 dépend de tout ; CHANGELOG centralisé dans #302 → zéro conflit inter-maillons) → PRs #304-#308, 0 needs-clarification, 0 blocked ([rapport](reports/DAILY-REPORT-2026-07-20.md)). `/pr-review` **×5 parallèles → APPROVE ×5** ; seule retouche user-facing : coquille FR `docs.editions` (« tout la » → « tout de la », `6de9617`). Merge : tip cumulé validé (871 vitest + build + cargo 106) puis **ff-only** → push `main` **refusé : branche protégée** (whitelist `maximus`, règle du 2026-03-07, jamais rencontrée avant) — cause réelle : `~/.git-credentials` porte 3 identités Forgejo et `defenseur-auto-bot` en 1re position **shadow** `maximus` (git prend la 1re entrée du host ; les pushes de branches passaient, seule `main` whitelistée refusait). Fix scopé : `git remote set-url origin https://maximus@…` (ne PAS réordonner le fichier partagé — les agents defenseurs s'authentifient par lui). Push OK → 5 issues auto-fermées (`Resolves #N`), 5 PRs fermées + commentées, milestone 6/6 fermée, branches supprimées (+ purge de 7 branches `worktree-agent-*` du harness ; le « résidu `origin/issue-259` » signalé au warmup était un tracking ref périmé faute de `fetch --prune` — la branche remote avait bien été supprimée le 19). Décisions workers notables : tuiles `ProfileSelectionPage` **non** verrouillées (page atteinte seulement sans profil actif résoluble → un verrou heuristique risquerait un soft-lock hors de tous les profils) ; dev-override en **tête** de résolution (sinon un compte Premium actif masquerait `SR_DEV_EDITION`) ; `#[serde(default)]` préexistant sur `features` = les licences Base déjà émises sans le champ ne régressent pas. Gotcha process : un fork `/pr-review` lancé pendant que la CI est `pending` se termine « en attente du monitor CI » **sans jamais poster** (rien ne survit au fork) → monitorer la CI depuis le main loop, relancer le skill après le vert. Post-merge non bloquant : extraction `LockBadge`/`UpsellPanel` si une 3e surface apparaît ; label PIN « (annuler) » (bug cosmétique préexistant) ; job CI ponctuel `--features dev-override`. ADR 0017 Accepted. Aucune migration DB (v1→v16). (ref planned-2026-07-19-feature-gating, #297-#302, PRs #303-#308)
- 2026-07-19 : **Chantier gating par tier planifié via `/spec` → milestone `spec-feature-gating` (#297-#302)**. Origine : réflexion monétisation de Max (« changer le périmètre des abonnements »). Matrice tranchée : **Free** = Dashboard/Import/Transactions/Catégories/**rapport Tendance**/Export chiffré/Changelog (mono-profil) ; **Base** = + les 4 autres rapports/Budget/multi-profils/auto-update ; **Premium** = + Bilan complet (patrimoine + cours). Admin (Max) = licence Premium auto-émise (pas de 4e édition). Décisions clés : upsell **verrouillé** (pas masqué) ; durcissement **sec non destructif** (blocage d'accès, données conservées, récupérables par upgrade) ; entitlements **statique + override `features[]`** ; **enforcement UI-only** (soft-paywall GPL assumé, seul le gate cours reste dur/server-enforced) ; **#271 absorbé** (auto-update Base+, fermé superseded). Re-ancrage Phase 3b : socle déjà là (3 éditions Ed25519, `current_edition`, `check_entitlement`) mais `is_feature_allowed` ignore `features[]` + `useLicense` per-appel → **LicenseProvider** requis pour un `useEntitlement` sync ; rapports = routes distinctes (gate par route) ; #271 = 1 ligne. 6 issues (socle→garde UI→routes/sidebar→multi-profils→#271 Rust→docs), specs force-add (gitignorées, précédent #295), aucune migration DB. **`/review-spec` 3 experts → verdict 🟡, tout corrigé dans le plan** : 2 🔴 (nav `reports` gaté à tort alors que hub Free ; `advanced-reports` Rust mort contredit `reports-advanced`) + 6 🟡 (override `features[]` **fail-closed en Free** CWE-863 : une clé copiée downgrade free mais expose ses features signées ; `SR_DEV_EDITION` derrière une Cargo feature `dev-override` pas `debug_assertions` CWE-489 ; LicenseProvider récup. d'erreur CWE-703 ; `useEntitlement``{allowed,ready}` anti-flash ; `useIsPremium.test` à migrer ; gate création profil dans `ProfileFormModal`, point unique). Ajustements tranché **Base** par Max. **Re-homée `planned-2026-07-19-feature-gating`** (`/plan-run` Step 0 : bodies rendus auto-suffisants ; résiduel drainé — CTA « Obtenir » désactivé + « bientôt », dev-override gardé). Prête `/autopilot`. (ref planned-2026-07-19-feature-gating, #297-#302)
- 2026-07-18 : **Epic #260 « rapports uniformes » fermée + #259 livrée en PR #296 (review)**. #260 fermée après `/analyze` : **4/5 rapports pleinement conformes** (income-statement + filtres partagés + collapse par-profil) ; les 3 écarts restants vs le texte de l'epic sont des **décisions de conception assumées**, entérinés par Max — (1) lignes vides budget non masquées (spec décision 6 : grille = surface d'édition) ; (2) collapse budget replié « comme partout » (#289/ADR 0016 inverse le « sauf budget » de l'epic) ; (3) dashboard convergé sur le modèle Cartes plutôt qu'une table income-statement hiérarchique (décision 5). **Correction STATE** : les entrées 07-08/07-11 « reste de #260 = #259 » sont fausses — #259 est une migration de taxonomie sans lien avec l'epic (recadrée 07-12), et les vraies déviations #260 n'y étaient pas tracées. **#259** (fusion des catégories custom) livrée en PR #296 (`issue-259-merge-custom-categories`) : bloc préservé de `StepSimulate` rendu en `MappingRow`, reducer `RESOLVE_ROW` résout rows+preserved (`unresolved` compté sur seed only → ne bloque pas « Suivant »), writer via helper `isResolvedTarget` (fourre-tout créé seulement s'il reste une custom non fusionnée ; customs fusionnées désactivées au lieu d'être re-parentées). Plan-check pass (6 MINOR, 3 intégrés), **836 vitest** + build tsc/vite propres, régression parent/enfant custom couverte, aucune migration DB. `/pr-review` **APPROVE**, mergée (rebase) le 2026-07-19 → `main` `2314a64`, #259 fermée, branche supprimée. (ref #260, #259 PR #296)
- 2026-07-18 : **Milestone collapse multi-niveaux (#288-291) livrée → v0.14.0**. Cycle complet en une session : `/plan-run` (spec 2 fichiers, 8 décisions drainées) → `/review-spec` (3 experts, **verdict 🔴**) → refonte v2 → `/autopilot` (4 workers) → `/pr-review` ×4 → ff-merge → release. **Le point clé** : la revue a tué l'algo v1. Il suivait un curseur de profondeur supposant un **ordre DFS** ; or la grille Budget (`useBudget.ts:361`) trie par **niveau** — les 3 experts l'ont trouvé indépendamment. Refonte v2 : **visibilité par remontée de `parent_id`** (une ligne visible ssi tous ses ancêtres dépliés), order-independant → résout d'un coup l'ordre budget, le tri par type qui sépare parent/enfant, la feuille « (direct) » qui partage la clé du parent, et la contrainte `visible()`-avant-`reorderRows`. **Pile linéaire forcée** (B/C/D dépendent tous du nouveau hook de A → pas de wave parallèle) : PRs #292-#295, chacune basée sur la précédente. `/pr-review` **APPROVE ×3 + 1 REQUEST_CHANGES** (#295 : ligne `- Spec:` de l'ADR 0016 pointant des specs gitignorées → 404 ; corrigé par **force-add des specs à la racine**, précédent repo `spec-refonte-rapports.md` ; au passage la revue s'est trompée en disant que 0015 n'avait pas de ligne Spec — elle en a une, cassée pareil, bug pré-existant signalé). **ff-merge** de la pile (4 issues auto-fermées via `Resolves #N`, milestone 4/4, 4 branches supprimées) → **v0.14.0** taggée. **Persistance migrée `localStorage` → `user_preferences`** (base du profil) : `deleteProfile` ne purge aucun `localStorage` → une clé par-profil y serait un résidu survivant à la suppression, révélant les catégories explorées d'un profil PIN-protégé (exigence privacy-first, **ADR 0016**, frontière tracée : état UI par-profil → DB profil, état UI machine → localStorage). Le hook gagne `defaultExpanded` + `storageKey` nullable, qui **unifie aussi les 2 arbres de catégories** (#290 : `CategoryTree` déplié-par-défaut, guide replié ; corrige le bug `allExpanded = size>0` du guide ; worker a trouvé un **4e consommateur** `StepDiscover` non listé au plan, migré). 828 vitest, aucune migration DB. Écarts protocole tracés au [rapport](reports/DAILY-REPORT-2026-07-15.md) : workers auto-validés **sans forker `/pr-review`** (évite le double-post [[feedback-pr-review-subagent-forks]]) ; #294 sans test (refactor de rendu, non testable sans jsdom) → vérif runtime déléguée à la revue. Reste `spec-ci-build-optimization` (2/4) + `spec-paiements` (#270/#271) + #259. (ref #288-291, PRs #292-#295)
- 2026-07-12 : **#259 recadrée — l'issue décrivait un flux qui n'existe pas** (rectifie les entrées des 2026-07-05 / 07-11 qui la classaient « mapping manuel compte sans similaire auto » et « reliquat de l'epic #260 » : les deux sont faux). Le corps d'origine, rédigé à chaud le 2026-07-05 pendant le run v0.12.0, avait **transposé par analogie** le signalement de Max vers le module Bilan sans ouvrir le fichier : il décrivait une auto-association des comptes standards aux comptes existants du profil dans `StarterAccountsModal`, avec « case désactivée quand aucun similaire n'est auto-identifié ». **Aucune notion de mapping n'existe dans ce flux** — la case est un simple « créer ce compte : oui/non », désactivée **quand une collision EST détectée** (`StarterAccountsModal.tsx:160`), soit la polarité inverse ; le commentaire `l.8` cité (« the matching checkbox ») désignait « la case **correspondante** », pas « la case de matching ». **Le vrai sujet** (confirmé par Max) est la **migration des catégories** : `computeMigrationPlan` range les catégories en 2 seaux et un seul est éditable — `plan.rows` (seed) passe par le moteur d'appariement + type-ahead par ligne (#246/#252), tandis que `plan.preserved` (catégories **custom**) est poussé avec `v1TargetId: null` **sans même être soumis au moteur d'appariement**, rendu en `<li>` texte brut (`StepSimulate.tsx:202-216`, aucun picker) et déversé d'office par le writer sous le fourre-tout « Catégories personnalisées (migration) ». Sémantique tranchée avec Max : **fusion** (choisir une feuille standard réassigne tx/budgets/mots-clés/fournisseurs et fait disparaître la custom ; ne rien choisir = comportement actuel, et ne doit **pas** bloquer le bouton « Suivant »). Re-parentage écarté. Travail = câblage sur 3 couches (UI `StepSimulate``MappingRow` réutilisable tel quel ; reducer `RESOLVE_ROW` qui ne voit que `plan.rows` ; 4 retouches du writer) — la machinerie de fusion est **déjà générique** (`buildMappingFromRows` filtre les cibles nulles, étapes 3-7 bouclent sur la Map). Complexité Medium. Piège à couvrir : fusionner une custom **parente ayant des enfants custom** (liste `preserved` plate → l'enfant non résolu retombe au fourre-tout, pas d'orphelin, mais aucun test ne le garantit). Leçon process : une issue rédigée par analogie en fin de run, sans lecture du code, peut inverser la prémisse **et** se tromper de module — le `/analyze` l'a rattrapée 6 jours plus tard. (ref #259) - 2026-07-12 : **#259 recadrée — l'issue décrivait un flux qui n'existe pas** (rectifie les entrées des 2026-07-05 / 07-11 qui la classaient « mapping manuel compte sans similaire auto » et « reliquat de l'epic #260 » : les deux sont faux). Le corps d'origine, rédigé à chaud le 2026-07-05 pendant le run v0.12.0, avait **transposé par analogie** le signalement de Max vers le module Bilan sans ouvrir le fichier : il décrivait une auto-association des comptes standards aux comptes existants du profil dans `StarterAccountsModal`, avec « case désactivée quand aucun similaire n'est auto-identifié ». **Aucune notion de mapping n'existe dans ce flux** — la case est un simple « créer ce compte : oui/non », désactivée **quand une collision EST détectée** (`StarterAccountsModal.tsx:160`), soit la polarité inverse ; le commentaire `l.8` cité (« the matching checkbox ») désignait « la case **correspondante** », pas « la case de matching ». **Le vrai sujet** (confirmé par Max) est la **migration des catégories** : `computeMigrationPlan` range les catégories en 2 seaux et un seul est éditable — `plan.rows` (seed) passe par le moteur d'appariement + type-ahead par ligne (#246/#252), tandis que `plan.preserved` (catégories **custom**) est poussé avec `v1TargetId: null` **sans même être soumis au moteur d'appariement**, rendu en `<li>` texte brut (`StepSimulate.tsx:202-216`, aucun picker) et déversé d'office par le writer sous le fourre-tout « Catégories personnalisées (migration) ». Sémantique tranchée avec Max : **fusion** (choisir une feuille standard réassigne tx/budgets/mots-clés/fournisseurs et fait disparaître la custom ; ne rien choisir = comportement actuel, et ne doit **pas** bloquer le bouton « Suivant »). Re-parentage écarté. Travail = câblage sur 3 couches (UI `StepSimulate``MappingRow` réutilisable tel quel ; reducer `RESOLVE_ROW` qui ne voit que `plan.rows` ; 4 retouches du writer) — la machinerie de fusion est **déjà générique** (`buildMappingFromRows` filtre les cibles nulles, étapes 3-7 bouclent sur la Map). Complexité Medium. Piège à couvrir : fusionner une custom **parente ayant des enfants custom** (liste `preserved` plate → l'enfant non résolu retombe au fourre-tout, pas d'orphelin, mais aucun test ne le garantit). Leçon process : une issue rédigée par analogie en fin de run, sans lecture du code, peut inverser la prémisse **et** se tromper de module — le `/analyze` l'a rattrapée 6 jours plus tard. (ref #259)
- 2026-07-11 : **M2 « rapports-parite » (#277-#279) shippée** — dernière grosse tranche de l'epic #260 « rapports uniformes ». Run `/autopilot` (3 workers en worktree → PRs #285/#286/#287), reviewée via **3 `/pr-review` adversariales parallèles** (read-only, `git show` sans checkout) : **APPROVE #285** (grille budget income-first, #278) + **APPROVE #286** (BVA réel-vs-budget income-first, #277) + **REQUEST_CHANGES #287** (dashboard Cartes, #279). Blocage #287 = **le point I7 prédit au plan** confirmé réel : `getCartesSnapshot` propageait `accountIds` aux sous-rapports top-movers/budget mais **pas** aux séries `fetchMonthlyFlows` (KPIs/sparklines/overlay 12 mois) ni `fetchSeasonality` → sur le dashboard (1re page à exposer le filtre par-dessus ces séries) les KPI montraient des totaux non filtrés à côté de barres/tendance filtrées. **Fix** (`9ee5ad3`) : threader `accountIds?` dans les 2 fetchers (`source_id IN (...)` paramétré via `inPlaceholders`, index `$3`/`$4`) + câblage `getCartesSnapshot` ; le CHANGELOG #279 promettait déjà le filtrage → **code aligné sur la promesse, texte inchangé** ; +2 assertions cartes. **Merge local de la pile** (3 merges `--no-ff`, conflit additif CHANGELOG ×2 résolu par union #277/#278/#279 — i18n **non conflictuel** car #277/#278 réutilisent des clés existantes, seul #279 touche les locales ; STATE non conflictuel car branches ne l'ont pas commité) → tip cumulé validé (tsc + vite build + **811 vitest** + cargo check + **98 Rust**) → push `main` `a982f9e`. Réconciliation Forgejo : 3 issues auto-fermées via `Resolves #N`, 3 PRs fermées manuellement (merges locaux non détectés *merged*), milestone `overnight-2026-07-08-rapports-parite` **3/3 fermée**, branches supprimées, worktrees leftover nettoyés (gotcha recursion). Aucune migration DB (v1→v16), non taggé ([Unreleased]). Wrinkle process : le fork du skill `/pr-review` avait posté un APPROVE prématuré sur #287 (I7 gradé non bloquant) → superséé + commentaire stale supprimé par la passe adversariale indépendante (trace code + « 1re page à exposer le filtre » + CHANGELOG à honorer) ; valeur de la double-vérif indépendante. Suivi non bloquant : `getDashboardSummary` = code mort à retirer. Reste de #260 : #259 (mapping manuel compte sans similaire auto). (ref #260, #277-#279, PRs #285-#287) - 2026-07-11 : **M2 « rapports-parite » (#277-#279) shippée** — dernière grosse tranche de l'epic #260 « rapports uniformes ». Run `/autopilot` (3 workers en worktree → PRs #285/#286/#287), reviewée via **3 `/pr-review` adversariales parallèles** (read-only, `git show` sans checkout) : **APPROVE #285** (grille budget income-first, #278) + **APPROVE #286** (BVA réel-vs-budget income-first, #277) + **REQUEST_CHANGES #287** (dashboard Cartes, #279). Blocage #287 = **le point I7 prédit au plan** confirmé réel : `getCartesSnapshot` propageait `accountIds` aux sous-rapports top-movers/budget mais **pas** aux séries `fetchMonthlyFlows` (KPIs/sparklines/overlay 12 mois) ni `fetchSeasonality` → sur le dashboard (1re page à exposer le filtre par-dessus ces séries) les KPI montraient des totaux non filtrés à côté de barres/tendance filtrées. **Fix** (`9ee5ad3`) : threader `accountIds?` dans les 2 fetchers (`source_id IN (...)` paramétré via `inPlaceholders`, index `$3`/`$4`) + câblage `getCartesSnapshot` ; le CHANGELOG #279 promettait déjà le filtrage → **code aligné sur la promesse, texte inchangé** ; +2 assertions cartes. **Merge local de la pile** (3 merges `--no-ff`, conflit additif CHANGELOG ×2 résolu par union #277/#278/#279 — i18n **non conflictuel** car #277/#278 réutilisent des clés existantes, seul #279 touche les locales ; STATE non conflictuel car branches ne l'ont pas commité) → tip cumulé validé (tsc + vite build + **811 vitest** + cargo check + **98 Rust**) → push `main` `a982f9e`. Réconciliation Forgejo : 3 issues auto-fermées via `Resolves #N`, 3 PRs fermées manuellement (merges locaux non détectés *merged*), milestone `overnight-2026-07-08-rapports-parite` **3/3 fermée**, branches supprimées, worktrees leftover nettoyés (gotcha recursion). Aucune migration DB (v1→v16), non taggé ([Unreleased]). Wrinkle process : le fork du skill `/pr-review` avait posté un APPROVE prématuré sur #287 (I7 gradé non bloquant) → superséé + commentaire stale supprimé par la passe adversariale indépendante (trace code + « 1re page à exposer le filtre » + CHANGELOG à honorer) ; valeur de la double-vérif indépendante. Suivi non bloquant : `getDashboardSummary` = code mort à retirer. Reste de #260 : #259 (mapping manuel compte sans similaire auto). (ref #260, #277-#279, PRs #285-#287)
- 2026-07-08 : **Suite #260 — M1 « filtres-fondation » mergée** (`main` `fe9ae01`), M2 prête. Planifiée via `/plan-overnight`+`/review-spec` (filtre **multi-comptes** `accountIds[]`/`IN(...)` tranché après analyse, révise le « sourceId singulier » initial). **M1 livrée** via `/autopilot` (5 workers séquentiels en worktree → PRs #280-#284 en **pile linéaire**) + `/pr-review` **APPROVE ×5** + **merge local fast-forward** : hook `useReportsPeriod` additif (+`accountIds` URL `sources` + type `ReportFilters`, #272), 7 services en `accountIds[]` via helper paramétré `sqlFilters.inPlaceholders` (#273), `<FilterPanel>` slot temporel + multi-select « sources d'import » (libellé distinct du Bilan, #274), adoption Tendances (#275) + Compare/Budget (#276 — incl. `CompareBudgetView` ; câblage budget via `getActualTotalsForYear`, le body citait à tort `getBudgetVsActualData`). Aucune migration DB. Build + **791 vitest** verts (728 + 63). NB : le « 4676 / 277 fichiers » vu en pré-merge était une **pollution de worktrees**`vitest` récursait dans les 5 worktrees leftover sous `.claude/worktrees/` (791 × ~6) ; nettoyés depuis, compteur réel = 791. Gotcha : nettoyer les worktrees (ou exclure `.claude/worktrees/`) avant de valider un tip. Réconciliation Forgejo complète (5 issues auto-fermées via `Resolves #N`, 5 PRs fermées, milestone fermée, branches supprimées ; worktrees nettoyés). **M2 `overnight-2026-07-08-rapports-parite` (#277-#279) prête**`/autopilot` (BVA + grille budget income-first + dashboard Cartes). Notes review pour M2 : séries `getCartesSnapshot` (`fetchMonthlyFlows`/`fetchSeasonality`) non filtrées (I7/#279) ; grille budget câblée via `getActualTotalsForYear`. (ref #260, #272-#276, PRs #280-#284)
- 2026-07-07 : **Tendances hiérarchiques livrées** (1re slice de l'epic #260 « rapports uniformes »). Préparée via `/plan-overnight` (13 décisions drainées ; `/review-spec` 3 experts → 3 critiques résolus par l'approche **sidecar** : garder le pivot par mois pour chart **+ dashboard** (`DashboardPage:142` = consommateur partagé oublié), ajouter un arbre **clé-par-id** pour le tableau — pivot clé-par-nom aurait faussé le Résultat net sur homonymes une fois le `LIMIT 50` retiré), puis exécutée **le même jour** via `/autopilot` (session interactive, ciblée par nom de milestone **hors fenêtre horaire** — cf. mémoire [[feedback-plan-execute-temporal-decoupling]]). 4 workers en worktree → issues **#262-#265**, `/pr-review` **APPROVE ×4**, merge local de la pile (#266 standalone + chaîne #267→#268→#269 ; CHANGELOG **auto-résolu par section**, vérifié) ; tip cumulé validé build + **728 vitest** → push `main` `fe87313`, réconciliation Forgejo (4 PRs fermées, 4 issues auto-fermées via `Resolves #N`, milestone `overnight-2026-07-08-tendances-hierarchiques` fermée, branches supprimées). Contenu : tendance par catégorie → tableau income-statement **hiérarchique + collapse replié-par-défaut** (défaut byCategory+table = fix « paie invisible »), `getCategoryOverTime` en **arbre id-keyed sidecar** via `buildLeafDrivenTree<T>` générique extrait de `buildCompareTree` (behavior-preserving), Résultat avant transferts interleavé. Aucune migration DB, non taggé ([Unreleased]). 2 polish non bloquants : perf dashboard (arbre construit non lu, #264), DRY `typeOf` dupliqué (#265). Reste de #260 (BVA-au-standard, budget, dashboard, filtres partagés) différé. (ref #260, #262-#265, PRs #266-#269)
- 2026-07-07 : #254 (collapse/expand des sous-catégories) livré + #258 (db-locked) fermé. **#254** : chevron par catégorie parente + « tout replier/déplier » sur les 2 rapports comparables hiérarchiques (`ComparePeriodTable` réel-vs-réel, `BudgetVsActualTable` réel-vs-budget) ; **repli par défaut** (contrainte remontée de #260), état persisté par rapport en localStorage. Repli purement visuel — sous-totaux/totaux/résultats calculés sur les lignes brutes, jamais les visibles (tracé en /pr-review). Module `collapsibleRows.ts` (pur) + hook `useCollapsibleGroups.ts` + 12 tests → **713 vitest**, aucune migration DB. `CategoryOverTimeTable` exclu (liste plate top-N, rien à replier) → relève de l'epic #260. Livré via sous-agent en worktree isolé + /pr-review APPROVE + merge API (single PR, pas de conflit i18n/CHANGELOG) → PR #261, #254 auto-fermée. **#258** fermé comme déjà corrigé en v0.10.1 (`8cb7e53`, présent v0.11.0/v0.12.0) : la piste « BEGIN imbriqué » du body était une mauvaise lecture — `withTransaction` (`db.ts`) n'émet aucun BEGIN, `proposeStarterAccountsInTransaction:720` est le seul (même pattern que `saveSnapshotAtomic`/`upsertSnapshotLines` qui ne deadlockent jamais) ; smell latent noté (garde `inTransaction` = booléen global, pas un compteur → requête simple concurrente pendant une transaction bypasse le verrou). Prochaine étape : /plan-overnight de l'epic #260. (ref #254/#258, PR #261)
- 2026-07-05 : **v0.12.0 shippée** (tag `v0.12.0` → CI release #311). Compare réel-vs-réel (#253) + tableau de tendance par catégorie (#256) refondus en **analyse de résultat** : revenu affiché, **Résultat avant transferts** (revenus dépenses) + **Résultat net** (après transferts), sections revenu→dépense→transfert ; rapports comparables par défaut au **mois complet précédent** (#253). Origine : #243 (montants de transfert inclus dans le comparable) recadré par Max en « voir si je suis over/under ». Livré en 2 issues/PRs stackées #255→#257 (merge local + réconciliation Forgejo, PRs fermées, branches supprimées). Modules purs `compareResults.ts`/`overTimeResults.ts` testés ; 2 blocages `/pr-review` corrigés (fuite du revenu dans les top-movers Cartes via `COMPARE_DELTA_SQL` élargi income+transfer → filtre expense-only + régression ; garde StrictMode value-change sur le sync mois-réf, `syncReferenceOnPeriodChange` pur). 701 vitest verts, aucune migration DB. Ouvert au passage : #258 (db-locked `proposeStarterAccounts`, probable BEGIN imbriqué dans `withTransaction`), #259 (mapping manuel compte sans similaire), #254 (collapse/expand). Suivi différé : aligner le placement « Résultat avant transferts » de `CategoryOverTimeTable` (bas) sur le compare (interleavé). (ref #253/#256, PRs #255/#257)
- 2026-07-05 : Run `/autopilot` sur `overnight-2026-07-05-bilan-rapports-ux` (préparée la veille via `/analyze` #243-#246 + split #247) → 5 issues livrées, PRs #248-#252 toutes `/pr-review` APPROVE. **Merge local de la pile** (l'API merge échoue sur les conflits i18n/CHANGELOG garantis — 4 fichiers partagés `fr.json`/`en.json`/`CHANGELOG.md`/`CHANGELOG.fr.md`, voir mémoire [[reference-shared-conflict-files]]) ; réconciliation Forgejo (PRs fermées manuellement, issues auto-fermées via `Resolves #N`, milestone fermée) ; release **v0.11.0** taggée. Validation locale du tip cumulé : build + **679 vitest** + cargo verts (`check.yml` ne tourne ni sur push `main` ni sur les bases non-`main` type #249). Polish #252 post-review : picker migration feuilles-seulement + helper `comboboxCategoriesForTarget` testé. (ref #243-#247, PRs #248-#252)
- 2026-07-04 : Override `@babel/core` mergé (issue #241, PR #242 — review /pr-review APPROVE + CI verte rust/frontend) — clôt le reliquat `@babel/core` low du sprint `deps-security-2026-07`. GHSA-4x5r-pxfx-6jf8 (arbitrary file read via `sourceMappingURL`, CVSS 3.2, dev/build-only via `@vitejs/plugin-react`) : `npm audit fix` inopérant (version pinnée par le parent → empile une copie sans résoudre), fix = `overrides` scopé `{"@vitejs/plugin-react":{"@babel/core":"^7.29.7"}}` → patch 7.x non-breaking (7.29.0→7.29.7), arbre 109→109 (pas d'explosion), `npm audit` 1→0. Build + 635 vitest verts. Détecté via /analyse-vulnerabilite — rapport VPS `--fresh` inaccessible (auth navigateur Tailscale SSH en attente, jamais reçu le lien) → `npm audit` live utilisé ; rapport local périmé (2026-05-06) portait 1 faux positif `secrets` (« Generic Token Assignment » sur `balance.service.ts`, contenu disparu depuis la réécriture Bilan). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #241, PR #242)
- 2026-07-01 : Sprint `deps-security-2026-07` livré (4/4, milestone fermée) via /sprint — 4 vulns Defenseur (2026-06-30) remédiées en 2 PRs séquentielles (fichier partagé `package.json` → pas de worktrees parallèles). PR A #239 : `react-router-dom` 7.13→7.18.1 ferme #235 (racine, 7 advisories dont turbo-stream RCE) + #238 (0 CVE propre, héritage transitif — 1 bump du parent suffit, `npm audit viaParents`). PR B #240 : `vite` 6.4.2→6.4.3 (#236) + `vitest` 4.0.18→4.1.9 (#237), restés en 6.x/4.x (majors 8.x/latest écartés), 635 vitest verts (bump vitest 4.0→4.1 sans régression). `npm audit` 5→1 ; reste `@babel/core` low volontairement hors scope (`npm audit fix` ajoute 77 paquets sans résoudre, pinné par parent → override scopé dédié à faire). Surface réelle faible (app Tauri desktop, pas de serveur SSR/RSC). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #235-#238, PRs #239/#240)
- 2026-06-30 : hotfix v0.10.1 shippé + déployé (PR #230) — corrige le « database is locked » 0.10.0 (après abandon d'un snapshot en cours). Cause racine : `tauri-plugin-sql` charge SQLite via un pool sqlx multi-connexions (max 10) sans primitive de transaction JS → `db.execute("BEGIN")`/`"COMMIT"` en appels séparés pouvaient frapper des connexions différentes → transaction d'écriture zombie. Fix : sérialisation FIFO de tout l'accès DB dans `db.ts` ; `withTransaction()` tient le verrou sur tout le `BEGIN..COMMIT` ; appliqué aux 5 sites (saveSnapshotAtomic, upsertSnapshotLines, proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) par extraction de helper en place ; `db.test.ts` (régression). + console de log : `getLogs` retourne un snapshot immuable (live-update) + API `logInfo/logWarn/logError`. 635 vitest, CI vert. Voir mémoire [[reference-tauri-sql-transactions]] (ref #230)
- 2026-06-29 : #228 mergé (PR #229) — garde d'abort migration v16 scopée aux comptes convertibles (`JOIN balance_categories` + `c.asset_type IS NOT NULL` dans Migration v16 + V16_SQL + V16_CORRUPT). Un compte simple portant un symbole résiduel (recat priced→simple) avec lignes qty-NULL ne fait plus aborter v16 → l'app redémarre. Test régression `migration_v16_leaves_simple_account_with_residual_symbol_intact`. CI vert (#229 → main, contrairement à la pile #219-#227). Milestone overnight-2026-06-05-bilan-detail-titres complète 10/10, fermée. Reste : tag release 0.10.0 (ref #228)
## Blockers actifs ## Blockers actifs
- #135 / #136 — maximus-api Stripe webhooks license auto-generate (BLOCKED par maximus-api Phase 2) - Aucun blocker externe dur. `spec-monetisation` **fermée 12/12** (2026-07-08) — #50/#52/#53/#135/#136 livrées, ne sont plus des blockers.
- #53 — online activation + machine limit enforcement (status:needs-fix) - Backlog ouvert (`status:ready`, non bloqué) : `spec-paiements` (#270 activation /v1 + product explicite + URL achat localisée — c'est elle qui activera le CTA « Obtenir » des UpsellGate, aujourd'hui désactivé « bientôt disponible » ; #271 absorbé par #301, livré) ; `spec-ci-build-optimization` **3/4** (#232 livrée le 2026-07-27 ; reste **#234** connectivité du serveur de cache runner, qui débloquerait `Swatinem/rust-cache` — accès hôte VPS requis).
- #50 / #52 — Stripe integration desktop + purchase page (status:ready, design en attente) - Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. Restent **#317** (re-évaluer `react-router` à une migration v8) et **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications).
- Nouveau bug ouvert : **#320** — les installations `.rpm` ne reçoivent aucune mise à jour (`latest.json` ne porte qu'une entrée `linux-x86_64` construite depuis le `.deb`, `release.yml:104-122`), alors que les `.rpm` continuent d'être publiés en assets. Documenté en §3 de `docs/qa-update-cycle.md` (« ne pas tester : cassé, pas seulement non vérifié »).
- Suivis ouverts du chantier import (milestone fermée) : le refus du format « montant absolu + indicateur D/C » ne scanne que `amountCol ± 1`, donc une colonne D/C placée ailleurs passe encore ; dérives de comptes préexistantes dans `CLAUDE.md` et `docs/architecture.md` (composants, pages, services, `Version actuelle : 0.6.3` vs tag v0.14.0) mesurées et documentées en revue, non corrigées.
- Release à considérer : le `[Unreleased]` porte le feature-gating, les deux lots d'advisories **et** le chantier import (migration v17) → candidate `v0.15.0` via `/release`. **L'étape 10 s'appliquera** : ces lots touchent `tauri-plugin-updater` et ses dépendances (`rustls-webpki`, `tar`, `plist`/`quick-xml`, `deep-link`), donc `docs/qa-update-cycle.md` est à dérouler pour de vrai — deux machines de bureau, une clé Base+ sur chacune, partir de la version précédente. Et #320 reste ouverte : les installations `.rpm` ne se mettront pas à jour, quelle que soit la release.

View file

@ -0,0 +1,129 @@
# ADR 0017 — Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé
- Status: **Accepted**
- Date: 2026-07-20
- Issues: #297 (socle LicenseContext + matrice + `useEntitlement`), #298 (garde `RequireFeature` + `UpsellGate`), #299 (routes gatées + cadenas Sidebar/tuiles), #300 (multi-profils non destructif), #301 (Rust : `auto-update` Base+, `features[]`, dev-override), #302 (cette doc)
- Spec: [`spec-decisions-feature-gating.md`](../../spec-decisions-feature-gating.md), [`spec-plan-feature-gating.md`](../../spec-plan-feature-gating.md)
- S'appuie sur la licence JWT Ed25519 machine-bindée (Phase 3b monétisation) et sur [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) (récupération de cours via maximus-api, seul gate appliqué côté serveur)
## Contexte
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 liée à la machine (ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
| Module | Gratuite | Base | Premium |
|---|---|---|---|
| Tableau de bord, Import, Transactions, Catégories | ✓ | ✓ | ✓ |
| Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ |
| Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ |
| Profil (un seul) | ✓ | ✓ | ✓ |
| Budget | — | ✓ | ✓ |
| Ajustements | — | ✓ | ✓ |
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
| Profils multiples | — | ✓ | ✓ |
| Mises à jour automatiques | — | ✓ | ✓ |
| Bilan complet (patrimoine, détail par titre, cours) | — | — | ✓ |
Pas de quatrième édition « admin » : Max s'auto-émet une licence Premium.
Trois contraintes structurent la solution :
1. **L'app est GPL-3.0.** Tout enforcement embarqué dans le client est contournable par recompilation — un « durcissement » côté Rust n'apporterait aucune garantie réelle, seulement de la complexité (un aller-retour IPC par gate, gestion d'erreur, latence au boot d'une app offline-first).
2. **La licence est machine-bindée.** Une clé `license.key` copiée sur une autre machine est déclassée en `free` par le chemin de validation (token d'activation ↔ machine id). Mais le JWT copié porte toujours son tableau signé `features[]` (overrides par-licence) : sans précaution, un check « matrice OU features » re-donnerait à une clé déclassée les fonctionnalités qu'elle liste — c'est une autorisation incorrecte (CWE-863).
3. **Le contexte licence n'existait pas côté React.** `useLicense` invoquait `get_edition` à chaque appel (asynchrone, par composant) — inutilisable pour gater des routes et des items de navigation sans flash ni cascade d'IPC.
## Décision
### 1. Matrice statique côté UI + override signé `features[]`, fail-closed en Free
La source de vérité front est `src/shared/entitlements.ts` : un type `FeatureKey` fermé (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance` — kebab-case car le namespace est partagé avec le `features[]` du JWT et avec Rust) et une table `ENTITLEMENTS: Record<FeatureKey, Edition[]>`. Les modules Free n'ont **pas** de clé : ce qui n'est pas dans la matrice n'est jamais gaté.
`isEntitled(feature, edition, licenseFeatures)` combine la matrice et l'override signé par-licence, avec un court-circuit **fail-closed en Free** (CWE-863) :
```ts
if (edition === "free") return false; // l'override ne ressuscite JAMAIS une clé déclassée
return tiers.includes(edition) || licenseFeatures.includes(feature);
```
L'override `features[]` sert à débloquer une fonctionnalité au-dessus du tier d'une licence payante (ex. une licence Base portant `balance`), jamais à secourir une édition `free` — qu'elle soit native ou issue d'un déclassement machine-binding. Le même court-circuit existe côté Rust (`entitlements::is_entitled`), en défense en profondeur : `current_entitlements` retourne déjà `("free", [])` sur tout échec de validation, donc les features signées sont perdues dès le déclassement, et le court-circuit les refuse même si un chemin futur les laissait passer. `requiredTierFor(feature)` (dérivé de l'appartenance à la matrice, pas de l'ordre du tableau) alimente le libellé d'upsell « Obtenir Base / Premium ».
### 2. Enforcement UI-only — soft-paywall GPL assumé
Le gating est appliqué **uniquement dans l'interface** (routes, navigation, points d'entrée). C'est un choix explicite, pas un oubli : sous GPL, un utilisateur qui recompile l'app sans les gardes est un cas assumé — le paywall s'adresse à l'utilisateur des binaires officiels, pas à un adversaire. La seule fonctionnalité réellement enforced l'est **côté serveur** : la récupération de cours (Premium) passe par maximus-api qui vérifie la licence à chaque requête ([ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Côté Rust, `FEATURE_TIERS` ne conserve que `auto-update` → Base+ (absorbe l'issue #271) — la matrice UI vit uniquement en TS, les deux tables ont des rôles disjoints.
### 3. `LicenseProvider` machine-level + `useEntitlement` synchrone anti-flash
`LicenseContext` est monté **au-dessus** de `ProfileProvider` : la licence est une propriété de la machine, pas du profil actif, et le provider survit au remount `BrowserRouter key={refreshKey}` déclenché par un changement de profil. Il charge édition + licence **une fois au boot**, puis :
- **Récupération d'erreur (CWE-703)** : le provider est un point unique de défaillance ; si l'invoke de boot échoue, l'état passe à `status: "error"` (édition précédente préservée) et un retry à backoff exponentiel plafonné (1 s → 30 s) relance le chargement. Les consommateurs rendent un placeholder neutre tant que `status !== "ready"` — jamais l'upsell — pour qu'une panne IPC transitoire ne verrouille pas un client payant.
- **Validation de clé orthogonale** : un `submitKey` rejeté (clé mal saisie) alimente `validationError` sans toucher `status` — pas de flash de verrouillage sur une typo, et la boucle de retry ne peut pas s'armer sur une erreur de validation.
`useEntitlement(feature)` retourne `{ allowed, ready }` (pas un booléen nu) : `allowed` est fail-closed pendant le boot (édition par défaut `free`), `ready` permet aux surfaces de supprimer le cadenas/upsell tant que la licence n'est pas résolue.
### 4. Upsell verrouillé visible, jamais masqué
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**.
### 5. Durcissement sec, non destructif
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, machine changée) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
### 6. Dev-override compilé hors des builds normaux
Pour tester les trois éditions sans forger de licences, `SR_DEV_EDITION` force l'édition résolue — mais **uniquement** dans un build compilé avec la Cargo feature `dev-override` (off par défaut, `cargo test --features dev-override`). Le choix d'une feature explicite plutôt que `debug_assertions` évite qu'un artefact debug distribué par erreur embarque la porte dérobée (CWE-489) : l'activation est un acte opt-in, jamais un effet de profil de build.
## Alternatives considérées
### A. Enforcement dur côté Rust pour tous les modules — rejeté
Faire passer chaque gate par `check_entitlement` (IPC) et refuser les données côté commandes. Rejeté : sous GPL le client reste recompilable, donc la garantie est illusoire ; le coût est réel (latence, gestion d'erreur par gate, couplage des services SQL — qui n'appellent aucune commande Rust par convention — au module licence). Le seul enforcement qui vaut quelque chose est côté serveur, et il existe déjà pour les cours.
### B. Masquer les fonctionnalités non licenciées — rejeté
Retirer de la Sidebar et du hub ce que l'édition ne couvre pas. Rejeté : l'utilisateur Gratuite doit **voir** ce que Base et Premium offrent (découvrabilité = le canal de vente d'une app sans télémétrie) ; un module invisible ne se vend pas. D'où l'upsell verrouillé : cadenas + écran explicite.
### C. Downgrade destructif ou données en lecture seule exportable — rejeté
Purger ou geler les données des modules perdus au déclassement. Rejeté sans débat : contraire au principe privacy-first « vos données vous appartiennent », et transforme toute expiration de licence en incident. Le blocage d'accès réversible donne le même incitatif d'upgrade sans risque de perte.
### D. Édition admin dédiée — rejetée
Une quatrième édition pour l'usage interne de Max. Rejetée : une licence Premium auto-émise donne le même résultat sans quatrième branche dans la matrice, les tests et l'UI.
### E. `features[]` seul, sans matrice statique — rejeté
Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : chaque licence devrait énumérer toutes ses fonctionnalités (fragile à l'ajout d'un module — les licences déjà émises ne le porteraient pas), et le serveur d'émission deviendrait la seule source de vérité d'un comportement client. La matrice donne le défaut par édition ; l'override signé reste l'exception par-licence.
## Conséquences
### Positives
- **Un point de vérité par couche** : `ENTITLEMENTS` (TS) pour l'UI, `FEATURE_TIERS` (Rust) réduit à `auto-update` — rôles disjoints, namespace kebab-case partagé (`features[]` JWT lisible par les deux).
- **Fail-closed partout** : édition par défaut `free` au boot, override refusé en Free (CWE-863) des deux côtés, échec de résolution Rust → `("free", [])`.
- **Pas de flash de verrouillage** : `{ allowed, ready }` + loader neutre dans `RequireFeature` + retry backoff dans le provider — un client payant ne voit jamais l'upsell sur une erreur transitoire.
- **Zéro migration, zéro perte** : aucune table, aucun changement de schéma ; le déclassement est purement un état d'affichage réversible.
- **#271 absorbé** : `auto-update` passe Base+ par une ligne de `FEATURE_TIERS`, sans code nouveau.
### Négatives / risques actés
- **Contournable par build local** : assumé (GPL, soft-paywall). Ne jamais présenter ce gating comme une protection — la seule barrière réelle est serveur (cours).
- **CTA d'achat inerte** : « Obtenir \<tier\> » est affiché désactivé (« bientôt ») tant que #270 (activation en ligne + URL d'achat) n'est pas livré. Fenêtre où l'upsell promet sans vendre — la voie « J'ai déjà une clé » reste fonctionnelle.
- **Deux tables à ne pas confondre** : un futur gate ajouté côté Rust dans `FEATURE_TIERS` ne gaterait rien dans l'UI, et réciproquement. La règle est documentaire (cet ADR + commentaires des deux modules).
- **`dev-override` à surveiller en release** : la feature Cargo ne doit jamais apparaître dans un build publié ; le choix opt-in la rend improbable, pas impossible.
### Neutre
- Le chemin abonnement Compte Maximus (Premium) ne porte pas de `features[]` — l'override est propre aux licences JWT ; c'est cohérent, Premium débloque déjà toute la matrice.
- `useIsPremium` subsiste comme raccourci d'affichage (badge licence) au-dessus de `LicenseContext` ; `useLicense` (invoke par appel) est supprimé.
## Liens
- `src/shared/entitlements.ts` — matrice `ENTITLEMENTS`, `isEntitled` (court-circuit Free), `requiredTierFor`
- `src/contexts/LicenseContext.tsx` — provider machine-level, retry backoff (CWE-703), `validationError` orthogonal
- `src/hooks/useEntitlement.ts``{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium
- `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé
- `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
- `src-tauri/src/commands/entitlements.rs``FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs``current_entitlements` (machine-binding), `dev_override_edition` (CWE-489)
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur
- Issues #297#302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (câblage du CTA d'achat, à venir)

View file

@ -0,0 +1,94 @@
# ADR 0018 — Suppression d'advisories RustSec non atteignables : liste par ID, preuve par cible livrée, garde-fou anti-péremption
- Status: **Accepted**
- Date: 2026-07-27
- Issues: #310 (les 9 advisories découvertes, cette décision), #312 (déclencheur de retrait des entrées `quick-xml`), #314 (le cron de `audit.yml` n'a jamais démarré)
- S'appuie sur #232 (split de `check.yml`, création de `audit.yml`, retrait du `|| true` qui masquait la sortie de `cargo audit`)
> **Amendement du 2026-07-27 (#312/#313)** — la décision et ses trois règles sont inchangées ; seules les entrées ont bougé. Les deux advisories `quick-xml` ont **quitté la liste le jour même** : `plist` 1.10.0 tire `quick-xml` 0.41.0, qui porte le correctif, dans la borne existante de `tauri`. Elles sont donc **résolues, pas acceptées**, et le mécanisme a fonctionné comme prévu — la vérification par cible a montré qu'un correctif était devenu atteignable, et l'entrée est partie. Il ne reste que `RUSTSEC-2023-0071` (`rsa`), et le garde-fou ne boucle plus que sur ce crate.
>
> Trois passages ci-dessous sont **datés du 2026-07-27 avant ce bump** et à lire comme historiques : `quick-xml` comme exemple de l'insuffisance de `--target all` (règle 1), l'entrée `quick-xml` du tableau de contexte, et l'alternative « override / `[patch.crates-io]` » — cette dernière **n'est plus vraie**, `plist` 1.10.0 déclarant désormais `quick-xml ^0.41.0`. L'argument de fond qu'elle illustre (`[patch.crates-io]` ne franchit pas une frontière semver-incompatible) reste correct, seul le cas d'espèce a disparu.
## Contexte
#232 a retiré le `|| true` qui avalait la sortie de `cargo audit` et créé `audit.yml`, un job quotidien (06:00 UTC) **volontairement bloquant** : il *est* le canal de notification pour les advisories publiées entre deux PR Rust, `check-rust.yml` ne tournant que sur ~1 PR sur 40.
Le retrait du masque a découvert 9 advisories RustSec préexistantes dans `src-tauri/Cargo.lock`. Six sont atteignables et corrigeables par un simple `cargo update` (`rustls-webpki` ×4, `tar` ×2, tous sur le chemin de `tauri-plugin-updater`). Les trois restantes ne le sont pas :
| Crate | Advisories | Situation |
|---|---|---|
| `quick-xml` 0.38.4 | RUSTSEC-2026-0194, RUSTSEC-2026-0195 (7.5 high) | Tiré par `plist`, dont `tauri` ne dépend que pour le bundling Apple. Absent des arbres `x86_64-unknown-linux-gnu` et `x86_64-pc-windows-msvc` ; présent seulement sur `x86_64-apple-darwin`, cible que le projet ne livre pas. Corrigé en `>= 0.41.0` alors que `plist` exige `^0.38` — frontière semver-incompatible que `[patch.crates-io]` ne peut pas franchir. |
| `rsa` 0.9.10 | RUSTSEC-2023-0071 (Marvin, 5.9 medium) | Aucun correctif publié (liste `patched` vide). Seul parent dans le lock : `sqlx-mysql`, artefact du graphe multi-backend de sqlx ; le projet parle à SQLite via `tauri-plugin-sql`. `cargo tree -i rsa --target all` ne retourne rien. |
Le problème n'est donc pas de corriger ces trois advisories — c'est impossible — mais de décider ce que devient le gate quotidien. Le laisser rouge en permanence reproduit exactement le défaut que #232 venait de supprimer : une alarme qui sonne tous les jours sans qu'on puisse rien y faire finit ignorée, et la prochaine advisory réelle se noie dedans. C'est la même perte de signal que le `|| true`, obtenue par un autre chemin.
## Décision
**Une liste de suppressions par ID d'advisory, dans un `.cargo/audit.toml` versionné à la racine du dépôt**, encadrée par trois règles.
### Règle 1 — critère d'admission
Une advisory ne peut être listée que si son crate est absent du graphe de dépendances de **toutes** les cibles livrées (Windows et Linux), **ou** si aucun correctif n'a été publié. Une advisory atteignable dont le correctif existe se corrige, elle ne se supprime jamais.
La preuve est mécanique et reproductible, et doit être refaite contre le lock courant :
```
cargo tree --manifest-path src-tauri/Cargo.toml -i <crate> --target x86_64-unknown-linux-gnu
cargo tree --manifest-path src-tauri/Cargo.toml -i <crate> --target x86_64-pc-windows-msvc
```
Sortie vide sur les deux cibles = entrée justifiée. `--target all` ne suffit pas : il répond « présent » pour des crates conditionnels Apple qui ne sont compilés nulle part chez nous, ce qui est précisément le cas de `quick-xml`.
### Règle 2 — clé par ID, jamais par crate
Les entrées portent un ID d'advisory (`RUSTSEC-YYYY-NNNN`), jamais un nom de crate. Une nouvelle advisory déposée contre un crate déjà listé **repasse le gate au rouge**, volontairement : elle est examinée pour elle-même. C'est ce qui distingue une suppression justifiée d'un silence permanent, et un élargissement au crate entier suffirait à annuler le bénéfice du gate.
### Règle 3 — garde-fou anti-péremption
L'atteignabilité est une propriété du graphe **résolu aujourd'hui**, pas une propriété permanente. Un bump `tauri`/`plist` rendant `quick-xml` inconditionnel laisserait la suppression cacher une advisory devenue vivante, et l'audit resterait vert : l'inverse exact du rouge permanent que cette décision cherche à éviter.
`check-rust.yml` porte donc une étape bloquante qui rejoue la règle 1 pour chaque crate supprimé, sur les deux cibles livrées, et échoue si l'un d'eux entre dans un arbre. Elle est placée après `cargo check` (index de registre déjà chaud) et passe `--locked` pour que `cargo tree` ne réécrive pas le lock contre lequel l'audit a été pris. Le scénario qui périmerait la justification est lui-même une modification de `src-tauri/`, soit exactement ce qui déclenche ce workflow.
Trois détails la rendent fiable plutôt que décorative :
- le code de sortie de `cargo tree` est testé séparément de sa sortie standard — un crate absent sort en 0 avec un stdout vide, tandis qu'un échec de `cargo tree` sort en non-zéro avec un stdout vide lui aussi ; sans cette distinction, une panne de l'outil se lirait comme une preuve d'absence ;
- un **canari** (`tar`, dépendance réellement présente) doit être trouvé à chaque exécution, faute de quoi le silence de la boucle ne prouve rien ;
- `.cargo/**` est ajouté aux `paths` de `check-rust.yml` pour qu'une PR ne touchant que la liste déclenche bien la vérification.
Le pendant — une entrée devenue *inutile*, que rien ne signale — est couvert par une issue de suivi portant la condition de retrait : #312 l'a joué pour `quick-xml` (et a servi : le déclencheur avait sauté sans que personne le remarque), #319 le fait pour `rsa`. **Toute entrée de la liste doit en avoir une**, sinon le garde-fou ne couvre qu'une moitié du risque.
## Alternatives considérées
- **Laisser `audit.yml` rouge en permanence.** Rejeté : c'est la perte de signal que #232 venait de corriger, obtenue autrement. Une alarme toujours rouge n'est plus une alarme.
- **Remettre `continue-on-error` ou `|| true` sur le job quotidien.** Rejeté pour la même raison, en pire : cela supprime le signal pour *toutes* les advisories, pas seulement pour les trois inévitables.
- **Dupliquer des `--ignore` dans `audit.yml` et `check-rust.yml`.** Rejeté : deux listes à maintenir en phase, sans endroit naturel où écrire la justification. Le fichier `.cargo/audit.toml` est lu par les deux workflows sans qu'aucun n'ait à le savoir, et il porte les preuves à côté des entrées.
- **Ignorer au niveau du crate plutôt que de l'advisory.** Rejeté — voir la règle 2.
- **Un override / `[patch.crates-io]` pour `quick-xml`.** Impossible : le correctif est en `0.41.0` et `plist` exige `^0.38`. `[patch.crates-io]` ne franchit pas une frontière semver-incompatible. *(Périmé le jour même — `plist` 1.10.0 déclare `quick-xml ^0.41.0`, et un simple `cargo update -p plist` a suffi ; voir l'amendement en tête. Le principe reste, le cas d'espèce a disparu.)*
- **Retirer `sqlx-mysql` du graphe pour éliminer `rsa`.** Écarté : `rsa` n'étant compilé sur aucune cible, l'opération serait un contorsionnement du manifeste pour un gain nul.
## Conséquences
### Positives
- `audit.yml` redevient un signal exploitable : rouge veut dire « quelque chose de nouveau et d'actionnable », et non « les trois mêmes advisories qu'hier ».
- Les six advisories réellement atteignables sont corrigées, sans toucher `Cargo.toml` (`rustls-webpki` 0.103.9 → 0.103.13, `tar` 0.4.44 → 0.4.46, dans les bornes existantes).
- La justification de chaque suppression vit à côté de l'entrée, avec les commandes pour la rejouer — un lecteur futur n'a pas à re-dériver l'analyse ni à re-litiguer la branche « override ».
### Négatives
- **Un audit vert ne signifie plus « zéro advisory »**, mais « zéro advisory hors de la liste ». Le contrat du gate a changé et doit être lu comme tel — d'où la mention dans `audit.yml`, dans `docs/architecture.md` et dans `CLAUDE.md`.
- La liste et la boucle du garde-fou sont **couplées à la main** : ajouter une entrée dans `.cargo/audit.toml` sans ajouter son crate dans `check-rust.yml` laisse la nouvelle entrée sans couverture. Les deux fichiers portent le rappel réciproque, mais rien ne l'impose mécaniquement.
- Le garde-fou ne tourne que sur les PR touchant `src-tauri/` ou `.cargo/`. C'est le bon déclencheur pour le scénario visé, mais ce n'est pas une vérification continue.
### Neutre
- Les 23 `warning` (bindings gtk-rs GTK3 non maintenus, `fxhash`, crates *yanked*) ne sont pas concernés : ils ne comptent pas dans le code de sortie de `cargo audit` et ne sont donc pas ce qui rend le gate vert ou rouge. Le crate *yanked* `tauri-plugin-deep-link 2.4.8`, dépendance directe, est traité séparément en #313.
- Un `workflow_dispatch` vert prouve que la commande sort en 0, **pas** que l'alarme quotidienne existe : au moment de cette décision, `audit.yml` n'a encore jamais été déclenché par son `schedule` (#314).
## Liens
- [`.cargo/audit.toml`](../../.cargo/audit.toml) — la liste, ses preuves et ses critères de retrait
- [`.forgejo/workflows/check-rust.yml`](../../.forgejo/workflows/check-rust.yml) — étape « Verify suppressed advisories are still unreachable »
- [`.forgejo/workflows/audit.yml`](../../.forgejo/workflows/audit.yml) — le gate quotidien bloquant
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) — récupération de cours via `reqwest`, l'un des deux chemins qui rendent `rustls-webpki` atteignable
- Issues #310 (cette décision), #312 (retrait `quick-xml`, **fait le 2026-07-27**), #313 (`tauri-plugin-deep-link` yanked, fait), #314 (cron jamais déclenché), #315 (smoke-test du cycle de mise à jour), #319 (déclencheur de retrait `rsa`, la dernière entrée restante)

View file

@ -0,0 +1,99 @@
# ADR 0019 — Le format d'import est une donnée persistée intégralement, jamais re-devinée
- Status: **Accepted**
- Date: 2026-08-13
- Issues: #326 (corpus de fixtures figeant le comportement fautif), #323 (migration v17), #324 (codec `formatToRow`/`formatFromRow`, racine du bug), #325 (règle débit/crédit + parseur ancré), #327 (détection lexicale par libellé), #328 (score de confiance + détection autonome), #329 (aperçu obligatoire + recap signé), #330 (signatures de banques + dérive d'en-tête), #331 (sources et modèles dans le format SREF), #332 (cette doc)
- Spec: [`spec-decisions-import-csv-format.md`](../../spec-decisions-import-csv-format.md), [`spec-plan-import-csv-format.md`](../../spec-plan-import-csv-format.md)
- Prolonge [ADR 0003](0003-sqlx-migrations.md) (migrations SQL inline additives) pour la migration v17
## Contexte
Un import CSV transforme une cellule de texte en un montant signé au grand livre. Deux informations décident du signe, et elles seules :
- **`amount_mode`** — le fichier porte-t-il un montant unique, ou deux colonnes débit et crédit ?
- **`sign_convention`** — en montant unique, une dépense s'écrit-elle négative ou positive ?
Ces deux champs vivaient sur `import_config_templates` mais **pas** sur `import_sources`. La source — la configuration réellement utilisée à chaque import — ne portait que les réglages mécaniques (délimiteur, encodage, format de date, mapping de colonnes). L'asymétrie était structurelle, et le code la comblait en devinant : `useImportWizard.ts:321-323` ré-inférait le mode depuis `mapping.debitAmount !== undefined` et écrivait `signConvention: "negative_expense"` **en dur**.
Une source de carte de crédit configurée en dépenses positives revenait donc sur la convention par défaut à son **deuxième** import, et chaque montant était nié : les dépenses arrivaient en revenus. Sans erreur, sans avertissement, et sans contrôle agrégé capable de le voir — un signe inversé ne fait que changer le signe du total.
Le défaut n'avait pas une voie mais **trois**, indépendantes l'une de l'autre :
1. **La restauration en dur.** Recharger une source configurée écrasait sa convention par la valeur codée en dur (#324).
2. **La dérive d'en-tête.** La banque déplace, renomme ou ajoute une colonne ; le mapping mémorisé continue de pointer sur des positions qui ne désignent plus les mêmes données, et l'import passe sans rien signaler (#330).
3. **L'export de données.** `dataExportService` ne sérialisait ni les sources ni les modèles, puis exécutait `DELETE FROM import_sources` à la restauration et les remplaçait par une source synthétique « Data Import ». Restaurer une sauvegarde détruisait toute configuration d'import (#331).
Les trois produisent la même classe de panne — un format perdu, remplacé par une supposition — et une correction ponctuelle sur l'une n'apprend rien aux deux autres.
## Décision
**Le format d'import est une valeur persistée intégralement sur `import_sources`, lue telle quelle, jamais re-inférée.** Quatre règles la tiennent.
### 1. La base porte le format en entier (migration v17)
v17 ajoute quatre colonnes à `import_sources`, en additif pur (v1→v16 intactes) :
| Colonne | Définition |
|---|---|
| `amount_mode` | `TEXT NOT NULL DEFAULT 'single'` + `CHECK (amount_mode IN ('single','debit_credit','absolute_indicator'))` |
| `sign_convention` | `TEXT NOT NULL DEFAULT 'negative_expense'` + `CHECK (sign_convention IN ('negative_expense','positive_expense'))` |
| `header_signature` | `TEXT` — libellés normalisés de l'en-tête du dernier import réussi (détection de dérive) |
| `template_id` | `INTEGER REFERENCES import_config_templates(id) ON DELETE SET NULL` |
Le `CHECK` d'`amount_mode` admet `absolute_indicator` **dès maintenant** : le troisième mode de montant pourra être livré sans nouvelle migration, tandis que la base refuse déjà toute valeur corrompue. Le backfill (`UPDATE … WHERE column_mapping LIKE '%debitAmount%'`) reproduit exactement ce que le code déduisait à la volée — v17 ne change aucun comportement observable. `sign_convention` n'est délibérément **pas** backfillée : son `DEFAULT` restitue précisément la valeur que le code codait en dur, la seule convention passée qui puisse être inférée ; en deviner une autre réécrirait silencieusement du sens.
`LIKE` plutôt que `json_extract`, pour que la migration ne dépende d'aucune extension JSON1 dans le SQLite embarqué.
### 2. Un codec unique, pas un type composé
La garantie de complétude vient de **`src/utils/importFormat.ts`**, seul point de conversion entre :
- `ImportFormatRow` — la forme persistée (snake_case, mapping en JSON, `has_header` normalisé en 0/1) ;
- `ImportFormat` — la forme domaine consommée par le wizard et par `mapRow`.
Un type `ImportFormat` **composé**, partagé par les deux porteurs, était l'approche naturelle et elle est **structurellement impossible** : `ImportSource.has_header` est déclaré `boolean`, `ImportConfigTemplate.has_header` est un `number`, et `SourceConfig` est camelCase sur un mapping déjà parsé. Les porteurs sont incompatibles ; le point de passage, lui, peut être unique.
Ce qui donne des dents au codec est son **test de complétude**, sur deux niveaux :
- `FORMAT_FIELD_PAIRS` est typé `Record<keyof ImportFormat, keyof ImportFormatRow>` — un champ ajouté au format **ne compile pas** tant qu'il n'y figure pas ;
- le test compare les clés réellement produites par chaque sens du codec à cette table — un champ déclaré mais non câblé **fait échouer le test**.
Supprimer `sign_convention` de `formatToRow` — la forme exacte du bug d'origine — fait tomber 14 tests. Les deux écrivains de modèles passent aussi par le codec, pour qu'un nouveau champ ne puisse pas atteindre une table et manquer l'autre.
`formatFromRow` **lève** sur une valeur qu'il ne sait pas lire au lieu de retomber sur un défaut : retomber sur la branche `single` lirait la mauvaise colonne à chaque ligne, et toute valeur autre que `positive_expense` signifierait silencieusement `negative_expense`. L'erreur porte une clé i18n et le wizard s'ouvre alors sur une configuration neuve — bloquer le wizard rendrait « reconfigurer cette source » impossible.
### 3. `template_id` est une étiquette de provenance, jamais relue comme format
Un modèle sert à **initialiser** une source ; il ne la gouverne pas. `template_id` est enregistré, restauré et affiché, mais **jamais relu pour dériver un format**. Éditer un modèle ne change aucune source liée — c'est un critère d'acceptation testé de bout en bout, avec vérification de non-vacuité (le modèle a bien changé). `ON DELETE SET NULL` : supprimer le modèle efface l'étiquette et laisse le format intact.
La raison est la même que celle du reste de l'ADR : si le format se déduisait du modèle, il redeviendrait une valeur devinée, portée par une ligne que l'utilisateur peut modifier ailleurs et à un autre moment.
### 4. Le format persisté gagne, et les deux autres voies de perte sont fermées
- **Détection** — elle se déclenche seule sur une source **sans format enregistré**, et **jamais** sur une source qui en a un (garde `!existing`). Le bouton baguette reste, pour la rejouer à la demande. Une source dont le format stocké échoue à décoder émet son erreur explicite plutôt qu'une nouvelle supposition.
- **Dérive d'en-tête**`header_signature` stocke les libellés normalisés du dernier import réussi, en **tableau JSON et non en hachage** : le panneau doit pouvoir nommer les colonnes qui ont bougé (« Montant : colonne 3 → 4 »). Un en-tête qui normalise différemment ouvre `FormatDriftPanel` au-dessus de l'aperçu, avec les deux seules issues qui existent — **adopter** le format re-détecté ou **conserver** celui enregistré. Un simple renommage cosmétique normalise à l'identique et ne dit rien. Une source dont les fichiers n'ont pas de ligne d'en-tête garde `header_signature` à NULL et la détection de dérive y est inopérante : c'est documenté, pas contourné — une signature inventée depuis les données se déclencherait à chaque import.
- **Export/import de données**`import_sources` et `import_config_templates` sont sérialisés dans l'enveloppe SREF derrière un `format_version` explicite. Les modèles sont restaurés **avant** les sources (`template_id` est une clé étrangère), upsertés par nom, et `template_id` est remappé sur les identifiants résolus. Le wipe et la restauration sont enveloppés dans `withTransaction`.
## Alternatives considérées
- **Un type `ImportFormat` composé, partagé par les deux tables.** Rejeté : impossible sans réécrire les deux porteurs (`has_header` `boolean` d'un côté, `number` de l'autre, mapping parsé vs JSON). Le codec obtient la même garantie sans toucher aux formes existantes, et son test la rend vérifiable — ce qu'un type partagé n'aurait pas donné à lui seul.
- **Backfiller `sign_convention` en devinant depuis les données** (ex. « majorité de montants négatifs ⇒ `negative_expense` »). Rejeté : c'est exactement la re-inférence que cet ADR supprime, appliquée une fois pour toutes et sans possibilité de la relire. Le `DEFAULT` restitue la seule convention réellement en vigueur avant v17.
- **Dériver le format depuis `template_id`.** Rejeté : voir la règle 3 — cela redonne au format un porteur mutable ailleurs.
- **Hacher `header_signature`.** Rejeté : un hachage détecte la dérive mais ne peut pas la décrire, et le panneau de dérive n'aurait plus rien à montrer que « ça a changé ».
- **Corriger rétroactivement les transactions déjà importées à l'envers.** Rejeté : on ne mute jamais des montants déjà écrits. La voie de réparation est `deleteImportWithTransactions` puis re-import — `RepairPathNotice` l'énonce dans l'interface, parce que ré-importer par-dessus **double** les lignes (`findDuplicates` apparie sur date + description + montant, donc une ligne corrigée ne s'apparie pas à la fautive).
## Conséquences
- **Positif.** Le format d'une source est lisible en base et rejoué à l'identique. Le codec et son test forment le point de contrôle unique : ajouter un champ de format sans le persister ne compile pas, ou ne passe pas. Les trois voies de perte sont fermées par construction, pas par vigilance.
- **Positif.** L'aperçu signé, obligatoire à chaque import, montre ce que le format *signifie* (sorties, entrées, totaux signés, lignes en erreur) — un score de lecture parfait ne dit rien du signe.
- **Coût.** Une source dont le format enregistré est illisible (valeur hors `CHECK` arrivée par un chemin non prévu, JSON corrompu) affiche une erreur et repart sur une configuration neuve, au lieu de continuer sur un défaut. C'est le comportement voulu, mais c'est un arrêt visible là où il y avait un silence.
- **Coût.** `header_signature` n'est écrite qu'à l'import réussi et reste NULL pour les fichiers sans en-tête : la détection de dérive ne couvre pas ces sources.
- **À surveiller.** Le `CHECK` d'`amount_mode` admet `absolute_indicator`, que l'application **refuse** aujourd'hui avec un message dédié plutôt que de l'importer de travers. Le jour où ce mode sera livré, le codec devra l'accepter — la migration, elle, n'est pas à refaire.
- **Portée.** Les modules d'import restent entièrement en édition Gratuite ; ce chantier n'ajoute aucun gating ([ADR 0017](0017-feature-gating-par-tier.md)).
## Liens
- Spec : [`spec-decisions-import-csv-format.md`](../../spec-decisions-import-csv-format.md), [`spec-plan-import-csv-format.md`](../../spec-plan-import-csv-format.md)
- [ADR 0003](0003-sqlx-migrations.md) — migrations SQL inline via tauri-plugin-sql (v17 en suit la convention additive)
- [ADR 0004](0004-aes-256-gcm-encryption.md) — chiffrement de l'enveloppe SREF, dont le `format_version` est étendu par #331
- `docs/architecture.md` — section « Import CSV — format persisté et détection »

View file

@ -1,6 +1,6 @@
# Architecture technique — Simpl'Résultat # Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-04-25 — Version 0.8.x (Bilan) > Document mis à jour le 2026-08-13 — Version 0.14.x (format d'import persisté, migration v17)
## Stack technique ## Stack technique
@ -32,19 +32,19 @@ simpl-resultat/
│ │ ├── budget/ # 5 composants │ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants │ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 2 composants │ │ ├── dashboard/ # 2 composants
│ │ ├── import/ # 13 composants (wizard d'import) │ │ ├── import/ # 14 composants (wizard d'import, dont FormatDriftPanel et RepairPathNotice)
│ │ ├── layout/ # AppShell, Sidebar │ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie) │ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard) │ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
│ │ ├── shared/ # 6 composants réutilisables │ │ ├── shared/ # 9 composants réutilisables (dont RequireFeature, UpsellGate)
│ │ └── transactions/ # 5 composants │ │ └── transactions/ # 5 composants
│ ├── contexts/ # ProfileContext (état global profil) │ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine) │ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 14 services métier │ ├── services/ # 14 services métier
│ ├── shared/ # Types et constantes partagés │ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts)
│ ├── utils/ # 4 utilitaires (parsing, CSV, charts) │ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts)
│ ├── i18n/ # Config i18next + locales FR/EN │ ├── i18n/ # Config i18next + locales FR/EN
│ ├── App.tsx # Router principal │ ├── App.tsx # Router principal
│ └── main.tsx # Point d'entrée │ └── main.tsx # Point d'entrée
@ -65,8 +65,12 @@ simpl-resultat/
│ │ └── main.rs │ │ └── main.rs
│ ├── capabilities/ # Permissions Tauri │ ├── capabilities/ # Permissions Tauri
│ └── Cargo.toml │ └── Cargo.toml
├── .github/workflows/ # CI/CD ├── .forgejo/workflows/ # CI/CD (hôte primaire)
│ ├── check-rust.yml
│ ├── check-frontend.yml
│ ├── audit.yml
│ └── release.yml │ └── release.yml
├── .github/workflows/ # Miroir GitHub (dormant)
├── docs/ # Documentation technique ├── docs/ # Documentation technique
└── config/ # Configuration └── config/ # Configuration
``` ```
@ -77,7 +81,7 @@ simpl-resultat/
| Table | Description | | Table | Description |
|-------|-------------| |-------|-------------|
| `import_sources` | Configuration des sources d'import CSV | | `import_sources` | Configuration des sources d'import CSV. Porte le **format d'import en entier** depuis v17 : `amount_mode` (`CHECK ∈ {single, debit_credit, absolute_indicator}`) et `sign_convention` (`CHECK ∈ {negative_expense, positive_expense}`) s'ajoutent aux réglages mécaniques (délimiteur, encodage, format de date, `column_mapping`), plus `header_signature` (libellés normalisés du dernier import réussi, détection de dérive) et `template_id` (**étiquette de provenance**, `ON DELETE SET NULL`, jamais relue comme format) — voir [ADR 0019](adr/0019-format-import-persiste.md) et la section « Import CSV » |
| `imported_files` | Suivi des fichiers importés (hash anti-doublons) | | `imported_files` | Suivi des fichiers importés (hash anti-doublons) |
| `categories` | Catégories hiérarchiques (dépenses/revenus) | | `categories` | Catégories hiérarchiques (dépenses/revenus) |
| `suppliers` | Fournisseurs avec auto-catégorisation | | `suppliers` | Fournisseurs avec auto-catégorisation |
@ -155,6 +159,9 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
| 14 | v14 | Étape 2 : `balance_securities` + `balance_snapshot_holdings` + 2 index (additive) — [ADR 0015](adr/0015-balance-detail-par-titre.md) | | 14 | v14 | Étape 2 : `balance_securities` + `balance_snapshot_holdings` + 2 index (additive) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
| 15 | v15 | Étape 2 : `balance_accounts.kind` (`simple`/`detailed`) + `detailed_since` + backfill depuis `category.kind` (`priced` → `detailed`) — [ADR 0015](adr/0015-balance-detail-par-titre.md) | | 15 | v15 | Étape 2 : `balance_accounts.kind` (`simple`/`detailed`) + `detailed_since` + backfill depuis `category.kind` (`priced` → `detailed`) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
| 16 | v16 | Étape 2 : conversion des comptes cotés existants en détaillés 1-position (security + holding miroir, gardée anti-perte, idempotente) — [ADR 0015](adr/0015-balance-detail-par-titre.md) | | 16 | v16 | Étape 2 : conversion des comptes cotés existants en détaillés 1-position (security + holding miroir, gardée anti-perte, idempotente) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
| 17 | v17 | Import : `import_sources.amount_mode` + `sign_convention` (les deux avec `CHECK`) + `header_signature` + `template_id` (FK `ON DELETE SET NULL`), backfill `amount_mode = 'debit_credit' WHERE column_mapping LIKE '%debitAmount%'` (additive, aucun changement de comportement observable) — [ADR 0019](adr/0019-format-import-persiste.md) |
v17 est purement additive : elle n'ajoute **ni table ni index** (20 tables / 24 index inchangés). Le `CHECK` d'`amount_mode` admet `absolute_indicator` dès maintenant, pour que le troisième mode de montant puisse être livré sans nouvelle migration, alors que l'application le **refuse** encore explicitement (voir plus bas). `LIKE` plutôt que `json_extract` : la migration ne dépend d'aucune extension JSON1 dans le SQLite embarqué. `sign_convention` n'est délibérément pas backfillée — son `DEFAULT` restitue la valeur que le code codait en dur, la seule convention passée inférable.
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations). Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
@ -166,7 +173,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `profileService.ts` | Gestion des profils | | `profileService.ts` | Gestion des profils |
| `categoryService.ts` | CRUD catégories hiérarchiques | | `categoryService.ts` | CRUD catégories hiérarchiques |
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) | | `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) |
| `importSourceService.ts` | Configuration des sources d'import | | `importSourceService.ts` | Configuration des sources d'import — lit et écrit le format complet via le codec `importFormat.ts` ([ADR 0019](adr/0019-format-import-persiste.md)) |
| `importedFileService.ts` | Suivi des fichiers importés | | `importedFileService.ts` | Suivi des fichiers importés |
| `importConfigTemplateService.ts` | Modèles de configuration d'import | | `importConfigTemplateService.ts` | Modèles de configuration d'import |
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) | | `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
@ -174,7 +181,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `budgetService.ts` | Gestion budgétaire | | `budgetService.ts` | Gestion budgétaire |
| `dashboardService.ts` | Agrégation données tableau de bord : `getExpensesByCategory` (barres classées, `accountIds`), `getDashboardSummary` (non consommé depuis #279), `deriveNetWorthTile` (pur — tuile valeur nette du Bilan, réutilise `deriveLandingState`) | | `dashboardService.ts` | Agrégation données tableau de bord : `getExpensesByCategory` (barres classées, `accountIds`), `getDashboardSummary` (non consommé depuis #279), `deriveNetWorthTile` (pur — tuile valeur nette du Bilan, réutilise `deriveLandingState`) |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) | | `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
| `dataExportService.ts` | Export de données (chiffré) | | `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
| `userPreferenceService.ts` | Stockage préférences utilisateur | | `userPreferenceService.ts` | Stockage préférences utilisateur |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) | | `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
@ -201,7 +208,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useCategories` | Catégories avec hiérarchie | | `useCategories` | Catégories avec hiérarchie |
| `useTransactions` | Transactions et filtrage | | `useTransactions` | Transactions et filtrage |
| `useDataImport` | Import de données | | `useDataImport` | Import de données |
| `useImportWizard` | Assistant d'import multi-étapes | | `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md) |
| `useImportHistory` | Historique des imports | | `useImportHistory` | Historique des imports |
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
@ -217,8 +224,9 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useBalanceOverview` | Bilan — page `/balance` : sélecteur de période (`3M / 6M / 1A / 3A / Tout`), série temporelle agrégée, mode chart (`line` / `stacked`), tableau des comptes avec valeurs courantes et Δ% sur la période. Les rendements multi-horizons sont chargés *lazily* dans `BalanceAccountsTable` (un appel `compute_account_return` par cellule) | | `useBalanceOverview` | Bilan — page `/balance` : sélecteur de période (`3M / 6M / 1A / 3A / Tout`), série temporelle agrégée, mode chart (`line` / `stacked`), tableau des comptes avec valeurs courantes et Δ% sur la période. Les rendements multi-horizons sont chargés *lazily* dans `BalanceAccountsTable` (un appel `compute_account_return` par cellule) |
| `useDataExport` | Export de données | | `useDataExport` | Export de données |
| `useTheme` | Thème clair/sombre | | `useTheme` | Thème clair/sombre |
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) | | `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` |
| `useLicense` | État de la licence et entitlements | | `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext``ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») |
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) | | `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
### Hook transverse — `useCollapsibleGroups` ### Hook transverse — `useCollapsibleGroups`
@ -228,6 +236,72 @@ Chaque hook encapsule la logique d'état via `useReducer` :
- `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible. - `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible.
- `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler). - `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler).
## Import CSV — format persisté et détection
Le chantier #323-#332 a fait du **format d'import une donnée persistée intégralement, jamais re-devinée** — [ADR 0019](adr/0019-format-import-persiste.md) porte la décision, ses trois voies de perte et les alternatives rejetées. Quatre couches, du schéma à l'écran.
### 1. Le codec — `src/utils/importFormat.ts`
Point de conversion **unique** entre `ImportFormatRow` (persisté : snake_case, mapping en JSON, `has_header` normalisé 0/1) et `ImportFormat` (domaine). Il n'existe pas de type composé partagé par les deux porteurs — c'est structurellement impossible (`ImportSource.has_header` est `boolean`, `ImportConfigTemplate.has_header` est `number`), et la garantie de complétude vient du codec et de son test, pas d'une forme commune :
- `FORMAT_FIELD_PAIRS: Record<keyof ImportFormat, keyof ImportFormatRow>` — un champ ajouté au format **ne compile pas** tant qu'il n'y est pas listé ;
- le test compare les clés réellement produites par `formatToRow` / `formatFromRow` à cette table — un champ listé mais non câblé **fait échouer le test**.
`formatFromRow` lève (`ImportFormatError`, clé i18n) sur une valeur illisible plutôt que de retomber sur un défaut. Le module porte aussi `mapRow` (la règle de ligne, pure), `detectAmountSeparators`, `summarizeParsedRows` (le récapitulatif signé de l'aperçu), `flipSignFormat` et `clearMappingForMode`.
**La règle de montant** : `amount = crédit débit` sur des **magnitudes** (`Math.abs`), ce qui ne demande aucun cas particulier pour la colonne inutilisée remplie `0,00` (zéro est l'élément neutre de la soustraction) — le `isNaN(credit)` d'avant importait chaque débit d'un tel fichier à 0. Une ligne illisible dans les **deux** colonnes est une erreur de ligne, pas un 0,00 gratuit. `parseFrenchAmount` est **ancré** (plus de `parseFloat` rendant le plus long préfixe valide) : `100,00 CAD` rend NaN au lieu de 10000, `50,00-` rend 50, `(50,00)` rend 50. Le séparateur décimal est arbitré **par colonne** (`1.234` vaut 1234 en colonne française et 1.234 en anglaise ; aucune règle appliquée à la cellule seule ne peut trancher). L'écriture de la configuration se fait à `executeImport`, point d'écriture unique — un import abandonné à l'étape des doublons ne laisse aucune configuration derrière lui.
### 2. La détection — `csvAutoDetect.ts` + `headerDictionary.ts` + `bankSignatures.ts`
Trois modules distincts, évalués du plus spécifique au plus générique.
| Module | Rôle |
|---|---|
| `bankSignatures.ts` | Dispositions d'export documentées de **Desjardins, RBC, BNC et Tangerine** : un jeu de libellés normalisés + délimiteur + préambule. Évalué **avant** le dictionnaire générique. `MIN_SIGNATURE_LABELS = 4` (`Date;Description;Montant` est trop banal pour attribuer un nom de banque). Porte aussi la **signature d'en-tête stockée** (`buildHeaderSignature` / `parseHeaderSignature` / `detectHeaderDrift`) : même objet — une ligne d'en-tête réduite à ses libellés normalisés — vu deux fois |
| `headerDictionary.ts` | Dictionnaire lexical FR/EN des rôles de colonnes (date ; description/libellé/détail/transaction ; montant/amount ; débit/retrait/déboursé/withdrawal ; crédit/dépôt/encaissement/deposit ; solde/balance) + `normalizeHeaderCell` / `matchHeaderColumn` / `matchTransactionHeaders`. **Module séparé de `csvAutoDetect.ts` par nécessité** : `montant` est le mot-clé *primaire* du montant pour les transactions et un token d'*exclusion* pour l'import de titres (`VALUE_HEADER_KEYWORDS`, #245) — deux tables qui ne doivent pas se percuter. Les helpers ont été **déplacés** plutôt qu'exportés-puis-réimportés, pour que la dépendance reste à sens unique |
| `csvAutoDetect.ts` | Heuristiques de forme (délimiteur, encodage, en-tête, colonnes candidates) + `detectImportFormat`, l'entrée pleine fidélité qui rend soit une config soit une **raison** de refus, et le score |
**L'ordre débit/crédit vient du libellé, plus de la position.** La paire est toujours trouvée par la forme (colonnes creuses et complémentaires), mais laquelle est le débit est décidé par le dictionnaire — connaître l'une suffit. Avant, `Date;Description;Credit;Debit` inversait **tous** les signes, silencieusement (un total inversé ne fait que changer de signe). `detectHeader` gagne un **signal lexical** : une ligne que le test de forme rejette *uniquement* à cause d'un nombre est un en-tête si elle nomme ≥ 2 rôles (`MIN_HEADER_ROLE_MATCHES`). Chaque indice lexical reste une **préférence que les données peuvent contredire** ; sans en-tête, ou avec des libellés inconnus, tout retombe sur la forme exactement comme avant.
**Un format est explicitement refusé** : montants absolus accompagnés d'une colonne d'indicateur `D`/`C` (ou `DB`/`CR`) adjacente portant ≥ 2 valeurs distinctes. Il était auparavant indiscernable d'un fichier tout-positif, déduit `positive_expense`, et **chaque dépôt s'importait en dépense**. Message dédié (`import.errors.absoluteIndicatorFormat`) plutôt qu'un import de travers.
**Le score de confiance** (`DetectionScore` : `readRows`, `totalRows`, `ratio`, `confident`, seuil `CONFIDENCE_THRESHOLD = 0.9`) rejoue la configuration décidée sur **toutes** les lignes du fichier via `mapRow` — la fonction de production, pas un second mappeur — sous la même arbitration décimale. Il **colore, il ne bloque pas** : un score parfait ne dit rien du **signe** (le fixture `all-positive` score 100 % alors que chaque crédit s'importe en dépense). La détection se déclenche **seule** sur une source sans format enregistré, et **jamais** sur une source qui en a un (garde `!existing`) — le format stocké gagne. Le score est invalidé au changement de source, à l'édition manuelle du format et au chargement d'un modèle.
### 3. Le wizard — l'aperçu est obligatoire
L'étape `file-preview`, déclarée depuis l'origine dans `ImportWizardStep` sans qu'aucun dispatch ne la vise, est **rendue et traversée à chaque import** ; `FilePreviewModal` (l'aperçu optionnel qui l'avait supplantée) est supprimé. `checkDuplicates` en est le bouton « suivant », donc les lignes validées sont exactement les lignes vérifiées, sans second parsing entre les deux. L'étape n'est gatée par rien — surtout pas par le seuil de 90 %, puisque le cas qu'elle attrape est précisément le score parfait au signe inversé.
- **Récapitulatif signé** (`summarizeParsedRows`) : sorties et leur total, entrées et le leur, lignes en erreur. Les totaux restent **signés** — des magnitudes masqueraient la seule chose que le récap expose. Calculé sur tout le fichier, jamais sur les vingt lignes affichées.
- **« Inverser les signes »** (`flipSignFormat`) agit sur la **configuration**, donc la correction est mémorisée avec la source. En mode `single` elle bascule la convention ; en `debit_credit` elle **échange les deux indices de colonnes**, parce que `mapRow` y calcule `crédit débit` sur des magnitudes et ne lit jamais la convention — une bascule y aurait été inerte.
- **`FormatDriftPanel`** s'ouvre au-dessus de l'aperçu quand l'en-tête normalise différemment de `header_signature`, colonne par colonne (« Montant : colonne 3 → 4 »), avec les deux seules issues qui existent : **adopter** le format re-détecté ou **conserver** celui enregistré. Un renommage cosmétique normalise à l'identique et ne dit rien. Une source dont les fichiers n'ont pas d'en-tête garde `header_signature` NULL et la détection de dérive y est inopérante — documenté, pas contourné.
- **`RepairPathNotice`**, rendu dans le panneau de dérive **et** à côté du bouton d'inversion : `findDuplicates` apparie sur date + description + montant, donc ré-importer un fichier « maintenant qu'il se lit bien » ne corrige pas les lignes déjà écrites — il les **double**, et une inversion de signe produit des paires miroir qui s'annulent dans tous les rapports. La seule voie sûre est `deleteImportWithTransactions` puis rejeu. Aucun montant déjà écrit n'est jamais muté.
- **`ImportConfirmation`** énonce désormais le mode de montant, la convention de signe (seulement dans le mode qui l'applique) et le mapping nommé par en-tête. Le sélecteur de convention est **masqué** en mode débit/crédit, où le parsing ne le lit pas — masqué, pas réinitialisé : la valeur stockée est intacte.
### 4. Le format SREF porte les sources et les modèles
`dataExportService` sérialisait uniquement catégories, fournisseurs, mots-clés et transactions, puis exécutait `DELETE FROM import_sources` à la restauration en les remplaçant par une source synthétique « Data Import » — restaurer une sauvegarde détruisait toute configuration d'import. Depuis #331 : `import_sources` et `import_config_templates` sont dans l'enveloppe derrière un `format_version` explicite (`SREF_FORMAT_VERSION = 2` ; une enveloppe sans le champ est l'ancien format, ses tableaux absents traités comme vides). Les **modèles sont restaurés avant les sources** (`template_id` est une FK), upsertés par nom, et `template_id` remappé sur les identifiants résolus. Wipe et restauration des deux chemins sont enveloppés dans `withTransaction` — le service n'en avait aucune, et une violation de contrainte en cours de restauration détruisait l'historique financier sans rollback. `amount_mode` et `sign_convention` sont whitelistés à la frontière d'import avec un message lisible (le `CHECK` v17 refuse déjà les mauvaises valeurs, mais une erreur de contrainte SQLite n'est pas un message utilisateur).
### Corpus de fixtures — `src/__fixtures__/csv/`
15 fichiers CSV **synthétiques** (aucune donnée réelle, aucun relevé réel n'entre dans ce dépôt — les signatures de banques sont écrites depuis des dispositions documentées). Ils couvrent des formes qu'un vrai relevé ne réunit jamais toutes : montant signé, débit/crédit, débit/crédit inversés, colonne inutilisée à `0,00`, préambule, en-tête contenant un nombre, en-tête à libellé numérique, sans en-tête, tout-positif, Desjardins quoté, montant absolu + indicateur, plus les quatre dispositions de banque. Posés en #326 **avant** la refonte, avec le comportement fautif figé et marqué `KNOWN DEFECT` + l'issue devant le corriger ; chaque maillon suivant a mis à jour l'attente et retiré le marqueur, sans jamais supprimer un test.
## Gating par édition (licence)
Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de licence (`free` / `base` / `premium`) **côté interface uniquement** — un soft-paywall assumé (l'app est GPL ; le seul gate appliqué côté serveur reste la récupération de cours Premium via maximus-api). La matrice complète tier → modules, le rationale et les alternatives rejetées sont dans l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
| Brique | Fichier | Rôle |
|---|---|---|
| Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé copiée déclassée par le machine-binding ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell |
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status` |
| `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }``allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue |
| `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (`<Outlet/>`) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé |
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé : CTA « Obtenir \<tier\> » **désactivé** avec mention « bientôt » (câblage boutique par #270) + « J'ai déjà une clé » → `/settings/users` (prop `onNavigate?` pour les hôtes modaux) |
| Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) |
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell compact) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
| Rust | `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` réduit à `auto-update` → Base+ (la matrice UI vit en TS) ; `check_entitlement` = matrice OU `features[]` signés avec court-circuit Free (même CWE-863), entitlements résolus par `license_commands::current_entitlements` |
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
## Commandes Tauri (36) ## Commandes Tauri (36)
### `fs_commands.rs` — Système de fichiers (6) ### `fs_commands.rs` — Système de fichiers (6)
@ -296,9 +370,10 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
### `entitlements.rs` — Entitlements (1) ### `entitlements.rs` — Entitlements (1)
- `check_entitlement` — Vérifie si une feature est autorisée selon l'édition - `check_entitlement` — Vérifie si une feature est autorisée : `is_feature_allowed` (matrice statique) OU présence dans le `features[]` signé de la licence, avec **court-circuit fail-closed en Free** (CWE-863 : une clé copiée, déclassée `free` par le machine-binding, ne récupère jamais ses features signées)
- Source de vérité : `FEATURE_TIERS` dans `entitlements.rs`. Modifier cette constante pour changer les gates, jamais ailleurs dans le code - Source de vérité Rust : `FEATURE_TIERS` dans `entitlements.rs`, réduit depuis #301 à `auto-update``[base, premium]` (absorbe l'issue #271). Les gates UI (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`) vivent dans la matrice TS `src/shared/entitlements.ts` — voir la section « Gating par édition » et l'[ADR 0017](adr/0017-feature-gating-par-tier.md)
- Temporaire : `auto-update` est ouvert à `free` en attendant le serveur de licences (issue #49). À re-gater à `[base, premium]` quand l'activation payante sera live - Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin machine-binding que `get_edition`, tout échec de validation retourne `("free", [])`
- Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`)
### `balance_commands.rs` — Bilan (1) ### `balance_commands.rs` — Bilan (1)
@ -346,6 +421,8 @@ Fichiers : `src-tauri/src/lib.rs` (wiring), `src-tauri/src/commands/auth_command
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate). Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
Les modules payants sont enveloppés dans des **layout-routes pathless `RequireFeature`** (une par feature, soft paywall — voir la section « Gating par édition ») : `/adjustments` (`adjustments`), `/budget` (`budget`), `/reports/highlights` + `/reports/compare` + `/reports/category` + `/reports/cartes` (`reports-advanced` — le hub `/reports` et `/reports/trends` restent Free, hors de tout gate), `/balance` + `/balance/accounts` + `/balance/snapshot` (`balance`).
### Gestion d'erreurs ### Gestion d'erreurs
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback - **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
@ -358,7 +435,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| Route | Page | Description | | Route | Page | Description |
|-------|------|-------------| |-------|------|-------------|
| `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) | | `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
| `/import` | `ImportPage` | Assistant d'import CSV | | `/import` | `ImportPage` | Assistant d'import CSV : source → configuration (détection automatique + score) → sélection des fichiers → **aperçu obligatoire** (récap signé, inversion des signes, panneau de dérive) → doublons → confirmation |
| `/transactions` | `TransactionsPage` | Liste avec filtres | | `/transactions` | `TransactionsPage` | Liste avec filtres |
| `/categories` | `CategoriesPage` | Gestion hiérarchique | | `/categories` | `CategoriesPage` | Gestion hiérarchique |
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels | | `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
@ -393,16 +470,31 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
## CI/CD ## CI/CD
Deux workflows Forgejo Actions (avec miroir GitHub) dans `.forgejo/workflows/` : Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.
### `check.yml` — Vérifications sur branches et PR ### `check-rust.yml` — Vérifications Rust sur PR
Déclenché sur chaque push de branche (sauf `main`) et chaque PR vers `main`. Lance en parallèle : Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même). Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`).
- `cargo check` + `cargo test` (Rust)
- `npm run build` (tsc + vite)
- `npm test` (vitest)
Doit être vert avant tout merge. Évite de découvrir des régressions au moment du tag de release. Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`tar`, réellement présent) garantit que son silence prouve quelque chose. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que produit `/autopilot` — déclenche donc bien la CI. Aucune étape de cache non plus : le conteneur de job n'atteint pas le serveur de cache du runner ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)), le restore et le save échouent tous les deux. Le cache reviendra via `Swatinem/rust-cache` une fois #234 réglé.
### `check-frontend.yml` — Vérifications frontend sur PR
Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown (`paths-ignore`). Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Le cache npm est retiré pour la même raison que côté Rust.
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. Conséquence à connaître : `npm audit` remonte **2 high en permanence**, les deux clés d'une même advisory `react-router` ([GHSA-qwww-vcr4-c8h2](https://github.com/advisories/GHSA-qwww-vcr4-c8h2), mode React Server Components). Elle est acceptée : l'app est un client de bureau sans serveur — `App.tsx` monte un `BrowserRouter` client-only — et `react-router-dom` étant figé en 7.18.1, sortir de la plage vulnérable demanderait une migration vers react-router v8, pas un bump. Déclencheur de re-évaluation : [#317](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/317). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm.
### `audit.yml` — Audit RustSec quotidien
`schedule` quotidien (06:00 UTC) + `workflow_dispatch`. `check-rust.yml` ne tournant que sur les PR qui touchent le Rust — environ 1 PR sur 40 — ce workflow garantit qu'un avis de sécurité publié sur une dépendance inchangée ne passe pas inaperçu. Échec bloquant : c'est le canal de notification.
**Un run vert ne signifie pas « zéro advisory »** mais « zéro advisory hors de la liste acceptée ». Cette liste vit dans `.cargo/audit.toml`, à la racine du dépôt, avec pour chaque entrée sa preuve de non-atteignabilité et sa condition de retrait ; la politique qui l'encadre est l'[ADR 0018](adr/0018-suppression-advisories-non-atteignables.md). Au moment de sa mise en place, le `schedule` n'avait encore jamais déclenché ce workflow ([#314](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/314)) — un `workflow_dispatch` vert prouve que la commande sort en 0, pas que l'alarme quotidienne fonctionne.
Les deux workflows `check-*` doivent être verts avant tout merge. Ils évitent de découvrir des régressions au moment du tag de release.
> Le miroir GitHub (`.github/workflows/check.yml`) est laissé tel quel : aucune PR n'est ouverte côté GitHub, un push-mirror ne déclenche pas d'événement `pull_request`.
### `release.yml` — Build et publication ### `release.yml` — Build et publication
@ -437,3 +529,6 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
| [0014](adr/0014-balance-vehicule-attribut.md) | Bilan : le véhicule fiscal est un attribut du compte (Étape 1) | 2026-06-01 | Accepted | | [0014](adr/0014-balance-vehicule-attribut.md) | Bilan : le véhicule fiscal est un attribut du compte (Étape 1) | 2026-06-01 | Accepted |
| [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted | | [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted |
| [0016](adr/0016-persistance-etat-ui-par-profil.md) | Persistance de l'état UI par profil : repli des catégories dans `user_preferences` | 2026-07-15 | Accepted | | [0016](adr/0016-persistance-etat-ui-par-profil.md) | Persistance de l'état UI par profil : repli des catégories dans `user_preferences` | 2026-07-15 | Accepted |
| [0017](adr/0017-feature-gating-par-tier.md) | Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé | 2026-07-20 | Accepted |
| [0018](adr/0018-suppression-advisories-non-atteignables.md) | Suppression d'advisories non atteignables : admission sur preuve par cible livrée, clé par ID, garde-fou bloquant | 2026-07-27 | Accepted |
| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted |

View file

@ -100,8 +100,12 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista
### Fonctionnalités ### Fonctionnalités
- Assistant d'import multi-étapes avec aperçu des données - Détection automatique du format à l'ouverture d'une source jamais configurée : délimiteur, encodage, format de date et colonnes reconnues par leur libellé (français et anglais)
- Mapping de colonnes configurable, délimiteur et format de date - Formats reconnus par banque — Desjardins, RBC, Banque Nationale et Tangerine sont lus par leur nom
- Score de confiance affiché après détection (« 147 des 150 lignes lues ») : il indique ce qui a pu être lu, jamais si le sens est bon
- Aperçu obligatoire avant chaque import, avec un récapitulatif signé : sorties, entrées, totaux et lignes en erreur, plus un bouton « Inverser les signes »
- Avertissement quand l'en-tête d'un fichier ne correspond plus à celui du dernier import réussi, colonne par colonne
- Mapping de colonnes configurable, délimiteur, encodage et format de date modifiables à tout moment
- Détection automatique des doublons (dans le lot et contre les données existantes) - Détection automatique des doublons (dans le lot et contre les données existantes)
- Modèles d'import pour sauvegarder et réutiliser les configurations - Modèles d'import pour sauvegarder et réutiliser les configurations
- Historique des imports avec possibilité de supprimer les imports précédents - Historique des imports avec possibilité de supprimer les imports précédents
@ -110,16 +114,24 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista
1. Définissez votre dossier d'import via le sélecteur de dossier en haut de la page 1. Définissez votre dossier d'import via le sélecteur de dossier en haut de la page
2. Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV 2. Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV
3. Cliquez sur une source pour ouvrir l'assistant d'import 3. Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat
4. Configurez le délimiteur, l'encodage, le format de date et le mapping des colonnes 4. Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain
5. Sélectionnez les fichiers à importer et prévisualisez les données analysées 5. Sélectionnez les fichiers à importer
6. Vérifiez les doublons, examinez le résumé, puis confirmez l'import 6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants
7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de cette banque se liront correctement d'eux-mêmes
8. Vérifiez les doublons, examinez le résumé, puis confirmez l'import
### Astuces ### Astuces
- Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois - Le récapitulatif de l'aperçu est le seul contrôle qui voit le **sens** des montants : un score de 100 % signifie que chaque ligne a été lue, pas que vos dépenses ne sont pas comptées comme des revenus. Prenez deux secondes pour le lire.
- Votre configuration est mémorisée intégralement sur la source, y compris le mode de montant et la convention de signe : une source configurée une fois n'est plus jamais re-devinée aux imports suivants
- Si votre banque change la disposition de ses colonnes, un panneau le signale avant l'import et propose deux choix : adopter le nouveau format détecté, ou conserver celui enregistré
- Pour réparer un import déjà écrit de travers, **ne ré-importez pas le fichier par-dessus** : les doublons sont repérés sur la date, la description ET le montant, donc une ligne corrigée s'ajoute au lieu de remplacer la fautive. Supprimez d'abord l'import fautif dans l'historique, puis rejouez le fichier.
- Un relevé à montants positifs accompagné d'une colonne « D / C » est refusé plutôt qu'importé à l'envers : réexportez-le depuis votre banque avec des montants signés, ou avec deux colonnes débit et crédit séparées
- Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois. Un modèle sert à initialiser une source ; le modifier ensuite ne change aucune source déjà configurée.
- Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons - Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons
- Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions - Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions
- Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer
--- ---
@ -501,3 +513,47 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page - Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique - Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement - En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement
---
## 12. Éditions
Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium**. L'édition détermine quels modules sont accessibles — elle ne touche jamais à vos données, qui restent locales et complètes quelle que soit l'édition active.
### Ce que débloque chaque édition
| Module | Gratuite | Base | Premium |
|---|:---:|:---:|:---:|
| Tableau de bord | ✓ | ✓ | ✓ |
| Import CSV | ✓ | ✓ | ✓ |
| Transactions | ✓ | ✓ | ✓ |
| Catégories | ✓ | ✓ | ✓ |
| Rapports — hub et Tendances | ✓ | ✓ | ✓ |
| Export / import chiffré | ✓ | ✓ | ✓ |
| Journal des modifications | ✓ | ✓ | ✓ |
| Profils | 1 profil | ✓ multiples | ✓ multiples |
| Ajustements | — | ✓ | ✓ |
| Budget | — | ✓ | ✓ |
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
| Mises à jour automatiques | — | ✓ | ✓ |
| Bilan (patrimoine, détail par titre, cours du marché) | — | — | ✓ |
### Comment ça se présente
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan) et sur les tuiles de rapports avancés du hub Rapports. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
- **Obtenir Base / Premium** — l'achat en ligne arrive bientôt ; le bouton est affiché mais désactivé en attendant
- **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
### Comment faire
1. Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition
2. Cliquez sur un module verrouillé pour voir l'édition requise
3. Si vous avez une clé de licence, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
4. Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire
### Astuces
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, changement de machine), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
- En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste

97
docs/qa-update-cycle.md Normal file
View file

@ -0,0 +1,97 @@
# QA — Cycle de mise à jour automatique
Checklist manuelle pour valider qu'une release est réellement installable **par la mise à jour automatique**, et pas seulement téléchargeable. À dérouler après chaque release qui touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la configuration `updater` de `tauri.conf.json`.
Elle existe parce que la CI ne peut rien prouver ici : `cargo check` et `cargo test` prouvent que le chemin de mise à jour **compile**, jamais qu'il fonctionne. Une régression s'y manifeste chez l'utilisateur, au moment de la mise à jour — et la mise à jour automatique étant une fonctionnalité **Base+**, ce sont des utilisateurs payants.
> **Cette checklist tourne après que la release est publiée.** `release.yml` a déjà poussé `latest.json` vers le registre de paquets, qui est l'endpoint configuré dans `tauri.conf.json`. Tout utilisateur Base+ qui clique sur « Vérifier les mises à jour » reçoit donc déjà la nouvelle version pendant que vous déroulez ceci. En cas d'échec, voir « Si ça casse » en bas — et lire cette section **avant** de commencer, pas pendant l'incident.
---
## Prérequis
Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode d'échec où la checklist se coche « OK » sans avoir rien vérifié.
- [ ] **Une machine Windows** (hôte ou VM) et **une machine Linux de bureau**. Les deux flux sont réellement différents, ils ne se substituent pas l'un à l'autre.
- [ ] Sur la machine Linux, **un agent polkit qui tourne**. L'installation du `.deb` passe par `pkexec` ; sans agent, l'app retombe en cascade sur `zenity`/`kdialog` puis sur un `sudo` dans un terminal inexistant, et **paraît figée au lieu d'échouer — sur l'écran de téléchargement, pas sur `readyToInstall`** (voir §2, c'est le piège qui produit le mauvais rapport de bug). Noter plus bas quelle invite est réellement apparue : si ce n'est pas polkit, c'est un chemin de repli qui a été validé, pas le chemin nominal.
- [ ] **La version précédente installée sur chaque machine**, depuis les artefacts de la release précédente (`.deb` et `-setup.exe` attachés à la release Forgejo). Ne pas tester sur la machine qui vient de builder la nouvelle version.
> Sans ça, `check()` compare `release.version > version_courante`, trouve faux, et l'UI affiche « à jour ». La checklist se coche alors comme « rien à mettre à jour » — soit exactement le saut silencieux qu'elle est censée éliminer, avec en prime une trace écrite affirmant qu'elle est passée.
- [ ] **Une clé de licence Base ou Premium valide sur chaque machine.** La mise à jour automatique est verrouillée par édition : `useUpdater` appelle `check_entitlement("auto-update")` et bascule en `notEntitled` **avant** même d'appeler `check()`. Une installation Gratuite ne télécharge jamais rien, et les builds de release n'ont pas de `dev-override`.
- [ ] Sur chaque machine, **supprimer tout `activation.token` provenant d'une autre machine** (VM clonée, snapshot restauré) dans le répertoire de données de l'app. Une `license.key` seule fonctionne — l'absence de token est un état de pré-activation toléré — mais un token émis pour un autre `machine_id` fait retomber l'édition en Gratuite, et le testeur voit une carte « non éligible » anodine plutôt qu'un échec.
---
## Ce que ce test prouve — et ce qu'il ne prouve pas
À écrire explicitement, parce que la tentation est de croire qu'un cycle réussi couvre tout le chemin de mise à jour.
**Prouvé** sur les deux cibles : le téléchargement TLS (donc `rustls-webpki`), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version.
**Non prouvé** : l'extraction d'archive, donc `tar`. Dans `tauri-plugin-updater`, `tar` n'est atteint que par `install_appimage` et la branche macOS `.app.tar.gz`. Aucun de nos bundles ne passe par là — le `.deb` fait `pkexec dpkg -i`, le NSIS écrit le `.exe` dans un répertoire temporaire et le lance. **Tant qu'on ne livre pas d'AppImage, le chemin `tar` reste mort** et aucune checklist manuelle ne le couvrira.
---
## 1. Windows (NSIS)
Point d'entrée : **Paramètres → Systèmes → carte Mises à jour → « Vérifier les mises à jour »**. Il n'y a **aucune vérification automatique dans l'app** : les quatre déclencheurs sont manuels — ce bouton, l'icône rafraîchir de l'état `upToDate`, le « réessayer » de l'état `error`, et celui de la page d'erreur, qui **détecte seulement** et n'offre aucun chemin de téléchargement.
- [ ] La carte passe en `checking`, puis affiche `available` avec le **numéro de la nouvelle version** et les notes extraites du CHANGELOG.
- [ ] Cliquer sur télécharger → état `downloading`, **la progression avance** (elle vient de `contentLength`, donc un serveur qui ne le renvoie pas se voit ici).
- [ ] L'installeur NSIS s'ouvre et **l'application se ferme d'elle-même**.
> Attendu, et propre à Windows : `downloadAndInstall` ne rend jamais la main — le processus appelle `exit(0)` et laisse l'installeur prendre le relais. Les états `readyToInstall` et `installing` **ne s'affichent jamais** sur cette cible. Ne pas les attendre, ne pas les cocher.
- [ ] Terminer l'installation, relancer l'app, vérifier la version dans Paramètres → Systèmes.
- [ ] Re-cliquer sur « Vérifier les mises à jour » → `upToDate`.
## 2. Linux (.deb)
- [ ] Même point d'entrée, mêmes états jusqu'à `downloading`.
- [ ] **Pendant que la carte affiche encore « Téléchargement en cours… »**, une invite d'authentification apparaît. **Noter laquelle** : polkit (nominal), zenity/kdialog, ou rien du tout (repli `sudo` → l'app reste figée **sur l'écran de téléchargement**).
Invite observée : `________________`
> C'est bien le moment nominal, et c'est contre-intuitif : `downloadAndInstall` télécharge **et** installe dans le même appel (`updater.rs:723-729`), et l'événement `Finished` est un no-op côté UI (`useUpdater.ts:119-121`). L'écran reste donc sur `downloading` pendant tout le `dpkg -i`. Un testeur qui guette l'invite **après** `readyToInstall` ne la verra jamais arriver au bon moment : il rapportera « le téléchargement bloque » là où le vrai diagnostic est « pas d'agent polkit ».
- [ ] Authentifier → l'app **reste vivante** et passe en `readyToInstall` (« Mise à jour prête à installer » + bouton « Installer et redémarrer »).
> À ce stade le `.deb` est **déjà installé sur le disque** : `installAndRestart` ne fait que `relaunch()`. Le libellé du bouton ment sur cette cible — qui s'arrête là croit que rien n'a été installé, et qui voit la version changer après le clic attribue l'installation au clic.
- [ ] Cliquer → état `installing`, l'app redémarre.
- [ ] Vérifier la version, re-vérifier → `upToDate`.
## 3. RPM — cassé, connu
- [ ] **Ne pas tester** : ce chemin est cassé, pas seulement non vérifié.
`latest.json` ne porte qu'une entrée `linux-x86_64`, construite à partir du `.deb` (`release.yml:104-122`). Une installation rpm reçoit donc des octets deb, que `Installer::Rpm` rejette après vérification de la signature magique du payload. Les artefacts `.rpm` continuent pourtant d'être publiés comme assets de release. Suivi en **#320**.
---
## Si ça casse
L'ordre compte, et la première action ne répare pas ce qu'on croit.
1. **Republier le `latest.json` précédent** sur `generic/simpl-resultat/latest` (`DELETE` puis `PUT`, avec le `PACKAGE_TOKEN` qu'utilise `release.yml`). Le corps est récupérable depuis les assets de la release précédente — sa copie dans le registre a été détruite par le `DELETE` que `release.yml` fait avant chaque envoi.
**Les deux verbes ne visent pas le même préfixe d'API**, et `release.yml:217-219` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois :
```
DELETE {serveur}/api/v1/packages/{owner}/generic/simpl-resultat/latest
PUT {serveur}/api/packages/{owner}/generic/simpl-resultat/latest
```
Rejouer les deux sur la même URL donne un 404 sur l'un des deux, au moment où on peut le moins se permettre de le débugger.
2. **Comprendre ce que ça arrête, et ce que ça n'arrête pas.** `check()` compare strictement `release.version > version_courante`. Republier vN-1 **stoppe la propagation** vers ceux qui n'ont pas encore cliqué. Les utilisateurs déjà passés à la vN cassée ne se verront **jamais** proposer vN-1 : ils sont bloqués dessus.
3. **Le vrai correctif est donc une vN+1**, pas un retour en arrière. Le republication de vN-1 n'achète que du temps — et si elle reste en place, elle prive aussi les utilisateurs restés en vN-1 de tout chemin de mise à jour.
---
## Trace
- [ ] Consigner le résultat en commentaire de la release Forgejo : cibles déroulées, versions de départ et d'arrivée, invite d'authentification observée sous Linux.
- [ ] Si la checklist **n'a pas été déroulée**, l'écrire explicitement avec la raison, plutôt que de laisser le silence. Une étape non tracée est indiscernable d'une étape passée — c'est le mode d'échec que cette page combat.

15
package-lock.json generated
View file

@ -2889,9 +2889,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2899,6 +2899,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"bin": { "bin": {
"nanoid": "bin/nanoid.cjs" "nanoid": "bin/nanoid.cjs"
}, },
@ -2959,9 +2960,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.13", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2979,7 +2980,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.16",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },

View file

@ -0,0 +1,82 @@
# Spec Decisions — Gating des fonctionnalités par édition de licence
> Date: 2026-07-19
> Projet: simpl-resultat
> Statut: Draft
> Slug: feature-gating
## Contexte
Simpl'Résultat n'applique aujourd'hui **aucun gating de fonctionnalités** : un utilisateur Free a accès à tout, sauf la récupération des cours (seul gate dur, vérifié côté serveur via maximus-api). L'infrastructure de licence est pourtant déjà là — 3 éditions signées Ed25519 (`free`/`base`/`premium`), table `FEATURE_TIERS`, champ `features[]` signé — mais seuls 3 call-sites de gate existent (auto-update, laissé ouvert ; cours, Premium). Max veut définir un vrai périmètre d'abonnement en verrouillant des modules selon le tier.
## Objectif
Appliquer une matrice d'entitlements Free/Base/Premium à travers l'app : chaque module hors du tier de l'utilisateur reste **visible mais verrouillé**, avec un écran d'upsell. Le gating est piloté par un hook central côté React, adossé à l'édition de licence déjà vérifiée, avec un override par-licence optionnel. Aucune donnée n'est supprimée — le durcissement bloque l'accès, il ne détruit rien.
## Scope
### IN
- Matrice d'entitlements Free/Base/Premium (ci-dessous).
- Hook central `useEntitlement(feature)` + garde de navigation (Sidebar) et de route.
- Écran/zone d'upsell « verrouillé » (cadenas, CTA upgrade), i18n FR/EN.
- Enrichir `FEATURE_TIERS` (Rust) + câbler l'override par-licence (`features[]` consulté par `is_feature_allowed`).
- Gating **par onglet** des rapports (`/reports/*`) : Free = Tendance uniquement.
- Absorption du re-gate auto-update (#271) : l'auto-update devient Base+.
- Durcissement sec **non destructif** : blocage d'accès immédiat, données conservées, récupérables par upgrade.
### OUT (explicitement exclu)
- Le champ `product` explicite (#270) — reste dans spec-paiements.
- Le go-live Stripe / le flux d'achat en ligne (l'upsell pointe vers l'achat, dont l'URL localisée relève de #270).
- Un vrai système de codes d'invitation (l'émission de clés reste manuelle via l'endpoint admin `generate`).
- Tout gate « dur » server-enforced au-delà de l'existant (cours) — le gating local est un soft paywall assumé (GPL).
- Un 4e tier « admin » : l'accès total de Max = une licence Premium auto-émise.
- Mode lecture-seule des features verrouillées (écarté : durcissement sec, pas de distinction voir/éditer).
## Matrice d'entitlements
| Module | Free | Base | Premium |
|---|:--:|:--:|:--:|
| Dashboard | ✅ | ✅ | ✅ |
| Import CSV | ✅ | ✅ | ✅ |
| Transactions | ✅ | ✅ | ✅ |
| Catégories (+ guide, migration) | ✅ | ✅ | ✅ |
| Rapport **Tendance** | ✅ | ✅ | ✅ |
| Autres rapports (Highlights, Comparaison, Catégorie, Cartes) | ❌ | ✅ | ✅ |
| Budget | ❌ | ✅ | ✅ |
| Ajustements (écritures manuelles + split de transactions) | ❌ | ✅ | ✅ |
| Multi-profils | ❌ | ✅ | ✅ |
| Auto-update | ❌ | ✅ | ✅ |
| **Bilan complet** (patrimoine + cours) | ❌ | ❌ | ✅ |
| Export/Import chiffré | ✅ | ✅ | ✅ |
| Changelog / Docs | ✅ | ✅ | ✅ |
Admin (Max) = licence **Premium** auto-émise (superset).
## Décisions prises
| Question | Décision | Raison |
|---|---|---|
| UX du gate (feature hors tier) | **Verrouillé + upsell** : entrée visible avec cadenas ; clic → écran « Passez à Base/Premium » | Meilleure conversion — l'utilisateur voit ce qu'il rate |
| Données existantes lors du durcissement | **Durcissement sec non destructif** : blocage d'accès (vue + édition) immédiat, mais **données conservées**, jamais supprimées, récupérables par upgrade | Simple à coder (pas de mode lecture-seule) ; ne détruit aucune donnée financière (clarifié par Max) |
| Modèle d'entitlements | **Statique + override licence** : map `tier → features` en dur, PLUS `license.features[]` consulté en additif (`allowed = feature ∈ tier OU feature ∈ license.features`) | Matrice centralisée et simple, + débloque le champ `features[]` déjà signé pour des licences spéciales sans créer de tier |
| Coordination spec-paiements | **Absorber #271** (re-gate auto-update) ici ; **#270** (`product`) reste séparé | L'auto-update est dans la matrice → gaté ici, #271 redondant ; `product`/URL d'achat = go-live, hors scope |
| Enforcement | **UI-only** (gardes React : nav + route). `current_edition()` (Rust) reste la source de vérité lue ; les commandes Tauri ne refusent pas (sauf l'existant : cours server-enforced) | Cohérent avec le soft-paywall GPL assumé — un gate Rust serait aussi retirable par un fork, sans gain de sécurité |
| Écran d'upsell | **Composant générique paramétré** (feature + tier requis), i18n FR/EN ; CTA « Obtenir Base/Premium » (lien d'achat, dépend de #270) + « J'ai déjà une clé » → Réglages → Licence | Un seul composant réutilisable, pas un écran par feature |
| Admin (accès total de Max) | **Licence Premium auto-émise** (endpoint admin `generate`), pas de 4e édition ni mode dev | Premium = superset ; réutilise l'infra existante, zéro code neuf |
| Free multi-profils existants | **Profil actif conservé** ; profils supplémentaires verrouillés → upsell (données conservées) | Cohérent avec durcissement sec + conservation des données |
| `/adjustments` (module Ajustements) | **Base** | Outil de correction avancé (écritures + split multi-catégories) → différenciateur payant (tranché post-review 2026-07-19) |
## Contraintes
- **GPL-3.0 + privacy-first** : le gating local est un soft paywall **assumé** (contournable par fork). Seules les features adossées à maximus-api (les cours, aujourd'hui) sont dures. Aucune donnée financière ne quitte l'appareil.
- **Séquencement** : le gating est livrable et testable **indépendamment de Stripe** (pas encore en live) via des clés admin-émises. Corollaire : sans ce chantier, distribuer des clés n'a aucun effet visible (tout est déjà ouvert).
## Références
| Source | Pertinence |
|---|---|
| `spec-monetisation.md` | Modèle des 3 tiers, contraintes GPL / soft-paywall, format de licence |
| `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` + `is_feature_allowed` — table à enrichir, override `features[]` à câbler |
| `src-tauri/src/commands/license_commands.rs` | `current_edition()` (source de vérité de l'édition) + `LicenseClaims.features` (champ signé à consulter) |
| `src/services/licenseService.ts`, `src/hooks/useLicense.ts`, `src/hooks/useIsPremium.ts` | Base du futur hook `useEntitlement` |
| `src/App.tsx` (routes `/reports/*`), `src/components/layout/Sidebar` | Points de garde nav + route |

View file

@ -0,0 +1,79 @@
# Spec Decisions — Import CSV et reconnaissance de format
> Date: 2026-08-12
> Projet: simpl-resultat
> Statut: Draft
> Slug: import-csv-format
## Contexte
L'import CSV est la porte d'entrée du produit : sans lui, aucune autre page n'a de données. Le module a été conçu au début du projet et n'a pas été retouché depuis, alors que le reste de l'app (Bilan, rapports, gating) a été refondu plusieurs fois. Une revue d'implémentation menée le 2026-08-12 a établi que le symptôme rapporté — « l'app oublie le format, y compris les colonnes de montant positif/négatif » — n'est pas une faiblesse d'heuristique mais un trou de persistance, doublé de plusieurs voies de corruption silencieuse.
**Le bug racine.** La table `import_sources` ne possède ni `amount_mode` ni `sign_convention` (`consolidated_schema.sql:8-20`) ; ces deux colonnes n'existent que sur `import_config_templates` (`consolidated_schema.sql:147-159`). À la restauration d'une source configurée, `useImportWizard.ts:323` écrit donc `signConvention: "negative_expense"` en dur. Une source réglée en `positive_expense` — cas classique d'un relevé de carte de crédit où les dépenses sont positives — revient au défaut inverse au deuxième import, et `useImportWizard.ts:514` applique alors la négation à contresens : **toutes les dépenses deviennent des revenus, sans la moindre erreur affichée**. Le mode de montant subit le même sort, re-deviné depuis la présence de `mapping.debitAmount` (`useImportWizard.ts:321`) plutôt que lu ; comme `ColumnMappingEditor.tsx:82-94` ne nettoie pas le mapping au changement de mode, un basculement non suivi d'une re-sélection de colonne se perd ou s'inverse au rechargement.
La documentation Desjardins confirme que le cas n'est pas théorique : le signe des montants diffère selon le type de compte, et un même utilisateur a couramment un compte-chèque et une carte de crédit dans deux conventions opposées.
**Corruption silencieuse au parsing.** `useImportWizard.ts:509` calcule `amount = isNaN(credit) ? -debit : credit` — le crédit gagne toujours. Beaucoup de banques écrivent `0,00` dans la colonne inutilisée plutôt que de la laisser vide : tous les débits deviennent alors 0 $, et la ligne passe la validation puisque `isNaN(0)` est faux. Les fallbacks `?? 0` des lignes 504-512 lisent la colonne 0 — souvent la date — quand le mapping est incomplet.
**Détection aveugle aux en-têtes.** `csvAutoDetect.ts:461-470` assigne le débit et le crédit par ordre de colonne, si bien qu'un fichier `Date;Description;Crédit;Débit` est inversé intégralement. `detectSingleAmount` (l.507-530) déduit la convention au vote majoritaire de négatifs sur 20 lignes. `detectHeader` (l.214-238) retourne `!hasDate && !hasNumber`, donc un en-tête contenant « Solde 2024 » passe pour une ligne de données. Le savoir-faire manquant existe pourtant dans le même fichier : le flux d'import de titres (#245, juillet 2026) fait du matching par libellé via `normalizeHeaderCell` et `matchHeaderColumn` (l.633-666).
**Rien ne rattrape l'erreur.** La détection auto n'est jamais lancée d'office — elle est derrière un bouton (`SourceConfigPanel.tsx:69-77`), et une source neuve démarre sur un défaut plausible (`;`, `DD/MM/YYYY`, colonnes 0/1/2) qui produit un import faux plutôt qu'une erreur franche. `autoDetectConfig` ne teste jamais sa propre config sur les données. L'aperçu est un modal optionnel de 20 lignes sans totaux. L'écran de confirmation affiche délimiteur, encodage, format de date et lignes ignorées, mais **ni le mode de montant, ni la convention de signe, ni le mapping** (`ImportConfirmation.tsx:57-80`). Enfin la config est écrite en base dès l'étape doublons (`useImportWizard.ts:588-606`), donc un import annulé persiste quand même une configuration potentiellement fausse.
**Aucun filet.** `csvAutoDetect.test.ts` ne couvre que le flux holdings. `autoDetectConfig`, `detectAmountMode`, `preprocessQuotedCSV` et l'intégralité de `useImportWizard` n'ont aucun test, sur les 871 que compte le projet.
## Objectif
Faire du format d'import une donnée persistée intégralement et vérifiée, plutôt qu'un ensemble de réglages partiellement mémorisés et re-devinés à chaque passage. La reconnaissance s'appuie sur les libellés d'en-tête plutôt que sur la seule forme des données, annonce sa confiance, et le wizard impose un contrôle visuel des montants signés avant toute écriture en base.
## Scope
### IN
- Migration v17 : `amount_mode` et `sign_convention` sur `import_sources`, avec backfill préservant le comportement actuel.
- Persistance et restauration du format complet ; suppression de la valeur en dur et de la ré-inférence du mode.
- Le mode de montant devient la source de vérité du mapping : changer de mode nettoie les colonnes de l'autre mode.
- Règle débit/crédit corrigée (`crédit débit` sur magnitudes), gestion de la colonne inutilisée à `0,00`, suppression des fallbacks `?? 0` au profit d'une erreur de ligne explicite.
- Détection par libellé d'en-tête, dictionnaire FR/EN, réutilisant les helpers du flux holdings.
- Détection auto lancée d'office sur une source non configurée.
- Score de confiance : la config détectée est rejouée sur les données et le taux de lignes lues est affiché.
- Aperçu promu en étape obligatoire du wizard, avec récapitulatif signé (sorties / entrées, totaux) et bascule de convention en un geste.
- Récapitulatif du format complet — mode, convention, mapping — sur l'écran de confirmation.
- La config n'est plus écrite en base avant confirmation de l'import.
- Signatures de banques reconnues automatiquement (Desjardins, RBC, BNC, Tangerine), sans sélecteur de banque.
- Détection de dérive : signature d'en-tête mémorisée, re-détection et présentation de l'écart au ré-import.
- `parseFrenchAmount` : parenthèses comptables `(50,00)` et signe suffixe `50,00-`.
- Sauvegarde et restauration des configurations de sources et des modèles dans l'export/import de données (format SREF).
- Corpus de fixtures CSV synthétiques + tests sur la détection, le parsing et le cycle sauvegarde/restauration du format.
### OUT (explicitement exclu)
- Toute correction rétroactive des transactions déjà importées avec un signe inversé. La voie de réparation existe déjà (`deleteImportWithTransactions` — supprimer l'import fautif et le rejouer) et aucune mutation automatique de données financières déjà catégorisées et budgétées ne sera ajoutée.
- Le troisième mode de montant « montant absolu + colonne indicateur » (`D`/`C`, `DB`/`CR`). Hors des formats des banques canadiennes personnelles visées.
- Suppression ou fusion de `import_config_templates`.
- Import d'autres formats que CSV (OFX, QFX, QIF, PDF).
- Modification du gating : l'import reste entièrement en édition Free.
## Decisions prises
| Question | Decision | Raison |
|----------|----------|--------|
| Portée du chantier | Les trois vagues de la revue en un seul chantier | Les vagues 2 et 3 dépendent du socle de persistance de la vague 1 ; les séparer imposerait de rouvrir les mêmes fichiers trois fois. |
| Transactions déjà importées à l'envers | Aucune correction rétroactive | `deleteImportWithTransactions` couvre déjà le besoin. Une inversion en masse mutilerait des données déjà catégorisées et budgétées pour un gain qu'un ré-import obtient sans risque. |
| Presets banques | Signatures reconnues automatiquement | Cohérent avec la détection par en-tête, aucune liste à maintenir dans l'UI, aucun choix de plus à faire, et un preset ne peut pas être appliqué à tort. Un fichier inconnu retombe sur le dictionnaire générique. |
| Corpus de tests | Fixtures synthétiques | Elles couvrent chaque clause du contrat, y compris les cas qu'un vrai relevé ne contient pas (débit/crédit inversés, colonne à `0,00`, en-tête numérique). Aucune donnée réelle au dépôt, démarrage immédiat. |
| Dérive de format | Re-détecter et présenter l'écart | Ni blocage sec sur un écart bénin, ni passage en silence sur un mapping périmé. L'utilisateur arbitre sur un diff lisible. |
| Statut de l'aperçu | Étape obligatoire du wizard | Décision de Max, contre la recommandation d'un aperçu conditionné à la confiance. L'étape `file-preview` existe déjà dans `ImportWizardStep` sans avoir jamais été rendue — le modal l'avait supplantée. |
| Modèles de configuration | Conservés, schéma aligné sur celui des sources | Le modèle reste un format nommé réutilisable entre plusieurs comptes d'une même banque ; la source porte le format en vigueur. Mêmes champs des deux côtés, l'asymétrie qui a causé le bug devient impossible. |
| Configurations de sources dans l'export de données | Sérialisées à l'export, restaurées à l'import | Découvert au re-ancrage de Phase 3b : l'export ne porte que catégories, fournisseurs, mots-clés et transactions, et l'import fait `DELETE FROM import_sources` (`dataExportService.ts:265` et `:362`) avant de créer une source factice « Data Import ». Restaurer une sauvegarde détruit donc toutes les configurations d'import. Troisième voie de perte du format, indépendante du bug racine et définitive ; la laisser ouverte viderait le chantier de son sens. |
| Troisième mode de montant | Hors scope, sans le fermer | `amount_mode` reste sans contrainte `CHECK` en base, donc l'ajouter plus tard ne demandera aucune migration. Un fichier de ce type sera refusé explicitement plutôt qu'importé de travers. |
| Exécution | Milestone `planned-2026-08-12-import-csv-format`, prête pour `/autopilot` | Bodies auto-suffisants, découpage en pile linéaire — la migration et le socle de détection sont des dépendances de presque tout le reste. |
## References
| Source | Pertinence |
|--------|------------|
| [CSV Format Bank Statement: UK Data Mapping Guide](https://snyp.ai/blog/csv-format-bank-statement) | Confirme la règle « une seule logique de montant par fichier » — ne jamais mélanger montant signé et colonnes débit/crédit séparées. Le bug `useImportWizard.ts:509` vient précisément d'un mélange mal arbitré. Recense les libellés sources à normaliser (Withdrawal, Deposit) vers un schéma cible Date / Description / Débit / Crédit / Montant / Solde — matière directe du dictionnaire d'en-têtes. |
| [Bank Statement CSV Format for Clean Accounting Imports](https://thebankstatementconverter.app/bank-statement-csv-format) | Établit que dans un format à deux colonnes, débit et crédit sont **tous deux positifs** — d'où la règle `crédit débit` sur magnitudes retenue, et non la comparaison de nullité actuelle. |
| [How to Export a CSV from Desjardins (AccèsD)](https://www.flowvista.ca/guides/export-csv-desjardins) | Format Desjardins : Date, Description, Montant, Solde ; point-virgule ; virgule décimale ; en-têtes FR ou EN. Signale que certains exports de cartes de crédit n'ont pas de ligne d'en-tête et que **le signe des montants diffère selon le type de compte** — validation externe du bug racine. Base de la signature Desjardins. |
| [Import a CSV bank statement — Xero Central](https://central.xero.com/0/article/Import-a-CSV-bank-statement) | Documente la troisième convention (montant absolu + indicateur `D`/`C`) et la pratique du multiplicateur `-1` sur la colonne crédit. Sert à cadrer ce qui est laissé hors scope et à garder `amount_mode` extensible. |
| `csvAutoDetect.ts:633-737` (interne) | Le flux d'import de titres (#245) implémente déjà la détection par libellé d'en-tête avec normalisation des accents. Modèle à généraliser au flux transactions plutôt qu'à réécrire. |

205
spec-plan-feature-gating.md Normal file
View file

@ -0,0 +1,205 @@
# Spec Plan — Gating des fonctionnalités par édition de licence
> Date: 2026-07-19
> Projet: simpl-resultat
> Statut: Draft (revu — voir Révision — Synthèse)
> Slug: feature-gating
> Decisions: [spec-decisions-feature-gating.md](./spec-decisions-feature-gating.md)
## Ancrage code (vérifié 2026-07-19)
- **Rapports = routes distinctes** (`App.tsx:117-122`) : `/reports` (hub Free), `/reports/highlights|trends|compare|category|cartes`. Le gating « par onglet » = **gating par route**.
- **Édition + features déjà en mémoire** : `useLicense` (`useLicense.ts:59`) charge `edition` + `info.features[]` au boot (`getEdition` + `readLicense`). ⇒ `useEntitlement` peut être **synchrone**. MAIS `useLicense` est un état **par-appel** (chaque montage refait 2 invokes) → il faut un **Context** partagé.
- **`FEATURE_TIERS` Rust** (`entitlements.rs:14-21`) déclare 4 features dont 3 **mortes** (`web-sync`, `cloud-backup`, `advanced-reports` — aucun call-site) ; `is_feature_allowed` ne regarde **que l'édition** (`entitlements.rs:24-30`) — l'override `features[]` n'est pas câblé. Une feature absente = deny-all.
- **Auto-update déjà branché** sur `check_entitlement("auto-update")` (`useUpdater.ts:79`), UI gère l'état `notEntitled`. ⇒ #271 = **1 ligne** dans `FEATURE_TIERS`.
- **`LicenseInfo.features[]`** est peuplé depuis le JWT (`license_commands.rs:132`) et exposé au front (`licenseService.ts:8`). ⚠️ `readLicense`/`read_license` **ne vérifie pas** le machine-binding (contrairement à `current_edition`) → l'override doit être fail-closed en Free (voir Sécurité).
- **Sidebar** lit `NAV_ITEMS` (`constants/index.ts:6-61`), liste `{key, path, icon, labelKey}`. **`useIsPremium`** (`useIsPremium.ts`) remonte `useLicense` (à rebrancher). **`LicenseCard`** est monté à `/settings/users` (`UsersSettingsPage.tsx:34`). **Création de profil** = `ProfileFormModal` (`createProfile`), ouvert depuis `ProfileSwitcher` ET `ProfileSelectionPage`.
## Design
### Architecture
Source de vérité de l'**édition** = inchangée (`current_edition()` Rust, fail-closed, machine-binding). Nouveau : une **couche d'entitlements côté TS** pour un gating UI synchrone.
```
LicenseProvider (context, chargé 1× au boot, monté AU-DESSUS de ProfileProvider dans main.tsx)
├── status, edition, features[], info, refresh, submitKey
└── consommé par :
useEntitlement(featureKey) → { allowed, ready } — lit ENTITLEMENTS + override features[]
useIsPremium() → refactoré pour lire le context
```
- **Matrice UI** (`src/shared/entitlements.ts`, nouveau) — source unique côté front :
```ts
export type FeatureKey = "budget" | "adjustments" | "reports-advanced" | "multi-profile" | "balance";
export const ENTITLEMENTS: Record<FeatureKey, Edition[]> = {
"budget": ["base", "premium"],
"adjustments": ["base", "premium"],
"reports-advanced": ["base", "premium"],
"multi-profile": ["base", "premium"],
"balance": ["premium"],
};
// Override par-licence via le JWT features[] — mais fail-closed en Free : une
// license.key copiee sur une autre machine downgrade edition->free (machine-binding),
// on ne re-grant PAS ses features[] signees (CWE-863).
export function isEntitled(f: FeatureKey, edition: Edition, licenseFeatures: string[]): boolean {
if (edition === "free") return false;
return ENTITLEMENTS[f].includes(edition) || licenseFeatures.includes(f);
}
```
Clés en **kebab-case** (`reports-advanced`) pour rester cohérent avec les strings Rust existants (`auto-update`) — le JWT `features[]` est un **namespace partagé** entre l'override Rust et TS. `auto-update` n'est **pas** ici (géré côté Rust). Modules Free (dashboard, import, transactions, catégories, `reports/trends`, export, changelog, docs) = pas de clé → jamais gatés. **Ajustements passe en Base** (clé `adjustments`).
- **Enforcement UI-only** (soft-paywall assumé). Le Rust ne change que pour `auto-update` (#271) + le câblage de l'override `check_entitlement`, **fail-closed en Free** comme côté TS.
### UX / Interface
- **`useEntitlement(f): { allowed: boolean; ready: boolean }`** — `ready = status === "ready"`. Tous les consommateurs (RequireFeature, Sidebar, tuiles ReportsPage, ProfileSwitcher) **suppriment le cadenas/upsell tant que `!ready`** (rendent un placeholder neutre), pour ne jamais flasher « verrouillé » à un utilisateur payant pendant le chargement.
- **`<UpsellGate feature requiredTier>`** (nouveau, `src/components/shared/`) : écran plein — cadenas, titre « Fonctionnalité Base/Premium », description paramétrée, 2 CTA : « Obtenir <tier> » (lien d'achat — placeholder jusqu'à #270) + « J'ai déjà une clé » → `navigate("/settings/users")`. i18n FR/EN.
- **`<RequireFeature feature>`** : `ready ? (allowed ? children : <UpsellGate…>) : <Loader/>`. Wrappe les routes gatées. Les routes de même feature (`/balance*`, les 4 `/reports/*` avancés) sont regroupées sous **une layout-route** `<Route element={<RequireFeature feature=…><Outlet/></RequireFeature>}>` (convention `SettingsLayout`/`AppShell`), pour ne pas répéter le wrapper.
- **Sidebar** : `NavItem` gagne `feature?: FeatureKey`, renseigné sur **budget, adjustments et balance**. `reports` reste **non gaté** (pointe vers le hub Free). Un item non autorisé (et `ready`) affiche un cadenas ; le clic mène à la route → `RequireFeature` affiche l'upsell.
- **Hub `/reports`** (Free) : accessible ; les tuiles vers les rapports avancés portent un cadenas.
- **Multi-profils** : `ProfileSwitcher` marque d'un cadenas les profils au-delà de l'actif quand `!allowed` ; la **création** est verrouillée au point unique `ProfileFormModal` pour un Free ayant déjà ≥ 1 profil. **Données conservées** : rien n'est supprimé de `profiles.json`.
### Données
Aucune nouvelle table, **aucune migration DB** — le gating lit la licence (fichier `license.key`), pas la base profil. `NavItem` (type TS) gagne un champ optionnel `feature`.
## Plan de travail
### Issue 1 — Socle : LicenseProvider + matrice d'entitlements + useEntitlement [type:feature]
Dependances : aucune
- [ ] `src/contexts/LicenseContext.tsx` : provider (modelé sur `ProfileContext``createContext<T|null>`, `useReducer`, hook consommateur qui throw), charge édition + info 1×, expose `{ status, edition, features, info, refresh, submitKey }`. **Monter dans `main.tsx` AU-DESSUS de `ProfileProvider`** (licence = machine-level → survit au remount `BrowserRouter key={refreshKey}` sur changement de profil).
- [ ] **Récupération d'erreur** : sur échec de boot (`getEdition`/`readLicense` throw → `status:"error"`), retry avec backoff + `refresh` manuel. Les consommateurs rendent un état neutre pendant `status==="error"` (PAS l'upsell) — sinon un échec transitoire bloque un payant en upsell jusqu'au restart (CWE-703).
- [ ] `src/shared/entitlements.ts` : `FeatureKey` (kebab-case), `ENTITLEMENTS`, `isEntitled()` pur **fail-closed en Free**.
- [ ] `src/hooks/useEntitlement.ts` : `useEntitlement(f): { allowed, ready }` (sync, lit le context).
- [ ] Refactor `useIsPremium.ts` pour lire `LicenseContext` (behavior-preserving, supprime le double-invoke).
- [ ] **Migrer `useIsPremium.test.ts`** : il mocke `./useLicense` (`vi.mock`) — le refactor le casse ; le faire mocker le context (ou le module d'entitlements). (`PriceFetchControl`/`PriceFetchConsentToggle` mockent `useIsPremium` directement → non affectés.)
- [ ] Refactor `LicenseCard` pour consommer le context.
- [ ] Tests : `entitlements.test.ts` (matrice, override `features[]`, **override ignoré en Free**, édition inconnue).
### Issue 2 — Garde UI : RequireFeature + UpsellGate + i18n [type:feature]
Dependances : Issue 1
- [ ] `src/components/shared/UpsellGate.tsx` : écran verrouillé paramétré, 2 CTA.
- [ ] `src/components/shared/RequireFeature.tsx` : `ready ? (allowed ? children : UpsellGate) : Loader`. Rendre comme layout-route (`<Outlet/>`) pour regrouper les routes de même feature.
- [ ] i18n `{fr,en}.json` : `upsell.*` + `nav.locked`.
### Issue 3 — Gating des routes + Sidebar [type:feature]
Dependances : Issue 2
- [ ] `App.tsx` : layout-route `<RequireFeature feature="balance"><Outlet/></RequireFeature>` groupant `/balance`, `/balance/accounts`, `/balance/snapshot` ; layout-route `"reports-advanced"` groupant `/reports/highlights|compare|category|cartes` (PAS `/reports/trends`, PAS le hub `/reports`) ; `"budget"` sur `/budget` ; `"adjustments"` sur `/adjustments`.
- [ ] `NavItem` (`src/shared/types`) + `feature?: FeatureKey` ; renseigner dans `constants/index.ts` sur **budget, adjustments et balance****PAS `reports`** (hub Free, non route-wrappé).
- [ ] `Sidebar.tsx` : cadenas sur item non autorisé **et `ready`**, reste cliquable.
- [ ] `ReportsPage` (hub) : cadenas sur les tuiles des rapports avancés.
### Issue 4 — Gating multi-profils (Base+) non destructif [type:feature]
Dependances : Issue 1 ET Issue 2 (rend `UpsellGate`)
- [ ] `ProfileSwitcher` : cadenas + upsell sur les profils au-delà de l'actif quand `!allowed` (et `ready`).
- [ ] `ProfileFormModal` (point unique de création, ouvert depuis `ProfileSwitcher` ET `ProfileSelectionPage`) : verrouillé pour un Free ayant déjà ≥ 1 profil (ou désactiver les 2 boutons d'entrée).
- [ ] Garde NON destructive : ne rien retirer de `profiles.json`.
### Issue 5 — Absorber #271 : auto-update Base+ + override licence (Rust) [type:feature]
Dependances : aucune (Rust indépendant — parallélisable)
- [ ] `entitlements.rs:17` : `("auto-update", &[EDITION_BASE, EDITION_PREMIUM])`, retirer le commentaire « temporarily open », ajuster le test `free_allows_auto_update_temporarily``free_denied_auto_update`.
- [ ] **Purger les entrées mortes** de `FEATURE_TIERS` : `web-sync`, `cloud-backup`, `advanced-reports` (aucun call-site ; `advanced-reports`→Premium **contredit** la matrice TS `reports-advanced`→Base+). Ne laisser que `auto-update`.
- [ ] Câbler l'override `features[]` dans `check_entitlement`, **fail-closed en Free** : résoudre l'édition ET les features via le même chemin machine-binding (features ignorées si `current_edition` downgrade à `free`), puis `is_feature_allowed(feature, edition) || features.contains(feature)`.
- [ ] Dev override : gater derrière une **Cargo feature dédiée `dev-override`** (off par défaut, jamais dans le feature-set release), **PAS `debug_assertions`** (activable sur un build release → backdoor Premium, CWE-489). `current_edition` lit `SR_DEV_EDITION` uniquement sous cette feature ; test que `SR_DEV_EDITION` n'a aucun effet quand la feature est off.
- [ ] Absorbe #271 (fermé superseded).
### Issue 6 — Docs : ADR + architecture + CHANGELOG [type:feature]
Dependances : Issues 1-5
- [ ] ADR `docs/adr/00XX-feature-gating-par-tier.md` : matrice tier→features + override par-licence (fail-closed Free), enforcement UI-only, soft-paywall GPL assumé.
- [ ] `docs/architecture.md` : nouveau context/hook/module d'entitlements.
- [ ] `docs/guide-utilisateur.md` + i18n `docs.*` : ce que débloque chaque tier.
- [ ] CHANGELOG.md + CHANGELOG.fr.md : **une** entrée globale (durcissement — modules désormais Base/Premium).
### Ordre d'execution
```
Issue 1 → Issue 2 → Issue 3
→ Issue 4 (Issue 4 dépend de 1 ET 2)
Issue 5 (indépendant, parallèle)
Issues 1-5 → Issue 6
```
## Fichiers concernes
| Fichier | Action | Raison |
|---|---|---|
| `src/contexts/LicenseContext.tsx` | Créer | Provider licence partagé (1 chargement) + récupération d'erreur |
| `src/shared/entitlements.ts` | Créer | Matrice `ENTITLEMENTS` + `isEntitled` (pur, fail-closed Free) |
| `src/hooks/useEntitlement.ts` | Créer | Hook `{ allowed, ready }` |
| `src/hooks/useIsPremium.ts` + `.test.ts` | Modifier | Lire le context ; migrer le mock du test |
| `src/components/settings/LicenseCard.tsx` | Modifier | Consommer le context |
| `src/components/shared/UpsellGate.tsx`, `RequireFeature.tsx` | Créer | Écran verrouillé + wrapper (layout-route) |
| `src/App.tsx` | Modifier | Wrappers budget / adjustments / reports-advanced / balance |
| `src/shared/types` (NavItem), `src/shared/constants/index.ts` | Modifier | `feature?` sur budget + adjustments + balance |
| `src/components/layout/Sidebar.tsx` | Modifier | Cadenas (si `ready`) |
| `src/pages/ReportsPage.tsx` | Modifier | Cadenas sur tuiles avancées |
| `src/components/profile/ProfileSwitcher.tsx`, `ProfileFormModal.tsx`, `src/pages/ProfileSelectionPage.tsx` | Modifier | Cadenas profils + gate création au point unique |
| `src-tauri/src/commands/entitlements.rs` | Modifier | auto-update Base+ (#271), purge dead rows, override fail-closed |
| `src-tauri/src/commands/license_commands.rs` | Modifier | Override features via chemin machine-binding + dev-override (Cargo feature) |
| `src-tauri/Cargo.toml` | Modifier | Cargo feature `dev-override` (off par défaut) |
| `src/i18n/locales/{fr,en}.json` | Modifier | `upsell.*`, `nav.locked` |
| `docs/adr/00XX-*.md`, `docs/architecture.md`, `CHANGELOG*.md` | Créer/Modifier | Documentation |
## Plan de tests
### Tests unitaires
- `entitlements.ts` : `isEntitled` — chaque feature × chaque édition, override `features[]`, **override ignoré quand edition==="free"** (régression CWE-863), feature absente.
- Rust `entitlements.rs` : auto-update denied Free / allowed Base+Premium ; override fail-closed (features ignorées si édition downgrade free) ; `SR_DEV_EDITION` sans effet quand la feature `dev-override` est off.
### Tests d'integration
- Rust : `current_edition` + `check_entitlement` bout-en-bout avec une licence signée de test portant `features:[…]` ET un activation.token machine-mismatch → vérifier que les features ne sont PAS accordées.
### Tests de regression
- `useIsPremium` : le refactor vers le context ne change pas le résultat (Premium ⇢ true). Le gate cours (`PriceFetchControl` via `useIsPremium`) ne régresse pas.
**Contrainte** : ni jsdom ni @testing-library (confirmé — les `*.test.tsx` existants mockent les hooks, ne rendent pas les composants). La logique testable vit dans `isEntitled` (TS pur) et les checks Rust ; le rendu est délégué à la vérif runtime / `/pr-review`.
## Criteres d'acceptation
- [ ] Un Free voit Budget, Ajustements, les 4 rapports avancés et le Bilan **verrouillés** (cadenas + upsell), garde Dashboard/Import/Transactions/Catégories/Tendance/Export/Changelog + le hub `/reports`.
- [ ] Un Base débloque Budget, Ajustements, tous les rapports, multi-profils, auto-update ; Bilan reste verrouillé.
- [ ] Un Premium a tout.
- [ ] Durcissement **non destructif** : budgets/Bilan/profils réapparaissent intacts après upgrade.
- [ ] Un Free avec 2 profils garde l'actif ; les autres verrouillés (conservés) ; création verrouillée.
- [ ] Auto-update refusé pour Free (#271 absorbé), autorisé Base+.
- [ ] Une licence **valide (machine-bound)** portant `features:["<clé>"]` débloque cette feature ; une licence copiée (downgrade free) ne débloque **rien** via `features[]`.
- [ ] **Aucun flash « verrouillé »** au boot (Sidebar, tuiles, profils, routes) — `ready` supprime le cadenas pendant le chargement.
- [ ] Aucune migration DB ; suite de tests verte.
## Edge cases et risques
| Cas | Mitigation |
|---|---|
| `/adjustments` (écritures manuelles + split de transactions) | **Tranché : Base** (outil de correction avancé). Clé `adjustments` — route `/adjustments` + item nav gatés. |
| Max se bloque de son Bilan en dev (dev = `free`) | Cargo feature `dev-override` + `SR_DEV_EDITION=premium` (Issue 5). Sinon poser une clé Premium locale. |
| Flash « verrouillé » pendant le chargement licence | `useEntitlement` renvoie `ready` ; tous les consommateurs suppriment le cadenas tant que `!ready`. |
| Échec de chargement licence (invoke throw) | État neutre + retry/backoff, jamais l'upsell (CWE-703) — un payant n'est pas bloqué par un échec transitoire. |
| Override `features[]` sur une clé copiée (edition downgrade free) | `isEntitled`/`check_entitlement` fail-closed en Free — features[] ignorées quand l'édition est free (CWE-863). |
| Désync matrice TS ↔ Rust | Après purge des dead rows (Issue 5), `FEATURE_TIERS` ne garde qu'`auto-update` → matrice UI uniquement en TS. |
| Rollout : gating partiel sur `main` entre issues | Sans effet en prod — visible seulement à la prochaine release taggée. Ordre socle→gardes garde `main` cohérent. |
| Contournement (fork retire le `if`) | Assumé (GPL, soft-paywall). Seul le gate serveur (cours) est dur. |
## Revision — Synthese
> Date: 2026-07-19 | Experts: Securite, Architecture, Technique | Corrections intégrées dans cette passe.
### Verdict
🟡 **AMELIORATIONS INTEGREES** — 2 critiques + 6 améliorations trouvées et **corrigées ci-dessus** ; le plan est prêt à exécuter. Aucune décision produit en suspens (`/adjustments` tranché **Base** par Max le 2026-07-19).
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points clés |
|--------|-----|-----|-----|-------------|
| Securite | 0 | 3 | 1 | override `features[]` vs machine-binding (CWE-863) ; `SR_DEV_EDITION` sur debug-assertions (CWE-489) ; LicenseProvider SPOF sans récupération d'erreur (CWE-703) |
| Architecture | 2 | 2 | 2 | `reports` nav gaté à tort (hub Free) ; `advanced-reports` Rust contredit `reports-advanced` ; casing incohérent ; boolean sans état de chargement ; **mount point validé sain** |
| Technique | 0 | 4 | 3 | `useIsPremium.test.ts` cassé par le refactor ; gate création dans `ProfileFormModal` (pas Sidebar) ; `/adjustments` à trancher ; ordre 4→(1,2) |
### Actions requises (toutes intégrées)
1. 🔴 **Nav `reports` non gaté**`feature` sur budget + balance uniquement ; hub `/reports` reste Free. ✓
2. 🔴 **Purger `advanced-reports`/`web-sync`/`cloud-backup`** du Rust `FEATURE_TIERS` (Issue 5) ; mitigation « double source » corrigée. ✓
3. 🟡 **`useEntitlement``{ allowed, ready }`** ; tous les consommateurs suppriment le cadenas tant que `!ready`. ✓
4. 🟡 **Override `features[]` fail-closed en Free** (TS `isEntitled` + Rust `check_entitlement`) — CWE-863. ✓
5. 🟡 **`SR_DEV_EDITION` derrière une Cargo feature `dev-override`**, pas `debug_assertions` — CWE-489. ✓
6. 🟡 **Récupération d'erreur du LicenseProvider** (retry + état neutre, jamais upsell) — CWE-703. ✓
7. 🟡 **Migrer `useIsPremium.test.ts`** (mock du context) — ajouté à Issue 1. ✓
8. 🟡 **Gate création profil dans `ProfileFormModal`** (point unique) — fichiers ajoutés à Issue 4. ✓
9. 🟢 **Kebab-case** `reports-advanced` ; **layout-route** groupant les routes de même feature ; `/adjustments` = **Base** (tranché par Max). ✓

View file

@ -0,0 +1,366 @@
# Spec Plan — Import CSV et reconnaissance de format
> Date: 2026-08-12
> Projet: simpl-resultat
> Statut: Draft
> Slug: import-csv-format
> Decisions: [spec-decisions-import-csv-format.md](./spec-decisions-import-csv-format.md)
## Design
### UX / Interface
Le wizard passe de six étapes rendues à sept. `ImportWizardStep` déclare déjà `file-preview` (`types/index.ts:543`) sans que `ImportPage.tsx` ne la rende jamais — le modal optionnel l'avait supplantée. L'étape est restaurée et devient obligatoire.
**Étape configuration.** À l'ouverture d'une source jamais configurée, la détection se lance seule ; le bouton baguette magique reste pour la rejouer. Le panneau gagne un bandeau de résultat : soit une banque reconnue par signature (« Format Desjardins reconnu »), soit un score générique (« Format reconnu — 147 des 150 lignes lues »), soit un avertissement sous le seuil. Le sélecteur de convention de signe, aujourd'hui affiché en permanence alors que le parsing ne l'applique qu'en mode simple (`useImportWizard.ts:514`), n'apparaît plus qu'en mode montant unique.
**Étape aperçu.** Nouvelle étape traversée à chaque import. Au-dessus du tableau des vingt premières lignes, un récapitulatif signé : nombre de sorties et total, nombre d'entrées et total, nombre de lignes en erreur. C'est le contrôle qui rattrape visuellement toute erreur de convention, quelle qu'en soit la cause. Un bouton *Inverser les signes* bascule `sign_convention` et relance le parsing — il agit sur la configuration, jamais sur les données seules, pour que la correction soit mémorisée.
**Étape confirmation.** Le récapitulatif des réglages passe de quatre entrées à sept : le mode de montant, la convention de signe et le mapping des colonnes rejoignent délimiteur, encodage, format de date et lignes ignorées.
> **🟡 SECURITE** — « Inverser les signes » est inerte en mode débit/crédit. `sign_convention` n'est appliqué que dans la branche montant unique (`useImportWizard.ts:514`), et l'issue 6 masque son sélecteur en mode débit/crédit : le bouton présenté comme rattrapant « toute erreur de convention, quelle qu'en soit la cause » ne fait rien sur un fichier dont les deux colonnes sont mappées à l'envers.
> **Resolution :** En mode débit/crédit, le même bouton permute `debitAmount` et `creditAmount` dans le mapping puis relance le parsing.
**Dérive de format.** Au ré-import d'une source connue dont l'en-tête ne correspond plus à la signature mémorisée, un panneau présente l'écart colonne par colonne (« Montant : 3 → 4 ») avec deux issues : adopter la configuration re-détectée, ou conserver l'ancienne.
### Donnees
**Migration v17** — additive, `v1` à `v16` intactes.
```sql
ALTER TABLE import_sources ADD COLUMN amount_mode TEXT NOT NULL DEFAULT 'single'
CHECK (amount_mode IN ('single','debit_credit','absolute_indicator'));
ALTER TABLE import_sources ADD COLUMN sign_convention TEXT NOT NULL DEFAULT 'negative_expense'
CHECK (sign_convention IN ('negative_expense','positive_expense'));
ALTER TABLE import_sources ADD COLUMN header_signature TEXT;
ALTER TABLE import_sources ADD COLUMN template_id INTEGER REFERENCES import_config_templates(id) ON DELETE SET NULL;
UPDATE import_sources SET amount_mode = 'debit_credit'
WHERE column_mapping LIKE '%debitAmount%';
```
Le backfill reproduit exactement la règle appliquée aujourd'hui à la volée (`useImportWizard.ts:321`), donc aucune source ne change de comportement à la migration. Le test `LIKE` est préféré à `json_extract` pour ne dépendre d'aucune extension JSON1 dans le SQLite embarqué.
Les deux colonnes d'énumération portent un `CHECK`, conformément au pattern de la v15 sur `balance_accounts.kind`. Celui d'`amount_mode` accepte d'emblée `absolute_indicator`, la valeur du troisième mode laissé hors scope : la contrainte protège dès aujourd'hui contre une valeur corrompue, et le mode pourra être implémenté plus tard sans migration. La liste blanche applicative de la frontière SREF reste nécessaire — elle produit un message lisible là où la base ne rendrait qu'une erreur de contrainte.
> **🟢 ARCHITECTURE** — L'objectif (ajouter le 3e mode sans migration) s'atteint sans renoncer à la contrainte : `CHECK (amount_mode IN ('single','debit_credit','absolute_indicator'))` tient dès aujourd'hui et accepte déjà la valeur future.
> **Resolution :** Écrire le `CHECK` élargi en v17 plutôt que de l'omettre.
> **🟡 SECURITE** — Sans `CHECK`, une valeur inconnue restaurée depuis une sauvegarde SREF éditable retombe en silence sur un défaut : `useImportWizard.ts:501` branche `if (amountMode === "debit_credit") … else`, donc toute autre valeur lit la mauvaise colonne, et toute valeur autre que `positive_expense` signifie `negative_expense`. C'est exactement la classe d'erreur silencieuse que le chantier existe pour tuer.
> **Resolution :** Valider les deux champs contre une liste blanche à la frontière d'import SREF et à la lecture dans `importSourceService`, avec erreur visible plutôt que repli.
> *Ref : CWE-20*
`consolidated_schema.sql` reçoit les quatre colonnes, non pour alimenter les nouveaux profils — ils les reçoivent de la v17, puisque le script consolidé s'exécute après toutes les migrations et n'utilise que `CREATE TABLE IF NOT EXISTS` — mais pour rester la définition de référence testée. Une constante `V17_SQL` miroir est ajoutée côté tests, conformément au pattern `V13_SQL` à `V16_SQL`, avec un test de parité sur le modèle de `consolidated_schema_has_holdings_tables_and_kind_at_parity` (`lib.rs:2824`) : sans lui, les `DEFAULT` et les `CHECK` peuvent diverger entre les deux définitions.
> **🟢 SECURITE + TECHNIQUE** — Le rationale est inversé, vérification faite. `get_new_profile_init_sql` s'exécute **après** que tauri-plugin-sql a appliqué toutes les migrations, et le script consolidé utilise `CREATE TABLE IF NOT EXISTS` : les quatre colonnes qui y sont ajoutées sont inertes en production. Les nouveaux profils les reçoivent de la v17 seule. Le miroir sert au test de parité, pas au chemin de production.
> **Resolution :** Corriger la formulation et exiger un test de parité sur le modèle de `consolidated_schema_has_holdings_tables_and_kind_at_parity` (`lib.rs:2824`), sans quoi le `DEFAULT` de `sign_convention` peut diverger entre les deux définitions.
Après la v17, `import_sources` et `import_config_templates` portent les mêmes huit champs de format. L'asymétrie qui a causé le bug racine disparaît structurellement.
`template_id` est une **étiquette de provenance** : elle enregistre le modèle depuis lequel la source a été configurée et n'est **jamais relue comme format**. Le format en vigueur est toujours celui des huit colonnes de la source. La colonne sert à l'affichage (« configurée depuis le modèle Desjardins ») et au signalement de divergence ; éditer un modèle ne modifie aucune source liée, ce qu'un critère d'acceptation vérifie.
> **🟡 ARCHITECTURE** — `template_id` réintroduit une seconde source de vérité que la migration venait d'éliminer. Les huit champs sont déjà **copiés** sur la source ; or `updateConfigTemplate` modifie un modèle en place (`useImportWizard.ts:965`), donc le format d'une source liée diverge silencieusement de son modèle, sans qu'aucune règle ne dise lequel fait foi.
> **Resolution :** Soit retirer `template_id` (YAGNI — le format est déjà copié), soit énoncer dans le plan et l'ADR qu'il est une **étiquette de provenance**, jamais relue comme format, avec un critère d'acceptation prouvant qu'éditer un modèle ne modifie aucune source liée.
**Format SREF.** Le fichier d'export gagne deux tableaux, `import_sources` et `import_config_templates`. À l'import, les sources sont restaurées au lieu d'être détruites ; la source factice « Data Import » n'est créée que pour rattacher des transactions orphelines. Une sauvegarde produite avant ce changement ne porte aucun de ces tableaux : l'import doit alors conserver le comportement actuel plutôt qu'échouer.
### Architecture
Deux types et un codec. `ImportFormatRow` est la forme persistée — snake_case, mapping en JSON, `has_header` normalisé — partagée par `import_sources` et `import_config_templates`. `ImportFormat` est la forme de domaine — camelCase, mapping parsé — manipulée par le wizard et la détection. Une paire `formatToRow` / `formatFromRow` est le **seul point de conversion** entre les deux.
La garantie recherchée ne vient donc pas de la structure des types, qui ne peut pas être commune (les porteurs actuels mélangent les casses et typent `has_header` en `boolean` d'un côté, `number` de l'autre), mais du codec et de son test : un champ ajouté au format sans être traversé par le codec fait échouer le test de complétude. C'est ce qui ferme la classe d'erreur à l'origine du chantier.
> **🔴 ARCHITECTURE + TECHNIQUE** — La composition est structurellement impossible telle qu'écrite : les porteurs ont des formes incompatibles, vérifiées dans `types/index.ts``ImportSource.has_header: boolean` (`:12`) et `column_mapping: string` (`:10`), `ImportConfigTemplate.has_header: number` (`:164`), `SourceConfig.hasHeader: boolean` en camelCase avec `columnMapping: ColumnMapping` objet (`:225`), plus `AutoDetectResult` qui ne porte que 7 des 8 champs (pas d'`encoding`). Un type unique composé dans les quatre ne peut pas exister sans trancher une casse et une représentation du mapping.
> **Resolution :** Deux types et un codec : `ImportFormatRow` (persisté, snake_case, mapping en JSON, `has_header` normalisé) et `ImportFormat` (domaine, camelCase, mapping parsé), reliés par une paire `formatToRow`/`formatFromRow` **unique point de conversion**. La garantie de complétude vient alors du codec et de son test, pas de la structure du type.
La détection gagne une couche lexicale en amont de l'heuristique existante. `normalizeHeaderCell` et `matchHeaderColumn` (`csvAutoDetect.ts:633-666`) sont déjà au niveau module et sont réutilisés tels quels ; c'est le **dictionnaire** qui est nouveau, et il vit dans son propre module plutôt que dans `csvAutoDetect.ts`, déjà long de 856 lignes pour deux flux sans rapport. La séparation n'est pas cosmétique : `montant` est un token d'**exclusion** pour les titres (`VALUE_HEADER_KEYWORDS`, `:630`) et le mot-clé de montant **principal** pour les transactions — deux tables distinctes, deux flux qui ne se marchent pas dessus.
> **🟡 ARCHITECTURE** — Le « remontage » est un no-op : les deux fonctions sont déjà au niveau module du même fichier que `autoDetectConfig` (`:633`, `:646`). La mitigation du tableau des risques couvre donc un risque inexistant, tandis que le vrai couplage n'est pas traité — les tables de mots-clés sont partagées, et `montant` est un **token d'exclusion** pour les holdings (`VALUE_HEADER_KEYWORDS`, `:630`) alors qu'il est le mot-clé de montant principal pour les transactions.
> **Resolution :** Retirer la tâche de remontage ; placer le dictionnaire transactions dans son propre module à côté de `bankSignatures.ts`, important les deux helpers et laissant les tables holdings intactes. `csvAutoDetect.ts` fait déjà 856 lignes pour deux flux sans rapport. Le dictionnaire FR/EN couvre date, description (libellé, détail), montant, débit (retrait, déboursé), crédit (dépôt, encaissement) et solde. Quand les libellés tranchent, ils priment ; quand ils sont muets — fichier sans en-tête, libellés inconnus — l'heuristique de forme actuelle reprend la main. C'est ce qui résout d'un coup l'ordre débit/crédit deviné par position (`csvAutoDetect.ts:461-470`), la détection d'en-tête aveugle aux nombres et le choix de la colonne description par longueur moyenne.
La règle de mapping d'une ligne — date, montant, application du signe — est extraite de `parseFilesInternal` en une fonction pure exportée `mapRow(raw, format): ParsedRow`. Elle devient le point unique consommé par le wizard, par le calcul de score et par le récapitulatif d'aperçu. Sans cette extraction, le score réimplémenterait la règle et pourrait afficher 100 % pendant que l'import écrit de mauvais signes — la divergence même que ce chantier combat. Elle rend au passage la règle testable unitairement, le dépôt n'ayant pas de jsdom (d'où le pattern d'export des reducers de `useSnapshotEditor.ts`).
`autoDetectConfig` retourne désormais un score : la configuration détectée est rejouée via `mapRow` sur les lignes de l'échantillon et le taux de lignes parsées sans erreur est remonté à l'appelant.
> **🔴 ARCHITECTURE + TECHNIQUE** — Rejouer la configuration exige la règle de mapping de ligne (date + montant + signe), qui vit dans `parseFilesInternal`, un `useCallback` de `useImportWizard.ts:461-557`. `autoDetectConfig` est un util pur qui ne reçoit que `rawContent` : calculer le score dans le module de détection en écrirait une **seconde implémentation**. Deux mappers, c'est précisément la classe de divergence que ce chantier existe pour tuer — le score pourrait afficher 100 % pendant que l'import écrit de mauvais signes.
> **Resolution :** Extraire en issue 3 une fonction pure exportée `mapRow(raw, format): ParsedRow` dans `src/utils/`, consommée par le hook, le calcul de score et le récapitulatif d'aperçu. Elle rend au passage possibles les tests unitaires promis par l'issue 3 — le dépôt n'a pas de jsdom, d'où le pattern d'export des reducers et builders de `useSnapshotEditor.ts`.
Les signatures de banques sont un tableau de règles déclaratives — ensemble de libellés d'en-tête normalisés, délimiteur, particularités de préambule. Elles sont évaluées avant le dictionnaire générique et n'ajoutent aucune surface d'interface.
Le point d'écriture de la configuration en base migre de `checkDuplicatesInternal` (`useImportWizard.ts:588-606`) vers `executeImport`, pour qu'un import abandonné ne laisse plus de configuration derrière lui.
## Plan de travail
### Issue 1 — Migration v17 : le format complet sur les sources [type:schema]
Dependances : Issue 4
- [ ] Migration v17 dans `lib.rs` : quatre colonnes + backfill `amount_mode`
- [ ] Miroir dans `consolidated_schema.sql`
- [ ] Constante `V17_SQL` + test d'application sur une base v16
- [ ] Test : le backfill reproduit la règle actuelle (source avec `debitAmount``debit_credit`, sans → `single`)
- [ ] Test de parité entre `consolidated_schema.sql` et la chaîne v1→v17 (colonnes, `DEFAULT`, `CHECK`)
- [ ] Test de non-régression : les chaînes SQL `v1` à `v16` absentes du diff
> **🟡 TECHNIQUE** — « Checksums intacts » n'est pas une assertion testable ici : aucun harnais de checksum n'existe. Les constantes `V10_SQL` à `V16_SQL` sont des copies manuelles appliquées via `execute_batch`, et les checksums ne vivent qu'au runtime dans `_sqlx_migrations` (réparés par `profile_commands.rs:223-275`). Un worker autonome va soit bâcler la case, soit y brûler un cycle.
> **Resolution :** Remplacer par ce que le harnais vérifie réellement — les chaînes SQL v1 à v16 absentes du diff, plus l'application de `V17_SQL` sur une base v16 peuplée — et retirer le mot « checksums ». Ajouter le test de parité du schéma consolidé.
### Issue 2 — Persister et restaurer le format d'import [type:bug]
Dependances : Issue 1
- [ ] Type `ImportFormat` partagé ; `ImportSource` et `ImportConfigTemplate` le composent
- [ ] `importSourceService` : les quatre nouvelles colonnes en création, mise à jour et lecture
- [ ] `useImportWizard.selectSource` : lire `amount_mode` et `sign_convention` ; supprimer la valeur en dur (`:323`) et la ré-inférence du mode (`:321`)
- [ ] `ColumnMappingEditor.onAmountModeChange` nettoie les colonnes du mode abandonné
- [ ] Déplacer l'écriture de la config de `checkDuplicatesInternal` vers `executeImport`
- [ ] Lier la source au modèle appliqué (`template_id`), persisté et restauré
- [ ] Test de cycle : configurer → sauvegarder → recharger → le format est identique au bit près
### Issue 3 — Règle débit/crédit et parsing des montants [type:bug]
Dependances : Issue 2
- [ ] `amount = crédit débit` sur magnitudes, en remplacement de la comparaison de nullité (`:509`)
- [ ] Colonne inutilisée à `0,00` traitée comme absente
- [ ] Supprimer les fallbacks `?? 0` (`:504-512`) au profit d'une erreur de ligne « colonne de montant non mappée »
- [ ] `parseFrenchAmount` : parenthèses comptables `(50,00)` et signe suffixe `50,00-`
- [ ] Séparateur décimal arbitré au niveau de la colonne et non de la cellule
- [ ] **Validation ancrée** : `parseFrenchAmount` rend `NaN` sur tout caractère résiduel après normalisation — aujourd'hui `"50,00-"` rend 5000, `"1 234,56 CR"` rend 123456 et `"100,00 CAD"` rend 10000, erreurs de facteur 100 qui passent `isNaN`
- [ ] Durcissement appliqué **partout** (décision tranchée) : vérifier les 11 sites d'appel — 8 dans `csvAutoDetect.ts` (`:224`, `:289`, `:323`, `:489`, `:517`, `:543-544`, `:712`) et 3 dans `useSnapshotEditor.ts:191-202` (import CSV de titres, #245)
- [ ] Extraire `mapRow(raw, format): ParsedRow` pur et exporté depuis `parseFilesInternal`, consommé par le wizard, le score et l'aperçu
- [ ] Tests unitaires sur chaque cas, dans le nouveau `src/utils/amountParser.test.ts`
- [ ] Régression : les tests holdings existants restent verts
> **🔴 SECURITE** — Le défaut est plus grave que décrit, vérifié en exécutant la fonction : `parseFrenchAmount` termine sur `parseFloat`, qui s'arrête au premier caractère invalide au lieu de rejeter. `"50,00-"` rend **5000**, `"1 234,56 CR"` rend **123456**, `"100,00 CAD"` rend **10000** — une erreur de facteur 100, pas un signe perdu. Chacune passe `isNaN` et sera donc comptée comme ligne **valide** dans le nouveau récapitulatif signé, ce qui neutralise le filet de sécurité principal du chantier. Ajouter la forme `50,00-` traite un suffixe et laisse passer tous les autres.
> **Resolution :** Valider la chaîne entière par une regex ancrée après normalisation et rendre `NaN` sur tout caractère résiduel. Ajouter ces trois chaînes aux tests unitaires.
> *Ref : CWE-1284*
> **🔴 TECHNIQUE** — `parseFrenchAmount` a 11 sites d'appel que l'issue ne liste pas : 8 dans `csvAutoDetect.ts` (`:224` `detectHeader`, `:289`, `:323`, `:489`, `:517` `detectSingleAmount`, `:543-544` `isSparseComplementary`, `:712` holdings) et 3 dans `useSnapshotEditor.ts:191-202` — l'import CSV de titres (#245). `useSnapshotEditor.ts` est absent du tableau « Fichiers concernés ». Changer le parser déplace silencieusement `hasNumber`, `negCount` et les quantités de titres.
> **Resolution :** Ajouter `csvAutoDetect.ts` et `useSnapshotEditor.ts` au périmètre de l'issue, avec une case de régression sur les tests holdings, et trancher explicitement si les nouvelles formes sont globales ou opt-in.
### Issue 4 — Corpus de fixtures CSV [type:feature]
Dependances : aucune — **premier maillon de la pile**
> **🔴 TECHNIQUE + ARCHITECTURE** — Le corpus censé « figer le comportement avant la refonte » arrive **après** le changement qu'il doit servir de référence. L'issue 3 modifie `parseFrenchAmount`, dont dépendent `detectHeader`, `detectSingleAmount`, `pickBestAmountColumn` et `isSparseComplementary` : la ligne de base enregistrerait donc un comportement déjà modifié.
> **Resolution :** Déplacer cette issue en première position — elle ne dépend de rien, ni migration ni persistance. Nouvel ordre : 4 → 1 → 2 → 3 → 5 → … Les issues 3, 5 et 6 se mesurent alors toutes à un contrat préexistant. Étendre les assertions aux sites d'appel holdings.
- [ ] Fixtures synthétiques : montant signé, débit/crédit, débit/crédit inversés, colonne à `0,00`, préambule, en-tête contenant un nombre, sans en-tête, tout-positif, ligne entière entre guillemets
- [ ] Tests de contrat figeant le comportement de `autoDetectConfig` avant sa refonte
- [ ] Tests de bout en bout du parsing sur chaque fixture
### Issue 5 — Détection par libellé d'en-tête [type:feature]
Dependances : Issue 3
- [ ] Remonter `normalizeHeaderCell` et `matchHeaderColumn` en helpers partagés du module
- [ ] Dictionnaire FR/EN : date, description, montant, débit, crédit, solde
- [ ] Ordre débit/crédit résolu par libellé plutôt que par position
- [ ] `detectHeader` : signal lexical en complément de la forme
- [ ] Repli sur l'heuristique actuelle quand les libellés sont muets
- [ ] Dictionnaire dans son propre module, hors de `csvAutoDetect.ts` — les tables holdings restent intactes (`montant` y est un token d'exclusion)
- [ ] **Refus du troisième format** : détecter montants tous positifs + colonne voisine à une seule lettre `D`/`C`, et refuser explicitement avec un message dédié plutôt que d'importer chaque débit comme un revenu
- [ ] Tests sur les fixtures, dont l'inversion crédit-avant-débit et le fichier à indicateur refusé
> **🟡 SECURITE** — Le document de décisions promet qu'un fichier au format « montant absolu + indicateur `D`/`C` » sera « refusé explicitement plutôt qu'importé de travers », mais aucune issue du plan ne le détecte ni ne le refuse. En l'état, un tel fichier présente une colonne de montants tous positifs, `detectSingleAmount` rend `positive_expense`, et chaque débit est importé comme un revenu.
> **Resolution :** Ajouter ici une règle de détection (colonne de montants tous positifs plus une colonne voisine à une seule lettre `D`/`C`) et un message de refus explicite, avec un critère d'acceptation et une fixture dans l'issue de corpus.
### Issue 6 — Score de confiance et détection lancée d'office [type:feature]
Dependances : Issue 5
- [ ] `autoDetectConfig` rejoue sa configuration sur l'échantillon et retourne un taux
- [ ] Détection déclenchée seule à l'ouverture d'une source non configurée
- [ ] Bandeau de résultat dans `SourceConfigPanel` : reconnu, score, ou avertissement
- [ ] Convention de signe masquée en mode débit/crédit
- [ ] Clés i18n FR et EN
### Issue 7 — Aperçu obligatoire et récapitulatif signé [type:feature]
Dependances : Issue 6
- [ ] Rendre l'étape `file-preview` dans `ImportPage`, traversée à chaque import
- [ ] Récapitulatif : sorties et total, entrées et total, lignes en erreur
- [ ] Bouton *Inverser les signes* agissant sur la configuration, avec re-parsing
- [ ] `ImportConfirmation` affiche mode, convention et mapping
- [ ] `useImportWizard` : nouvelle transition vers `file-preview` dans le reducer, recâblage de `checkDuplicates` (aujourd'hui code mort) et de `parseAndCheckDuplicates` qui saute l'étape
- [ ] `ImportPage` : remplacer la paire de boutons Aperçu / Vérifier-doublons par `WizardNavigation`
- [ ] Retirer `FilePreviewModal`
- [ ] En mode débit/crédit, *Inverser les signes* permute `debitAmount` et `creditAmount`
- [ ] Clés i18n FR et EN
> **🟡 TECHNIQUE** — L'étape est un changement de machine à états, pas un rendu, et le périmètre annoncé est trop étroit. `file-preview` n'a **aucune transition** dans le reducer (`SET_STEP` ne la vise jamais), `parseAndCheckDuplicates` saute de `source-config` à `duplicate-check` (`:719`, commentaire « skips preview step »), `ImportPage` porte encore la paire de boutons Aperçu / Vérifier-doublons (`:126-140`) et le `FilePreviewModal` (`:196-202`) ; enfin `checkDuplicates` (`:685`, exporté `:1006`) est du code mort à recâbler. Modifier `FilePreviewTable`, seul fichier listé, mute aussi le modal encore vivant.
> **Resolution :** Étendre le périmètre à `useImportWizard` (nouvelle transition + recâblage de `checkDuplicates`), `ImportPage` (remplacer la paire de boutons par `WizardNavigation`) et `FilePreviewModal` (le retirer).
### Issue 8 — Signatures de banques et dérive de format [type:feature]
Dependances : Issue 7
- [ ] Signatures déclaratives Desjardins, RBC, BNC, Tangerine, évaluées avant le dictionnaire générique
- [ ] `header_signature` mémorisée à l'import réussi
- [ ] Au ré-import : comparaison, re-détection, panneau d'écart avec adopter ou conserver
- [ ] Clés i18n FR et EN
- [ ] Le panneau de dérive et l'aperçu énoncent le seul chemin de réparation sûr : supprimer l'import fautif via l'historique avant de le rejouer — un ré-import corrigé ne s'apparie pas aux lignes déjà écrites et les double
- [ ] Tests sur fixtures par banque et sur un cas de dérive
### Issue 9 — Sources et modèles dans l'export de données [type:bug]
Dependances : Issue 8
- [ ] Sérialiser `import_sources` et `import_config_templates` à l'export
- [ ] Restaurer les sources à l'import au lieu du `DELETE` suivi d'une source factice (`:265`, `:362`)
- [ ] **Envelopper purge et restauration dans `withTransaction`** — les deux fonctions n'en ont aucune aujourd'hui, une violation de contrainte à mi-course détruit l'historique sans retour arrière
- [ ] Ordre de restauration : modèles avant sources ; stratégie d'identifiants explicite (upsert par nom + remap de `template_id`), `import_config_templates` n'étant dans aucune liste de purge
- [ ] Liste blanche sur `amount_mode` et `sign_convention` à la frontière d'import, avec erreur lisible
- [ ] Rétrocompatibilité : une sauvegarde antérieure sans ces tableaux s'importe comme aujourd'hui
- [ ] Tests de cycle export/import préservant les configurations
- [ ] Test : une restauration qui échoue à la ligne N laisse le profil intact
> **🔴 SECURITE** — La restauration SREF n'est enveloppée dans **aucune transaction** : `dataExportService.ts` ne contient pas une seule occurrence de `withTransaction` (vérifié), et enchaîne `DELETE FROM transactions / imported_files / import_sources / keywords / suppliers / categories` (`:263-268`, `:360-362`) puis des `db.execute` d'insertion. Cette issue ajoute deux boucles d'insertion de plus, sur des tables à contrainte `UNIQUE(name)` et une clé étrangère `template_id` : la moindre violation abandonne la restauration à mi-course et l'historique financier de l'utilisateur est perdu sans retour arrière.
> **Resolution :** Envelopper l'ensemble purge + restauration des deux fonctions dans `withTransaction`, et ajouter un critère d'acceptation : une restauration qui échoue à la ligne N laisse le profil intact.
> *Ref : CWE-460*
> **🔴 SECURITE** — `import_config_templates` n'apparaît dans aucune des deux listes de purge (vérifié) : réinsérer des modèles restaurés dans un profil qui en possède déjà échoue sur `UNIQUE constraint failed: import_config_templates.name`. Ce n'est pas un cas théorique — restaurer dans un profil existant est le chemin normal. L'ordre de restauration n'est pas non plus spécifié alors que `template_id` porte désormais une clé étrangère vers cette table.
> **Resolution :** Spécifier l'ordre (modèles avant sources) et la stratégie d'identifiants : soit purger aussi `import_config_templates`, soit faire un upsert par nom et remapper `import_sources.template_id` vers les identifiants résolus.
### Issue 10 — Documentation [type:feature]
Dependances : Issue 9
- [ ] `docs/architecture.md` : migration v17, quatre colonnes, couche de détection lexicale, nouvelle étape du wizard
- [ ] ADR 0019 — le format d'import est une donnée persistée intégralement, jamais re-devinée
> **🟢 ARCHITECTURE** — `.gitignore:71-72` ignore `spec-decisions-*.md` et `spec-plan-*.md` : l'ADR 0016 a livré une ligne `Spec:` morte pour cette raison exacte, corrigée par un force-add (PR #295).
> **Resolution :** Ajouter `git add -f spec-decisions-import-csv-format.md spec-plan-import-csv-format.md` à la checklist, avant d'écrire la ligne `Spec:`.
- [ ] `docs/guide-utilisateur.md` + clés `docs.*` FR et EN
- [ ] `CHANGELOG.md` et `CHANGELOG.fr.md` sous `[Unreleased]`
### Ordre d'execution
```
4 → 1 → 2 → 3 → 5 → 6 → 7 → 8 → 9 → 10
```
Pile strictement linéaire. Chaque maillon touche `useImportWizard.ts`, `csvAutoDetect.ts` ou les deux ; une exécution en vagues parallèles produirait des conflits sur ces deux fichiers à chaque étage. Le CHANGELOG est centralisé dans l'issue 10 pour la même raison.
> **🔴 TECHNIQUE + ARCHITECTURE** — L'ordre place le corpus de contrat (issue 4) après le changement de parser qu'il doit servir de référence (issue 3). Ordre corrigé :
> ```
> 4 → 1 → 2 → 3 → 5 → 6 → 7 → 8 → 9 → 10
> ```
> **Resolution :** L'issue 4 ne dépend de rien et passe en tête ; la chaîne reste linéaire. Les issues Forgejo #323-#332 doivent être renumérotées en conséquence dans leurs lignes `Depends on`.
## Fichiers concernes
| Fichier | Action | Raison |
|---------|--------|--------|
| `src-tauri/src/lib.rs` | Modifier | Migration v17 + constante `V17_SQL` + tests |
| `src-tauri/src/database/consolidated_schema.sql` | Modifier | Quatre colonnes sur `import_sources` pour les nouveaux profils |
| `src/shared/types/index.ts` | Modifier | Type `ImportFormat`, composition dans `ImportSource` et `ImportConfigTemplate` |
| `src/services/importSourceService.ts` | Modifier | Persistance des quatre colonnes en création, mise à jour, lecture |
| `src/services/importConfigTemplateService.ts` | Modifier | Alignement sur `ImportFormat` |
| `src/hooks/useImportWizard.ts` | Modifier | Restauration réelle, règle débit/crédit, point d'écriture, score, étape aperçu |
| `src/utils/csvAutoDetect.ts` | Modifier | Couche lexicale, helpers partagés, score, signatures de banques |
| `src/utils/amountParser.ts` | Modifier | Parenthèses comptables, signe suffixe, arbitrage par colonne |
| `src/hooks/useSnapshotEditor.ts` | Modifier | **Manquant** — 3 appels à `parseFrenchAmount` (`:191-202`), import CSV de titres (#245) |
| `src/utils/amountParser.test.ts` | Créer | **Manquant** — n'existe pas aujourd'hui, requis par l'issue de parsing |
| `src/components/import/ColumnMappingEditor.tsx` | Modifier | Le mode nettoie le mapping opposé |
| `src/components/import/SourceConfigPanel.tsx` | Modifier | Bandeau de détection, convention masquée en débit/crédit |
| `src/components/import/FilePreviewTable.tsx` | Modifier | Récapitulatif signé et bascule de convention |
| `src/components/import/ImportConfirmation.tsx` | Modifier | Mode, convention et mapping au récapitulatif |
| `src/components/import/FormatDriftPanel.tsx` | Créer | Panneau d'écart au ré-import |
| `src/pages/ImportPage.tsx` | Modifier | Étape `file-preview` rendue, panneau de dérive |
| `src/services/dataExportService.ts` | Modifier | Sources et modèles sérialisés et restaurés |
| `src/utils/bankSignatures.ts` | Créer | Signatures déclaratives par banque |
| `src/utils/csvAutoDetect.test.ts` | Modifier | Tests du flux transactions, aujourd'hui absents |
| `src/__fixtures__/csv/` | Créer | Corpus synthétique |
> **🟡 TECHNIQUE + ARCHITECTURE** — Le chemin `src/test/fixtures/csv/` inventait une troisième convention : `src/test/` n'existe pas (vérifié). Les tests sont colocalisés (`src/utils/*.test.ts`, `src/services/*.test.ts`), les fixtures vivent dans `src/__fixtures__/` et les tests d'intégration dans `src/__integration__/`. Corrigé dans le tableau ci-dessus.
> **Resolution :** Corpus dans `src/__fixtures__/csv/`, tests de contrat dans `src/utils/csvAutoDetect.test.ts` et le nouveau `src/utils/amountParser.test.ts`.
| `src/i18n/locales/{fr,en}.json` | Modifier | Clés de détection, aperçu, dérive, aide |
| `docs/architecture.md` | Modifier | v17, détection lexicale, étape du wizard |
| `docs/adr/0019-format-import-persiste.md` | Créer | Décision structurante |
| `docs/guide-utilisateur.md` | Modifier | Nouveau parcours d'import |
| `CHANGELOG.md`, `CHANGELOG.fr.md` | Modifier | Entrées sous `[Unreleased]` |
## Plan de tests
### Tests unitaires
`parseFrenchAmount` sur chaque forme de montant, dont parenthèses et signe suffixe. `parseDate` inchangé, couvert en régression. La couche lexicale : chaque libellé du dictionnaire, l'ordre débit/crédit inversé, les libellés muets qui déclenchent le repli. `autoDetectConfig` sur chaque fixture, contrat complet — délimiteur, en-tête, lignes ignorées, format de date, mode, convention, mapping, score. Les signatures de banques, une par banque plus un fichier inconnu qui doit retomber sur le générique.
### Tests d'integration
Le cycle de format configurer → sauvegarder → recharger, qui est le test qui aurait attrapé le bug racine. Le cycle export → import de données préservant sources et modèles, plus l'import d'une sauvegarde antérieure sans ces tableaux. L'application de la migration v17 sur une base v16 peuplée, avec vérification du backfill. Le parsing de bout en bout sur chaque fixture, du fichier brut aux montants signés.
### Tests de regression
Les tests existants restent verts — 871 vitest et 106 Rust au dernier relevé. Le corpus de fixtures de l'issue 4 fige le comportement de `autoDetectConfig` avant sa refonte, de sorte que les issues 5 et 6 se mesurent à un contrat établi plutôt qu'à une intention. Les migrations v1 à v16 conservent leurs checksums.
## Criteres d'acceptation
- [ ] Une source réglée en montants positifs conserve sa convention au deuxième import, et à tous les suivants
- [ ] Un fichier `Date;Description;Crédit;Débit` est mappé dans le bon ordre sans intervention
- [ ] Un fichier dont la colonne inutilisée porte `0,00` produit les bons montants, aucun n'est nul
- [ ] Un mapping incomplet produit une erreur de ligne explicite, jamais une lecture de la colonne 0
- [ ] Basculer de mode puis recharger la source restitue le mode choisi
- [ ] Une source jamais configurée déclenche la détection sans action de l'utilisateur
- [ ] L'aperçu est traversé à chaque import et affiche sorties, entrées et totaux
- [ ] L'écran de confirmation affiche mode, convention et mapping
- [ ] Un import abandonné ne laisse aucune configuration en base
- [ ] Un fichier Desjardins est reconnu par signature et annoncé comme tel
- [ ] Un en-tête modifié depuis le dernier import déclenche le panneau d'écart
- [ ] Exporter puis réimporter ses données préserve toutes les configurations de sources
- [ ] Une sauvegarde produite avant ce chantier s'importe sans erreur
- [ ] Les migrations v1 à v16 sont inchangées ; la v17 s'applique sur une base v16 peuplée
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| Le backfill v17 ne peut pas deviner la convention passée d'une source existante | Aucune source ne change de comportement — le défaut restitue exactement ce que le code appliquait déjà. Une source qui souffrait du bug continue de souffrir jusqu'au prochain import, où le score de confiance et l'aperçu obligatoire exposent l'écart. C'est une amélioration franche sans mutation silencieuse, cohérente avec la décision d'exclure toute correction rétroactive. |
| Un ré-import corrigé double-compte les lignes déjà importées de travers | `findDuplicates` (`transactionService.ts:164`) apparie sur `date AND description AND amount`. Les issues 2 et 3 changent les montants produits par une source — signe inversé, débits qui ne valent plus 0 : le ré-import que la ligne précédente invite à faire **ne reconnaîtra pas** les lignes fautives et les ajoutera en double, une inversion de signe produisant en prime des paires miroir qui se compensent à ~0 au lieu de sauter aux yeux. Le seul chemin de réparation sûr est `deleteImportWithTransactions` sur l'import fautif **avant** de le rejouer — à énoncer dans l'interface de dérive et d'aperçu, pas seulement ici. |
| Un fichier sans ligne d'en-tête ne bénéficie d'aucun signal lexical | Repli explicite sur l'heuristique de forme actuelle, qui reste testée par le corpus. Le score de confiance sera mécaniquement plus bas, ce qui est l'information juste. |
| Un fichier sans en-tête n'a pas de signature à mémoriser | `header_signature` reste nulle et la détection de dérive est inopérante sur ces sources. Documenté plutôt que contourné : inventer une signature sur les données produirait de faux positifs à chaque changement de contenu. |
| `json_extract` indisponible dans le SQLite embarqué | Backfill écrit en `LIKE '%debitAmount%'`, sans dépendance à l'extension JSON1. |
| L'aperçu obligatoire ajoute un clic à chaque import mensuel | Coût accepté par décision explicite, contre la recommandation d'un aperçu conditionné à la confiance. |
| Les signatures de banques sont écrites sans relevés réels | Elles reposent sur les formats documentés et sur `preprocessQuotedCSV`, qui prouve déjà le cas Desjardins. Un fichier non reconnu retombe sur le dictionnaire générique — l'échec d'une signature dégrade, il ne casse pas. |
| Modifier `csvAutoDetect.ts` risque de régresser le flux holdings (#245) | Les helpers sont remontés sans changer leur comportement ; les tests holdings existants font foi et doivent rester verts à chaque maillon. |
| Une pile de dix issues sur deux fichiers centraux dérive au rebase | Ordre strictement linéaire, CHANGELOG centralisé dans le dernier maillon, chaque PR basée sur la précédente. |
## Revision — Synthese
> Date: 2026-08-12 | Experts: Securite, Architecture, Technique
### Verdict
🟡 **CRITIQUES ADRESSEES — plan corrige le 2026-08-13** — A la revue, deux fondations du plan etaient fausses telles qu'ecrites (le type `ImportFormat` compose, l'ordre des issues) et trois defauts du code se sont reveles plus graves que ce que le plan enoncait. Les 7 critiques et les 9 ameliorations sont desormais integrees au corps du document ; les annotations restent en trace de revue. Decisions tranchees : 4 (voir ci-dessous).
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|-----|-----|-----|-------------|
| Securite | 3 | 4 | 1 | Restauration SREF hors transaction ; `parseFloat` accepte les suffixes et rend une magnitude ×100 ; modeles absents de la liste de purge |
| Architecture | 3 | 3 | 2 | `ImportFormat` compose est structurellement impossible ; le score dupliquerait la regle de parsing ; `template_id` recree une seconde source de verite |
| Technique | 4 | 3 | 1 | Le corpus de contrat arrive apres le changement qu'il fige ; 11 sites d'appel non listes ; l'etape apercu est un changement de machine a etats |
Trois constats ont ete verifies a la main avant annotation, et un constat d'agent a ete durci plutot que repris : `parseFrenchAmount("50,00-")` rend **5000**, pas 50 — la revue d'implementation initiale sous-estimait ce defaut.
### Actions requises
1. 🔴 **`ImportFormat` compose** — remplacer par `ImportFormatRow` + `ImportFormat` relies par un codec unique ; les quatre porteurs ont des casses et des types incompatibles.
2. 🔴 **Ordre des issues** — le corpus de contrat passe en tete : `4 → 1 → 2 → 3 → 5 → …`.
3. 🔴 **`parseFrenchAmount`** — validation ancree rendant `NaN` sur tout residu ; les suffixes produisent aujourd'hui une erreur de facteur 100 qui passe `isNaN`.
4. 🔴 **Sites d'appel du parser** — ajouter `csvAutoDetect.ts` et `useSnapshotEditor.ts` au perimetre ; 11 appels non listes, dont le flux holdings.
5. 🔴 **Score de confiance** — extraire `mapRow(raw, format)` pur en amont, sinon le score reimplemente la regle de parsing.
6. 🔴 **Restauration SREF** — envelopper purge et restauration dans `withTransaction` ; aujourd'hui aucune.
7. 🔴 **Modeles a la restauration** — ordre et strategie d'identifiants a specifier ; `import_config_templates` n'est dans aucune liste de purge.
8. 🟡 Whitelist des valeurs `amount_mode` / `sign_convention` a la frontiere SREF.
9. 🟡 `template_id` — le retirer ou le declarer etiquette de provenance.
10. 🟡 Bouton d'inversion inerte en mode debit/credit.
11. 🟡 Le refus du troisieme mode de montant est promis sans livrable.
12. 🟡 Un re-import corrige double-compte les lignes fautives (`findDuplicates` apparie sur le montant).
13. 🟡 Perimetre de l'etape apercu — machine a etats, pas rendu.
14. 🟡 Le remontage des helpers est un no-op ; le vrai couplage est le dictionnaire partage.
15. 🟡 « Checksums intacts » n'est pas testable ; chemin des fixtures corrige en `src/__fixtures__/csv/`.
### Decisions tranchees (2026-08-13)
| Decision | Retenu | Rationale |
|----------|--------|-----------|
| Contrainte `CHECK` sur `amount_mode` | `CHECK` elargi a `absolute_indicator` | Revise la decision de cadrage « pas de CHECK ». L'objectif d'origine — ajouter le 3e mode sans migration — est atteint par la valeur future deja admise dans la contrainte, sans renoncer a la garantie en base. `sign_convention` recoit le meme traitement. |
| Role de `template_id` | Etiquette de provenance | La colonne enregistre d'ou vient la configuration et n'est jamais relue comme format ; les huit colonnes de la source font foi. Un critere d'acceptation verifie qu'editer un modele ne modifie aucune source liee. |
| Troisieme format de montant | Detecte et refuse explicitement | Le document de decisions promettait un refus explicite sans qu'aucune issue ne le livre. Sans la regle, un tel fichier importe chaque debit comme un revenu — l'echec silencieux que le chantier combat. |
| Portee du durcissement de `parseFrenchAmount` | Global, tous les sites d'appel | Un `"100,00 CAD"` qui rend 10000 est un bug partout, y compris a l'import de titres. Une seule fonction a raisonner, au prix d'une passe de regression sur les tests holdings existants. |
### Corrections integrees sans arbitrage
`ImportFormatRow` + `ImportFormat` relies par un codec unique (la composition d'un type unique etait structurellement impossible) ; ordre des issues `4 → 1 → 2 → 3 → 5 → …` ; extraction de `mapRow` avant le calcul de score ; `withTransaction` sur la purge et la restauration SREF ; ordre et strategie d'identifiants a la restauration des modeles ; perimetre de l'etape apercu etendu au reducer, a `ImportPage` et au modal ; dictionnaire lexical dans son propre module ; `useSnapshotEditor.ts` et `src/utils/amountParser.test.ts` ajoutes au tableau des fichiers ; chemin des fixtures corrige en `src/__fixtures__/csv/` ; assertion « checksums » remplacee par ce que le harnais verifie reellement ; chemin de reparation sur re-import enonce dans l'interface.
### Decisions de planification (2026-08-13, seance /plan-run)
| Question | Decision | Portee |
|----------|----------|--------|
| Les specs sont gitignorees, les workers ne les verraient pas | Force-add et commit des deux fichiers (precedent PR #295) **et** bodies d'issues auto-suffisants | Toutes les issues ; debloque la ligne `Spec:` de l'ADR 0019 |
| Seuil de confiance non chiffre | **90 %** de lignes lues. En dessous : bandeau d'avertissement + score detaille (« 132/150 lignes »). Au-dessus : bandeau neutre. L'apercu obligatoire reste le filet reel quel que soit le score | Issues 6 et 7 (#328, #329) |
| Contenu de `header_signature` | Tableau JSON des libelles normalises par `normalizeHeaderCell`, ex. `["date","description","montant","solde"]`. Un hash rendrait impossible l'ecart colonne par colonne promis par le panneau de derive | Issue 8 (#330) |
| Compatibilite du format SREF | Champ de version explicite dans le fichier exporte. A l'import, son absence signifie « format anterieur » et les tableaux manquants sont traites comme vides | Issue 9 (#331) |
| Emplacement du codec et de `mapRow` | `src/utils/importFormat.ts` — meme dossier que `amountParser`, `dateParser` et `csvAutoDetect`, qui portent deja la logique pure du domaine. Les types restent dans `src/shared/types/` | Issues 2 et 3 (#324, #325) |

24
src-tauri/Cargo.lock generated
View file

@ -3498,9 +3498,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]] [[package]]
name = "plist" name = "plist"
version = "1.8.0" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.0", "indexmap 2.13.0",
@ -3648,9 +3648,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@ -4111,9 +4111,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.9" version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@ -4645,9 +4645,9 @@ dependencies = [
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.9.8" version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [ dependencies = [
"lock_api", "lock_api",
] ]
@ -5058,9 +5058,9 @@ dependencies = [
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.44" version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
@ -5206,9 +5206,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-deep-link" name = "tauri-plugin-deep-link"
version = "2.4.8" version = "2.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db49816aee496a9b200d55b55ab6ae73fd50847c79f2fabc7ee20871fa75c95" checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
dependencies = [ dependencies = [
"dunce", "dunce",
"plist", "plist",

View file

@ -66,3 +66,13 @@ hmac = "0.12"
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
# HTTP mock server for balance_commands fetch_price tests (Issue #155). # HTTP mock server for balance_commands fetch_price tests (Issue #155).
mockito = "1.6" mockito = "1.6"
[features]
# Dev-only escape hatch: when enabled, the SR_DEV_EDITION env var forces the
# resolved edition (free|base|premium) so the three license tiers can be tested
# without real license files. MUST stay out of `default` and of any release
# feature set — gating this on debug_assertions instead would let a custom
# release build honor the env var and become a Premium backdoor (CWE-489).
# Usage: cargo test --features dev-override, or `tauri dev` with
# `-- --features dev-override`.
dev-override = []

View file

@ -1,8 +1,15 @@
// Centralized feature → tier mapping for license entitlements. // Centralized feature → tier mapping for license entitlements.
// //
// This module is the single source of truth for which features are gated by which tier. // This module is the single source of truth for which features are gated by which tier
// To change what is gated where, modify FEATURE_TIERS only — never sprinkle edition checks // on the Rust side. To change what is gated where, modify FEATURE_TIERS only — never
// throughout the codebase. // sprinkle edition checks throughout the codebase.
//
// Since the tier-gating work (#297-#301), UI-level gates (budget, adjustments,
// reports-advanced, multi-profile, balance) live in the TS matrix
// `src/shared/entitlements.ts` (soft-paywall, UI-only enforcement). This table
// only keeps the features actually checked through `check_entitlement` — the
// JWT `features[]` array is a namespace shared with that TS layer, so keys are
// kebab-case on both sides.
/// Editions, ordered from least to most privileged. /// Editions, ordered from least to most privileged.
pub const EDITION_FREE: &str = "free"; pub const EDITION_FREE: &str = "free";
@ -10,17 +17,11 @@ pub const EDITION_BASE: &str = "base";
pub const EDITION_PREMIUM: &str = "premium"; pub const EDITION_PREMIUM: &str = "premium";
/// Maps feature name → list of editions allowed to use it. /// Maps feature name → list of editions allowed to use it.
/// A feature absent from this list is denied for all editions. /// A feature absent from this list is denied for all editions, unless the
const FEATURE_TIERS: &[(&str, &[&str])] = &[ /// license carries it in its signed `features[]` override (see [`is_entitled`]).
// auto-update is temporarily open to FREE until the license server (issue #49) const FEATURE_TIERS: &[(&str, &[&str])] = &[("auto-update", &[EDITION_BASE, EDITION_PREMIUM])];
// is live. Re-gate to [BASE, PREMIUM] once paid activation works end-to-end.
("auto-update", &[EDITION_FREE, EDITION_BASE, EDITION_PREMIUM]),
("web-sync", &[EDITION_PREMIUM]),
("cloud-backup", &[EDITION_PREMIUM]),
("advanced-reports", &[EDITION_PREMIUM]),
];
/// Pure check: does `edition` grant access to `feature`? /// Pure check: does `edition` grant access to `feature` via the static matrix?
pub fn is_feature_allowed(feature: &str, edition: &str) -> bool { pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
FEATURE_TIERS FEATURE_TIERS
.iter() .iter()
@ -29,10 +30,28 @@ pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Static matrix check OR signed per-license `features[]` override.
///
/// Fail-closed in Free (CWE-863): a `license.key` copied onto another machine
/// resolves to "free" through the machine-binding path, which already drops the
/// signed features (see `license_commands::current_entitlements`). As defense
/// in depth we also refuse the override here whenever the edition is free, so
/// signed features can never rescue a downgraded license.
pub fn is_entitled(feature: &str, edition: &str, features: &[String]) -> bool {
if edition == EDITION_FREE {
return is_feature_allowed(feature, edition);
}
is_feature_allowed(feature, edition) || features.iter().any(|f| f == feature)
}
/// Tauri command: is `feature` available right now? Edition AND signed
/// per-license feature overrides are resolved through the same machine-binding
/// path (`license_commands::current_entitlements`), then combined by
/// [`is_entitled`].
#[tauri::command] #[tauri::command]
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> { pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
let edition = crate::commands::license_commands::current_edition(&app); let (edition, features) = crate::commands::license_commands::current_entitlements(&app);
Ok(is_feature_allowed(&feature, &edition)) Ok(is_entitled(&feature, &edition, &features))
} }
#[cfg(test)] #[cfg(test)]
@ -40,9 +59,8 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn free_allows_auto_update_temporarily() { fn free_denied_auto_update() {
// Temporary: auto-update is open to FREE until the license server is live. assert!(!is_feature_allowed("auto-update", EDITION_FREE));
assert!(is_feature_allowed("auto-update", EDITION_FREE));
} }
#[test] #[test]
@ -51,20 +69,43 @@ mod tests {
} }
#[test] #[test]
fn premium_unlocks_everything() { fn premium_unlocks_auto_update() {
assert!(is_feature_allowed("auto-update", EDITION_PREMIUM)); assert!(is_feature_allowed("auto-update", EDITION_PREMIUM));
assert!(is_feature_allowed("web-sync", EDITION_PREMIUM));
assert!(is_feature_allowed("cloud-backup", EDITION_PREMIUM));
}
#[test]
fn base_does_not_unlock_premium_features() {
assert!(!is_feature_allowed("web-sync", EDITION_BASE));
assert!(!is_feature_allowed("cloud-backup", EDITION_BASE));
} }
#[test] #[test]
fn unknown_feature_denied() { fn unknown_feature_denied() {
assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM)); assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM));
} }
#[test]
fn matrix_grants_without_override() {
assert!(is_entitled("auto-update", EDITION_BASE, &[]));
assert!(is_entitled("auto-update", EDITION_PREMIUM, &[]));
}
#[test]
fn unlisted_feature_denied_without_override() {
// "balance" lives in the TS matrix only — absent from FEATURE_TIERS,
// so the static path denies it for every edition.
assert!(!is_entitled("balance", EDITION_BASE, &[]));
assert!(!is_entitled("balance", EDITION_PREMIUM, &[]));
}
#[test]
fn override_grants_unlisted_feature_for_paid_editions() {
let features = vec!["balance".to_string()];
assert!(is_entitled("balance", EDITION_BASE, &features));
assert!(is_entitled("balance", EDITION_PREMIUM, &features));
}
#[test]
fn override_ignored_in_free() {
// CWE-863: signed features[] must never rescue a license downgraded to
// free (copied key / machine mismatch) — not even for a feature that a
// paid edition would get from the static matrix.
let features = vec!["balance".to_string(), "auto-update".to_string()];
assert!(!is_entitled("balance", EDITION_FREE, &features));
assert!(!is_entitled("auto-update", EDITION_FREE, &features));
}
} }

View file

@ -221,51 +221,117 @@ pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
Ok(current_edition(&app)) Ok(current_edition(&app))
} }
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any /// Dev-only edition override, compiled in ONLY under the `dev-override` Cargo
/// failure resolves to "free" so feature gates fail closed. /// feature (off by default and absent from any release feature set). Gating on
/// `debug_assertions` instead would be CWE-489: a custom release build could
/// flip it on and the env var would become a Premium backdoor. When compiled
/// in, `SR_DEV_EDITION` forces the resolved edition (free|base|premium) so the
/// three tiers can be tested without real licenses; unrecognized values are
/// ignored and resolution falls through to the normal path.
fn dev_override_edition() -> Option<String> {
#[cfg(feature = "dev-override")]
{
if let Ok(edition) = std::env::var("SR_DEV_EDITION") {
if edition == EDITION_FREE || edition == EDITION_BASE || edition == EDITION_PREMIUM {
return Some(edition);
}
}
}
None
}
/// Pure resolution of `(edition, signed features)` from license material.
/// ///
/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free. /// Single choke point for the machine-binding rule: every downgrade path
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String { /// returns `("free", [])`, so the signed `features[]` of a copied license can
// Check Compte Maximus subscription first — Premium overrides Base /// never be honored once the edition is downgraded (CWE-863). Separated from
/// the fs/AppHandle plumbing so tests can exercise it with in-memory keys.
fn resolve_license_entitlements(
license_key: &str,
activation_token: Option<&str>,
local_machine_id: &str,
decoding_key: &DecodingKey,
) -> (String, Vec<String>) {
let Ok(info) = validate_with_key(license_key, decoding_key) else {
return (EDITION_FREE.to_string(), Vec::new());
};
// If an activation token exists, it must match the local machine. A missing
// token is accepted (graceful pre-activation state).
if let Some(token) = activation_token {
if validate_activation_with_key(token, local_machine_id, decoding_key).is_err() {
return (EDITION_FREE.to_string(), Vec::new());
}
}
(info.edition, info.features)
}
/// Internal helper used by `entitlements::check_entitlement`. Resolves the
/// effective edition AND the signed per-license feature overrides through the
/// SAME machine-binding path. Never returns an error — any failure resolves to
/// `("free", [])` so feature gates fail closed, features included (CWE-863: a
/// copied `license.key` must not keep its signed `features[]` once downgraded).
///
/// Priority: dev override (dev-override builds only) > Premium (via Compte
/// Maximus with active subscription) > Base (offline license) > Free.
pub(crate) fn current_entitlements(app: &tauri::AppHandle) -> (String, Vec<String>) {
// Dev-only tier testing — compiled out of normal builds (see the helper).
if let Some(edition) = dev_override_edition() {
return (edition, Vec::new());
}
// Check Compte Maximus subscription first — Premium overrides Base. This
// path never reads the license JWT, so it carries no signed features.
if let Some(edition) = check_account_edition(app) { if let Some(edition) = check_account_edition(app) {
if edition == EDITION_PREMIUM { if edition == EDITION_PREMIUM {
return edition; return (edition, Vec::new());
} }
} }
let free = || (EDITION_FREE.to_string(), Vec::new());
let Ok(path) = license_path(app) else { let Ok(path) = license_path(app) else {
return EDITION_FREE.to_string(); return free();
}; };
if !path.exists() { if !path.exists() {
return EDITION_FREE.to_string(); return free();
} }
let Ok(key) = fs::read_to_string(&path) else { let Ok(key) = fs::read_to_string(&path) else {
return EDITION_FREE.to_string(); return free();
}; };
let Ok(decoding_key) = embedded_decoding_key() else { let Ok(decoding_key) = embedded_decoding_key() else {
return EDITION_FREE.to_string(); return free();
};
let Ok(info) = validate_with_key(&key, &decoding_key) else {
return EDITION_FREE.to_string();
}; };
// If an activation token exists, it must match the local machine. A missing token is // Read the activation token when present. An unreadable token or machine
// accepted (graceful pre-activation). // id resolves to free, matching the strict posture of `current_edition`
if let Ok(activation_path) = activation_path(app) { // before this refactor.
if activation_path.exists() { let mut activation_token: Option<String> = None;
let Ok(token) = fs::read_to_string(&activation_path) else { let mut local_machine_id = String::new();
return EDITION_FREE.to_string(); if let Ok(act_path) = activation_path(app) {
if act_path.exists() {
let Ok(token) = fs::read_to_string(&act_path) else {
return free();
}; };
let Ok(local_id) = machine_id_internal() else { let Ok(local_id) = machine_id_internal() else {
return EDITION_FREE.to_string(); return free();
}; };
if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() { activation_token = Some(token);
return EDITION_FREE.to_string(); local_machine_id = local_id;
}
} }
} }
info.edition resolve_license_entitlements(
&key,
activation_token.as_deref(),
&local_machine_id,
&decoding_key,
)
}
/// Edition-only view of [`current_entitlements`], used by `get_edition` and any
/// caller that does not need the feature overrides.
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
current_entitlements(app).0
} }
/// Read the HMAC-verified account cache to check for an active Premium /// Read the HMAC-verified account cache to check for an active Premium
@ -677,4 +743,118 @@ mod tests {
// Sanity check that the production PEM constant is well-formed. // Sanity check that the production PEM constant is well-formed.
assert!(embedded_decoding_key().is_ok()); assert!(embedded_decoding_key().is_ok());
} }
// === Entitlements resolution (edition + signed features, machine binding) =================
fn base_license_with_features(enc: &EncodingKey, features: Vec<String>) -> String {
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: EDITION_BASE.to_string(),
features,
machine_limit: 3,
};
let jwt = make_token(enc, &claims);
format!("{}{}", KEY_PREFIX_BASE, jwt)
}
fn activation_for(enc: &EncodingKey, machine_id: &str) -> String {
let claims = ActivationClaims {
sub: "license-id".to_string(),
iat: now(),
exp: now() + 86400,
machine_id: machine_id.to_string(),
};
make_token(enc, &claims)
}
#[test]
fn machine_match_keeps_edition_and_features() {
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let token = activation_for(&enc, "machine-A");
let (edition, features) =
resolve_license_entitlements(&key, Some(&token), "machine-A", &dec);
assert_eq!(edition, EDITION_BASE);
assert_eq!(features, vec!["balance".to_string()]);
}
#[test]
fn machine_mismatch_downgrades_to_free_and_drops_features() {
// CWE-863: a copied license.key + activation.token still carries its
// signed features[] — they must vanish together with the downgrade.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let token = activation_for(&enc, "machine-A");
let (edition, features) =
resolve_license_entitlements(&key, Some(&token), "machine-B", &dec);
assert_eq!(edition, EDITION_FREE);
assert!(
features.is_empty(),
"signed features must not survive the machine-binding downgrade"
);
}
#[test]
fn missing_activation_token_keeps_edition_and_features() {
// Graceful pre-activation state: no token yet, the license still counts.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let (edition, features) = resolve_license_entitlements(&key, None, "machine-A", &dec);
assert_eq!(edition, EDITION_BASE);
assert_eq!(features, vec!["balance".to_string()]);
}
#[test]
fn invalid_license_resolves_free_without_features() {
let (_enc, dec) = default_keys();
let (edition, features) =
resolve_license_entitlements("SR-BASE-not.a.jwt", None, "machine-A", &dec);
assert_eq!(edition, EDITION_FREE);
assert!(features.is_empty());
}
// === Dev override =========================================================================
/// The dev override must be dead code in normal builds: even with
/// SR_DEV_EDITION set, nothing reads it when the `dev-override` Cargo
/// feature is off (CWE-489 — a release binary must never honor the env
/// var). Env-var note: under this feature-off build NO code path reads
/// SR_DEV_EDITION, so setting it here cannot race with parallel tests.
#[cfg(not(feature = "dev-override"))]
#[test]
fn sr_dev_edition_has_no_effect_when_feature_off() {
std::env::set_var("SR_DEV_EDITION", "premium");
assert_eq!(dev_override_edition(), None);
std::env::remove_var("SR_DEV_EDITION");
}
// Companion coverage for `cargo test --features dev-override` (not part of
// the normal CI run). SR_DEV_EDITION is process-global and cargo test runs
// tests on parallel threads, so every env manipulation serializes on a lock.
#[cfg(feature = "dev-override")]
mod dev_override_on {
use super::super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn sr_dev_edition_forces_edition() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("SR_DEV_EDITION", "premium");
assert_eq!(dev_override_edition(), Some("premium".to_string()));
std::env::remove_var("SR_DEV_EDITION");
}
#[test]
fn sr_dev_edition_unknown_value_ignored() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("SR_DEV_EDITION", "enterprise");
assert_eq!(dev_override_edition(), None);
std::env::remove_var("SR_DEV_EDITION");
}
}
} }

View file

@ -15,6 +15,22 @@ CREATE TABLE IF NOT EXISTS import_sources (
column_mapping TEXT NOT NULL, column_mapping TEXT NOT NULL,
skip_lines INTEGER NOT NULL DEFAULT 0, skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1, has_header INTEGER NOT NULL DEFAULT 1,
-- Full import format (migration v17). These four columns are INERT on the
-- production path: this script runs AFTER tauri-plugin-sql has applied every
-- migration and only uses CREATE TABLE IF NOT EXISTS, so a brand-new profile
-- actually receives them from v17. They are mirrored here to keep this file
-- the tested reference definition -- a parity test compares it against the
-- v1->v17 chain so the DEFAULT and CHECK of the two cannot drift apart.
-- `amount_mode` admits 'absolute_indicator' from the start so the third mode
-- can ship without another migration. `template_id` is a provenance tag
-- only, never re-read as format: the eight format columns above are
-- authoritative, so editing a template changes no linked source.
amount_mode TEXT NOT NULL DEFAULT 'single'
CHECK (amount_mode IN ('single','debit_credit','absolute_indicator')),
sign_convention TEXT NOT NULL DEFAULT 'negative_expense'
CHECK (sign_convention IN ('negative_expense','positive_expense')),
header_signature TEXT,
template_id INTEGER REFERENCES import_config_templates(id) ON DELETE SET NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );

View file

@ -313,6 +313,58 @@ pub fn run() {
DROP TABLE _v16_guard;", DROP TABLE _v16_guard;",
kind: MigrationKind::Up, kind: MigrationKind::Up,
}, },
// Migration v17 — the full import format on the sources themselves (#323).
//
// Until now `import_sources` carried only the mechanical CSV settings
// (delimiter, encoding, date format, column mapping). The two fields
// that decide how an amount is READ — `amount_mode` and
// `sign_convention` — existed only on `import_config_templates`. That
// asymmetry is the root bug of this chantier: restoring a saved source
// re-inferred the mode from the mapping and hardcoded
// `signConvention: "negative_expense"` (`useImportWizard.ts:321-323`),
// so a source configured with positive expenses silently flipped back
// on its second import. After v17 both tables carry the same eight
// format fields and the asymmetry is gone structurally.
//
// All four columns are additive and either defaulted or nullable, so
// the ALTERs are safe on a populated v16 database:
// - `amount_mode` / `sign_convention` carry a CHECK, same pattern as
// the v15 `balance_accounts.kind`. `amount_mode` admits
// 'absolute_indicator' from the start: that third mode (absolute
// amount + a D/C indicator column) is out of scope today but must
// be implementable without another migration, and the constraint
// still refuses a corrupted value right now.
// - `header_signature` stores the normalized header labels seen at
// the last successful import, for drift detection. NULL until an
// import records one, and permanently NULL for headerless files.
// - `template_id` is a PROVENANCE TAG only, never re-read as format:
// the eight columns of the source are authoritative, so editing a
// template must not change any linked source. ON DELETE SET NULL
// keeps the source and its format intact when a template is deleted.
//
// The backfill reproduces EXACTLY the rule the wizard applies on the
// fly today (`useImportWizard.ts:321` — a mapping carrying
// `debitAmount` means debit/credit, anything else means single amount),
// so no existing source changes behaviour on migration. The test is
// `LIKE '%debitAmount%'` rather than `json_extract` so the migration
// depends on no JSON1 extension in the bundled SQLite. `sign_convention`
// is deliberately NOT backfilled: its DEFAULT restores precisely the
// value the code hardcoded, which is the only past convention that can
// be inferred — guessing anything else would silently rewrite meaning.
Migration {
version: 17,
description: "add amount_mode, sign_convention, header_signature and template_id to import_sources",
sql: "ALTER TABLE import_sources ADD COLUMN amount_mode TEXT NOT NULL DEFAULT 'single' \
CHECK (amount_mode IN ('single','debit_credit','absolute_indicator')); \
ALTER TABLE import_sources ADD COLUMN sign_convention TEXT NOT NULL DEFAULT 'negative_expense' \
CHECK (sign_convention IN ('negative_expense','positive_expense')); \
ALTER TABLE import_sources ADD COLUMN header_signature TEXT; \
ALTER TABLE import_sources ADD COLUMN template_id INTEGER \
REFERENCES import_config_templates(id) ON DELETE SET NULL; \
UPDATE import_sources SET amount_mode = 'debit_credit' \
WHERE column_mapping LIKE '%debitAmount%';",
kind: MigrationKind::Up,
},
]; ];
tauri::Builder::default() tauri::Builder::default()
@ -3415,5 +3467,460 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(xyz_secs, 0, "no security for a priced account without asset_type"); assert_eq!(xyz_secs, 0, "no security for a priced account without asset_type");
} }
// =========================================================================
// Migration v17 — the full import format on import_sources (#323)
// -------------------------------------------------------------------------
// What these tests guarantee:
// - v17 applies on a POPULATED v16 database with zero loss: every existing
// source keeps its identity and its mechanical CSV settings, and the
// child rows that reference it survive the ALTERs.
// - the backfill reproduces EXACTLY the rule the wizard applied on the fly
// (`useImportWizard.ts:321`), so no source changes behaviour on
// migration — a mapping carrying `debitAmount` becomes 'debit_credit',
// everything else stays 'single', and `sign_convention` lands on the
// value the code used to hardcode.
// - the two CHECKs really bite (an unknown enum value is refused, and
// 'absolute_indicator' is already admitted so the third amount mode
// needs no further migration).
// - `template_id` is a provenance tag: deleting the template it points at
// leaves the source alive with a NULL tag, and editing a template
// changes no linked source's format.
// - consolidated_schema.sql and the v1→v17 chain agree column for column,
// DEFAULT for DEFAULT, CHECK for CHECK, FK for FK.
//
// "v1..v16 unchanged" is NOT asserted here: no checksum harness exists in
// this crate (V10_SQL..V17_SQL are hand-kept copies, and the real checksums
// only live at runtime in `_sqlx_migrations`). It is verified by the diff —
// v17 adds strictly new lines and touches no earlier migration string.
// =========================================================================
/// Production v3 SQL — kept in sync with the Migration { version: 3 } entry.
const V3_SQL: &str =
"ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;";
/// Production v5 SQL — kept in sync with the Migration { version: 5 } entry.
const V5_SQL: &str = "CREATE TABLE IF NOT EXISTS import_config_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
column_mapping TEXT NOT NULL,
amount_mode TEXT NOT NULL DEFAULT 'single',
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);";
/// Production v17 SQL — kept in sync with the Migration { version: 17 } entry.
const V17_SQL: &str = "ALTER TABLE import_sources ADD COLUMN amount_mode TEXT NOT NULL DEFAULT 'single' \
CHECK (amount_mode IN ('single','debit_credit','absolute_indicator')); \
ALTER TABLE import_sources ADD COLUMN sign_convention TEXT NOT NULL DEFAULT 'negative_expense' \
CHECK (sign_convention IN ('negative_expense','positive_expense')); \
ALTER TABLE import_sources ADD COLUMN header_signature TEXT; \
ALTER TABLE import_sources ADD COLUMN template_id INTEGER \
REFERENCES import_config_templates(id) ON DELETE SET NULL; \
UPDATE import_sources SET amount_mode = 'debit_credit' \
WHERE column_mapping LIKE '%debitAmount%';";
/// Build the pre-v17 state of the two tables v17 touches. `import_sources`
/// and `import_config_templates` are shaped by exactly three migrations —
/// v1 (creation), v3 (`has_header`) and v5 (the templates table). None of
/// v2, v4 and v6→v16 alters either table, so this IS their v16 shape; the
/// parity test below re-proves it by comparing the result against the
/// consolidated reference definition.
fn db_pre_v17() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute("PRAGMA foreign_keys = ON;", [])
.expect("enable FKs");
conn.execute_batch(crate::database::SCHEMA)
.expect("apply v1 SCHEMA");
conn.execute_batch(V3_SQL).expect("apply v3");
conn.execute_batch(V5_SQL).expect("apply v5");
conn
}
/// Insert a source and return its id. Only `name` and `column_mapping` have
/// no default at v16, so everything else is left to the schema.
fn seed_source(conn: &Connection, name: &str, mapping: &str) -> i64 {
conn.execute(
"INSERT INTO import_sources (name, column_mapping) VALUES (?1, ?2)",
rusqlite::params![name, mapping],
)
.unwrap();
conn.last_insert_rowid()
}
/// (name, type, notnull, dflt_value) for every column of `table`, sorted by
/// name. Sorting drops physical column order on purpose: ALTER TABLE can
/// only append, while a CREATE TABLE places a column where it reads best —
/// the order is not part of the contract, the definitions are.
fn column_shape(conn: &Connection, table: &str) -> Vec<(String, String, i64, Option<String>)> {
let mut cols: Vec<(String, String, i64, Option<String>)> = conn
.prepare(&format!(
"SELECT name, type, \"notnull\", dflt_value FROM pragma_table_info('{table}')"
))
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
cols.sort();
cols
}
/// (referenced table, from column, to column, ON DELETE action) per FK.
fn fk_shape(conn: &Connection, table: &str) -> Vec<(String, String, String, String)> {
let mut fks: Vec<(String, String, String, String)> = conn
.prepare(&format!(
"SELECT \"table\", \"from\", \"to\", on_delete FROM pragma_foreign_key_list('{table}')"
))
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
fks.sort();
fks
}
#[test]
fn migration_v17_applies_on_a_populated_v16_db() {
let conn = db_pre_v17();
// A realistic populated v16 profile: a configured source, a template and
// an imported file hanging off the source by FK.
let src = seed_source(
&conn,
"Desjardins",
r#"{"date":"Date","description":"Description","amount":"Montant"}"#,
);
conn.execute(
"UPDATE import_sources SET delimiter = ',', encoding = 'windows-1252', \
date_format = '%Y-%m-%d', skip_lines = 3, has_header = 0 WHERE id = ?1",
[src],
)
.unwrap();
conn.execute(
"INSERT INTO import_config_templates (name, column_mapping) VALUES ('Modèle A', '{}')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO imported_files (source_id, filename, file_hash) \
VALUES (?1, 'janvier.csv', 'deadbeef')",
[src],
)
.unwrap();
conn.execute_batch(V17_SQL).expect("apply v17 on a v16 db");
// The four columns landed.
let cols: Vec<String> = column_shape(&conn, "import_sources")
.into_iter()
.map(|(n, _, _, _)| n)
.collect();
for expected in &[
"amount_mode",
"sign_convention",
"header_signature",
"template_id",
] {
assert!(
cols.contains(&expected.to_string()),
"v17 must add {expected} to import_sources"
);
}
// Nothing the source already carried was touched.
let (name, delim, enc, fmt, skip, header, mapping): (
String,
String,
String,
String,
i64,
i64,
String,
) = conn
.query_row(
"SELECT name, delimiter, encoding, date_format, skip_lines, has_header, \
column_mapping FROM import_sources WHERE id = ?1",
[src],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
))
},
)
.unwrap();
assert_eq!(name, "Desjardins");
assert_eq!(delim, ",");
assert_eq!(enc, "windows-1252");
assert_eq!(fmt, "%Y-%m-%d");
assert_eq!(skip, 3);
assert_eq!(header, 0);
assert_eq!(
mapping,
r#"{"date":"Date","description":"Description","amount":"Montant"}"#
);
// The new columns land on their defaults: a source that never carried a
// format now carries the exact one the code applied to it.
let (mode, sign, sig, tpl): (String, String, Option<String>, Option<i64>) = conn
.query_row(
"SELECT amount_mode, sign_convention, header_signature, template_id \
FROM import_sources WHERE id = ?1",
[src],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(mode, "single");
assert_eq!(sign, "negative_expense");
assert!(sig.is_none(), "no header signature until an import records one");
assert!(tpl.is_none(), "an existing source has no known provenance");
// The child row and its FK survived the ALTERs.
let files: i64 = conn
.query_row(
"SELECT COUNT(*) FROM imported_files WHERE source_id = ?1",
[src],
|r| r.get(0),
)
.unwrap();
assert_eq!(files, 1, "imported_files must survive the v17 ALTERs");
let templates: i64 = conn
.query_row("SELECT COUNT(*) FROM import_config_templates", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(templates, 1, "templates are untouched by v17");
}
#[test]
fn migration_v17_backfill_reproduces_the_wizard_rule() {
let conn = db_pre_v17();
// The rule being frozen (`useImportWizard.ts:321`):
// mapping.debitAmount !== undefined ? "debit_credit" : "single"
let cases: &[(&str, &str, &str)] = &[
(
"Deux colonnes",
r#"{"date":"Date","description":"Libellé","debitAmount":"Débit","creditAmount":"Crédit"}"#,
"debit_credit",
),
(
"Débit seul",
r#"{"date":"Date","description":"Libellé","debitAmount":"Retrait"}"#,
"debit_credit",
),
(
"Montant unique",
r#"{"date":"Date","description":"Libellé","amount":"Montant"}"#,
"single",
),
(
"Crédit seul",
r#"{"date":"Date","description":"Libellé","creditAmount":"Dépôt"}"#,
"single",
),
(
"Mapping minimal",
r#"{"date":"Date","description":"Libellé"}"#,
"single",
),
];
for (name, mapping, _) in cases {
seed_source(&conn, name, mapping);
}
conn.execute_batch(V17_SQL).expect("apply v17");
for (name, _, expected_mode) in cases {
let (mode, sign): (String, String) = conn
.query_row(
"SELECT amount_mode, sign_convention FROM import_sources WHERE name = ?1",
[name],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(
mode, *expected_mode,
"{name}: the backfill must reproduce the wizard rule"
);
// The wizard hardcoded this on every restore, so every migrated
// source must land on it — that is what "no behaviour change" means.
assert_eq!(
sign, "negative_expense",
"{name}: sign_convention restores the previously hardcoded value"
);
}
}
#[test]
fn migration_v17_checks_reject_unknown_enum_values() {
let conn = db_pre_v17();
conn.execute_batch(V17_SQL).expect("apply v17");
let set = |col: &str, val: &str| {
conn.execute(
&format!("INSERT INTO import_sources (name, column_mapping, {col}) VALUES (?1, '{{}}', ?2)"),
rusqlite::params![format!("{col}-{val}"), val],
)
};
// The third amount mode is admitted from the start: implementing it later
// must not require another migration.
assert!(
set("amount_mode", "absolute_indicator").is_ok(),
"absolute_indicator must be accepted by the v17 CHECK"
);
assert!(set("amount_mode", "debit_credit").is_ok());
assert!(set("sign_convention", "positive_expense").is_ok());
// A corrupted value — e.g. restored from a hand-edited SREF backup — is
// refused by the database rather than silently mis-read at parse time.
assert!(
set("amount_mode", "montants_bizarres").is_err(),
"an unknown amount_mode must be refused"
);
assert!(
set("sign_convention", "whatever").is_err(),
"an unknown sign_convention must be refused"
);
}
#[test]
fn migration_v17_template_id_is_a_nullable_provenance_tag() {
let conn = db_pre_v17();
conn.execute_batch(V17_SQL).expect("apply v17");
conn.execute(
"INSERT INTO import_config_templates (name, column_mapping, amount_mode, sign_convention) \
VALUES ('Desjardins', '{}', 'single', 'positive_expense')",
[],
)
.unwrap();
let tpl = conn.last_insert_rowid();
let src = seed_source(&conn, "Compte chèque", "{}");
conn.execute(
"UPDATE import_sources SET template_id = ?1, sign_convention = 'positive_expense' \
WHERE id = ?2",
[tpl, src],
)
.unwrap();
// Editing the template changes NO linked source: the eight format columns
// on the source are authoritative, the tag only records provenance.
conn.execute(
"UPDATE import_config_templates SET amount_mode = 'debit_credit', \
sign_convention = 'negative_expense' WHERE id = ?1",
[tpl],
)
.unwrap();
let (mode, sign): (String, String) = conn
.query_row(
"SELECT amount_mode, sign_convention FROM import_sources WHERE id = ?1",
[src],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(mode, "single", "editing a template must not touch a source");
assert_eq!(sign, "positive_expense");
// Deleting the template keeps the source and its format — only the tag
// goes (ON DELETE SET NULL).
conn.execute("DELETE FROM import_config_templates WHERE id = ?1", [tpl])
.unwrap();
let (tag, sign_after): (Option<i64>, String) = conn
.query_row(
"SELECT template_id, sign_convention FROM import_sources WHERE id = ?1",
[src],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert!(tag.is_none(), "deleting a template nulls the provenance tag");
assert_eq!(
sign_after, "positive_expense",
"deleting a template must not alter the source format"
);
// A dangling tag is refused outright.
assert!(
conn.execute(
"UPDATE import_sources SET template_id = 9999 WHERE id = ?1",
[src]
)
.is_err(),
"template_id must point at a real template"
);
}
#[test]
fn consolidated_schema_matches_v17_chain_on_import_sources_at_parity() {
// The consolidated schema is the tested reference definition for new
// profiles. Without this test the DEFAULT or the CHECK of the four v17
// columns could drift between the two definitions unnoticed — the
// columns are inert on the production path (this script runs after every
// migration and only uses CREATE TABLE IF NOT EXISTS), so nothing else
// would surface the divergence.
let chain = db_pre_v17();
chain.execute_batch(V17_SQL).expect("apply v17");
let consolidated = consolidated_db();
// Same columns, same types, same NOT NULL flags, same DEFAULTs.
assert_eq!(
column_shape(&consolidated, "import_sources"),
column_shape(&chain, "import_sources"),
"consolidated import_sources must match the v1→v17 chain"
);
// Same FK, same ON DELETE action.
assert_eq!(
fk_shape(&consolidated, "import_sources"),
fk_shape(&chain, "import_sources"),
"consolidated import_sources must carry the same template_id FK"
);
assert_eq!(
fk_shape(&consolidated, "import_sources"),
vec![(
"import_config_templates".to_string(),
"template_id".to_string(),
"id".to_string(),
"SET NULL".to_string(),
)],
);
// The CHECKs are not exposed by pragma, so prove them behaviourally on
// BOTH definitions — the same values must be accepted and refused.
for conn in [&consolidated, &chain] {
for (col, val, ok) in [
("amount_mode", "absolute_indicator", true),
("amount_mode", "debit_credit", true),
("amount_mode", "nope", false),
("sign_convention", "positive_expense", true),
("sign_convention", "nope", false),
] {
let res = conn.execute(
&format!(
"INSERT INTO import_sources (name, column_mapping, {col}) \
VALUES (?1, '{{}}', ?2)"
),
rusqlite::params![format!("{col}-{val}"), val],
);
assert_eq!(
res.is_ok(),
ok,
"{col} = {val} must behave identically in both definitions"
);
}
}
}
} }

View file

@ -29,6 +29,7 @@ import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage"; import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage"; import ProfileSelectionPage from "./pages/ProfileSelectionPage";
import ErrorPage from "./components/shared/ErrorPage"; import ErrorPage from "./components/shared/ErrorPage";
import RequireFeature from "./components/shared/RequireFeature";
const STARTUP_TIMEOUT_MS = 10_000; const STARTUP_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
@ -112,23 +113,35 @@ export default function App() {
<Route path="/import" element={<ImportPage />} /> <Route path="/import" element={<ImportPage />} />
<Route path="/transactions" element={<TransactionsPage />} /> <Route path="/transactions" element={<TransactionsPage />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
{/* Gated routes (soft paywall): pathless layout-routes render
RequireFeature's <Outlet/> one group per feature, mirroring the
SettingsLayout convention. The /reports hub and /reports/trends
stay Free and OUTSIDE any gate. */}
<Route element={<RequireFeature feature="adjustments" />}>
<Route path="/adjustments" element={<AdjustmentsPage />} /> <Route path="/adjustments" element={<AdjustmentsPage />} />
</Route>
<Route element={<RequireFeature feature="budget" />}>
<Route path="/budget" element={<BudgetPage />} /> <Route path="/budget" element={<BudgetPage />} />
</Route>
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
<Route path="/reports/trends" element={<ReportsTrendsPage />} /> <Route path="/reports/trends" element={<ReportsTrendsPage />} />
<Route element={<RequireFeature feature="reports-advanced" />}>
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
<Route path="/reports/compare" element={<ReportsComparePage />} /> <Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} /> <Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} /> <Route path="/reports/cartes" element={<ReportsCartesPage />} />
</Route>
<Route path="/settings" element={<SettingsLayout />}> <Route path="/settings" element={<SettingsLayout />}>
<Route index element={<SettingsHomePage />} /> <Route index element={<SettingsHomePage />} />
<Route path="users" element={<UsersSettingsPage />} /> <Route path="users" element={<UsersSettingsPage />} />
<Route path="data" element={<DataSettingsPage />} /> <Route path="data" element={<DataSettingsPage />} />
<Route path="systems" element={<SystemsSettingsPage />} /> <Route path="systems" element={<SystemsSettingsPage />} />
</Route> </Route>
<Route element={<RequireFeature feature="balance" />}>
<Route path="/balance" element={<BalancePage />} /> <Route path="/balance" element={<BalancePage />} />
<Route path="/balance/accounts" element={<AccountsPage />} /> <Route path="/balance/accounts" element={<AccountsPage />} />
<Route path="/balance/snapshot" element={<SnapshotEditPage />} /> <Route path="/balance/snapshot" element={<SnapshotEditPage />} />
</Route>
<Route <Route
path="/settings/categories/standard" path="/settings/categories/standard"
element={<CategoriesStandardGuidePage />} element={<CategoriesStandardGuidePage />}

View file

@ -0,0 +1,7 @@
Date;Description;Montant;Sens
05/01/2025;EPICERIE METRO SAINTE-FOY;84,32;D
15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00;C
18/01/2025;HYDRO QUEBEC PREAUTORISE;142,18;D
22/01/2025;RESTAURANT LE BISTRO;56,75;D
27/01/2025;VIREMENT RECU;300,00;C
31/01/2025;FRAIS MENSUELS;6,95;D
1 Date Description Montant Sens
2 05/01/2025 EPICERIE METRO SAINTE-FOY 84,32 D
3 15/01/2025 DEPOT PAIE EMPLOYEUR 1250,00 C
4 18/01/2025 HYDRO QUEBEC PREAUTORISE 142,18 D
5 22/01/2025 RESTAURANT LE BISTRO 56,75 D
6 27/01/2025 VIREMENT RECU 300,00 C
7 31/01/2025 FRAIS MENSUELS 6,95 D

View file

@ -0,0 +1,7 @@
Date;Description;Montant
05/01/2025;EPICERIE METRO SAINTE-FOY;84,32
15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00
18/01/2025;HYDRO QUEBEC PREAUTORISE;142,18
22/01/2025;RESTAURANT LE BISTRO;56,75
27/01/2025;VIREMENT RECU;300,00
31/01/2025;FRAIS MENSUELS;6,95
1 Date Description Montant
2 05/01/2025 EPICERIE METRO SAINTE-FOY 84,32
3 15/01/2025 DEPOT PAIE EMPLOYEUR 1250,00
4 18/01/2025 HYDRO QUEBEC PREAUTORISE 142,18
5 22/01/2025 RESTAURANT LE BISTRO 56,75
6 27/01/2025 VIREMENT RECU 300,00
7 31/01/2025 FRAIS MENSUELS 6,95

View file

@ -0,0 +1,7 @@
Date;Description;Catégorie;Débit;Crédit;Solde
05/01/2025;EPICERIE METRO SAINTE-FOY;Alimentation;84,32;;915,68
15/01/2025;DEPOT PAIE EMPLOYEUR;Revenu;;1250,00;2165,68
18/01/2025;HYDRO QUEBEC PREAUTORISE;Services publics;142,18;;2023,50
22/01/2025;RESTAURANT LE BISTRO;Restaurants;56,75;;1966,75
27/01/2025;VIREMENT RECU;Transferts;;300,00;2266,75
30/01/2025;FRAIS MENSUELS;Frais bancaires;6,95;;2259,80
1 Date Description Catégorie Débit Crédit Solde
2 05/01/2025 EPICERIE METRO SAINTE-FOY Alimentation 84,32 915,68
3 15/01/2025 DEPOT PAIE EMPLOYEUR Revenu 1250,00 2165,68
4 18/01/2025 HYDRO QUEBEC PREAUTORISE Services publics 142,18 2023,50
5 22/01/2025 RESTAURANT LE BISTRO Restaurants 56,75 1966,75
6 27/01/2025 VIREMENT RECU Transferts 300,00 2266,75
7 30/01/2025 FRAIS MENSUELS Frais bancaires 6,95 2259,80

View file

@ -0,0 +1,7 @@
Date;Description;Montant;Solde
05/01/2025;EPICERIE METRO SAINTE-FOY;-84,32;915,68
15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00;2165,68
18/01/2025;HYDRO QUEBEC PREAUTORISE;-142,18;2023,50
22/01/2025;RESTAURANT LE BISTRO;-56,75;1966,75
27/01/2025;VIREMENT RECU;300,00;2266,75
30/01/2025;FRAIS MENSUELS;-6,95;2259,80
1 Date Description Montant Solde
2 05/01/2025 EPICERIE METRO SAINTE-FOY -84,32 915,68
3 15/01/2025 DEPOT PAIE EMPLOYEUR 1250,00 2165,68
4 18/01/2025 HYDRO QUEBEC PREAUTORISE -142,18 2023,50
5 22/01/2025 RESTAURANT LE BISTRO -56,75 1966,75
6 27/01/2025 VIREMENT RECU 300,00 2266,75
7 30/01/2025 FRAIS MENSUELS -6,95 2259,80

View file

@ -0,0 +1,7 @@
Account Type,Account Number,Transaction Date,Cheque Number,Description 1,Description 2,CAD$,USD$
Chequing,1234567,05/01/2025,,EPICERIE METRO SAINTE-FOY,,-84.32,
Chequing,1234567,15/01/2025,,DEPOT PAIE EMPLOYEUR,PAIE,1250.00,
Chequing,1234567,18/01/2025,,HYDRO QUEBEC PREAUTORISE,,-142.18,
Chequing,1234567,22/01/2025,,RESTAURANT LE BISTRO,,-56.75,
Chequing,1234567,27/01/2025,,VIREMENT RECU,,300.00,
Chequing,1234567,30/01/2025,241,FRAIS MENSUELS,,-6.95,
1 Account Type Account Number Transaction Date Cheque Number Description 1 Description 2 CAD$ USD$
2 Chequing 1234567 05/01/2025 EPICERIE METRO SAINTE-FOY -84.32
3 Chequing 1234567 15/01/2025 DEPOT PAIE EMPLOYEUR PAIE 1250.00
4 Chequing 1234567 18/01/2025 HYDRO QUEBEC PREAUTORISE -142.18
5 Chequing 1234567 22/01/2025 RESTAURANT LE BISTRO -56.75
6 Chequing 1234567 27/01/2025 VIREMENT RECU 300.00
7 Chequing 1234567 30/01/2025 241 FRAIS MENSUELS -6.95

View file

@ -0,0 +1,7 @@
Date,Transaction,Name,Memo,Amount
05/01/2025,DEBIT,EPICERIE METRO SAINTE-FOY,,-84.32
15/01/2025,CREDIT,DEPOT PAIE EMPLOYEUR,Paie bimensuelle,1250.00
18/01/2025,DEBIT,HYDRO QUEBEC PREAUTORISE,,-142.18
22/01/2025,DEBIT,RESTAURANT LE BISTRO,,-56.75
27/01/2025,CREDIT,VIREMENT RECU,,300.00
30/01/2025,DEBIT,FRAIS MENSUELS,,-6.95
1 Date Transaction Name Memo Amount
2 05/01/2025 DEBIT EPICERIE METRO SAINTE-FOY -84.32
3 15/01/2025 CREDIT DEPOT PAIE EMPLOYEUR Paie bimensuelle 1250.00
4 18/01/2025 DEBIT HYDRO QUEBEC PREAUTORISE -142.18
5 22/01/2025 DEBIT RESTAURANT LE BISTRO -56.75
6 27/01/2025 CREDIT VIREMENT RECU 300.00
7 30/01/2025 DEBIT FRAIS MENSUELS -6.95

View file

@ -0,0 +1,7 @@
Date;Description;Credit;Debit
05/01/2025;EPICERIE METRO SAINTE-FOY;;84,32
15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00;
18/01/2025;HYDRO QUEBEC PREAUTORISE;;142,18
22/01/2025;RESTAURANT LE BISTRO;;56,75
27/01/2025;VIREMENT RECU;300,00;
31/01/2025;FRAIS MENSUELS;;6,95
1 Date Description Credit Debit
2 05/01/2025 EPICERIE METRO SAINTE-FOY 84,32
3 15/01/2025 DEPOT PAIE EMPLOYEUR 1250,00
4 18/01/2025 HYDRO QUEBEC PREAUTORISE 142,18
5 22/01/2025 RESTAURANT LE BISTRO 56,75
6 27/01/2025 VIREMENT RECU 300,00
7 31/01/2025 FRAIS MENSUELS 6,95

View file

@ -0,0 +1,7 @@
Date;Description;Debit;Credit
05/01/2025;EPICERIE METRO SAINTE-FOY;84,32;
15/01/2025;DEPOT PAIE EMPLOYEUR;;1250,00
18/01/2025;HYDRO QUEBEC PREAUTORISE;142,18;
22/01/2025;RESTAURANT LE BISTRO;56,75;
27/01/2025;VIREMENT RECU;;300,00
31/01/2025;FRAIS MENSUELS;6,95;
1 Date Description Debit Credit
2 05/01/2025 EPICERIE METRO SAINTE-FOY 84,32
3 15/01/2025 DEPOT PAIE EMPLOYEUR 1250,00
4 18/01/2025 HYDRO QUEBEC PREAUTORISE 142,18
5 22/01/2025 RESTAURANT LE BISTRO 56,75
6 27/01/2025 VIREMENT RECU 300,00
7 31/01/2025 FRAIS MENSUELS 6,95

View file

@ -0,0 +1,7 @@
"Date,""Description"",Montant"
"05/01/2025,""EPICERIE METRO SAINTE-FOY"",-84.32"
"15/01/2025,""DEPOT PAIE EMPLOYEUR"",1250.00"
"18/01/2025,""HYDRO QUEBEC PREAUTORISE"",-142.18"
"22/01/2025,""RESTAURANT LE BISTRO"",-56.75"
"27/01/2025,""VIREMENT RECU"",300.00"
"31/01/2025,""FRAIS MENSUELS"",-6.95"
1 Date,"Description",Montant
2 05/01/2025,"EPICERIE METRO SAINTE-FOY",-84.32
3 15/01/2025,"DEPOT PAIE EMPLOYEUR",1250.00
4 18/01/2025,"HYDRO QUEBEC PREAUTORISE",-142.18
5 22/01/2025,"RESTAURANT LE BISTRO",-56.75
6 27/01/2025,"VIREMENT RECU",300.00
7 31/01/2025,"FRAIS MENSUELS",-6.95

View file

@ -0,0 +1,7 @@
Date;Description;2025 Montant
05/01/2025;EPICERIE METRO SAINTE-FOY;-84,32
15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00
18/01/2025;HYDRO QUEBEC PREAUTORISE;-142,18
22/01/2025;RESTAURANT LE BISTRO;-56,75
27/01/2025;VIREMENT RECU;300,00
31/01/2025;FRAIS MENSUELS;-6,95
1 Date Description 2025 Montant
2 05/01/2025 EPICERIE METRO SAINTE-FOY -84,32
3 15/01/2025 DEPOT PAIE EMPLOYEUR 1250,00
4 18/01/2025 HYDRO QUEBEC PREAUTORISE -142,18
5 22/01/2025 RESTAURANT LE BISTRO -56,75
6 27/01/2025 VIREMENT RECU 300,00
7 31/01/2025 FRAIS MENSUELS -6,95

View file

@ -0,0 +1,7 @@
Date;Description;Montant;Solde 2024
05/01/2025;EPICERIE METRO SAINTE-FOY;-84,32;1415,68
15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00;2665,68
18/01/2025;HYDRO QUEBEC PREAUTORISE;-142,18;2523,50
22/01/2025;RESTAURANT LE BISTRO;-56,75;2466,75
27/01/2025;VIREMENT RECU;300,00;2766,75
31/01/2025;FRAIS MENSUELS;-6,95;2759,80
1 Date Description Montant Solde 2024
2 05/01/2025 EPICERIE METRO SAINTE-FOY -84,32 1415,68
3 15/01/2025 DEPOT PAIE EMPLOYEUR 1250,00 2665,68
4 18/01/2025 HYDRO QUEBEC PREAUTORISE -142,18 2523,50
5 22/01/2025 RESTAURANT LE BISTRO -56,75 2466,75
6 27/01/2025 VIREMENT RECU 300,00 2766,75
7 31/01/2025 FRAIS MENSUELS -6,95 2759,80

View file

@ -0,0 +1,68 @@
/**
* Synthetic CSV corpus for the import-format work (issue #326).
*
* Every file in this directory is INVENTED. No real bank statement, no real
* account number, no real transaction ever lands in the repository the app is
* privacy-first and the corpus has to be readable by anyone. Synthetic is not a
* compromise here: these files deliberately cover shapes a single real statement
* never contains at once (a reversed debit/credit pair, an unused column filled
* with `0,00`, a header cell carrying a number), which is precisely the point.
*
* The corpus is the reference the detection rewrite (#323-#332) measures itself
* against. Read `csvAutoDetect.test.ts` for the frozen contract, including the
* cases that are frozen as DEFECTIVE.
*/
import { readFileSync } from "fs";
import { resolve } from "path";
/** Stable identifier of each corpus case. */
export type CsvFixtureName =
| "signed-amount"
| "debit-credit"
| "debit-credit-reversed"
| "unused-column-zero"
| "preamble"
| "header-with-number"
| "header-numeric-label"
| "no-header"
| "all-positive"
| "desjardins-quoted"
| "absolute-indicator"
// Bank-signature cases (#330). Each one is the DOCUMENTED header layout of a
// bank, invented row by row like the rest of the corpus. Three of them are
// shapes the generic dictionary reads WRONG — Tangerine's `Transaction` type
// column taken for the description, RBC's `CAD$` paired with the near-empty
// `Cheque Number` as a debit/credit pair — which is what the signatures exist
// for.
| "bank-desjardins"
| "bank-rbc"
| "bank-bnc"
| "bank-tangerine";
/** Every case in the corpus, in the order the issue lists them. */
export const CSV_FIXTURE_NAMES: readonly CsvFixtureName[] = [
"signed-amount",
"debit-credit",
"debit-credit-reversed",
"unused-column-zero",
"preamble",
"header-with-number",
"header-numeric-label",
"no-header",
"all-positive",
"desjardins-quoted",
"absolute-indicator",
"bank-desjardins",
"bank-rbc",
"bank-bnc",
"bank-tangerine",
];
/**
* Read one fixture as raw text, exactly as `useImportWizard` receives it from
* the file-reading Tauri command no trimming, no normalisation.
*/
export function readCsvFixture(name: CsvFixtureName): string {
return readFileSync(resolve(import.meta.dirname, `${name}.csv`), "utf-8");
}

View file

@ -0,0 +1,6 @@
05/01/2025;EPICERIE METRO SAINTE-FOY;-84,32
15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00
18/01/2025;HYDRO QUEBEC PREAUTORISE;-142,18
22/01/2025;RESTAURANT LE BISTRO;-56,75
27/01/2025;VIREMENT RECU;300,00
31/01/2025;FRAIS MENSUELS;-6,95
1 05/01/2025 EPICERIE METRO SAINTE-FOY -84,32
2 15/01/2025 DEPOT PAIE EMPLOYEUR 1250,00
3 18/01/2025 HYDRO QUEBEC PREAUTORISE -142,18
4 22/01/2025 RESTAURANT LE BISTRO -56,75
5 27/01/2025 VIREMENT RECU 300,00
6 31/01/2025 FRAIS MENSUELS -6,95

View file

@ -0,0 +1,10 @@
RELEVE DE COMPTE
Compte cheques 12345-6789
Periode couverte du 01 janvier 2025 au 31 janvier 2025
Date;Description;Montant
05/01/2025;EPICERIE METRO SAINTE-FOY;-84,32
15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00
18/01/2025;HYDRO QUEBEC PREAUTORISE;-142,18
22/01/2025;RESTAURANT LE BISTRO;-56,75
27/01/2025;VIREMENT RECU;300,00
31/01/2025;FRAIS MENSUELS;-6,95
1 RELEVE DE COMPTE
2 Compte cheques 12345-6789
3 Periode couverte du 01 janvier 2025 au 31 janvier 2025
4 Date;Description;Montant
5 05/01/2025;EPICERIE METRO SAINTE-FOY;-84,32
6 15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00
7 18/01/2025;HYDRO QUEBEC PREAUTORISE;-142,18
8 22/01/2025;RESTAURANT LE BISTRO;-56,75
9 27/01/2025;VIREMENT RECU;300,00
10 31/01/2025;FRAIS MENSUELS;-6,95

View file

@ -0,0 +1,7 @@
Date;Description;Montant
05/01/2025;EPICERIE METRO SAINTE-FOY;-84,32
15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00
18/01/2025;HYDRO QUEBEC PREAUTORISE;-142,18
22/01/2025;RESTAURANT LE BISTRO;-56,75
27/01/2025;VIREMENT RECU;300,00
31/01/2025;FRAIS MENSUELS;-6,95
1 Date Description Montant
2 05/01/2025 EPICERIE METRO SAINTE-FOY -84,32
3 15/01/2025 DEPOT PAIE EMPLOYEUR 1250,00
4 18/01/2025 HYDRO QUEBEC PREAUTORISE -142,18
5 22/01/2025 RESTAURANT LE BISTRO -56,75
6 27/01/2025 VIREMENT RECU 300,00
7 31/01/2025 FRAIS MENSUELS -6,95

View file

@ -0,0 +1,7 @@
Date;Description;Debit;Credit
05/01/2025;EPICERIE METRO SAINTE-FOY;84,32;0,00
15/01/2025;DEPOT PAIE EMPLOYEUR;0,00;1250,00
18/01/2025;HYDRO QUEBEC PREAUTORISE;142,18;0,00
22/01/2025;RESTAURANT LE BISTRO;56,75;0,00
27/01/2025;VIREMENT RECU;0,00;300,00
31/01/2025;FRAIS MENSUELS;6,95;0,00
1 Date Description Debit Credit
2 05/01/2025 EPICERIE METRO SAINTE-FOY 84,32 0,00
3 15/01/2025 DEPOT PAIE EMPLOYEUR 0,00 1250,00
4 18/01/2025 HYDRO QUEBEC PREAUTORISE 142,18 0,00
5 22/01/2025 RESTAURANT LE BISTRO 56,75 0,00
6 27/01/2025 VIREMENT RECU 0,00 300,00
7 31/01/2025 FRAIS MENSUELS 6,95 0,00

View file

@ -0,0 +1,262 @@
/**
* Import format save/reload integration (#324).
*
* The unit tests in `src/utils/importFormat.test.ts` prove the codec carries
* every field. This file proves the SQL on either side of it does too: a format
* written by `importSourceService` and read back is the same format, field by
* field. That is the test that would have caught the root bug before v17 the
* INSERT simply had no `amount_mode` / `sign_convention` column to write to, so
* a positive-expense source silently came back inverted on its second import.
*
* Like `balance-flow.test.ts`, real `tauri-plugin-sql` cannot be started outside
* the Tauri WebView, so the services run against an in-memory FakeDb that
* interprets the handful of statements they actually issue. It stores what it
* is given, so `has_header` lands as the 0/1 integer SQLite stores and comes
* back as one the exact shape the codec has to absorb.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../services/db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: unknown) => unknown) => fn(await getDb())),
};
});
import { getDb } from "../services/db";
import {
createSource,
getSourceByName,
updateSource,
} from "../services/importSourceService";
import {
createTemplate,
getAllTemplates,
updateTemplate,
} from "../services/importConfigTemplateService";
import { formatFromRow, formatToRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat";
import type { ImportFormat, SourceConfig } from "../shared/types";
// ---------------------------------------------------------------------------
// FakeDb — a tiny interpreter for the statements these two services issue.
// ---------------------------------------------------------------------------
type Row = Record<string, unknown>;
function makeFakeDb() {
const tables: Record<string, Row[]> = {
import_sources: [],
import_config_templates: [],
};
let nextId = 1;
const insert = (sql: string, params: unknown[]) => {
const [, table, cols] =
/INSERT INTO (\w+) \(([^)]+)\)/.exec(sql) ?? [];
const columns = cols.split(",").map((c) => c.trim());
const row: Row = { id: nextId++ };
columns.forEach((c, i) => (row[c] = params[i]));
if (/ON CONFLICT\(name\) DO UPDATE/.test(sql)) {
const clash = tables[table].find((r) => r.name === row.name);
if (clash) {
// Mimic `excluded.*`: overwrite every listed column, keep the id.
columns.forEach((c, i) => (clash[c] = params[i]));
nextId--;
return { lastInsertId: 0, rowsAffected: 1 };
}
}
tables[table].push(row);
return { lastInsertId: row.id as number, rowsAffected: 1 };
};
const update = (sql: string, params: unknown[]) => {
const [, table, assignments] =
/UPDATE (\w+)\s+SET ([\s\S]+?)\s+WHERE id\s*=\s*\$(\d+)/.exec(sql) ?? [];
const id = params[params.length - 1];
const row = tables[table].find((r) => r.id === id);
if (!row) return { rowsAffected: 0 };
for (const [, column, index] of assignments.matchAll(
/(\w+)\s*=\s*\$(\d+)/g
)) {
row[column] = params[Number(index) - 1];
}
return { rowsAffected: 1 };
};
return {
tables,
execute: vi.fn(async (sql: string, params: unknown[] = []) => {
if (sql.trimStart().startsWith("INSERT")) return insert(sql, params);
if (sql.trimStart().startsWith("UPDATE")) return update(sql, params);
throw new Error(`FakeDb: unsupported statement ${sql.slice(0, 40)}`);
}),
select: vi.fn(async (sql: string, params: unknown[] = []) => {
const [, table] = /FROM (\w+)/.exec(sql) ?? [];
const rows = tables[table];
if (/WHERE name = \$1/.test(sql)) return rows.filter((r) => r.name === params[0]);
if (/WHERE id = \$1/.test(sql)) return rows.filter((r) => r.id === params[0]);
return [...rows].sort((a, b) => String(a.name).localeCompare(String(b.name)));
}),
};
}
let db: ReturnType<typeof makeFakeDb>;
beforeEach(() => {
db = makeFakeDb();
vi.mocked(getDb).mockResolvedValue(db as never);
});
// ---------------------------------------------------------------------------
// Fixtures — a credit-card statement: expenses are POSITIVE. This is the
// configuration the old code could not keep.
// ---------------------------------------------------------------------------
const CREDIT_CARD: SourceConfig = {
name: "Visa Desjardins",
delimiter: ",",
encoding: "windows-1252",
dateFormat: "YYYY-MM-DD",
skipLines: 2,
hasHeader: false,
columnMapping: { date: 1, description: 3, amount: 4 },
amountMode: "single",
signConvention: "positive_expense",
};
/** What `useImportWizard.selectSource` does on a stored source. */
async function reload(name: string): Promise<SourceConfig> {
const stored = await getSourceByName(name);
if (!stored) throw new Error(`source ${name} not found`);
return { name: stored.name, ...formatFromRow(stored) };
}
function expectSameFormat(actual: ImportFormat, expected: ImportFormat) {
for (const field of Object.keys(FORMAT_FIELD_PAIRS) as Array<keyof ImportFormat>) {
expect(actual[field], `field ${field}`).toEqual(expected[field]);
}
}
// ---------------------------------------------------------------------------
describe("configure -> save -> reload (#324)", () => {
it("returns the saved format field by field", async () => {
await createSource({ name: CREDIT_CARD.name, ...formatToRow(CREDIT_CARD) });
expectSameFormat(await reload(CREDIT_CARD.name), CREDIT_CARD);
});
it("keeps positive_expense on the second import and every one after", async () => {
await createSource({ name: CREDIT_CARD.name, ...formatToRow(CREDIT_CARD) });
// Three consecutive imports re-save what was reloaded, as the wizard does.
let current = await reload(CREDIT_CARD.name);
for (let i = 0; i < 3; i++) {
const id = (await getSourceByName(CREDIT_CARD.name))!.id;
await updateSource(id, { name: current.name, ...formatToRow(current) });
current = await reload(CREDIT_CARD.name);
}
expect(current.signConvention).toBe("positive_expense");
expectSameFormat(current, CREDIT_CARD);
});
it("stores has_header as the integer SQLite holds, not a JS boolean", async () => {
await createSource({ name: CREDIT_CARD.name, ...formatToRow(CREDIT_CARD) });
expect(db.tables.import_sources[0].has_header).toBe(0);
expect((await reload(CREDIT_CARD.name)).hasHeader).toBe(false);
});
it("keeps the chosen mode when the mapping does not betray it", async () => {
const debitCredit: SourceConfig = {
...CREDIT_CARD,
amountMode: "debit_credit",
// No debitAmount: the old restore re-inferred "single" from this shape.
columnMapping: { date: 1, description: 3, creditAmount: 5 },
};
await createSource({ name: debitCredit.name, ...formatToRow(debitCredit) });
expect((await reload(debitCredit.name)).amountMode).toBe("debit_credit");
});
it("updates the format in place when the source is re-configured", async () => {
const id = await createSource({
name: CREDIT_CARD.name,
...formatToRow(CREDIT_CARD),
});
const flipped: SourceConfig = {
...CREDIT_CARD,
signConvention: "negative_expense",
amountMode: "debit_credit",
columnMapping: { date: 1, description: 3, debitAmount: 4, creditAmount: 5 },
};
await updateSource(id, { name: flipped.name, ...formatToRow(flipped) });
expectSameFormat(await reload(CREDIT_CARD.name), flipped);
expect(db.tables.import_sources).toHaveLength(1);
});
});
describe("template_id is provenance, never format (#324)", () => {
async function seedLinkedPair() {
const templateId = await createTemplate({
name: "Desjardins carte",
...formatToRow(CREDIT_CARD),
});
await createSource({
name: CREDIT_CARD.name,
...formatToRow(CREDIT_CARD),
template_id: templateId,
});
return templateId;
}
it("records the template the source was configured from", async () => {
const templateId = await seedLinkedPair();
expect((await getSourceByName(CREDIT_CARD.name))!.template_id).toBe(templateId);
});
// The acceptance criterion: the eight source columns are authoritative, so a
// template edit cannot reach through the link and change how a source reads.
it("editing the template alters no linked source", async () => {
const templateId = await seedLinkedPair();
await updateTemplate(templateId, {
name: "Desjardins carte",
...formatToRow({
...CREDIT_CARD,
delimiter: "\t",
encoding: "utf-8",
dateFormat: "DD/MM/YYYY",
skipLines: 0,
hasHeader: true,
columnMapping: { date: 0, description: 1, debitAmount: 2, creditAmount: 3 },
amountMode: "debit_credit",
signConvention: "negative_expense",
}),
});
expectSameFormat(await reload(CREDIT_CARD.name), CREDIT_CARD);
// …and the template really did change, so the test is not vacuous.
const [stored] = await getAllTemplates();
expect(formatFromRow(stored).signConvention).toBe("negative_expense");
expect(formatFromRow(stored).amountMode).toBe("debit_credit");
});
it("survives a re-import that re-saves the format", async () => {
const templateId = await seedLinkedPair();
const id = (await getSourceByName(CREDIT_CARD.name))!.id;
const current = await reload(CREDIT_CARD.name);
await updateSource(id, {
name: current.name,
...formatToRow(current),
template_id: templateId,
});
expect((await getSourceByName(CREDIT_CARD.name))!.template_id).toBe(templateId);
});
});

View file

@ -199,18 +199,20 @@ export default function StepSimulate({
</p> </p>
</div> </div>
</div> </div>
<ul className="text-sm space-y-1"> <ul className="space-y-1">
{plan.preserved.map((row) => ( {plan.preserved.map((row) => (
<li <li key={row.v2CategoryId}>
key={row.v2CategoryId} <MappingRow
className="px-3 py-1.5 rounded-md bg-[var(--muted)] text-[var(--foreground)]" row={row}
> isSelected={selectedRowV2Id === row.v2CategoryId}
<span className="font-medium">{row.v2CategoryName}</span> onSelect={onSelectRow}
<span className="text-xs text-[var(--muted-foreground)] ml-2"> onResolve={onResolveRow}
{t("categoriesSeed.migration.simulate.preserved.txCount", { transactionCount={
count: transactionCountByV2Id.get(row.v2CategoryId) ?? 0, transactionCountByV2Id.get(row.v2CategoryId) ?? 0
})} }
</span> targetCategories={targetCategories}
resolveTarget={resolveTarget}
/>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -1,12 +1,19 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ColumnMapping, AmountMode } from "../../shared/types"; import type { ColumnMapping, AmountMode } from "../../shared/types";
import { clearMappingForMode } from "../../utils/importFormat";
interface ColumnMappingEditorProps { interface ColumnMappingEditorProps {
headers: string[]; headers: string[];
mapping: ColumnMapping; mapping: ColumnMapping;
amountMode: AmountMode; amountMode: AmountMode;
onMappingChange: (mapping: ColumnMapping) => void; onMappingChange: (mapping: ColumnMapping) => void;
onAmountModeChange: (mode: AmountMode) => void; /**
* The mode carries the pruned mapping with it. Both have to land in a single
* state update: the parent's handlers each spread the same `config` prop, so
* two consecutive calls would see the same stale value and the second would
* overwrite the first.
*/
onAmountModeChange: (mode: AmountMode, mapping: ColumnMapping) => void;
} }
export default function ColumnMappingEditor({ export default function ColumnMappingEditor({
@ -18,6 +25,12 @@ export default function ColumnMappingEditor({
}: ColumnMappingEditorProps) { }: ColumnMappingEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// The mode is the source of truth for the mapping, not the reverse: leaving a
// mode drops its columns so a stale key cannot keep deciding how amounts are
// read (#324).
const selectMode = (mode: AmountMode) =>
onAmountModeChange(mode, clearMappingForMode(mapping, mode));
const columnOptions = headers.map((h, i) => ( const columnOptions = headers.map((h, i) => (
<option key={i} value={i}> <option key={i} value={i}>
{i}: {h} {i}: {h}
@ -79,7 +92,7 @@ export default function ColumnMappingEditor({
name="amountMode" name="amountMode"
value="single" value="single"
checked={amountMode === "single"} checked={amountMode === "single"}
onChange={() => onAmountModeChange("single")} onChange={() => selectMode("single")}
className="accent-[var(--primary)]" className="accent-[var(--primary)]"
/> />
{t("import.config.singleAmount")} {t("import.config.singleAmount")}
@ -90,7 +103,7 @@ export default function ColumnMappingEditor({
name="amountMode" name="amountMode"
value="debit_credit" value="debit_credit"
checked={amountMode === "debit_credit"} checked={amountMode === "debit_credit"}
onChange={() => onAmountModeChange("debit_credit")} onChange={() => selectMode("debit_credit")}
className="accent-[var(--primary)]" className="accent-[var(--primary)]"
/> />
{t("import.config.debitCredit")} {t("import.config.debitCredit")}

View file

@ -1,61 +0,0 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import FilePreviewTable from "./FilePreviewTable";
import type { ParsedRow } from "../../shared/types";
interface FilePreviewModalProps {
rows: ParsedRow[];
totalCount: number;
onClose: () => void;
}
export default function FilePreviewModal({
rows,
totalCount,
onClose,
}: FilePreviewModalProps) {
const { t } = useTranslation();
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold">{t("import.preview.title")}</h2>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-6">
<FilePreviewTable rows={rows} />
{totalCount > rows.length && (
<p className="text-sm text-[var(--muted-foreground)] text-center mt-4">
{t("import.preview.moreRows", {
count: totalCount - rows.length,
})}
</p>
)}
</div>
</div>
</div>,
document.body
);
}

View file

@ -1,15 +1,25 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertCircle } from "lucide-react"; import { AlertCircle, ArrowDownLeft, ArrowUpRight, RefreshCw } from "lucide-react";
import type { ParsedRow } from "../../shared/types"; import type { ParsedRow } from "../../shared/types";
import { isRowErrorKey, summarizeParsedRows } from "../../utils/importFormat";
import RepairPathNotice from "./RepairPathNotice";
/** Rows rendered in the table. The recap above it always covers the whole file. */
const DISPLAYED_ROWS = 20;
interface FilePreviewTableProps { interface FilePreviewTableProps {
/** ALL parsed rows — the recap is meaningless on a truncated sample. */
rows: ParsedRow[]; rows: ParsedRow[];
onFlipSigns?: () => void;
isFlipping?: boolean;
} }
export default function FilePreviewTable({ export default function FilePreviewTable({
rows, rows,
onFlipSigns,
isFlipping = false,
}: FilePreviewTableProps) { }: FilePreviewTableProps) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
@ -19,7 +29,17 @@ export default function FilePreviewTable({
); );
} }
const errorCount = rows.filter((r) => r.error).length; const totals = summarizeParsedRows(rows);
const displayedRows = rows.slice(0, DISPLAYED_ROWS);
const currency = new Intl.NumberFormat(
i18n.language === "fr" ? "fr-CA" : "en-CA",
{ style: "currency", currency: "CAD" }
);
// Row errors are i18n keys (#325); anything else reaches us verbatim.
const errorText = (error: string) =>
isRowErrorKey(error) ? t(error) : error;
return ( return (
<div> <div>
@ -31,15 +51,87 @@ export default function FilePreviewTable({
<span className="text-[var(--muted-foreground)]"> <span className="text-[var(--muted-foreground)]">
{t("import.preview.rowCount", { count: rows.length })} {t("import.preview.rowCount", { count: rows.length })}
</span> </span>
{errorCount > 0 && ( {totals.errorCount > 0 && (
<span className="flex items-center gap-1 text-[var(--negative)]"> <span className="flex items-center gap-1 text-[var(--negative)]">
<AlertCircle size={14} /> <AlertCircle size={14} />
{t("import.preview.errorCount", { count: errorCount })} {t("import.preview.errorCount", { count: totals.errorCount })}
</span> </span>
)} )}
</div> </div>
</div> </div>
{/*
The signed recap (#329) the last control before the database, and the
only one that looks at what the amounts MEAN. A statement showing zero
inflows next to a payroll line is wrong on its face, whatever produced
it. Computed over every row, never over the twenty displayed below.
*/}
<div className="mb-4 p-4 rounded-xl bg-[var(--card)] border border-[var(--border)]">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 flex-1 min-w-[16rem]">
<div>
<p className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<ArrowDownLeft size={14} className="text-[var(--negative)]" />
{t("import.preview.outflowCount", { count: totals.outflowCount })}
</p>
<p className="font-mono text-sm text-[var(--negative)]">
{currency.format(totals.outflowTotal)}
</p>
</div>
<div>
<p className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<ArrowUpRight size={14} className="text-[var(--positive)]" />
{t("import.preview.inflowCount", { count: totals.inflowCount })}
</p>
<p className="font-mono text-sm text-[var(--positive)]">
{currency.format(totals.inflowTotal)}
</p>
</div>
<div>
<p className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<AlertCircle size={14} />
{t("import.preview.errorRows")}
</p>
<p
className={`font-mono text-sm ${
totals.errorCount > 0
? "text-[var(--negative)]"
: "text-[var(--muted-foreground)]"
}`}
>
{totals.errorCount}
</p>
</div>
</div>
{onFlipSigns && (
<div className="text-right max-w-xs">
<button
onClick={onFlipSigns}
disabled={isFlipping}
className="flex items-center gap-1 px-3 py-2 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw size={14} />
{t("import.preview.flipSigns")}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("import.preview.flipSignsHint")}
</p>
</div>
)}
</div>
{/*
The repair path (#330), stated where the correction is offered. A user
who flips the signs here has just learned that earlier imports of the
same source read backwards; re-importing those files is the natural
next move and it double-books every row instead of fixing them.
*/}
<div className="mt-4">
<RepairPathNotice />
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-[var(--border)]"> <div className="overflow-x-auto rounded-xl border border-[var(--border)]">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@ -62,7 +154,7 @@ export default function FilePreviewTable({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--border)]"> <tbody className="divide-y divide-[var(--border)]">
{rows.map((row) => ( {displayedRows.map((row) => (
<tr <tr
key={row.rowIndex} key={row.rowIndex}
className={ className={
@ -77,7 +169,7 @@ export default function FilePreviewTable({
<td className="px-3 py-2"> <td className="px-3 py-2">
{row.parsed?.date || ( {row.parsed?.date || (
<span className="text-[var(--negative)] text-xs"> <span className="text-[var(--negative)] text-xs">
{row.error || "—"} {row.error ? errorText(row.error) : "—"}
</span> </span>
)} )}
</td> </td>
@ -104,6 +196,14 @@ export default function FilePreviewTable({
</tbody> </tbody>
</table> </table>
</div> </div>
{rows.length > displayedRows.length && (
<p className="text-sm text-[var(--muted-foreground)] text-center mt-4">
{t("import.preview.moreRows", {
count: rows.length - displayedRows.length,
})}
</p>
)}
</div> </div>
); );
} }

View file

@ -0,0 +1,133 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowRight, Minus, Plus } from "lucide-react";
import type { HeaderDriftEntry } from "../../utils/bankSignatures";
import RepairPathNotice from "./RepairPathNotice";
/**
* The header row of this source changed since its last successful import
* (#330).
*
* This is the failure mode the whole chantier is most afraid of, because it is
* the one nobody sees: a bank moves a column, the stored mapping keeps reading
* position 3, and the import succeeds with the balance in the amount column,
* for as long as it takes someone to notice. There is no signal in the totals,
* no error, no red row. The only signal is the header itself, which is why it
* is recorded and compared.
*
* The panel does not decide. It shows what moved, column by column, and offers
* the two answers that exist: read the file the way it looks now, or keep
* reading it the way it used to. Nothing here writes to the database.
*/
interface FormatDriftPanelProps {
entries: HeaderDriftEntry[];
/** False when detection could not read the new shape — nothing to adopt. */
canAdopt: boolean;
onAdopt: () => void;
onKeep: () => void;
isBusy?: boolean;
}
export default function FormatDriftPanel({
entries,
canAdopt,
onAdopt,
onKeep,
isBusy = false,
}: FormatDriftPanelProps) {
const { t } = useTranslation();
const buttonClass =
"px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
return (
<div className="p-4 rounded-xl bg-[var(--card)] border-2 border-[var(--accent)] space-y-4">
<div className="flex items-start gap-2">
<AlertTriangle
size={18}
className="text-[var(--accent)] shrink-0 mt-0.5"
/>
<div>
<h3 className="text-sm font-semibold text-[var(--foreground)]">
{t("import.drift.title")}
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-0.5">
{t("import.drift.intro")}
</p>
</div>
</div>
{/*
Column by column, naming the columns. This is what the stored signature
being a label list rather than a hash buys: a hash can say "something
changed" and nothing more.
*/}
<ul className="space-y-1 text-sm font-mono">
{entries.map((entry) => (
<li
key={`${entry.kind}-${entry.normalizedLabel}`}
className="flex items-center gap-2 text-[var(--foreground)]"
>
{entry.kind === "moved" && (
<ArrowRight size={14} className="text-[var(--accent)] shrink-0" />
)}
{entry.kind === "added" && (
<Plus size={14} className="text-[var(--positive)] shrink-0" />
)}
{entry.kind === "removed" && (
<Minus size={14} className="text-[var(--negative)] shrink-0" />
)}
<span>
{entry.kind === "moved" &&
t("import.drift.columnMoved", {
label: entry.label,
from: entry.previousIndex,
to: entry.currentIndex,
})}
{entry.kind === "added" &&
t("import.drift.columnAdded", {
label: entry.label,
to: entry.currentIndex,
})}
{entry.kind === "removed" &&
t("import.drift.columnRemoved", {
label: entry.label,
from: entry.previousIndex,
})}
</span>
</li>
))}
</ul>
<RepairPathNotice />
<div className="flex flex-wrap items-start gap-4">
<div>
<button
onClick={onAdopt}
disabled={!canAdopt || isBusy}
className={`${buttonClass} bg-[var(--primary)] text-white hover:opacity-90`}
>
{t("import.drift.adopt")}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)] max-w-xs">
{canAdopt
? t("import.drift.adoptHint")
: t("import.drift.adoptUnavailable")}
</p>
</div>
<div>
<button
onClick={onKeep}
disabled={isBusy}
className={`${buttonClass} border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]`}
>
{t("import.drift.keep")}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)] max-w-xs">
{t("import.drift.keepHint")}
</p>
</div>
</div>
</div>
);
}

View file

@ -5,6 +5,8 @@ import type { SourceConfig, ScannedFile, DuplicateCheckResult } from "../../shar
interface ImportConfirmationProps { interface ImportConfirmationProps {
sourceName: string; sourceName: string;
config: SourceConfig; config: SourceConfig;
/** Header labels of the parsed file, used to name the mapped columns. */
headers?: string[];
selectedFiles: ScannedFile[]; selectedFiles: ScannedFile[];
duplicateResult: DuplicateCheckResult; duplicateResult: DuplicateCheckResult;
excludedCount: number; excludedCount: number;
@ -13,6 +15,7 @@ interface ImportConfirmationProps {
export default function ImportConfirmation({ export default function ImportConfirmation({
sourceName, sourceName,
config, config,
headers = [],
selectedFiles, selectedFiles,
duplicateResult, duplicateResult,
excludedCount, excludedCount,
@ -22,6 +25,32 @@ export default function ImportConfirmation({
const rowsToImport = const rowsToImport =
duplicateResult.newRows.length + duplicateResult.duplicateRows.length - excludedCount; duplicateResult.newRows.length + duplicateResult.duplicateRows.length - excludedCount;
/**
* A mapped column, named by its header when the file has one. An index alone
* ("2") tells the reader nothing about whether the right column was picked.
*/
const columnLabel = (index: number | undefined): string => {
if (index === undefined) return t("import.confirm.columnUnmapped");
const header = headers[index]?.trim();
return header ? `${index}${header}` : String(index);
};
// The mapping as the amount mode actually reads it: naming a debit column on
// a single-amount format would describe an import that is not happening.
const mappedColumns: Array<[string, number | undefined]> =
config.amountMode === "debit_credit"
? [
["import.config.dateColumn", config.columnMapping.date],
["import.config.descriptionColumn", config.columnMapping.description],
["import.config.debitColumn", config.columnMapping.debitAmount],
["import.config.creditColumn", config.columnMapping.creditAmount],
]
: [
["import.config.dateColumn", config.columnMapping.date],
["import.config.descriptionColumn", config.columnMapping.description],
["import.config.amountColumn", config.columnMapping.amount],
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
@ -73,6 +102,47 @@ export default function ImportConfirmation({
<span className="font-medium">{t("import.config.skipLines")}:</span>{" "} <span className="font-medium">{t("import.config.skipLines")}:</span>{" "}
{config.skipLines} {config.skipLines}
</div> </div>
{/*
The amount mode and the sign convention (#329). They decide what
every amount MEANS, and they were the two settings this last
checkpoint did not show the most fragile pair, invisible.
*/}
<div>
<span className="font-medium">{t("import.config.amountMode")}:</span>{" "}
{config.amountMode === "debit_credit"
? t("import.config.debitCredit")
: t("import.config.singleAmount")}
</div>
{/*
Single-amount mode only: `mapRow` computes `credit - debit` on
magnitudes in debit/credit mode and never reads the convention
there, so stating one would describe a rule that is not applied.
*/}
{config.amountMode === "single" && (
<div>
<span className="font-medium">
{t("import.config.signConvention")}:
</span>{" "}
{config.signConvention === "positive_expense"
? t("import.config.positiveExpense")
: t("import.config.negativeExpense")}
</div>
)}
</div>
</div>
{/* Column mapping */}
<div className="p-4">
<p className="text-sm font-medium mb-2">
{t("import.config.columnMapping")}
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-[var(--muted-foreground)]">
{mappedColumns.map(([labelKey, index]) => (
<div key={labelKey}>
<span className="font-medium">{t(labelKey)}:</span>{" "}
{columnLabel(index)}
</div>
))}
</div> </div>
</div> </div>

View file

@ -7,6 +7,7 @@ import {
FileText, FileText,
} from "lucide-react"; } from "lucide-react";
import type { ImportReport } from "../../shared/types"; import type { ImportReport } from "../../shared/types";
import { isRowErrorKey } from "../../utils/importFormat";
interface ImportReportPanelProps { interface ImportReportPanelProps {
report: ImportReport; report: ImportReport;
@ -104,7 +105,11 @@ export default function ImportReportPanel({
{report.errors.map((err, i) => ( {report.errors.map((err, i) => (
<tr key={i}> <tr key={i}>
<td className="px-3 py-2">{err.rowIndex + 1}</td> <td className="px-3 py-2">{err.rowIndex + 1}</td>
<td className="px-3 py-2 text-[var(--negative)]">{err.message}</td> <td className="px-3 py-2 text-[var(--negative)]">
{/* Row errors are i18n keys (#325); an exception message
that reached this list is NOT one and stays verbatim. */}
{isRowErrorKey(err.message) ? t(err.message) : err.message}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View file

@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { LifeBuoy } from "lucide-react";
/**
* The only safe way to repair an import that was already written wrong (#330).
*
* WHY THIS IS IN THE INTERFACE AND NOT ONLY IN A RISK TABLE. Every correction
* this chantier delivers the restored format (#324), the anchored amount
* parser (#325), the sign flip and the drift panel changes the amounts a
* source PRODUCES. The natural next move is to re-import the file "now that it
* reads right", and that move silently doubles the ledger:
* `findDuplicates` (`transactionService.ts:164`) matches on
* `date AND description AND amount`, so a corrected row does not match the
* wrong row already stored. It is filed as new.
*
* A flipped sign is the worst shape of it: the wrong row and the corrected row
* are mirror images, they net to about zero in every report, and nothing on any
* screen looks off. The only path that leaves a correct ledger is deleting the
* faulty import from the history first `deleteImportWithTransactions` takes
* its transactions with it and replaying the file afterwards.
*
* Shown in both places a user can be about to do exactly that: the preview,
* next to the sign flip, and the format-drift panel.
*/
export default function RepairPathNotice() {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-[var(--muted)] border border-[var(--border)]">
<LifeBuoy
size={14}
className="text-[var(--muted-foreground)] shrink-0 mt-0.5"
/>
<div>
<p className="text-xs font-semibold text-[var(--foreground)]">
{t("import.repairPath.title")}
</p>
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
{t("import.repairPath.body")}
</p>
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wand2, Check, Save, X } from "lucide-react"; import { Wand2, Check, Save, X, AlertTriangle } from "lucide-react";
import type { import type {
ScannedSource, ScannedSource,
ScannedFile, ScannedFile,
@ -9,6 +9,11 @@ import type {
ColumnMapping, ColumnMapping,
ImportConfigTemplate, ImportConfigTemplate,
} from "../../shared/types"; } from "../../shared/types";
import type { DetectionScore } from "../../utils/csvAutoDetect";
import {
bankSignatureById,
type BankSignatureId,
} from "../../utils/bankSignatures";
import ColumnMappingEditor from "./ColumnMappingEditor"; import ColumnMappingEditor from "./ColumnMappingEditor";
interface SourceConfigPanelProps { interface SourceConfigPanelProps {
@ -27,6 +32,10 @@ interface SourceConfigPanelProps {
onUpdateTemplate: () => void; onUpdateTemplate: () => void;
onDeleteTemplate: (id: number) => void; onDeleteTemplate: (id: number) => void;
selectedTemplateId: number | null; selectedTemplateId: number | null;
/** Result of the last detection run, or null when none ran on this source. */
detectionScore?: DetectionScore | null;
/** Bank recognised by signature during that same run, or null (#330). */
detectedBank?: BankSignatureId | null;
isLoading?: boolean; isLoading?: boolean;
} }
@ -46,12 +55,24 @@ export default function SourceConfigPanel({
onUpdateTemplate, onUpdateTemplate,
onDeleteTemplate, onDeleteTemplate,
selectedTemplateId, selectedTemplateId,
detectionScore,
detectedBank,
isLoading, isLoading,
}: SourceConfigPanelProps) { }: SourceConfigPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showSaveTemplate, setShowSaveTemplate] = useState(false); const [showSaveTemplate, setShowSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState(""); const [templateName, setTemplateName] = useState("");
/*
A recognised bank replaces the generic wording, but ONLY on the confident
arm (#330). "Format Desjardins reconnu" over a file two thirds of whose
rows would not read is a claim the app cannot back: the label matched, the
data did not follow, and the uncertain wording is the one that helps.
*/
const bank = detectionScore?.confident
? bankSignatureById(detectedBank)
: null;
const selectClass = const selectClass =
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"; "w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]";
const inputClass = selectClass; const inputClass = selectClass;
@ -76,6 +97,60 @@ export default function SourceConfigPanel({
</button> </button>
</div> </div>
{/*
Detection result. Present only when a detection actually ran on THIS
source a source opened on its stored format shows nothing, because
that format was chosen once and is not being re-guessed.
The threshold colours the banner and nothing else: both arms leave every
control editable and every button enabled. A low score is a reason to
look at the preview, not a refusal to import.
*/}
{detectionScore && (
<div
className={`p-3 rounded-xl bg-[var(--card)] border-2 flex items-start gap-2 ${
detectionScore.confident
? "border-[var(--border)]"
: "border-[var(--accent)]"
}`}
>
{detectionScore.confident ? (
<Check size={16} className="text-[var(--positive)] shrink-0 mt-0.5" />
) : (
<AlertTriangle
size={16}
className="text-[var(--accent)] shrink-0 mt-0.5"
/>
)}
<div>
<p className="text-sm text-[var(--foreground)]">
{bank
? t("import.config.detectionBank", {
// A bank name is a proper noun: it is interpolated, never
// translated.
bank: bank.label,
read: detectionScore.readRows,
total: detectionScore.totalRows,
})
: t(
detectionScore.confident
? "import.config.detectionRecognized"
: "import.config.detectionUncertain",
{
read: detectionScore.readRows,
total: detectionScore.totalRows,
}
)}
</p>
{!detectionScore.confident && (
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
{t("import.config.detectionUncertainHint")}
</p>
)}
</div>
</div>
)}
{/* Template row */} {/* Template row */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-1 min-w-[200px]"> <div className="flex items-center gap-2 flex-1 min-w-[200px]">
@ -270,7 +345,16 @@ export default function SourceConfigPanel({
</div> </div>
</div> </div>
{/* Sign convention */} {/*
Sign convention single-amount mode ONLY.
`mapRow` computes `credit - debit` on magnitudes in debit/credit mode and
never reads `signConvention` there; the direction is carried by which
column holds the value. Showing the selector anyway offered a control
that changed nothing, which reads as "the app ignored my setting".
The stored value is left untouched: hidden, not reset.
*/}
{config.amountMode === "single" && (
<div> <div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1"> <label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.signConvention")} {t("import.config.signConvention")}
@ -304,6 +388,7 @@ export default function SourceConfigPanel({
</label> </label>
</div> </div>
</div> </div>
)}
{/* Column mapping */} {/* Column mapping */}
{headers.length > 0 && ( {headers.length > 0 && (
@ -314,8 +399,8 @@ export default function SourceConfigPanel({
onMappingChange={(mapping: ColumnMapping) => onMappingChange={(mapping: ColumnMapping) =>
onConfigChange({ ...config, columnMapping: mapping }) onConfigChange({ ...config, columnMapping: mapping })
} }
onAmountModeChange={(mode: AmountMode) => onAmountModeChange={(mode: AmountMode, mapping: ColumnMapping) =>
onConfigChange({ ...config, amountMode: mode }) onConfigChange({ ...config, amountMode: mode, columnMapping: mapping })
} }
/> />
)} )}

View file

@ -11,11 +11,14 @@ import {
Wallet, Wallet,
Settings, Settings,
Languages, Languages,
Lock,
Moon, Moon,
Sun, Sun,
} from "lucide-react"; } from "lucide-react";
import { NAV_ITEMS, APP_NAME } from "../../shared/constants"; import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
import { useEntitlement } from "../../hooks/useEntitlement";
import { useTheme } from "../../hooks/useTheme"; import { useTheme } from "../../hooks/useTheme";
import type { FeatureKey } from "../../shared/entitlements";
import ProfileSwitcher from "../profile/ProfileSwitcher"; import ProfileSwitcher from "../profile/ProfileSwitcher";
const iconMap: Record<string, React.ComponentType<{ size?: number }>> = { const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
@ -30,6 +33,31 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
Settings, Settings,
}; };
/**
* Lock badge next to a gated nav item. Rendered as a child component so the
* useEntitlement hook runs at a component top level (never inside the
* NAV_ITEMS.map callback). Shown ONLY when the license is `ready` AND the
* feature is not allowed never during boot, so a paying user sees no
* "locked" flash. The item itself stays clickable (route shows the upsell).
*/
function NavLock({ feature }: { feature: FeatureKey }) {
const { t } = useTranslation();
const { allowed, ready } = useEntitlement(feature);
if (!ready || allowed) return null;
return (
<span
className="ml-auto opacity-60"
title={t("nav.locked")}
aria-label={t("nav.locked")}
role="img"
>
<Lock size={14} aria-hidden="true" />
</span>
);
}
export default function Sidebar() { export default function Sidebar() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
@ -64,6 +92,7 @@ export default function Sidebar() {
> >
{Icon && <Icon size={18} />} {Icon && <Icon size={18} />}
<span>{t(item.labelKey)}</span> <span>{t(item.labelKey)}</span>
{item.feature && <NavLock feature={item.feature} />}
</NavLink> </NavLink>
); );
})} })}

View file

@ -1,7 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { X, Trash2, Lock, LockOpen, Plus } from "lucide-react"; import { X, Trash2, Lock, LockOpen, Plus, ShoppingCart } from "lucide-react";
import { useProfile } from "../../contexts/ProfileContext"; import { useProfile } from "../../contexts/ProfileContext";
import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor } from "../../shared/entitlements";
import { isProfileCreationLocked } from "../../shared/profileGate";
const PRESET_COLORS = [ const PRESET_COLORS = [
"#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6", "#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6",
@ -16,12 +19,20 @@ interface Props {
export default function ProfileFormModal({ onClose, editProfileId }: Props) { export default function ProfileFormModal({ onClose, editProfileId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, createProfile, updateProfile, deleteProfile, setPin } = useProfile(); const { profiles, createProfile, updateProfile, deleteProfile, setPin } = useProfile();
// Multi-profile gate (Base+, #300) — this modal is the SINGLE creation
// point (`createProfile`), reached from ProfileSwitcher AND
// ProfileSelectionPage, so gating here covers both entries. Managing
// existing profiles (rename/PIN/delete) stays available: the gate is
// non-destructive and only blocks creating a profile beyond the first.
const gate = useEntitlement("multi-profile");
const creationLocked = isProfileCreationLocked(profiles.length, gate);
const upsellTier = t(`license.editions.${requiredTierFor("multi-profile")}`);
const editProfile = editProfileId const editProfile = editProfileId
? profiles.find((p) => p.id === editProfileId) ? profiles.find((p) => p.id === editProfileId)
: null; : null;
const [mode, setMode] = useState<"list" | "create" | "edit">( const [mode, setMode] = useState<"list" | "create" | "edit" | "upsell">(
editProfileId ? "edit" : "list" editProfileId ? "edit" : "list"
); );
const [selectedId, setSelectedId] = useState<string | null>(editProfileId ?? null); const [selectedId, setSelectedId] = useState<string | null>(editProfileId ?? null);
@ -31,6 +42,10 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const handleCreate = () => { const handleCreate = () => {
if (creationLocked) {
setMode("upsell");
return;
}
setMode("create"); setMode("create");
setName(""); setName("");
setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]); setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]);
@ -50,6 +65,13 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
const handleSave = async () => { const handleSave = async () => {
if (!name.trim()) return; if (!name.trim()) return;
// Race guard: the create form is reachable while the license is still
// loading (`!ready` shows no lock, anti-flash). If the gate became active
// in the meantime, never call createProfile — show the upsell instead.
if (mode === "create" && creationLocked) {
setMode("upsell");
return;
}
setSaving(true); setSaving(true);
try { try {
if (mode === "create") { if (mode === "create") {
@ -89,7 +111,7 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]"> <div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]">
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]"> <div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
<h2 className="font-semibold text-[var(--foreground)]"> <h2 className="font-semibold text-[var(--foreground)]">
{mode === "create" {mode === "create" || mode === "upsell"
? t("profile.create") ? t("profile.create")
: mode === "edit" : mode === "edit"
? t("profile.edit") ? t("profile.edit")
@ -142,12 +164,54 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
))} ))}
<button <button
onClick={handleCreate} onClick={handleCreate}
className="flex items-center gap-2 w-full p-3 rounded-lg border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] text-[var(--muted-foreground)] text-sm transition-colors" title={creationLocked ? t("nav.locked") : undefined}
className={`flex items-center gap-2 w-full p-3 rounded-lg border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] text-[var(--muted-foreground)] text-sm transition-colors ${
creationLocked ? "opacity-60" : ""
}`}
> >
<Plus size={16} /> {/* Locked-not-hidden: the entry stays visible with a lock; the
click leads to the upsell panel (spec decision). */}
{creationLocked ? <Lock size={16} /> : <Plus size={16} />}
{t("profile.create")} {t("profile.create")}
</button> </button>
</div> </div>
) : mode === "upsell" ? (
/* Compact upsell panel — NOT the shared <UpsellGate/>: this modal
also opens from ProfileSelectionPage, which renders OUTSIDE
BrowserRouter (App.tsx mounts the router only once a profile is
active), so UpsellGate's unconditional useNavigate would throw.
Same i18n keys; the "I already have a key" CTA is omitted here
the ProfileSwitcher upsell dialog (always in-router) exposes it. */
<div className="space-y-4 py-2 text-center">
<Lock className="mx-auto h-12 w-12 text-[var(--muted-foreground)]" />
<div className="space-y-1">
<h3 className="font-semibold text-[var(--foreground)]">
{t("upsell.title", { tier: upsellTier })}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("upsell.features.multi-profile")}
</p>
</div>
<div>
<button
type="button"
disabled
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] opacity-50 cursor-not-allowed"
>
<ShoppingCart className="h-4 w-4" />
{t("upsell.ctaGet", { tier: upsellTier })}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("upsell.ctaGetSoon")}
</p>
</div>
<button
onClick={() => setMode("list")}
className="w-full px-4 py-2 rounded-lg border border-[var(--border)] text-sm text-[var(--foreground)] hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
</div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>

View file

@ -1,7 +1,11 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown, Lock, Settings } from "lucide-react"; import { ChevronDown, Lock, Settings, X } from "lucide-react";
import { useProfile } from "../../contexts/ProfileContext"; import { useProfile } from "../../contexts/ProfileContext";
import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor } from "../../shared/entitlements";
import { isProfileSwitchLocked } from "../../shared/profileGate";
import UpsellGate from "../shared/UpsellGate";
import PinDialog from "./PinDialog"; import PinDialog from "./PinDialog";
import ProfileFormModal from "./ProfileFormModal"; import ProfileFormModal from "./ProfileFormModal";
import type { Profile } from "../../services/profileService"; import type { Profile } from "../../services/profileService";
@ -9,9 +13,14 @@ import type { Profile } from "../../services/profileService";
export default function ProfileSwitcher() { export default function ProfileSwitcher() {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, activeProfile, switchProfile, updateProfile } = useProfile(); const { profiles, activeProfile, switchProfile, updateProfile } = useProfile();
// Multi-profile gate (Base+, #300): profiles beyond the active one are
// LOCKED for a Free user — non-destructive, switching is blocked but nothing
// is removed from profiles.json. No lock while `!ready` (anti-flash).
const gate = useEntitlement("multi-profile");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pinProfile, setPinProfile] = useState<Profile | null>(null); const [pinProfile, setPinProfile] = useState<Profile | null>(null);
const [showManage, setShowManage] = useState(false); const [showManage, setShowManage] = useState(false);
const [showUpsell, setShowUpsell] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// Close on outside click // Close on outside click
@ -25,10 +34,18 @@ export default function ProfileSwitcher() {
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, [open]); }, [open]);
const isLocked = (profile: Profile) =>
isProfileSwitchLocked(profile.id, activeProfile?.id ?? null, gate);
const handleSelect = (profile: Profile) => { const handleSelect = (profile: Profile) => {
setOpen(false); setOpen(false);
if (profile.id === activeProfile?.id) return; if (profile.id === activeProfile?.id) return;
if (isLocked(profile)) {
setShowUpsell(true);
return;
}
if (profile.pin_hash) { if (profile.pin_hash) {
setPinProfile(profile); setPinProfile(profile);
} else { } else {
@ -67,24 +84,34 @@ export default function ProfileSwitcher() {
{open && ( {open && (
<div className="absolute left-3 right-3 top-full mt-1 z-50 rounded-lg bg-[var(--sidebar-bg)] border border-white/10 shadow-lg overflow-hidden"> <div className="absolute left-3 right-3 top-full mt-1 z-50 rounded-lg bg-[var(--sidebar-bg)] border border-white/10 shadow-lg overflow-hidden">
{profiles.map((profile) => ( {profiles.map((profile) => {
const locked = isLocked(profile);
return (
<button <button
key={profile.id} key={profile.id}
onClick={() => handleSelect(profile)} onClick={() => handleSelect(profile)}
title={locked ? t("nav.locked") : undefined}
className={`flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors ${ className={`flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors ${
profile.id === activeProfile?.id profile.id === activeProfile?.id
? "bg-[var(--sidebar-active)] text-white" ? "bg-[var(--sidebar-active)] text-white"
: "hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]" : "hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
}`} } ${locked ? "opacity-60" : ""}`}
> >
<span <span
className="w-2.5 h-2.5 rounded-full flex-shrink-0" className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: profile.color }} style={{ backgroundColor: profile.color }}
/> />
<span className="truncate flex-1 text-left">{profile.name}</span> <span className="truncate flex-1 text-left">{profile.name}</span>
{profile.pin_hash && <Lock size={12} className="opacity-50" />} {/* One lock only: the gating lock replaces the PIN lock on
locked rows (the PIN dialog is unreachable there anyway). */}
{locked ? (
<Lock size={12} />
) : (
profile.pin_hash && <Lock size={12} className="opacity-50" />
)}
</button> </button>
))} );
})}
<button <button
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
@ -111,6 +138,28 @@ export default function ProfileSwitcher() {
{showManage && ( {showManage && (
<ProfileFormModal onClose={() => setShowManage(false)} /> <ProfileFormModal onClose={() => setShowManage(false)} />
)} )}
{showUpsell && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]">
<div className="flex justify-end p-3 pb-0">
<button
onClick={() => setShowUpsell(false)}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<X size={18} />
</button>
</div>
<div className="px-4 pb-8">
<UpsellGate
feature="multi-profile"
requiredTier={requiredTierFor("multi-profile")}
onNavigate={() => setShowUpsell(false)}
/>
</div>
</div>
</div>
)}
</> </>
); );
} }

View file

@ -69,6 +69,8 @@ const SOURCE_CHEQUING: ImportSource = {
column_mapping: "{}", column_mapping: "{}",
skip_lines: 0, skip_lines: 0,
has_header: true, has_header: true,
amount_mode: "single",
sign_convention: "negative_expense",
created_at: "2026-01-01", created_at: "2026-01-01",
updated_at: "2026-01-01", updated_at: "2026-01-01",
}; };

View file

@ -1,19 +1,44 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Lock } from "lucide-react";
import { useTranslation } from "react-i18next";
export interface HubReportNavCardProps { export interface HubReportNavCardProps {
to: string; to: string;
icon: ReactNode; icon: ReactNode;
title: string; title: string;
description: string; description: string;
/**
* Show a lock badge on the card (gated feature, license ready and not
* entitled). The card stays clickable the gated route renders the upsell.
*/
locked?: boolean;
} }
export default function HubReportNavCard({ to, icon, title, description }: HubReportNavCardProps) { export default function HubReportNavCard({
to,
icon,
title,
description,
locked,
}: HubReportNavCardProps) {
const { t } = useTranslation();
return ( return (
<Link <Link
to={to} to={to}
className="group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all" className="relative group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
> >
{locked && (
<span
className="absolute top-4 right-4 text-[var(--muted-foreground)]"
title={t("nav.locked")}
aria-label={t("nav.locked")}
role="img"
>
<Lock size={16} aria-hidden="true" />
</span>
)}
<div className="text-[var(--primary)]">{icon}</div> <div className="text-[var(--primary)]">{icon}</div>
<h3 className="text-base font-semibold text-[var(--foreground)] group-hover:text-[var(--primary)]"> <h3 className="text-base font-semibold text-[var(--foreground)] group-hover:text-[var(--primary)]">
{title} {title}

View file

@ -17,6 +17,7 @@ import {
Footprints, Footprints,
Printer, Printer,
Users, Users,
KeyRound,
} from "lucide-react"; } from "lucide-react";
const SECTIONS = [ const SECTIONS = [
@ -31,6 +32,7 @@ const SECTIONS = [
{ key: "reports", icon: BarChart3 }, { key: "reports", icon: BarChart3 },
{ key: "balance", icon: Wallet }, { key: "balance", icon: Wallet },
{ key: "settings", icon: Settings }, { key: "settings", icon: Settings },
{ key: "editions", icon: KeyRound },
] as const; ] as const;
export default function DocsContent() { export default function DocsContent() {

View file

@ -69,6 +69,24 @@ export default function ImportConfirmModal({
); );
} }
// The import configurations travel with both transaction modes (#331). They
// are listed so the user sees that a restore now brings its sources back
// rather than leaving every one of them to reconfigure.
if (importType !== "categories_only") {
if (summary.importSourcesCount > 0)
willImport.push(
t("settings.dataManagement.import.countImportSources", {
count: summary.importSourcesCount,
})
);
if (summary.importTemplatesCount > 0)
willImport.push(
t("settings.dataManagement.import.countImportTemplates", {
count: summary.importTemplatesCount,
})
);
}
const confirmWord = t("settings.dataManagement.import.confirmWord"); const confirmWord = t("settings.dataManagement.import.confirmWord");
const canConfirm = confirmText === confirmWord && !isImporting; const canConfirm = confirmText === confirmWord && !isImporting;

View file

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
import { useLicense } from "../../hooks/useLicense"; import { useLicenseContext } from "../../contexts/LicenseContext";
import { import {
MachineInfo, MachineInfo,
ActivationStatus, ActivationStatus,
@ -16,7 +16,8 @@ const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
export default function LicenseCard() { export default function LicenseCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, submitKey } = useLicense(); const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } =
useLicenseContext();
const [keyInput, setKeyInput] = useState(""); const [keyInput, setKeyInput] = useState("");
const [showInput, setShowInput] = useState(false); const [showInput, setShowInput] = useState(false);
const [showMachines, setShowMachines] = useState(false); const [showMachines, setShowMachines] = useState(false);
@ -26,7 +27,7 @@ export default function LicenseCard() {
const [deactivatingId, setDeactivatingId] = useState<string | null>(null); const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
const [machineError, setMachineError] = useState<string | null>(null); const [machineError, setMachineError] = useState<string | null>(null);
const hasLicense = state.edition !== "free"; const hasLicense = edition !== "free";
const loadActivation = useCallback(async () => { const loadActivation = useCallback(async () => {
if (!hasLicense) return; if (!hasLicense) return;
@ -108,7 +109,7 @@ export default function LicenseCard() {
return new Date(timestamp * 1000).toLocaleDateString(); return new Date(timestamp * 1000).toLocaleDateString();
}; };
const editionLabel = t(`license.editions.${state.edition}`); const editionLabel = t(`license.editions.${edition}`);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
@ -124,25 +125,25 @@ export default function LicenseCard() {
</p> </p>
<p className="text-base font-medium"> <p className="text-base font-medium">
{editionLabel} {editionLabel}
{state.edition !== "free" && ( {edition !== "free" && (
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" /> <CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
)} )}
</p> </p>
</div> </div>
{state.info && state.info.expires_at > 0 && ( {info && info.expires_at > 0 && (
<div className="text-right"> <div className="text-right">
<p className="text-xs text-[var(--muted-foreground)]"> <p className="text-xs text-[var(--muted-foreground)]">
{t("license.expiresAt")} {t("license.expiresAt")}
</p> </p>
<p className="text-sm">{formatExpiry(state.info.expires_at)}</p> <p className="text-sm">{formatExpiry(info.expires_at)}</p>
</div> </div>
)} )}
</div> </div>
{state.status === "error" && state.error && ( {licenseStatus === "error" && error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]"> <div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" /> <AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{state.error}</p> <p>{error}</p>
</div> </div>
)} )}
@ -155,7 +156,7 @@ export default function LicenseCard() {
> >
{t("license.enterKey")} {t("license.enterKey")}
</button> </button>
{state.edition === "free" && ( {edition === "free" && (
<button <button
type="button" type="button"
onClick={handlePurchase} onClick={handlePurchase}
@ -178,13 +179,19 @@ export default function LicenseCard() {
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]" className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
autoFocus autoFocus
/> />
{validationError && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{validationError}</p>
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="submit" type="submit"
disabled={state.status === "validating" || !keyInput.trim()} disabled={validating || !keyInput.trim()}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
> >
{state.status === "validating" && <Loader2 size={14} className="animate-spin" />} {validating && <Loader2 size={14} className="animate-spin" />}
{t("license.activate")} {t("license.activate")}
</button> </button>
<button <button

View file

@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import { Outlet } from "react-router-dom";
import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor, type FeatureKey } from "../../shared/entitlements";
import UpsellGate from "./UpsellGate";
interface RequireFeatureProps {
feature: FeatureKey;
children?: ReactNode;
}
/**
* Route guard for gated features (UI-only soft paywall).
*
* While the license is still loading or errored (`!ready`) it renders a
* NEUTRAL loader never the upsell so a paying user never sees a
* "locked" flash at boot or during a transient IPC failure (the retry loop
* lives in LicenseProvider; this component only respects `ready`).
*
* Usable two ways:
* - layout route grouping all routes of one feature (children omitted
* renders <Outlet/>):
* <Route element={<RequireFeature feature="balance" />}> sub-routes
* - explicit wrapper:
* <RequireFeature feature="budget"><BudgetPage /></RequireFeature>
*/
export default function RequireFeature({ feature, children }: RequireFeatureProps) {
const { allowed, ready } = useEntitlement(feature);
if (!ready) {
return (
<div className="flex min-h-full items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
</div>
);
}
if (!allowed) {
return <UpsellGate feature={feature} requiredTier={requiredTierFor(feature)} />;
}
return children !== undefined ? <>{children}</> : <Outlet />;
}

View file

@ -0,0 +1,80 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { KeyRound, Lock, ShoppingCart } from "lucide-react";
import type { Edition } from "../../services/licenseService";
import type { FeatureKey } from "../../shared/entitlements";
interface UpsellGateProps {
feature: FeatureKey;
requiredTier: Edition;
/**
* Called right after a CTA navigates away. Lets modal hosts (e.g. the
* ProfileSwitcher upsell dialog) close themselves the Sidebar stays
* mounted across navigations, so an uncontrolled modal would linger on top
* of the destination page. Optional: route-level usage ignores it.
*/
onNavigate?: () => void;
}
/**
* Full locked screen shown in place of a gated module (soft paywall).
*
* Two CTAs:
* - "Get <tier>" VISIBLE but DISABLED with a "coming soon" note: the online
* purchase flow (#270 / Stripe) is not live yet. It will be wired by #270.
* - "I already have a key" the active flow, navigates to the license card
* at /settings/users.
*
* The tier label reuses the existing `license.editions.*` keys; the feature
* description comes from `upsell.features.<FeatureKey>` (FR/EN).
*/
export default function UpsellGate({ feature, requiredTier, onNavigate }: UpsellGateProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const tier = t(`license.editions.${requiredTier}`);
return (
<div className="flex min-h-full items-center justify-center p-4">
<div className="max-w-md w-full space-y-6 text-center">
<Lock className="mx-auto h-16 w-16 text-[var(--muted-foreground)]" />
<div className="space-y-2">
<h1 className="text-2xl font-bold text-[var(--foreground)]">
{t("upsell.title", { tier })}
</h1>
<p className="text-[var(--muted-foreground)]">
{t(`upsell.features.${feature}`)}
</p>
</div>
<div className="flex flex-col gap-3">
<div>
<button
type="button"
disabled
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] opacity-50 cursor-not-allowed"
>
<ShoppingCart className="h-4 w-4" />
{t("upsell.ctaGet", { tier })}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("upsell.ctaGetSoon")}
</p>
</div>
<button
type="button"
onClick={() => {
navigate("/settings/users");
onNavigate?.();
}}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<KeyRound className="h-4 w-4" />
{t("upsell.ctaHaveKey")}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,102 @@
import { describe, it, expect } from "vitest";
import { licenseReducer, initialLicenseState } from "./LicenseContext";
import type { Edition, LicenseInfo } from "../services/licenseService";
const makeInfo = (edition: Exclude<Edition, "free">): LicenseInfo => ({
edition,
email: "max@example.com",
features: [],
machine_limit: 3,
issued_at: 1,
expires_at: 0,
});
// The CWE-703 retry loop arms exclusively on `status === "error"`, and its
// LOAD_START clears `error`. A rejected key must therefore never re-enter the
// load lifecycle: it would arm the auto refresh, which would wipe the very
// message the user is reading (~1s after submitting an invalid key).
const premiumReady = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
);
describe("licenseReducer — load lifecycle (CWE-703)", () => {
it("LOAD_ERROR preserves the last-known edition and info", () => {
const errored = licenseReducer(premiumReady, { type: "LOAD_ERROR", error: "ipc down" });
expect(errored.status).toBe("error");
expect(errored.error).toBe("ipc down");
expect(errored.edition).toBe("premium");
expect(errored.info).toEqual(makeInfo("premium"));
});
it("LOAD_START clears the load error but not a pending validation error", () => {
const rejected = licenseReducer(
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
const refreshed = licenseReducer(rejected, { type: "LOAD_START" });
expect(refreshed.status).toBe("loading");
expect(refreshed.error).toBeNull();
expect(refreshed.validationError).toBe("invalid key");
});
});
describe("licenseReducer — key validation is orthogonal to the load lifecycle", () => {
it("VALIDATE_ERROR on a ready license keeps status ready (retry never arms)", () => {
const validating = licenseReducer(premiumReady, { type: "VALIDATE_START" });
expect(validating.status).toBe("ready");
expect(validating.validating).toBe(true);
const rejected = licenseReducer(validating, { type: "VALIDATE_ERROR", error: "invalid key" });
expect(rejected.status).toBe("ready");
expect(rejected.validating).toBe(false);
expect(rejected.validationError).toBe("invalid key");
expect(rejected.edition).toBe("premium");
expect(rejected.info).toEqual(makeInfo("premium"));
});
it("VALIDATE_ERROR during a boot error keeps status error (load retry keeps running)", () => {
const bootError = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_ERROR", error: "boot fail" },
);
const rejected = licenseReducer(
licenseReducer(bootError, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
expect(rejected.status).toBe("error");
expect(rejected.error).toBe("boot fail");
expect(rejected.validationError).toBe("invalid key");
expect(rejected.edition).toBe("free");
});
it("VALIDATE_START clears the previous validation error", () => {
const rejected = licenseReducer(
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
const resubmit = licenseReducer(rejected, { type: "VALIDATE_START" });
expect(resubmit.validating).toBe(true);
expect(resubmit.validationError).toBeNull();
});
it("VALIDATE_DONE resets the full state, recovering from a boot error", () => {
const bootError = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_ERROR", error: "boot fail" },
);
const done = licenseReducer(
licenseReducer(bootError, { type: "VALIDATE_START" }),
{ type: "VALIDATE_DONE", info: makeInfo("base") },
);
expect(done).toEqual({
status: "ready",
edition: "base",
info: makeInfo("base"),
error: null,
validating: false,
validationError: null,
});
});
});

View file

@ -0,0 +1,192 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
useRef,
type ReactNode,
} from "react";
import {
getEdition,
readLicense,
storeLicense,
type Edition,
type LicenseInfo,
} from "../services/licenseService";
/**
* Machine-level license context loaded ONCE at boot, above ProfileProvider.
*
* Modeled on ProfileContext (createContext<T | null>, useReducer, consumer hook
* that throws). Mounting above the profile layer means it survives the
* `BrowserRouter key={refreshKey}` remount that a profile switch triggers the
* license is a property of the machine, not of the active profile.
*
* Error recovery (CWE-703): the provider is a SPOF. If the boot invoke throws,
* we expose a NEUTRAL state (previous edition preserved, `status: "error"`) and
* retry with capped exponential backoff. Consumers must render a neutral
* placeholder while `status !== "ready"` never the upsell so a transient IPC
* failure never locks a paying user out.
*
* Key validation is orthogonal to that load lifecycle: a rejected `submitKey`
* leaves the stored license and therefore `status` untouched, and surfaces
* `validationError` instead. The retry loop keys off `status === "error"`, so
* it can never arm on a bad key and auto-clear the message the user is reading,
* and `ready` consumers never regress on a typo'd key.
*/
type LicenseStatus = "idle" | "loading" | "ready" | "error";
interface LicenseState {
status: LicenseStatus;
edition: Edition;
info: LicenseInfo | null;
/** Load-lifecycle error — target of the CWE-703 retry loop. */
error: string | null;
validating: boolean;
/** Key-submission error — never retried, cleared on the next submit. */
validationError: string | null;
}
type LicenseAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
| { type: "LOAD_ERROR"; error: string }
| { type: "VALIDATE_START" }
| { type: "VALIDATE_DONE"; info: LicenseInfo }
| { type: "VALIDATE_ERROR"; error: string };
/** Exported for unit tests only — not part of the context API. */
export const initialLicenseState: LicenseState = {
status: "idle",
edition: "free",
info: null,
error: null,
validating: false,
validationError: null,
};
/** Exported for unit tests only — not part of the context API. */
export function licenseReducer(state: LicenseState, action: LicenseAction): LicenseState {
switch (action.type) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return { ...state, status: "ready", edition: action.edition, info: action.info, error: null };
case "LOAD_ERROR":
// Preserve the last-known edition: an error must not downgrade a paying
// user to the upsell (that is what fail-closed + `ready` guard protect).
return { ...state, status: "error", error: action.error };
case "VALIDATE_START":
return { ...state, validating: true, validationError: null };
case "VALIDATE_DONE":
return {
status: "ready",
edition: action.info.edition,
info: action.info,
error: null,
validating: false,
validationError: null,
};
case "VALIDATE_ERROR":
// Status untouched: "ready" stays ready (no gating flash on a typo'd
// key), and a boot-error retry loop keeps running through the failure.
return { ...state, validating: false, validationError: action.error };
}
}
type SubmitKeyResult =
| { ok: true; info: LicenseInfo }
| { ok: false; error: string };
interface LicenseContextValue {
status: LicenseStatus;
edition: Edition;
features: string[];
info: LicenseInfo | null;
error: string | null;
validating: boolean;
validationError: string | null;
refresh: () => Promise<void>;
submitKey: (key: string) => Promise<SubmitKeyResult>;
}
const LicenseContext = createContext<LicenseContextValue | null>(null);
// Capped exponential backoff for boot-error retries: 1s, 2s, 4s, ... max 30s.
const RETRY_BASE_MS = 1000;
const RETRY_MAX_MS = 30_000;
export function LicenseProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(licenseReducer, initialLicenseState);
const retryAttempt = useRef(0);
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
const [edition, info] = await Promise.all([getEdition(), readLicense()]);
dispatch({ type: "LOAD_DONE", edition, info });
} catch (e) {
dispatch({
type: "LOAD_ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const submitKey = useCallback(async (key: string): Promise<SubmitKeyResult> => {
dispatch({ type: "VALIDATE_START" });
try {
const info = await storeLicense(key);
dispatch({ type: "VALIDATE_DONE", info });
return { ok: true, info };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
dispatch({ type: "VALIDATE_ERROR", error: message });
return { ok: false, error: message };
}
}, []);
// Load once at boot.
useEffect(() => {
void refresh();
}, [refresh]);
// Error recovery: retry with capped exponential backoff (CWE-703). Load
// errors only by construction — validation failures never set status.
useEffect(() => {
if (state.status === "ready") {
retryAttempt.current = 0;
return;
}
if (state.status !== "error") return;
const attempt = retryAttempt.current;
retryAttempt.current = attempt + 1;
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
const timer = window.setTimeout(() => {
void refresh();
}, delay);
return () => window.clearTimeout(timer);
}, [state.status, refresh]);
const value: LicenseContextValue = {
status: state.status,
edition: state.edition,
features: state.info?.features ?? [],
info: state.info,
error: state.error,
validating: state.validating,
validationError: state.validationError,
refresh,
submitKey,
};
return <LicenseContext.Provider value={value}>{children}</LicenseContext.Provider>;
}
export function useLicenseContext(): LicenseContextValue {
const ctx = useContext(LicenseContext);
if (!ctx) throw new Error("useLicenseContext must be used within LicenseProvider");
return ctx;
}

View file

@ -95,6 +95,46 @@ describe("migrationReducer", () => {
expect(resolved.confidence).toBe("medium"); expect(resolved.confidence).toBe("medium");
}); });
it("RESOLVE_ROW resolves a preserved custom category and bumps its confidence (#259)", () => {
const plan = makePlan([makeRow(10, 1011)], [makeRow(9001, null)]);
const s1 = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
const s2 = migrationReducer(s1, {
type: "RESOLVE_ROW",
v2CategoryId: 9001,
v1TargetId: 1111,
v1TargetName: "Épicerie régulière",
});
const merged = s2.plan!.preserved.find((r) => r.v2CategoryId === 9001)!;
expect(merged.v1TargetId).toBe(1111);
expect(merged.v1TargetName).toBe("Épicerie régulière");
expect(merged.confidence).toBe("medium");
});
it("RESOLVE_ROW on a preserved custom does NOT change unresolved (seed rows only) (#259)", () => {
// One unresolved seed row + one custom: the guard counts the seed only.
const plan = makePlan([makeRow(10, null)], [makeRow(9001, null)]);
const s1 = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
expect(s1.unresolved).toBe(1);
const s2 = migrationReducer(s1, {
type: "RESOLVE_ROW",
v2CategoryId: 9001,
v1TargetId: 1111,
v1TargetName: "Épicerie régulière",
});
// Merging the custom must not decrement the seed-row guard.
expect(s2.unresolved).toBe(1);
});
it("GO_NEXT advances simulate -> consent with an unmerged custom still present (#259)", () => {
// All seed rows resolved, one custom left unmerged: the wizard must proceed.
const plan = makePlan([makeRow(10, 1011)], [makeRow(9001, null)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
s = migrationReducer(s, { type: "GO_NEXT" }); // discover -> simulate
expect(s.step).toBe("simulate");
s = migrationReducer(s, { type: "GO_NEXT" }); // simulate -> consent
expect(s.step).toBe("consent");
});
it("GO_NEXT blocks simulate -> consent when unresolved > 0", () => { it("GO_NEXT blocks simulate -> consent when unresolved > 0", () => {
const plan = makePlan([makeRow(10, null)]); const plan = makePlan([makeRow(10, null)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan }); let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });

View file

@ -118,28 +118,36 @@ export function migrationReducer(
case "RESOLVE_ROW": { case "RESOLVE_ROW": {
if (state.plan === null) return state; if (state.plan === null) return state;
const rows = state.plan.rows.map((r) => // A resolved target can land on a seeded row (plan.rows) OR a custom
// category (plan.preserved) — the latter is the "merge a custom into a
// standard leaf" path (#259). Apply the target in whichever bucket holds
// the row; it is a no-op for the other one.
const applyTarget = (r: MappingRow): MappingRow =>
r.v2CategoryId === action.v2CategoryId r.v2CategoryId === action.v2CategoryId
? { ? {
...r, ...r,
v1TargetId: action.v1TargetId, v1TargetId: action.v1TargetId,
v1TargetName: action.v1TargetName, v1TargetName: action.v1TargetName,
// Once a user resolves a row manually, bump the confidence badge // Resolving a row manually bumps the confidence badge to "medium"
// to "medium" so the simulate table reflects their decision. // so the table reflects the decision. Reason is left as-is so the
// We keep the reason as-is so that the tooltip still explains // tooltip still explains what the algorithm thought.
// what the algorithm thought.
confidence: r.confidence === "none" ? "medium" : r.confidence, confidence: r.confidence === "none" ? "medium" : r.confidence,
} }
: r, : r;
); const rows = state.plan.rows.map(applyTarget);
const preserved = state.plan.preserved.map(applyTarget);
const plan: MigrationPlan = { const plan: MigrationPlan = {
...state.plan, ...state.plan,
rows, rows,
preserved,
unresolved: rows.filter((r) => r.v1TargetId === null), unresolved: rows.filter((r) => r.v1TargetId === null),
}; };
return { return {
...state, ...state,
plan, plan,
// `unresolved` gates the Next button and counts seeded rows ONLY. A
// custom left unmerged is a legitimate choice and must never block the
// wizard.
unresolved: countUnresolved(rows), unresolved: countUnresolved(rows),
}; };
} }

View file

@ -6,6 +6,8 @@ import {
getExportSuppliers, getExportSuppliers,
getExportKeywords, getExportKeywords,
getExportTransactions, getExportTransactions,
getExportImportSources,
getExportImportTemplates,
serializeToJson, serializeToJson,
serializeTransactionsToCsv, serializeTransactionsToCsv,
type ExportMode, type ExportMode,
@ -61,6 +63,11 @@ export function useDataExport() {
} }
if (mode === "transactions_with_categories" || mode === "transactions_only") { if (mode === "transactions_with_categories" || mode === "transactions_only") {
data.transactions = await getExportTransactions(); data.transactions = await getExportTransactions();
// The import configurations travel with the transaction modes — the
// two the restore wipes `import_sources` for. A categories-only
// backup neither carries them nor touches them (#331).
data.import_sources = await getExportImportSources();
data.import_config_templates = await getExportImportTemplates();
} }
// Serialize // Serialize

View file

@ -1,4 +1,5 @@
import { useReducer, useCallback } from "react"; import { useReducer, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { import {
parseImportedJson, parseImportedJson,
@ -6,6 +7,7 @@ import {
importCategoriesOnly, importCategoriesOnly,
importTransactionsWithCategories, importTransactionsWithCategories,
importTransactionsOnly, importTransactionsOnly,
SrefValidationError,
type ExportEnvelope, type ExportEnvelope,
type ImportSummary, type ImportSummary,
} from "../services/dataExportService"; } from "../services/dataExportService";
@ -108,6 +110,20 @@ function parseContent(
export function useDataImport() { export function useDataImport() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const { t } = useTranslation();
/**
* A refusal at the configuration boundary carries an i18n key and the name of
* the offending source; everything else is a raw exception message. The card
* renders this string as it comes, so the translation happens here.
*/
const describeError = useCallback(
(e: unknown): string => {
if (e instanceof SrefValidationError) return t(e.i18nKey, e.params);
return e instanceof Error ? e.message : String(e);
},
[t]
);
const pickAndRead = useCallback(async () => { const pickAndRead = useCallback(async () => {
dispatch({ type: "READ_START" }); dispatch({ type: "READ_START" });
@ -136,12 +152,9 @@ export function useDataImport() {
const { summary, data, importType } = parseContent(content, filePath); const { summary, data, importType } = parseContent(content, filePath);
dispatch({ type: "CONFIRMING", filePath, summary, data, importType }); dispatch({ type: "CONFIRMING", filePath, summary, data, importType });
} catch (e) { } catch (e) {
dispatch({ dispatch({ type: "IMPORT_ERROR", error: describeError(e) });
type: "IMPORT_ERROR",
error: e instanceof Error ? e.message : String(e),
});
} }
}, []); }, [describeError]);
const readWithPassword = useCallback( const readWithPassword = useCallback(
async (password: string) => { async (password: string) => {
@ -156,13 +169,10 @@ export function useDataImport() {
const { summary, data, importType } = parseContent(content, state.filePath); const { summary, data, importType } = parseContent(content, state.filePath);
dispatch({ type: "CONFIRMING", filePath: state.filePath, summary, data, importType }); dispatch({ type: "CONFIRMING", filePath: state.filePath, summary, data, importType });
} catch (e) { } catch (e) {
dispatch({ dispatch({ type: "IMPORT_ERROR", error: describeError(e) });
type: "IMPORT_ERROR",
error: e instanceof Error ? e.message : String(e),
});
} }
}, },
[state.filePath] [state.filePath, describeError]
); );
const executeImport = useCallback(async () => { const executeImport = useCallback(async () => {
@ -183,12 +193,9 @@ export function useDataImport() {
} }
dispatch({ type: "IMPORT_SUCCESS" }); dispatch({ type: "IMPORT_SUCCESS" });
} catch (e) { } catch (e) {
dispatch({ dispatch({ type: "IMPORT_ERROR", error: describeError(e) });
type: "IMPORT_ERROR",
error: e instanceof Error ? e.message : String(e),
});
} }
}, [state.parsedData, state.importType, state.filePath]); }, [state.parsedData, state.importType, state.filePath, describeError]);
const reset = useCallback(() => dispatch({ type: "RESET" }), []); const reset = useCallback(() => dispatch({ type: "RESET" }), []);

View file

@ -0,0 +1,19 @@
import { useLicenseContext } from "../contexts/LicenseContext";
import { isEntitled, type FeatureKey } from "../shared/entitlements";
/**
* Synchronous feature-gating hook.
*
* Returns `{ allowed, ready }` (NOT a bare boolean) so consumers can suppress
* the lock/upsell while the license is still loading or errored (`ready` is
* false) instead of flashing "locked" to a paying user. `allowed` is fail-closed
* in Free and during boot (edition defaults to "free"), so it is safe to read
* even before `ready`.
*/
export function useEntitlement(feature: FeatureKey): { allowed: boolean; ready: boolean } {
const { status, edition, features } = useLicenseContext();
return {
allowed: isEntitled(feature, edition, features),
ready: status === "ready",
};
}

View file

@ -11,7 +11,6 @@ import type {
ImportReport, ImportReport,
ImportSource, ImportSource,
ImportConfigTemplate, ImportConfigTemplate,
ColumnMapping,
} from "../shared/types"; } from "../shared/types";
import { import {
getImportFolder, getImportFolder,
@ -40,12 +39,32 @@ import {
updateTemplate, updateTemplate,
deleteTemplate as deleteTemplateService, deleteTemplate as deleteTemplateService,
} from "../services/importConfigTemplateService"; } from "../services/importConfigTemplateService";
import { parseDate } from "../utils/dateParser";
import { parseFrenchAmount } from "../utils/amountParser";
import { import {
preprocessQuotedCSV, preprocessQuotedCSV,
autoDetectConfig as runAutoDetect, detectImportFormat as runAutoDetect,
type DetectionScore,
} from "../utils/csvAutoDetect"; } from "../utils/csvAutoDetect";
import {
buildHeaderSignature,
detectHeaderDrift,
type BankSignatureId,
type HeaderDriftEntry,
} from "../utils/bankSignatures";
import {
detectAmountSeparators,
flipSignFormat,
formatFromRow,
formatToRow,
ImportFormatError,
mapRow,
ROW_ERROR_KEYS,
} from "../utils/importFormat";
/** Error text for the banner: an i18n key when we have one, the raw message otherwise. */
function errorMessage(e: unknown): string {
if (e instanceof ImportFormatError) return e.i18nKey;
return e instanceof Error ? e.message : String(e);
}
interface WizardState { interface WizardState {
step: ImportWizardStep; step: ImportWizardStep;
@ -67,6 +86,29 @@ interface WizardState {
importedFilesBySource: Map<string, Set<string>>; importedFilesBySource: Map<string, Set<string>>;
configTemplates: ImportConfigTemplate[]; configTemplates: ImportConfigTemplate[];
selectedTemplateId: number | null; selectedTemplateId: number | null;
/**
* Result of the LAST detection run on the selected source, or null when none
* ran a source opened on its stored format, which is never re-detected.
*/
detectionScore: DetectionScore | null;
/**
* Bank whose documented layout the last detection recognised (#330). Tied to
* `detectionScore`: it is set with it and cleared with it, so a badge naming
* a bank can never outlive the detection that named it.
*/
detectedBank: BankSignatureId | null;
/**
* How the header row of the files being imported differs from the one the
* source recorded at its last successful import null when there is nothing
* to report, which includes every source that has no stored signature.
*/
formatDrift: HeaderDriftEntry[] | null;
/**
* The configuration re-detected on the drifted file, offered by the panel as
* "adopt". Null when detection could not read the new shape either: the drift
* is still worth reporting, there is just nothing to adopt.
*/
driftConfig: SourceConfig | null;
} }
type WizardAction = type WizardAction =
@ -88,6 +130,12 @@ type WizardAction =
| { type: "SET_CONFIGURED_SOURCES"; payload: { names: Set<string>; files: Map<string, Set<string>> } } | { type: "SET_CONFIGURED_SOURCES"; payload: { names: Set<string>; files: Map<string, Set<string>> } }
| { type: "SET_CONFIG_TEMPLATES"; payload: ImportConfigTemplate[] } | { type: "SET_CONFIG_TEMPLATES"; payload: ImportConfigTemplate[] }
| { type: "SET_SELECTED_TEMPLATE_ID"; payload: number | null } | { type: "SET_SELECTED_TEMPLATE_ID"; payload: number | null }
| { type: "SET_DETECTION_SCORE"; payload: DetectionScore | null }
| { type: "SET_DETECTED_BANK"; payload: BankSignatureId | null }
| {
type: "SET_FORMAT_DRIFT";
payload: { entries: HeaderDriftEntry[]; config: SourceConfig | null } | null;
}
| { type: "RESET" }; | { type: "RESET" };
const defaultConfig: SourceConfig = { const defaultConfig: SourceConfig = {
@ -122,6 +170,10 @@ const initialState: WizardState = {
importedFilesBySource: new Map(), importedFilesBySource: new Map(),
configTemplates: [], configTemplates: [],
selectedTemplateId: null, selectedTemplateId: null,
detectionScore: null,
detectedBank: null,
formatDrift: null,
driftConfig: null,
}; };
function reducer(state: WizardState, action: WizardAction): WizardState { function reducer(state: WizardState, action: WizardAction): WizardState {
@ -137,7 +189,19 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
case "SET_SCANNED_SOURCES": case "SET_SCANNED_SOURCES":
return { ...state, scannedSources: action.payload, isLoading: false }; return { ...state, scannedSources: action.payload, isLoading: false };
case "SET_SELECTED_SOURCE": case "SET_SELECTED_SOURCE":
return { ...state, selectedSource: action.payload }; // The score belongs to the source it was measured on. Carrying it over
// would let a source opened on its stored format inherit the confidence
// of the previous one, which is worse than showing nothing. The bank
// badge and the drift report are scoped the same way, for the same
// reason — both describe one source's files.
return {
...state,
selectedSource: action.payload,
detectionScore: null,
detectedBank: null,
formatDrift: null,
driftConfig: null,
};
case "SET_SELECTED_FILES": case "SET_SELECTED_FILES":
return { ...state, selectedFiles: action.payload }; return { ...state, selectedFiles: action.payload };
case "SET_SOURCE_CONFIG": case "SET_SOURCE_CONFIG":
@ -188,6 +252,25 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
return { ...state, configTemplates: action.payload }; return { ...state, configTemplates: action.payload };
case "SET_SELECTED_TEMPLATE_ID": case "SET_SELECTED_TEMPLATE_ID":
return { ...state, selectedTemplateId: action.payload }; return { ...state, selectedTemplateId: action.payload };
case "SET_DETECTION_SCORE":
// Clearing the score clears the bank with it, in ONE place: the three
// sites that drop the score (a hand edit, a template, a sign flip) then
// cannot leave "Format Desjardins reconnu" standing over a format
// detection never produced. A bank is only ever set by dispatching
// `SET_DETECTED_BANK` right after a non-null score.
return {
...state,
detectionScore: action.payload,
detectedBank: action.payload === null ? null : state.detectedBank,
};
case "SET_DETECTED_BANK":
return { ...state, detectedBank: action.payload };
case "SET_FORMAT_DRIFT":
return {
...state,
formatDrift: action.payload?.entries ?? null,
driftConfig: action.payload?.config ?? null,
};
case "RESET": case "RESET":
return { return {
...initialState, ...initialState,
@ -280,8 +363,62 @@ export function useImportWizard() {
} }
}, [state.importFolder, scanFolderInternal]); }, [state.importFolder, scanFolderInternal]);
/**
* Run detection on one file and report the outcome. Returns the detected
* format merged onto `base`, or null when detection produced no usable
* configuration (the reason is dispatched as the page error).
*
* THE single detection path, shared by the two things that start one: the
* magic-wand button and the automatic run on a source that has never been
* configured. Everything it needs is a parameter, so it never reads a piece of
* `state` its caller has just dispatched but not yet re-rendered which is
* exactly why the automatic run could not simply call the button's callback.
*
* `base` keeps the fields detection does not decide (the source name, the
* encoding), and the spread of `outcome.config` writes every field it does
* naming them one by one is how a field gets silently dropped (#324).
*/
const detectFormatForFile = useCallback(
async (filePath: string, base: SourceConfig): Promise<SourceConfig | null> => {
const content = await invoke<string>("read_file_content", {
filePath,
encoding: base.encoding,
});
const outcome = runAutoDetect(content);
if (outcome.status !== "ok") {
// A refused format states WHY (#327): "I cannot read this file" and
// "this file is a shape this version would import backwards" are not
// the same news, and only the second one is the app's own limitation.
// `SET_ERROR` clears the loading flag on its own.
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
dispatch({
type: "SET_ERROR",
payload:
outcome.status === "rejected"
? outcome.reason
: "import.errors.autoDetectFailed",
});
return null;
}
dispatch({ type: "SET_DETECTION_SCORE", payload: outcome.score });
// Always dispatched, including the `null` of an unknown file: a bank left
// over from the previously detected file would name the wrong bank.
dispatch({ type: "SET_DETECTED_BANK", payload: outcome.bank });
return { ...base, ...outcome.config };
},
[]
);
const selectSource = useCallback( const selectSource = useCallback(
async (source: ScannedSource) => { async (source: ScannedSource) => {
// The banner is page-wide: an error left by the previous source must not
// survive into this one. Cleared here, before anything that may set a new
// one — a stored format that will not decode, a detection that refuses.
dispatch({ type: "SET_ERROR", payload: null });
// Sort files: new files first, then already-imported // Sort files: new files first, then already-imported
const importedNames = state.importedFilesBySource.get(source.folder_name); const importedNames = state.importedFilesBySource.get(source.folder_name);
const sorted = [...source.files].sort((a, b) => { const sorted = [...source.files].sort((a, b) => {
@ -297,37 +434,48 @@ export function useImportWizard() {
dispatch({ type: "SET_SELECTED_SOURCE", payload: sortedSource }); dispatch({ type: "SET_SELECTED_SOURCE", payload: sortedSource });
dispatch({ type: "SET_SELECTED_FILES", payload: newFiles }); dispatch({ type: "SET_SELECTED_FILES", payload: newFiles });
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: null });
try {
// Check if this source already has config in DB // Check if this source already has config in DB
const existing = await getSourceByName(source.folder_name); const existing = await getSourceByName(source.folder_name);
dispatch({ type: "SET_EXISTING_SOURCE", payload: existing }); dispatch({ type: "SET_EXISTING_SOURCE", payload: existing });
// Provenance of the stored format, not the format itself: the template
// is never re-read, it is only shown as the source's origin. Blindly
// resetting it to null used to make an already-linked source look
// unconfigured.
dispatch({
type: "SET_SELECTED_TEMPLATE_ID",
payload: existing?.template_id ?? null,
});
let activeDelimiter = defaultConfig.delimiter; let activeDelimiter = defaultConfig.delimiter;
let activeEncoding = "utf-8"; let activeEncoding = "utf-8";
let activeSkipLines = 0; let activeSkipLines = 0;
let activeHasHeader = true; let activeHasHeader = true;
// Restore the format as it was SAVED. Nothing is re-inferred and
// nothing is defaulted here — the amount mode and the sign convention
// are columns now (v17), and reading them is the whole point of #324.
let restored: SourceConfig | null = null;
if (existing) { if (existing) {
// Restore config from DB try {
const mapping = JSON.parse(existing.column_mapping) as ColumnMapping; restored = { name: existing.name, ...formatFromRow(existing) };
const config: SourceConfig = { } catch (e) {
name: existing.name, // A stored format we cannot decode is REPORTED, not quietly swapped
delimiter: existing.delimiter, // for a plausible default — that substitution is how amounts got
encoding: existing.encoding, // flipped. The wizard then opens on a fresh configuration, because
dateFormat: existing.date_format, // "reconfigure this source" has to be an action the user can take.
skipLines: existing.skip_lines, dispatch({ type: "SET_ERROR", payload: errorMessage(e) });
columnMapping: mapping, }
amountMode: }
mapping.debitAmount !== undefined ? "debit_credit" : "single",
signConvention: "negative_expense", if (restored) {
hasHeader: !!existing.has_header, dispatch({ type: "SET_SOURCE_CONFIG", payload: restored });
}; activeDelimiter = restored.delimiter;
dispatch({ type: "SET_SOURCE_CONFIG", payload: config }); activeEncoding = restored.encoding;
activeDelimiter = existing.delimiter; activeSkipLines = restored.skipLines;
activeEncoding = existing.encoding; activeHasHeader = restored.hasHeader;
activeSkipLines = existing.skip_lines;
activeHasHeader = !!existing.has_header;
} else { } else {
// Auto-detect encoding for first file // Auto-detect encoding for first file
if (source.files.length > 0) { if (source.files.length > 0) {
@ -340,14 +488,35 @@ export function useImportWizard() {
} }
} }
dispatch({ const fresh: SourceConfig = {
type: "SET_SOURCE_CONFIG",
payload: {
...defaultConfig, ...defaultConfig,
name: source.folder_name, name: source.folder_name,
encoding: activeEncoding, encoding: activeEncoding,
}, };
});
// Detection fires on its own HERE and nowhere else: the arm reached
// when the source has no stored format at all. `defaultConfig` is
// `;` + `DD/MM/YYYY` + columns 0/1/2 — plausible enough to import a
// whole file wrong rather than fail, so leaving a never-configured
// source sitting on it is the defect (#328).
//
// The condition is `!existing`, not `!restored`: a source whose stored
// format will not decode already carries an explicit error, and
// re-detecting over it would replace that message with a silent guess.
// Its wand button is still there.
let active = fresh;
if (!existing && source.files.length > 0) {
const detected = await detectFormatForFile(
source.files[0].file_path,
fresh
);
if (detected) active = detected;
}
dispatch({ type: "SET_SOURCE_CONFIG", payload: active });
activeDelimiter = active.delimiter;
activeSkipLines = active.skipLines;
activeHasHeader = active.hasHeader;
} }
// Load preview headers from first file // Load preview headers from first file
@ -362,6 +531,9 @@ export function useImportWizard() {
} }
dispatch({ type: "SET_STEP", payload: "source-config" }); dispatch({ type: "SET_STEP", payload: "source-config" });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: errorMessage(e) });
}
}, },
[state.importedFilesBySource] // eslint-disable-line react-hooks/exhaustive-deps [state.importedFilesBySource] // eslint-disable-line react-hooks/exhaustive-deps
); );
@ -408,6 +580,19 @@ export function useImportWizard() {
(config: SourceConfig) => { (config: SourceConfig) => {
dispatch({ type: "SET_SOURCE_CONFIG", payload: config }); dispatch({ type: "SET_SOURCE_CONFIG", payload: config });
// The score measures the format DETECTION produced. The moment any of
// those eight fields is edited by hand, the number stops describing what
// the wizard would read — a banner still claiming "147 of 150 rows" over
// a mapping nobody measured is the same misinformation this chantier is
// removing. Comparing through the codec keeps a rename (the ninth field,
// which changes nothing about reading) from clearing it.
if (
JSON.stringify(formatToRow(config)) !==
JSON.stringify(formatToRow(state.sourceConfig))
) {
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
}
// Reload headers when delimiter, encoding, skipLines, or hasHeader changes // Reload headers when delimiter, encoding, skipLines, or hasHeader changes
if (state.selectedFiles.length > 0) { if (state.selectedFiles.length > 0) {
loadHeadersWithConfig( loadHeadersWithConfig(
@ -419,7 +604,7 @@ export function useImportWizard() {
); );
} }
}, },
[state.selectedFiles, loadHeadersWithConfig] [state.selectedFiles, state.sourceConfig, loadHeadersWithConfig]
); );
const toggleFile = useCallback( const toggleFile = useCallback(
@ -457,9 +642,16 @@ export function useImportWizard() {
} }
}, [state.selectedSource, state.importedFilesBySource]); }, [state.selectedSource, state.importedFilesBySource]);
// Internal helper: parses selected files and returns rows + headers // Internal helper: parses selected files and returns rows + headers.
const parseFilesInternal = useCallback(async (): Promise<{ rows: ParsedRow[]; headers: string[] }> => { //
const config = state.sourceConfig; // `configOverride` exists for the sign flip: it re-parses under a format that
// has just been dispatched and is therefore not yet readable in `state`.
// Reading the stale one would redisplay the exact table the user asked to
// correct, which reads as "the button did nothing".
const parseFilesInternal = useCallback(async (
configOverride?: SourceConfig
): Promise<{ rows: ParsedRow[]; headers: string[] }> => {
const config = configOverride ?? state.sourceConfig;
const allRows: ParsedRow[] = []; const allRows: ParsedRow[] = [];
let headers: string[] = []; let headers: string[] = [];
@ -486,66 +678,32 @@ export function useImportWizard() {
headers = firstDataRow.map((_, i) => `Col ${i}`); headers = firstDataRow.map((_, i) => `Col ${i}`);
} }
// Data rows of THIS file, kept aside so the decimal separator of each
// amount column is arbitrated over the whole column before any row is
// read. `1.234` alone is ambiguous; the column is not.
const dataRows: string[][] = [];
for (let i = startIdx; i < data.length; i++) { for (let i = startIdx; i < data.length; i++) {
const raw = data[i]; const raw = data[i];
if (raw.length <= 1 && raw[0]?.trim() === "") continue; if (raw.length <= 1 && raw[0]?.trim() === "") continue;
dataRows.push(raw);
}
const decimalSeparators = detectAmountSeparators(dataRows, config);
for (const raw of dataRows) {
try { try {
const date = parseDate( allRows.push(
raw[config.columnMapping.date]?.trim() || "", mapRow(raw, config, {
config.dateFormat
);
const description =
raw[config.columnMapping.description]?.trim() || "";
let amount: number;
if (config.amountMode === "debit_credit") {
const debit = parseFrenchAmount(
raw[config.columnMapping.debitAmount ?? 0] || ""
);
const credit = parseFrenchAmount(
raw[config.columnMapping.creditAmount ?? 0] || ""
);
amount = isNaN(credit) ? -(isNaN(debit) ? 0 : debit) : credit;
} else {
amount = parseFrenchAmount(
raw[config.columnMapping.amount ?? 0] || ""
);
if (config.signConvention === "positive_expense" && !isNaN(amount)) {
amount = -amount;
}
}
if (!date) {
allRows.push({
rowIndex: allRows.length, rowIndex: allRows.length,
raw,
parsed: null,
error: "Invalid date",
sourceFilename: file.filename, sourceFilename: file.filename,
}); decimalSeparators,
} else if (isNaN(amount)) { })
allRows.push({ );
rowIndex: allRows.length,
raw,
parsed: null,
error: "Invalid amount",
sourceFilename: file.filename,
});
} else {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: { date, description, amount },
sourceFilename: file.filename,
});
}
} catch { } catch {
allRows.push({ allRows.push({
rowIndex: allRows.length, rowIndex: allRows.length,
raw, raw,
parsed: null, parsed: null,
error: "Parse error", error: ROW_ERROR_KEYS.parseError,
sourceFilename: file.filename, sourceFilename: file.filename,
}); });
} }
@ -555,8 +713,15 @@ export function useImportWizard() {
return { rows: allRows, headers }; return { rows: allRows, headers };
}, [state.selectedFiles, state.sourceConfig]); }, [state.selectedFiles, state.sourceConfig]);
// Parse files and store preview (does NOT change wizard step) // Parse the selected files and STOP at the preview.
const parsePreview = useCallback(async () => { //
// Every import goes through that step now (#329). It used to be an optional
// modal nobody had to open, next to a button that went straight to the
// duplicate check — so a file read under a wrong convention reached the
// database without anyone ever seeing a total. The step is unconditional on
// purpose: the detection score does not gate it, because a perfect score says
// nothing about the sign of what was read.
const parseAndPreview = useCallback(async () => {
if (state.selectedFiles.length === 0) return; if (state.selectedFiles.length === 0) return;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
@ -568,44 +733,69 @@ export function useImportWizard() {
type: "SET_PARSED_PREVIEW", type: "SET_PARSED_PREVIEW",
payload: result, payload: result,
}); });
// Format drift (#330). Compared HERE and not at the configuration step
// because this is where the header of the files actually being imported
// is known — `previewHeaders` at the configuration step is read from the
// first file alone, under a delimiter the user may still be editing.
//
// `hasHeader` gates the whole thing: a headerless file has `Col 0`,
// `Col 1` … for headers, which is not a signature and must never be
// compared to one.
const drift = state.sourceConfig.hasHeader
? detectHeaderDrift(
state.existingSource?.header_signature,
result.headers
)
: null;
if (drift) {
// Re-detect on the drifted file so the panel has something to offer.
// This is the THIRD caller of the single detection path, and it goes
// through it rather than around it on purpose: a second detector is the
// divergence class this whole chantier removes. A file the re-detection
// cannot read leaves `null` — the drift is still reported, with nothing
// to adopt.
const reDetected = await detectFormatForFile(
state.selectedFiles[0].file_path,
state.sourceConfig
);
// That run measured the RE-DETECTED format, which is not the one the
// wizard is using — it is only what the panel offers. Leaving its score
// and its bank badge behind would put "Format reconnu — 6 of 6 rows
// read" next to the stored mapping the moment the user steps back to
// the configuration. Same rule as the sign flip: a badge describes the
// format in use or it describes nothing.
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
dispatch({
type: "SET_FORMAT_DRIFT",
payload: { entries: drift, config: reDetected },
});
} else {
dispatch({ type: "SET_FORMAT_DRIFT", payload: null });
}
dispatch({ type: "SET_STEP", payload: "file-preview" });
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e), payload: e instanceof Error ? e.message : String(e),
}); });
} }
}, [state.selectedFiles, parseFilesInternal]); }, [
state.selectedFiles,
state.sourceConfig,
state.existingSource,
parseFilesInternal,
detectFormatForFile,
]);
// Internal helper: runs duplicate checking against parsed rows // Internal helper: runs duplicate checking against parsed rows.
//
// Deliberately writes NOTHING. The source config used to be saved here, so an
// import abandoned at the duplicate step still left a — possibly wrong —
// configuration behind. It is written by `executeImport` now (#324).
const checkDuplicatesInternal = useCallback(async (parsedRows: ParsedRow[]) => { const checkDuplicatesInternal = useCallback(async (parsedRows: ParsedRow[]) => {
// Save/update source config in DB
const config = state.sourceConfig;
const mappingJson = JSON.stringify(config.columnMapping);
let sourceId: number;
if (state.existingSource) {
sourceId = state.existingSource.id;
await updateSource(sourceId, {
name: config.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
column_mapping: mappingJson,
skip_lines: config.skipLines,
has_header: config.hasHeader,
});
} else {
sourceId = await createSource({
name: config.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
column_mapping: mappingJson,
skip_lines: config.skipLines,
has_header: config.hasHeader,
});
}
// Check file-level duplicates (check ALL selected files, not just the first) // Check file-level duplicates (check ALL selected files, not just the first)
let fileAlreadyImported = false; let fileAlreadyImported = false;
let existingFileId: number | undefined; let existingFileId: number | undefined;
@ -679,9 +869,14 @@ export function useImportWizard() {
}, },
}); });
dispatch({ type: "SET_STEP", payload: "duplicate-check" }); dispatch({ type: "SET_STEP", payload: "duplicate-check" });
}, [state.sourceConfig, state.existingSource, state.selectedFiles]); }, [state.selectedFiles]);
// Check duplicates using already-parsed preview data // Leave the preview for the duplicate check, on the rows it just displayed.
//
// Dead code until #329: the wizard jumped from the configuration straight to
// the duplicates, re-parsing on the way. It is the preview step's "next"
// button now, so the rows the user validated are the rows that get checked —
// no second parse can quietly read the file differently in between.
const checkDuplicates = useCallback(async () => { const checkDuplicates = useCallback(async () => {
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
@ -696,27 +891,45 @@ export function useImportWizard() {
} }
}, [state.parsedPreview, checkDuplicatesInternal]); }, [state.parsedPreview, checkDuplicatesInternal]);
// Parse files then check duplicates in one step (skips preview step) /**
const parseAndCheckDuplicates = useCallback(async () => { * Read the file the other way round, and remember it.
if (state.selectedFiles.length === 0) return; *
* The correction lands on the CONFIGURATION (`flipSignFormat`, which knows
* that debit/credit flips by swapping columns rather than by toggling a
* convention `mapRow` ignores there), so it is persisted with the source at
* import time and the next file from that bank reads right on its own.
*
* Headers are deliberately NOT reloaded: a flip touches neither delimiter,
* encoding, skipped lines nor header flag, and `loadHeadersWithConfig`
* dispatches an empty row list racing it against this re-parse is a coin
* toss between the corrected table and a blank one.
*/
const flipSignConvention = useCallback(async () => {
const flipped: SourceConfig = {
...state.sourceConfig,
...flipSignFormat(state.sourceConfig),
};
dispatch({ type: "SET_SOURCE_CONFIG", payload: flipped });
// Detection did not produce this format. Keeping its badge would vouch for
// a configuration it never measured — the same rule as a manual edit.
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
const result = await parseFilesInternal(); const result = await parseFilesInternal(flipped);
dispatch({ dispatch({
type: "SET_PARSED_PREVIEW", type: "SET_PARSED_PREVIEW",
payload: result, payload: result,
}); });
await checkDuplicatesInternal(result.rows);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e), payload: e instanceof Error ? e.message : String(e),
}); });
} }
}, [state.selectedFiles, parseFilesInternal, checkDuplicatesInternal]); }, [state.sourceConfig, parseFilesInternal]);
const executeImport = useCallback(async () => { const executeImport = useCallback(async () => {
if (!state.duplicateResult) return; if (!state.duplicateResult) return;
@ -727,10 +940,39 @@ export function useImportWizard() {
try { try {
const config = state.sourceConfig; const config = state.sourceConfig;
// Get or create source ID // Persist the format — the ONLY write point. It happens here rather than
const dbSource = await getSourceByName(config.name); // at the duplicate step so an abandoned import leaves no configuration
if (!dbSource) throw new Error("Source not found in database"); // behind, and it goes through `formatToRow` so no field can be dropped.
const sourceId = dbSource.id; // It has to precede the file records, which carry a `source_id` FK.
const formatRow = formatToRow(config);
// The header row this import actually read, recorded so the NEXT one can
// be compared against it (#330). It is not a format field and does not go
// through the codec: it says what the file looked like, not how to read
// it. A headerless file writes null — `previewHeaders` holds `Col 0`,
// `Col 1` … there, and storing that would make every later import look
// like drift.
const headerSignature = buildHeaderSignature(
config.hasHeader ? state.previewHeaders : null
);
let sourceId: number;
if (state.existingSource) {
sourceId = state.existingSource.id;
await updateSource(sourceId, {
name: config.name,
...formatRow,
header_signature: headerSignature,
template_id: state.selectedTemplateId,
});
} else {
sourceId = await createSource({
name: config.name,
...formatRow,
header_signature: headerSignature,
template_id: state.selectedTemplateId,
});
}
// Determine rows to import: new rows + non-excluded duplicates // Determine rows to import: new rows + non-excluded duplicates
const includedDuplicates = state.duplicateResult.duplicateRows const includedDuplicates = state.duplicateResult.duplicateRows
@ -826,7 +1068,10 @@ export function useImportWizard() {
// Count errors from parsing // Count errors from parsing
const parseErrors = state.parsedPreview.filter((r) => r.error); const parseErrors = state.parsedPreview.filter((r) => r.error);
for (const err of parseErrors) { for (const err of parseErrors) {
errors.push({ rowIndex: err.rowIndex, message: err.error || "Parse error" }); errors.push({
rowIndex: err.rowIndex,
message: err.error || ROW_ERROR_KEYS.parseError,
});
} }
const report: ImportReport = { const report: ImportReport = {
@ -854,6 +1099,8 @@ export function useImportWizard() {
}, [ }, [
state.duplicateResult, state.duplicateResult,
state.sourceConfig, state.sourceConfig,
state.existingSource,
state.selectedTemplateId,
state.excludedDuplicateIndices, state.excludedDuplicateIndices,
state.parsedPreview, state.parsedPreview,
state.selectedFiles, state.selectedFiles,
@ -868,6 +1115,50 @@ export function useImportWizard() {
dispatch({ type: "RESET" }); dispatch({ type: "RESET" });
}, []); }, []);
/**
* Take the format re-detected on the drifted file, and re-read the preview
* under it (#330).
*
* Same shape as the sign flip, and for the same reason: the adopted format is
* PASSED to the parse instead of being read back from `state`, which has not
* re-rendered yet. Reading the stale one would redisplay the table the user
* just chose to replace.
*
* Nothing is written here. The adopted format reaches `import_sources` at
* `executeImport` like every other configuration an import abandoned on
* this screen leaves the stored format exactly as it was.
*/
const adoptDriftFormat = useCallback(async () => {
const adopted = state.driftConfig;
if (!adopted) return;
dispatch({ type: "SET_SOURCE_CONFIG", payload: adopted });
dispatch({ type: "SET_FORMAT_DRIFT", payload: null });
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const result = await parseFilesInternal(adopted);
dispatch({ type: "SET_PARSED_PREVIEW", payload: result });
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.driftConfig, parseFilesInternal]);
/**
* Keep the stored configuration and dismiss the panel.
*
* The rows on screen were already parsed under that configuration, so there
* is nothing to re-read. The stored signature is still refreshed at import
* time: the new header IS what this import read, whichever format read it.
*/
const keepCurrentFormat = useCallback(() => {
dispatch({ type: "SET_FORMAT_DRIFT", payload: null });
}, []);
const autoDetectConfig = useCallback(async () => { const autoDetectConfig = useCallback(async () => {
if (state.selectedFiles.length === 0) return; if (state.selectedFiles.length === 0) return;
@ -875,62 +1166,38 @@ export function useImportWizard() {
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
const content = await invoke<string>("read_file_content", { const filePath = state.selectedFiles[0].file_path;
filePath: state.selectedFiles[0].file_path, // Same path the automatic run takes, so the button REPLAYS detection
encoding: state.sourceConfig.encoding, // rather than running a second, drifting variant of it.
}); const newConfig = await detectFormatForFile(filePath, state.sourceConfig);
if (!newConfig) return; // reason already dispatched, loading already off
const result = runAutoDetect(content);
if (result) {
const newConfig = {
...state.sourceConfig,
delimiter: result.delimiter,
hasHeader: result.hasHeader,
skipLines: result.skipLines,
dateFormat: result.dateFormat,
columnMapping: result.columnMapping,
amountMode: result.amountMode,
signConvention: result.signConvention,
};
dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig }); dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
dispatch({ type: "SET_LOADING", payload: false }); dispatch({ type: "SET_LOADING", payload: false });
// Refresh column headers with new config // Refresh column headers with new config
await loadHeadersWithConfig( await loadHeadersWithConfig(
state.selectedFiles[0].file_path, filePath,
newConfig.delimiter, newConfig.delimiter,
newConfig.encoding, newConfig.encoding,
newConfig.skipLines, newConfig.skipLines,
newConfig.hasHeader newConfig.hasHeader
); );
} else {
dispatch({
type: "SET_ERROR",
payload: "Auto-detection failed. Please configure manually.",
});
}
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e), payload: e instanceof Error ? e.message : String(e),
}); });
} }
}, [state.selectedFiles, state.sourceConfig, loadHeadersWithConfig]); }, [
state.selectedFiles,
state.sourceConfig,
detectFormatForFile,
loadHeadersWithConfig,
]);
const saveConfigAsTemplate = useCallback(async (name: string) => { const saveConfigAsTemplate = useCallback(async (name: string) => {
const config = state.sourceConfig; await createTemplate({ name, ...formatToRow(state.sourceConfig) });
await createTemplate({
name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
skip_lines: config.skipLines,
has_header: config.hasHeader ? 1 : 0,
column_mapping: JSON.stringify(config.columnMapping),
amount_mode: config.amountMode,
sign_convention: config.signConvention,
});
const templates = await getAllTemplates(); const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates }); dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, [state.sourceConfig]); }, [state.sourceConfig]);
@ -938,20 +1205,25 @@ export function useImportWizard() {
const applyConfigTemplate = useCallback((templateId: number) => { const applyConfigTemplate = useCallback((templateId: number) => {
const template = state.configTemplates.find((t) => t.id === templateId); const template = state.configTemplates.find((t) => t.id === templateId);
if (!template) return; if (!template) return;
const mapping = JSON.parse(template.column_mapping) as ColumnMapping;
const newConfig: SourceConfig = { let newConfig: SourceConfig;
try {
newConfig = {
name: state.sourceConfig.name, name: state.sourceConfig.name,
delimiter: template.delimiter, ...formatFromRow(template),
encoding: template.encoding,
dateFormat: template.date_format,
skipLines: template.skip_lines,
columnMapping: mapping,
amountMode: template.amount_mode,
signConvention: template.sign_convention,
hasHeader: !!template.has_header,
}; };
} catch (e) {
dispatch({ type: "SET_ERROR", payload: errorMessage(e) });
return;
}
dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig }); dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
// Applying a template COPIES its format onto the source. The id recorded
// here is provenance only — the copy is what the next import reads.
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: templateId }); dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: templateId });
// The format is the template's now, so a score measured on the detected
// one would be vouching for a configuration it never saw.
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
// Reload headers with new config // Reload headers with new config
if (state.selectedFiles.length > 0) { if (state.selectedFiles.length > 0) {
@ -969,17 +1241,11 @@ export function useImportWizard() {
if (!state.selectedTemplateId) return; if (!state.selectedTemplateId) return;
const template = state.configTemplates.find((t) => t.id === state.selectedTemplateId); const template = state.configTemplates.find((t) => t.id === state.selectedTemplateId);
if (!template) return; if (!template) return;
const config = state.sourceConfig; // Writes to `import_config_templates` only: a source configured from this
// template keeps its own eight columns and is untouched.
await updateTemplate(state.selectedTemplateId, { await updateTemplate(state.selectedTemplateId, {
name: template.name, name: template.name,
delimiter: config.delimiter, ...formatToRow(state.sourceConfig),
encoding: config.encoding,
date_format: config.dateFormat,
skip_lines: config.skipLines,
has_header: config.hasHeader ? 1 : 0,
column_mapping: JSON.stringify(config.columnMapping),
amount_mode: config.amountMode,
sign_convention: config.signConvention,
}); });
const templates = await getAllTemplates(); const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates }); dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
@ -1002,12 +1268,14 @@ export function useImportWizard() {
updateConfig, updateConfig,
toggleFile, toggleFile,
selectAllFiles, selectAllFiles,
parsePreview, parseAndPreview,
checkDuplicates, checkDuplicates,
parseAndCheckDuplicates, flipSignConvention,
executeImport, executeImport,
goToStep, goToStep,
reset, reset,
adoptDriftFormat,
keepCurrentFormat,
autoDetectConfig, autoDetectConfig,
saveConfigAsTemplate, saveConfigAsTemplate,
applyConfigTemplate, applyConfigTemplate,

View file

@ -1,41 +1,57 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { useIsPremium } from "./useIsPremium"; import { useIsPremium } from "./useIsPremium";
vi.mock("./useLicense", () => ({ // useIsPremium now reads LicenseContext (was: useLicense). Mock the context hook.
useLicense: vi.fn(), vi.mock("../contexts/LicenseContext", () => ({
useLicenseContext: vi.fn(),
})); }));
import { useLicense } from "./useLicense"; import { useLicenseContext } from "../contexts/LicenseContext";
const mockUseLicense = vi.mocked(useLicense); const mockUseLicenseContext = vi.mocked(useLicenseContext);
describe("useIsPremium", () => { describe("useIsPremium", () => {
it('returns true when edition is "premium"', () => { it('returns true when edition is "premium"', () => {
mockUseLicense.mockReturnValue({ mockUseLicenseContext.mockReturnValue({
state: { status: "ready", edition: "premium", info: null, error: null }, status: "ready",
edition: "premium",
features: [],
info: null,
error: null,
validating: false,
validationError: null,
refresh: vi.fn(), refresh: vi.fn(),
submitKey: vi.fn(), submitKey: vi.fn(),
checkEntitlement: vi.fn(),
}); });
expect(useIsPremium()).toBe(true); expect(useIsPremium()).toBe(true);
}); });
it('returns false when edition is "base"', () => { it('returns false when edition is "base"', () => {
mockUseLicense.mockReturnValue({ mockUseLicenseContext.mockReturnValue({
state: { status: "ready", edition: "base", info: null, error: null }, status: "ready",
edition: "base",
features: [],
info: null,
error: null,
validating: false,
validationError: null,
refresh: vi.fn(), refresh: vi.fn(),
submitKey: vi.fn(), submitKey: vi.fn(),
checkEntitlement: vi.fn(),
}); });
expect(useIsPremium()).toBe(false); expect(useIsPremium()).toBe(false);
}); });
it('returns false when edition is "free"', () => { it('returns false when edition is "free"', () => {
mockUseLicense.mockReturnValue({ mockUseLicenseContext.mockReturnValue({
state: { status: "ready", edition: "free", info: null, error: null }, status: "ready",
edition: "free",
features: [],
info: null,
error: null,
validating: false,
validationError: null,
refresh: vi.fn(), refresh: vi.fn(),
submitKey: vi.fn(), submitKey: vi.fn(),
checkEntitlement: vi.fn(),
}); });
expect(useIsPremium()).toBe(false); expect(useIsPremium()).toBe(false);
}); });

View file

@ -1,10 +1,11 @@
import { useLicense } from "./useLicense"; import { useLicenseContext } from "../contexts/LicenseContext";
/** /**
* Returns true if the active license edition is "premium". * Returns true if the active license edition is "premium".
* Ergonomic helper only the server enforces entitlements independently (cf. ADR 0011 §UX). * Ergonomic helper only the server enforces entitlements independently (cf. ADR 0011 §UX).
* Reads the shared LicenseContext (single boot load) no per-call invoke.
*/ */
export function useIsPremium(): boolean { export function useIsPremium(): boolean {
const { state } = useLicense(); const { edition } = useLicenseContext();
return state.edition === "premium"; return edition === "premium";
} }

View file

@ -1,98 +0,0 @@
import { useCallback, useEffect, useReducer } from "react";
import {
Edition,
LicenseInfo,
checkEntitlement as checkEntitlementCmd,
getEdition,
readLicense,
storeLicense,
} from "../services/licenseService";
type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error";
interface LicenseState {
status: LicenseStatus;
edition: Edition;
info: LicenseInfo | null;
error: string | null;
}
type LicenseAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
| { type: "VALIDATE_START" }
| { type: "VALIDATE_DONE"; info: LicenseInfo }
| { type: "ERROR"; error: string };
const initialState: LicenseState = {
status: "idle",
edition: "free",
info: null,
error: null,
};
function reducer(state: LicenseState, action: LicenseAction): LicenseState {
switch (action.type) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return {
status: "ready",
edition: action.edition,
info: action.info,
error: null,
};
case "VALIDATE_START":
return { ...state, status: "validating", error: null };
case "VALIDATE_DONE":
return {
status: "ready",
edition: action.info.edition,
info: action.info,
error: null,
};
case "ERROR":
return { ...state, status: "error", error: action.error };
}
}
export function useLicense() {
const [state, dispatch] = useReducer(reducer, initialState);
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
const [edition, info] = await Promise.all([getEdition(), readLicense()]);
dispatch({ type: "LOAD_DONE", edition, info });
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const submitKey = useCallback(async (key: string) => {
dispatch({ type: "VALIDATE_START" });
try {
const info = await storeLicense(key);
dispatch({ type: "VALIDATE_DONE", info });
return { ok: true as const, info };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
dispatch({ type: "ERROR", error: message });
return { ok: false as const, error: message };
}
}, []);
const checkEntitlement = useCallback(
(feature: string) => checkEntitlementCmd(feature),
[],
);
useEffect(() => {
void refresh();
}, [refresh]);
return { state, refresh, submitKey, checkEntitlement };
}

View file

@ -161,7 +161,9 @@ export function holdingsFromServiceHoldings(
* column indices from `analyzeHoldingsCsv`. Behavior: * column indices from `analyzeHoldingsCsv`. Behavior:
* - Symbols are normalized (UPPER/TRIM) like manual entry (SecurityPicker) so * - Symbols are normalized (UPPER/TRIM) like manual entry (SecurityPicker) so
* an imported title collapses onto the same `balance_securities` row. * an imported title collapses onto the same `balance_securities` row.
* - Numbers are parsed with `parseFrenchAmount` (handles `1 234,56`, `1,234.56`). * - Numbers are parsed with `parseFrenchAmount` (handles `1 234,56`, `1,234.56`,
* `(140,10)`); a cell it cannot read is left EMPTY, or kept verbatim for the
* quantity, so validation refuses the row instead of storing a wrong number.
* - unit_price + book_cost are OPTIONAL: when the mapping's column is null (no * - unit_price + book_cost are OPTIONAL: when the mapping's column is null (no
* price/cost column) the field stays empty; the user fetches/types it later. * price/cost column) the field stays empty; the user fetches/types it later.
* - Duplicate symbols WITHIN the CSV are merged into one draft to respect the * - Duplicate symbols WITHIN the CSV are merged into one draft to respect the
@ -179,7 +181,13 @@ export function holdingsFromCsvRows(
const order: string[] = []; const order: string[] = [];
const bySymbol = new Map< const bySymbol = new Map<
string, string,
{ symbol: string; qty: number; book: number | null; price: string } {
symbol: string;
qty: number | null;
qtyRaw: string;
book: number | null;
price: string;
}
>(); >();
for (const row of rows) { for (const row of rows) {
@ -188,8 +196,14 @@ export function holdingsFromCsvRows(
const symbol = normalizeSecuritySymbol(rawSymbol); const symbol = normalizeSecuritySymbol(rawSymbol);
if (!symbol) continue; if (!symbol) continue;
const qtyParsed = parseFrenchAmount((row[mapping.quantity] ?? "").trim()); // An unreadable quantity used to be coerced to 0, which SAVED a
const qty = isNaN(qtyParsed) ? 0 : qtyParsed; // zero-value position without a word (#325). It stays `null` now and the
// draft keeps the offending text, so `buildDetailedLines` raises the
// existing `snapshot_priced_quantity_required` and the user sees the cell
// to correct.
const qtyRaw = (row[mapping.quantity] ?? "").trim();
const qtyParsed = parseFrenchAmount(qtyRaw);
const qty = isNaN(qtyParsed) ? null : qtyParsed;
let price = ""; let price = "";
if (mapping.unit_price !== null) { if (mapping.unit_price !== null) {
@ -205,12 +219,25 @@ export function holdingsFromCsvRows(
const existing = bySymbol.get(symbol); const existing = bySymbol.get(symbol);
if (existing) { if (existing) {
// One unreadable lot taints the merged quantity: summing it as 0 would
// hide the bad cell behind a plausible total.
if (existing.qty === null || qty === null) {
existing.qty = null;
if (!existing.qtyRaw) existing.qtyRaw = qtyRaw;
} else {
existing.qty += qty; existing.qty += qty;
}
if (book !== null) existing.book = (existing.book ?? 0) + book; if (book !== null) existing.book = (existing.book ?? 0) + book;
if (!existing.price && price) existing.price = price; // first non-empty if (!existing.price && price) existing.price = price; // first non-empty
} else { } else {
order.push(symbol); order.push(symbol);
bySymbol.set(symbol, { symbol, qty, book, price }); bySymbol.set(symbol, {
symbol,
qty,
qtyRaw: qty === null ? qtyRaw : "",
book,
price,
});
} }
} }
@ -219,7 +246,7 @@ export function holdingsFromCsvRows(
return { return {
...makeEmptyHolding(defaultAssetType), ...makeEmptyHolding(defaultAssetType),
symbol: a.symbol, symbol: a.symbol,
quantity: String(a.qty), quantity: a.qty !== null ? String(a.qty) : a.qtyRaw,
unit_price: a.price, unit_price: a.price,
book_cost: a.book !== null ? String(a.book) : "", book_cost: a.book !== null ? String(a.book) : "",
}; };

View file

@ -16,7 +16,8 @@
"budget": "Budget", "budget": "Budget",
"reports": "Reports", "reports": "Reports",
"balance": "Balance sheet", "balance": "Balance sheet",
"settings": "Settings" "settings": "Settings",
"locked": "Locked feature"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@ -112,7 +113,27 @@
"templateSaved": "Template saved", "templateSaved": "Template saved",
"deleteTemplate": "Delete template", "deleteTemplate": "Delete template",
"noTemplates": "No templates saved", "noTemplates": "No templates saved",
"updateTemplate": "Update template" "updateTemplate": "Update template",
"detectionRecognized": "Format recognized — {{read}} of {{total}} rows read",
"detectionUncertain": "Uncertain format — only {{read}} of {{total}} rows read",
"detectionUncertainHint": "Check the column mapping and the date format, then look at the preview before importing.",
"detectionBank": "{{bank}} format recognized — {{read}} of {{total}} rows read"
},
"drift": {
"title": "This source's format has changed",
"intro": "This file's header row no longer matches the one recorded at the last successful import. The stored columns may no longer point at the right data.",
"columnMoved": "{{label}}: column {{from}} → {{to}}",
"columnAdded": "{{label}}: new column, at position {{to}}",
"columnRemoved": "{{label}}: column gone, it was at position {{from}}",
"adopt": "Adopt the re-detected format",
"adoptHint": "Reads the file the way it looks today. The new configuration is saved at import time.",
"adoptUnavailable": "Detection could not read this new shape: fix the mapping at the previous step.",
"keep": "Keep the current configuration",
"keepHint": "Keeps the stored mapping. Use this when the header changed but the columns did not move."
},
"repairPath": {
"title": "Repairing an import that was already written wrong",
"body": "Do not re-import a file you have already imported in order to correct it: duplicates are matched on the date, the description AND the amount, so a row with a corrected amount does not match the faulty row — it is added next to it. A flipped sign also produces two mirror rows that cancel each other out in every report. Delete the faulty import from the import history first, then replay the file."
}, },
"preview": { "preview": {
"title": "Data Preview", "title": "Data Preview",
@ -123,7 +144,12 @@
"description": "Description", "description": "Description",
"amount": "Amount", "amount": "Amount",
"raw": "Raw data", "raw": "Raw data",
"moreRows": "... and {{count}} more row(s)" "moreRows": "... and {{count}} more row(s)",
"outflowCount": "{{count}} outflow(s)",
"inflowCount": "{{count}} inflow(s)",
"errorRows": "Rows in error",
"flipSigns": "Flip the signs",
"flipSignsHint": "Corrects the source configuration, not just this preview: the correction is remembered for the next imports."
}, },
"duplicates": { "duplicates": {
"title": "Duplicate Detection", "title": "Duplicate Detection",
@ -145,7 +171,8 @@
"files": "Files", "files": "Files",
"settings": "Settings", "settings": "Settings",
"rowsToImport": "Rows to import", "rowsToImport": "Rows to import",
"rowsSummary": "{{count}} row(s) to import, {{skipped}} duplicate(s) skipped" "rowsSummary": "{{count}} row(s) to import, {{skipped}} duplicate(s) skipped",
"columnUnmapped": "not mapped"
}, },
"progress": { "progress": {
"title": "Import in Progress", "title": "Import in Progress",
@ -185,6 +212,19 @@
"confirm": "Confirm", "confirm": "Confirm",
"import": "Import" "import": "Import"
}, },
"errors": {
"unsupportedAmountMode": "The amount mode saved for this source is not recognized. Reconfigure the source before importing.",
"unsupportedSignConvention": "The sign convention saved for this source is not recognized. Reconfigure the source before importing.",
"invalidColumnMapping": "The column mapping saved for this source cannot be read. Reconfigure the source before importing.",
"absoluteIndicatorFormat": "This file uses positive amounts with a separate column giving the direction (D for debit, C for credit). That format is not supported yet: importing it now would record every deposit as an expense. Export the statement with signed amounts, or with separate debit and credit columns.",
"autoDetectFailed": "Auto-detection could not read this file. Configure the format manually."
},
"rowErrors": {
"invalidDate": "Unreadable date",
"invalidAmount": "Unreadable amount",
"amountColumnNotMapped": "Amount column not mapped",
"parseError": "Unreadable row"
},
"help": { "help": {
"title": "How to import bank statements", "title": "How to import bank statements",
"tips": [ "tips": [
@ -580,12 +620,19 @@
"countSuppliers": "{{count}} supplier(s)", "countSuppliers": "{{count}} supplier(s)",
"countKeywords": "{{count}} keyword(s)", "countKeywords": "{{count}} keyword(s)",
"countTransactions": "{{count}} transaction(s)", "countTransactions": "{{count}} transaction(s)",
"countImportSources": "{{count}} import source(s)",
"countImportTemplates": "{{count}} import template(s)",
"irreversibleWarning": "This action is irreversible. All existing data of the selected type will be permanently deleted and replaced.", "irreversibleWarning": "This action is irreversible. All existing data of the selected type will be permanently deleted and replaced.",
"typeToConfirm": "Type \"{{word}}\" to confirm:", "typeToConfirm": "Type \"{{word}}\" to confirm:",
"confirmWord": "REPLACE", "confirmWord": "REPLACE",
"replaceButton": "Replace Data", "replaceButton": "Replace Data",
"success": "Import completed successfully", "success": "Import completed successfully",
"tryAgain": "Try again" "tryAgain": "Try again",
"errors": {
"unsupportedAmountMode": "This file cannot be imported: the source \"{{name}}\" was saved with an amount mode this version does not support ({{value}}). Nothing has been modified.",
"unsupportedSignConvention": "This file cannot be imported: the source \"{{name}}\" was saved with a sign convention this version does not support ({{value}}). Nothing has been modified.",
"invalidFormatRow": "This file cannot be imported: the import configuration of \"{{name}}\" is incomplete or damaged. Nothing has been modified."
}
} }
}, },
"userGuide": { "userGuide": {
@ -808,8 +855,12 @@
"title": "Import", "title": "Import",
"overview": "Import bank statements from CSV files using a step-by-step wizard. Each bank account is represented as a source folder.", "overview": "Import bank statements from CSV files using a step-by-step wizard. Each bank account is represented as a source folder.",
"features": [ "features": [
"Multi-step import wizard with data preview", "Automatic format detection when you open a source that was never configured: delimiter, encoding, date format, and columns recognised by their header label (French and English)",
"Configurable column mapping, delimiter, and date format", "Bank layouts recognised by name — Desjardins, RBC, National Bank and Tangerine are read by their documented export format",
"A confidence score shown after detection (“147 of 150 rows read”): it tells you what could be read, never whether the meaning is right",
"A mandatory preview before every import, with a signed recap: outflows, inflows, totals and rows in error, plus a “Flip the signs” button",
"A warning when a file's header no longer matches the one from the last successful import, column by column",
"Configurable column mapping; delimiter, encoding and date format editable at any time",
"Automatic duplicate detection (within batch and against existing data)", "Automatic duplicate detection (within batch and against existing data)",
"Import templates to save and reuse source configurations", "Import templates to save and reuse source configurations",
"Import history with the ability to delete past imports" "Import history with the ability to delete past imports"
@ -817,15 +868,23 @@
"steps": [ "steps": [
"Set your import folder via the folder picker at the top of the page", "Set your import folder via the folder picker at the top of the page",
"Create a subfolder for each bank/source and place CSV files inside", "Create a subfolder for each bank/source and place CSV files inside",
"Click on a source to open the import wizard", "Click on a source to open the import wizard — the first time, the format is detected automatically and a banner announces the result",
"Configure the delimiter, encoding, date format, and column mapping", "Check the proposed configuration (delimiter, encoding, date format, column mapping) and correct it if the banner reports an uncertain format",
"Select which files to import and preview the parsed data", "Select which files to import",
"Read the preview: the recap tells you how many outflows and inflows the file will produce, and for what amounts",
"If inflows and outflows are swapped, click “Flip the signs” — the correction is saved with the source, so the next files from that bank read correctly on their own",
"Check for duplicates, review the summary, then confirm the import" "Check for duplicates, review the summary, then confirm the import"
], ],
"tips": [ "tips": [
"Save your configuration as a template so you don't have to reconfigure each time", "The preview recap is the only check that sees what the amounts mean: a 100 % score means every row was read, not that your expenses are not being counted as income. Take two seconds to read it.",
"Your configuration is stored in full on the source, including the amount mode and the sign convention: a source configured once is never guessed at again on later imports",
"If your bank changes its column layout, a panel reports it before the import and offers two choices: adopt the newly detected format, or keep the stored one",
"To repair an import that was already written the wrong way round, do not re-import the file on top of it: duplicates are matched on date, description AND amount, so a corrected row is added instead of replacing the faulty one. Delete the faulty import from the history first, then replay the file.",
"A statement with positive amounts plus a “D / C” column is refused rather than imported backwards: re-export it from your bank with signed amounts, or with separate debit and credit columns",
"Save your configuration as a template so you don't have to reconfigure each time. A template initialises a source; editing it afterwards changes no source you have already configured.",
"Files already imported are marked with a badge — re-importing them will trigger duplicate detection", "Files already imported are marked with a badge — re-importing them will trigger duplicate detection",
"You can delete an import from the history to remove all its transactions" "You can delete an import from the history to remove all its transactions",
"Your import sources and templates are now part of the encrypted backup: restoring a backup no longer forces you to reconfigure everything"
] ]
}, },
"transactions": { "transactions": {
@ -1040,6 +1099,27 @@
"If you encounter an issue, copy the logs and attach them to your report", "If you encounter an issue, copy the logs and attach them to your report",
"Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry" "Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry"
] ]
},
"editions": {
"title": "Editions",
"overview": "Simpl'Résultat comes in three editions — Free, Base and Premium. The edition determines which modules are accessible; it never touches your data, which stays local and complete whatever the active edition.",
"features": [
"Free — Dashboard, CSV Import, Transactions, Categories, the Trends report (and the Reports hub), encrypted export/import and the changelog, with a single profile",
"Base — everything in Free, plus Budget, Adjustments, the advanced reports (Highlights, Compare, Category analysis, Cards), multiple profiles and automatic updates",
"Premium — everything in Base, plus the full Balance module (net worth, per-security detail, market prices)",
"Modules above your edition stay visible but locked: a lock badge shows in the sidebar and on the report tiles — opening one shows the unlock screen"
],
"steps": [
"Look for the lock badge in the sidebar or on the Reports hub tiles: it marks the modules above your edition",
"Click a locked module to see which edition it requires",
"If you have a license key, click \"I already have a key\" (or go to Settings → Users) and enter it",
"Modules unlock immediately — no reinstall or restart needed; online purchase is coming soon, the \"Get\" button will be enabled once the store is live"
],
"tips": [
"Locking is never destructive: if your edition goes down (expired key, machine change), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
"On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked",
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer"
]
} }
}, },
"profile": { "profile": {
@ -1124,6 +1204,19 @@
"noMachines": "No machines activated" "noMachines": "No machines activated"
} }
}, },
"upsell": {
"title": "{{tier}} feature",
"features": {
"budget": "Plan monthly budgets by category and compare them against your actual spending.",
"adjustments": "Add manual entries and split transactions across multiple categories.",
"reports-advanced": "Explore the advanced reports: Highlights, Compare, Category Analysis and Cards.",
"multi-profile": "Create multiple profiles, each with its own local database and PIN protection.",
"balance": "Track your net worth: accounts, snapshots, per-security detail and market prices."
},
"ctaGet": "Get {{tier}}",
"ctaGetSoon": "Online purchase coming soon",
"ctaHaveKey": "I already have a key"
},
"account": { "account": {
"title": "Maximus Account", "title": "Maximus Account",
"optional": "Optional", "optional": "Optional",
@ -1483,11 +1576,9 @@
"total": "Total" "total": "Total"
}, },
"preserved": { "preserved": {
"title_one": "{{count}} custom category preserved", "title_one": "{{count}} custom category",
"title_other": "{{count}} custom categories preserved", "title_other": "{{count}} custom categories",
"body": "Your custom categories will be grouped under the parent \"Custom categories (migration)\". You can move or rename them at your own pace after the migration.", "body": "These categories have no standard equivalent. Pick a target to merge a category: its transactions, budgets, keywords and suppliers are reassigned to it and the category disappears. Leave the field empty to keep it as-is under \"Custom categories (migration)\"."
"txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions"
}, },
"panel": { "panel": {
"title": "Affected transactions", "title": "Affected transactions",

View file

@ -16,7 +16,8 @@
"budget": "Budget", "budget": "Budget",
"reports": "Rapports", "reports": "Rapports",
"balance": "Bilan", "balance": "Bilan",
"settings": "Paramètres" "settings": "Paramètres",
"locked": "Fonctionnalité verrouillée"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@ -112,7 +113,27 @@
"templateSaved": "Modèle sauvegardé", "templateSaved": "Modèle sauvegardé",
"deleteTemplate": "Supprimer le modèle", "deleteTemplate": "Supprimer le modèle",
"noTemplates": "Aucun modèle sauvegardé", "noTemplates": "Aucun modèle sauvegardé",
"updateTemplate": "Mettre à jour le modèle" "updateTemplate": "Mettre à jour le modèle",
"detectionRecognized": "Format reconnu — {{read}} des {{total}} lignes lues",
"detectionUncertain": "Format incertain — {{read}} des {{total}} lignes lues seulement",
"detectionUncertainHint": "Vérifiez le mapping des colonnes et le format de date, puis regardez l'aperçu avant d'importer.",
"detectionBank": "Format {{bank}} reconnu — {{read}} des {{total}} lignes lues"
},
"drift": {
"title": "Le format de cette source a changé",
"intro": "L'en-tête de ce fichier ne correspond plus à celui du dernier import réussi. Les colonnes mémorisées ne pointent peut-être plus sur les bonnes données.",
"columnMoved": "{{label}} : colonne {{from}} → {{to}}",
"columnAdded": "{{label}} : nouvelle colonne, en position {{to}}",
"columnRemoved": "{{label}} : colonne disparue, elle était en position {{from}}",
"adopt": "Adopter le format re-détecté",
"adoptHint": "Relit le fichier tel qu'il se présente aujourd'hui. La nouvelle configuration est mémorisée à l'import.",
"adoptUnavailable": "La détection n'a pas su lire cette nouvelle forme : corrigez le mapping à l'étape précédente.",
"keep": "Conserver la configuration actuelle",
"keepHint": "Garde le mapping mémorisé. À utiliser si l'en-tête a changé sans que les colonnes bougent."
},
"repairPath": {
"title": "Réparer un import déjà écrit de travers",
"body": "Ne réimportez pas un fichier déjà importé pour le corriger : les doublons sont repérés sur la date, la description ET le montant, donc une ligne au montant corrigé ne s'apparie pas à la ligne fautive — elle s'ajoute. Une inversion de signe crée en prime deux lignes miroir qui s'annulent dans les rapports. Supprimez d'abord l'import fautif dans l'historique des imports, puis rejouez le fichier."
}, },
"preview": { "preview": {
"title": "Aperçu des données", "title": "Aperçu des données",
@ -123,7 +144,12 @@
"description": "Description", "description": "Description",
"amount": "Montant", "amount": "Montant",
"raw": "Données brutes", "raw": "Données brutes",
"moreRows": "... et {{count}} ligne(s) supplémentaire(s)" "moreRows": "... et {{count}} ligne(s) supplémentaire(s)",
"outflowCount": "{{count}} sortie(s)",
"inflowCount": "{{count}} entrée(s)",
"errorRows": "Lignes en erreur",
"flipSigns": "Inverser les signes",
"flipSignsHint": "Corrige la configuration de la source, pas seulement cet aperçu : la correction est mémorisée pour les prochains imports."
}, },
"duplicates": { "duplicates": {
"title": "Détection des doublons", "title": "Détection des doublons",
@ -145,7 +171,8 @@
"files": "Fichiers", "files": "Fichiers",
"settings": "Paramètres", "settings": "Paramètres",
"rowsToImport": "Lignes à importer", "rowsToImport": "Lignes à importer",
"rowsSummary": "{{count}} ligne(s) à importer, {{skipped}} doublon(s) ignoré(s)" "rowsSummary": "{{count}} ligne(s) à importer, {{skipped}} doublon(s) ignoré(s)",
"columnUnmapped": "non associée"
}, },
"progress": { "progress": {
"title": "Import en cours", "title": "Import en cours",
@ -185,6 +212,19 @@
"confirm": "Confirmer", "confirm": "Confirmer",
"import": "Importer" "import": "Importer"
}, },
"errors": {
"unsupportedAmountMode": "Le mode de montant enregistré pour cette source n'est pas reconnu. Reconfigurez la source avant d'importer.",
"unsupportedSignConvention": "La convention de signe enregistrée pour cette source n'est pas reconnue. Reconfigurez la source avant d'importer.",
"invalidColumnMapping": "Le mapping de colonnes enregistré pour cette source est illisible. Reconfigurez la source avant d'importer.",
"absoluteIndicatorFormat": "Ce fichier utilise des montants positifs accompagnés d'une colonne indiquant le sens (D pour débit, C pour crédit). Ce format n'est pas encore pris en charge : l'importer maintenant enregistrerait chaque dépôt comme une dépense. Exportez le relevé avec des montants signés ou avec deux colonnes débit et crédit.",
"autoDetectFailed": "La détection automatique n'a pas pu lire ce fichier. Configurez le format manuellement."
},
"rowErrors": {
"invalidDate": "Date illisible",
"invalidAmount": "Montant illisible",
"amountColumnNotMapped": "Colonne de montant non mappée",
"parseError": "Ligne illisible"
},
"help": { "help": {
"title": "Comment importer des relevés bancaires", "title": "Comment importer des relevés bancaires",
"tips": [ "tips": [
@ -580,12 +620,19 @@
"countSuppliers": "{{count}} fournisseur(s)", "countSuppliers": "{{count}} fournisseur(s)",
"countKeywords": "{{count}} mot(s)-clé(s)", "countKeywords": "{{count}} mot(s)-clé(s)",
"countTransactions": "{{count}} transaction(s)", "countTransactions": "{{count}} transaction(s)",
"countImportSources": "{{count}} source(s) d'import",
"countImportTemplates": "{{count}} modèle(s) d'import",
"irreversibleWarning": "Cette action est irréversible. Toutes les données existantes du type sélectionné seront définitivement supprimées et remplacées.", "irreversibleWarning": "Cette action est irréversible. Toutes les données existantes du type sélectionné seront définitivement supprimées et remplacées.",
"typeToConfirm": "Tapez « {{word}} » pour confirmer :", "typeToConfirm": "Tapez « {{word}} » pour confirmer :",
"confirmWord": "REMPLACER", "confirmWord": "REMPLACER",
"replaceButton": "Remplacer les données", "replaceButton": "Remplacer les données",
"success": "Import terminé avec succès", "success": "Import terminé avec succès",
"tryAgain": "Réessayer" "tryAgain": "Réessayer",
"errors": {
"unsupportedAmountMode": "Ce fichier ne peut pas être importé : la source « {{name}} » a été enregistrée avec un mode de montant que cette version ne prend pas en charge ({{value}}). Rien n'a été modifié.",
"unsupportedSignConvention": "Ce fichier ne peut pas être importé : la source « {{name}} » a été enregistrée avec une convention de signe que cette version ne prend pas en charge ({{value}}). Rien n'a été modifié.",
"invalidFormatRow": "Ce fichier ne peut pas être importé : la configuration d'import de « {{name}} » est incomplète ou endommagée. Rien n'a été modifié."
}
} }
}, },
"userGuide": { "userGuide": {
@ -808,8 +855,12 @@
"title": "Import", "title": "Import",
"overview": "Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assistant étape par étape. Chaque compte bancaire est représenté par un dossier source.", "overview": "Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assistant étape par étape. Chaque compte bancaire est représenté par un dossier source.",
"features": [ "features": [
"Assistant d'import multi-étapes avec aperçu des données", "Détection automatique du format à l'ouverture d'une source jamais configurée : délimiteur, encodage, format de date et colonnes reconnues par leur libellé (français et anglais)",
"Mapping de colonnes configurable, délimiteur et format de date", "Formats reconnus par banque — Desjardins, RBC, Banque Nationale et Tangerine sont lus par leur nom",
"Score de confiance affiché après détection (« 147 des 150 lignes lues ») : il indique ce qui a pu être lu, jamais si le sens est bon",
"Aperçu obligatoire avant chaque import, avec un récapitulatif signé : sorties, entrées, totaux et lignes en erreur, plus un bouton « Inverser les signes »",
"Avertissement quand l'en-tête d'un fichier ne correspond plus à celui du dernier import réussi, colonne par colonne",
"Mapping de colonnes configurable, délimiteur, encodage et format de date modifiables à tout moment",
"Détection automatique des doublons (dans le lot et contre les données existantes)", "Détection automatique des doublons (dans le lot et contre les données existantes)",
"Modèles d'import pour sauvegarder et réutiliser les configurations", "Modèles d'import pour sauvegarder et réutiliser les configurations",
"Historique des imports avec possibilité de supprimer les imports précédents" "Historique des imports avec possibilité de supprimer les imports précédents"
@ -817,15 +868,23 @@
"steps": [ "steps": [
"Définissez votre dossier d'import via le sélecteur de dossier en haut de la page", "Définissez votre dossier d'import via le sélecteur de dossier en haut de la page",
"Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV", "Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV",
"Cliquez sur une source pour ouvrir l'assistant d'import", "Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat",
"Configurez le délimiteur, l'encodage, le format de date et le mapping des colonnes", "Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain",
"Sélectionnez les fichiers à importer et prévisualisez les données analysées", "Sélectionnez les fichiers à importer",
"Examinez l'aperçu : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants",
"Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de cette banque se liront correctement d'eux-mêmes",
"Vérifiez les doublons, examinez le résumé, puis confirmez l'import" "Vérifiez les doublons, examinez le résumé, puis confirmez l'import"
], ],
"tips": [ "tips": [
"Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois", "Le récapitulatif de l'aperçu est le seul contrôle qui voit le sens des montants : un score de 100 % signifie que chaque ligne a été lue, pas que vos dépenses ne sont pas comptées comme des revenus. Prenez deux secondes pour le lire.",
"Votre configuration est mémorisée intégralement sur la source, y compris le mode de montant et la convention de signe : une source configurée une fois n'est plus jamais re-devinée aux imports suivants",
"Si votre banque change la disposition de ses colonnes, un panneau le signale avant l'import et propose deux choix : adopter le nouveau format détecté, ou conserver celui enregistré",
"Pour réparer un import déjà écrit de travers, ne ré-importez pas le fichier par-dessus : les doublons sont repérés sur la date, la description ET le montant, donc une ligne corrigée s'ajoute au lieu de remplacer la fautive. Supprimez d'abord l'import fautif dans l'historique, puis rejouez le fichier.",
"Un relevé à montants positifs accompagné d'une colonne « D / C » est refusé plutôt qu'importé à l'envers : réexportez-le depuis votre banque avec des montants signés, ou avec deux colonnes débit et crédit séparées",
"Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois. Un modèle sert à initialiser une source ; le modifier ensuite ne change aucune source déjà configurée.",
"Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons", "Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons",
"Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions" "Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions",
"Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer"
] ]
}, },
"transactions": { "transactions": {
@ -1040,6 +1099,27 @@
"En cas de problème, copiez les journaux et joignez-les à votre signalement", "En cas de problème, copiez les journaux et joignez-les à votre signalement",
"Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique" "Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique"
] ]
},
"editions": {
"title": "Éditions",
"overview": "Simpl'Résultat existe en trois éditions — Gratuite, Base et Premium. L'édition détermine quels modules sont accessibles ; elle ne touche jamais à vos données, qui restent locales et complètes quelle que soit l'édition active.",
"features": [
"Gratuite — Tableau de bord, Import CSV, Transactions, Catégories, rapport Tendances (et le hub Rapports), export/import chiffré et journal des modifications, avec un profil",
"Base — tout de la Gratuite, plus Budget, Ajustements, les rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes), les profils multiples et les mises à jour automatiques",
"Premium — tout de la Base, plus le module Bilan complet (patrimoine, détail par titre, cours du marché)",
"Les modules au-dessus de votre édition restent visibles mais verrouillés : cadenas dans la barre latérale et sur les tuiles de rapports — les ouvrir affiche l'écran de déverrouillage"
],
"steps": [
"Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition",
"Cliquez sur un module verrouillé pour voir l'édition requise",
"Si vous avez une clé de licence, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la",
"Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire ; l'achat en ligne arrive bientôt, le bouton « Obtenir » sera activé quand la boutique sera en ligne"
],
"tips": [
"Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, changement de machine), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée",
"En édition Gratuite, votre profil actif reste toujours accessible — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés",
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste"
]
} }
}, },
"profile": { "profile": {
@ -1124,6 +1204,19 @@
"noMachines": "Aucune machine activée" "noMachines": "Aucune machine activée"
} }
}, },
"upsell": {
"title": "Fonctionnalité {{tier}}",
"features": {
"budget": "Planifiez des budgets mensuels par catégorie et comparez-les à vos dépenses réelles.",
"adjustments": "Ajoutez des écritures manuelles et fractionnez des transactions sur plusieurs catégories.",
"reports-advanced": "Explorez les rapports avancés : Faits saillants, Comparables, Analyse par catégorie et Cartes.",
"multi-profile": "Créez plusieurs profils, chacun avec sa base de données locale et sa protection par NIP.",
"balance": "Suivez votre patrimoine : comptes, instantanés, détail par titre et cours du marché."
},
"ctaGet": "Obtenir {{tier}}",
"ctaGetSoon": "Achat en ligne bientôt disponible",
"ctaHaveKey": "J'ai déjà une clé"
},
"account": { "account": {
"title": "Compte Maximus", "title": "Compte Maximus",
"optional": "Optionnel", "optional": "Optionnel",
@ -1483,11 +1576,9 @@
"total": "Total" "total": "Total"
}, },
"preserved": { "preserved": {
"title_one": "{{count}} catégorie personnalisée préservée", "title_one": "{{count}} catégorie personnalisée",
"title_other": "{{count}} catégories personnalisées préservées", "title_other": "{{count}} catégories personnalisées",
"body": "Vos catégories personnalisées seront regroupées sous le parent « Catégories personnalisées (migration) ». Vous pourrez les déplacer ou les renommer à votre rythme après la migration.", "body": "Ces catégories n'ont pas d'équivalent standard. Choisissez une cible pour fusionner une catégorie : ses transactions, budgets, mots-clés et fournisseurs y sont réassignés et la catégorie disparaît. Laissez le champ vide pour la conserver telle quelle sous « Catégories personnalisées (migration) »."
"txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions"
}, },
"panel": { "panel": {
"title": "Transactions impactées", "title": "Transactions impactées",

View file

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { LicenseProvider } from "./contexts/LicenseContext";
import { ProfileProvider } from "./contexts/ProfileContext"; import { ProfileProvider } from "./contexts/ProfileContext";
import ErrorBoundary from "./components/shared/ErrorBoundary"; import ErrorBoundary from "./components/shared/ErrorBoundary";
import { initLogCapture } from "./services/logService"; import { initLogCapture } from "./services/logService";
@ -11,10 +12,12 @@ initLogCapture();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<LicenseProvider>
<ProfileProvider> <ProfileProvider>
<ErrorBoundary> <ErrorBoundary>
<App /> <App />
</ErrorBoundary> </ErrorBoundary>
</ProfileProvider> </ProfileProvider>
</LicenseProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View file

@ -1,17 +1,17 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useImportWizard } from "../hooks/useImportWizard"; import { useImportWizard } from "../hooks/useImportWizard";
import ImportFolderConfig from "../components/import/ImportFolderConfig"; import ImportFolderConfig from "../components/import/ImportFolderConfig";
import SourceList from "../components/import/SourceList"; import SourceList from "../components/import/SourceList";
import SourceConfigPanel from "../components/import/SourceConfigPanel"; import SourceConfigPanel from "../components/import/SourceConfigPanel";
import FilePreviewTable from "../components/import/FilePreviewTable";
import FormatDriftPanel from "../components/import/FormatDriftPanel";
import DuplicateCheckPanel from "../components/import/DuplicateCheckPanel"; import DuplicateCheckPanel from "../components/import/DuplicateCheckPanel";
import ImportConfirmation from "../components/import/ImportConfirmation"; import ImportConfirmation from "../components/import/ImportConfirmation";
import ImportProgress from "../components/import/ImportProgress"; import ImportProgress from "../components/import/ImportProgress";
import ImportReportPanel from "../components/import/ImportReportPanel"; import ImportReportPanel from "../components/import/ImportReportPanel";
import WizardNavigation from "../components/import/WizardNavigation"; import WizardNavigation from "../components/import/WizardNavigation";
import ImportHistoryPanel from "../components/import/ImportHistoryPanel"; import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
import FilePreviewModal from "../components/import/FilePreviewModal"; import { AlertCircle } from "lucide-react";
import { AlertCircle, Eye, X, ChevronLeft } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
export default function ImportPage() { export default function ImportPage() {
@ -24,11 +24,14 @@ export default function ImportPage() {
updateConfig, updateConfig,
toggleFile, toggleFile,
selectAllFiles, selectAllFiles,
parsePreview, parseAndPreview,
parseAndCheckDuplicates, checkDuplicates,
flipSignConvention,
executeImport, executeImport,
goToStep, goToStep,
reset, reset,
adoptDriftFormat,
keepCurrentFormat,
autoDetectConfig, autoDetectConfig,
saveConfigAsTemplate, saveConfigAsTemplate,
applyConfigTemplate, applyConfigTemplate,
@ -38,13 +41,6 @@ export default function ImportPage() {
setSkipAllDuplicates, setSkipAllDuplicates,
} = useImportWizard(); } = useImportWizard();
const [showPreviewModal, setShowPreviewModal] = useState(false);
const handlePreview = useCallback(async () => {
await parsePreview();
setShowPreviewModal(true);
}, [parsePreview]);
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name; const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
return ( return (
@ -58,8 +54,13 @@ export default function ImportPage() {
{state.error && ( {state.error && (
<div className="mb-4 p-3 rounded-xl bg-[var(--card)] border-2 border-[var(--negative)] flex items-center gap-2"> <div className="mb-4 p-3 rounded-xl bg-[var(--card)] border-2 border-[var(--negative)] flex items-center gap-2">
<AlertCircle size={16} className="text-[var(--negative)] shrink-0" /> <AlertCircle size={16} className="text-[var(--negative)] shrink-0" />
{/*
The wizard reports a translation key when it has one (a stored
format it cannot decode) and a raw message otherwise; `defaultValue`
renders the latter unchanged.
*/}
<p className="text-sm text-[var(--foreground)]"> <p className="text-sm text-[var(--foreground)]">
{state.error} {t(state.error, { defaultValue: state.error })}
</p> </p>
</div> </div>
)} )}
@ -103,43 +104,54 @@ export default function ImportPage() {
onUpdateTemplate={updateConfigTemplate} onUpdateTemplate={updateConfigTemplate}
onDeleteTemplate={deleteConfigTemplate} onDeleteTemplate={deleteConfigTemplate}
selectedTemplateId={state.selectedTemplateId} selectedTemplateId={state.selectedTemplateId}
detectionScore={state.detectionScore}
detectedBank={state.detectedBank}
isLoading={state.isLoading} isLoading={state.isLoading}
/> />
<div className="flex items-center justify-between pt-6 border-t border-[var(--border)]"> {/*
<div> One way forward, and it goes through the preview (#329). The pair of
<button buttons this replaces offered "Aperçu" as an optional detour and
onClick={reset} "Vérifier les doublons" as the real path, so the totals were the one
className="flex items-center gap-1 px-4 py-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" screen an import never had to show.
> */}
<X size={16} /> <WizardNavigation
{t("common.cancel")} onBack={() => goToStep("source-list")}
</button> onNext={parseAndPreview}
</div> onCancel={reset}
<div className="flex items-center gap-3"> nextLabel={t("import.wizard.preview")}
<button nextDisabled={nextDisabled || state.isLoading}
onClick={() => goToStep("source-list")} />
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ChevronLeft size={16} />
{t("import.wizard.back")}
</button>
<button
onClick={handlePreview}
disabled={nextDisabled || state.isLoading}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eye size={16} />
{t("import.wizard.preview")}
</button>
<button
onClick={parseAndCheckDuplicates}
disabled={nextDisabled || state.isLoading}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("import.wizard.checkDuplicates")}
</button>
</div>
</div> </div>
)}
{state.step === "file-preview" && (
<div className="space-y-6">
{/*
Format drift (#330) ABOVE the table, because it tells the user
whether the table below is worth reading. Present only when the
header row moved since the last successful import of this source.
*/}
{state.formatDrift && (
<FormatDriftPanel
entries={state.formatDrift}
canAdopt={state.driftConfig !== null}
onAdopt={adoptDriftFormat}
onKeep={keepCurrentFormat}
isBusy={state.isLoading}
/>
)}
<FilePreviewTable
rows={state.parsedPreview}
onFlipSigns={flipSignConvention}
isFlipping={state.isLoading}
/>
<WizardNavigation
onBack={() => goToStep("source-config")}
onNext={checkDuplicates}
onCancel={reset}
nextLabel={t("import.wizard.checkDuplicates")}
nextDisabled={state.isLoading}
/>
</div> </div>
)} )}
@ -153,7 +165,7 @@ export default function ImportPage() {
onIncludeAll={() => setSkipAllDuplicates(false)} onIncludeAll={() => setSkipAllDuplicates(false)}
/> />
<WizardNavigation <WizardNavigation
onBack={() => goToStep("source-config")} onBack={() => goToStep("file-preview")}
onNext={() => goToStep("confirm")} onNext={() => goToStep("confirm")}
onCancel={reset} onCancel={reset}
nextLabel={t("import.wizard.confirm")} nextLabel={t("import.wizard.confirm")}
@ -166,6 +178,7 @@ export default function ImportPage() {
<ImportConfirmation <ImportConfirmation
sourceName={state.sourceConfig.name} sourceName={state.sourceConfig.name}
config={state.sourceConfig} config={state.sourceConfig}
headers={state.previewHeaders}
selectedFiles={state.selectedFiles} selectedFiles={state.selectedFiles}
duplicateResult={state.duplicateResult} duplicateResult={state.duplicateResult}
excludedCount={state.excludedDuplicateIndices.size} excludedCount={state.excludedDuplicateIndices.size}
@ -191,15 +204,6 @@ export default function ImportPage() {
{state.step === "report" && state.importReport && ( {state.step === "report" && state.importReport && (
<ImportReportPanel report={state.importReport} onDone={reset} /> <ImportReportPanel report={state.importReport} onDone={reset} />
)} )}
{/* Preview modal */}
{showPreviewModal && state.parsedPreview.length > 0 && (
<FilePreviewModal
rows={state.parsedPreview.slice(0, 20)}
totalCount={state.parsedPreview.length}
onClose={() => setShowPreviewModal(false)}
/>
)}
</div> </div>
); );
} }

View file

@ -2,6 +2,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Lock, Plus } from "lucide-react"; import { Lock, Plus } from "lucide-react";
import { useProfile } from "../contexts/ProfileContext"; import { useProfile } from "../contexts/ProfileContext";
import { useEntitlement } from "../hooks/useEntitlement";
import { isProfileCreationLocked } from "../shared/profileGate";
import { APP_NAME } from "../shared/constants"; import { APP_NAME } from "../shared/constants";
import PinDialog from "../components/profile/PinDialog"; import PinDialog from "../components/profile/PinDialog";
import ProfileFormModal from "../components/profile/ProfileFormModal"; import ProfileFormModal from "../components/profile/ProfileFormModal";
@ -9,6 +11,13 @@ import ProfileFormModal from "../components/profile/ProfileFormModal";
export default function ProfileSelectionPage() { export default function ProfileSelectionPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, switchProfile, updateProfile } = useProfile(); const { profiles, switchProfile, updateProfile } = useProfile();
// Multi-profile gate (Base+, #300). This page renders only when no active
// profile resolves, so profile SELECTION stays free (never lock a user out
// of all of their profiles — see decisions log); only the creation entry is
// marked. The gate itself lives at the single creation point,
// ProfileFormModal, which this page opens.
const gate = useEntitlement("multi-profile");
const creationLocked = isProfileCreationLocked(profiles.length, gate);
const [pinProfileId, setPinProfileId] = useState<string | null>(null); const [pinProfileId, setPinProfileId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@ -66,10 +75,19 @@ export default function ProfileSelectionPage() {
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="flex flex-col items-center justify-center gap-3 p-6 rounded-xl border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer" title={creationLocked ? t("nav.locked") : undefined}
className={`flex flex-col items-center justify-center gap-3 p-6 rounded-xl border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer ${
creationLocked ? "opacity-60" : ""
}`}
> >
<div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--muted)]"> <div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--muted)]">
{/* Locked-not-hidden: the entry stays visible with a lock; the
modal's single creation gate shows the upsell on click. */}
{creationLocked ? (
<Lock size={24} className="text-[var(--muted-foreground)]" />
) : (
<Plus size={24} className="text-[var(--muted-foreground)]" /> <Plus size={24} className="text-[var(--muted-foreground)]" />
)}
</div> </div>
<span className="text-sm font-medium text-[var(--muted-foreground)]"> <span className="text-sm font-medium text-[var(--muted-foreground)]">
{t("profile.create")} {t("profile.create")}

View file

@ -4,6 +4,7 @@ import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel"; import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
import HubReportNavCard from "../components/reports/HubReportNavCard"; import HubReportNavCard from "../components/reports/HubReportNavCard";
import { useEntitlement } from "../hooks/useEntitlement";
import { useHighlights } from "../hooks/useHighlights"; import { useHighlights } from "../hooks/useHighlights";
import { useReportsPeriod } from "../hooks/useReportsPeriod"; import { useReportsPeriod } from "../hooks/useReportsPeriod";
@ -11,6 +12,10 @@ export default function ReportsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { data, isLoading, error } = useHighlights(); const { data, isLoading, error } = useHighlights();
// Advanced-reports lock badge: shown only when the license is ready AND not
// entitled (no "locked" flash at boot). Trends stays Free — never locked.
const { allowed: advancedAllowed, ready: licenseReady } = useEntitlement("reports-advanced");
const advancedLocked = licenseReady && !advancedAllowed;
const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const navCards = [ const navCards = [
@ -19,6 +24,7 @@ export default function ReportsPage() {
icon: <Sparkles size={24} />, icon: <Sparkles size={24} />,
title: t("reports.hub.highlights"), title: t("reports.hub.highlights"),
description: t("reports.hub.highlightsDescription"), description: t("reports.hub.highlightsDescription"),
locked: advancedLocked,
}, },
{ {
to: `/reports/trends${preserveSearch}`, to: `/reports/trends${preserveSearch}`,
@ -31,18 +37,21 @@ export default function ReportsPage() {
icon: <Scale size={24} />, icon: <Scale size={24} />,
title: t("reports.hub.compare"), title: t("reports.hub.compare"),
description: t("reports.hub.compareDescription"), description: t("reports.hub.compareDescription"),
locked: advancedLocked,
}, },
{ {
to: `/reports/category${preserveSearch}`, to: `/reports/category${preserveSearch}`,
icon: <Search size={24} />, icon: <Search size={24} />,
title: t("reports.hub.categoryZoom"), title: t("reports.hub.categoryZoom"),
description: t("reports.hub.categoryZoomDescription"), description: t("reports.hub.categoryZoomDescription"),
locked: advancedLocked,
}, },
{ {
to: `/reports/cartes${preserveSearch}`, to: `/reports/cartes${preserveSearch}`,
icon: <LayoutDashboard size={24} />, icon: <LayoutDashboard size={24} />,
title: t("reports.hub.cartes"), title: t("reports.hub.cartes"),
description: t("reports.hub.cartesDescription"), description: t("reports.hub.cartesDescription"),
locked: advancedLocked,
}, },
]; ];

View file

@ -22,6 +22,8 @@ vi.mock("./dataExportService", async () => {
getExportSuppliers: vi.fn(async () => []), getExportSuppliers: vi.fn(async () => []),
getExportKeywords: vi.fn(async () => []), getExportKeywords: vi.fn(async () => []),
getExportTransactions: vi.fn(async () => []), getExportTransactions: vi.fn(async () => []),
getExportImportSources: vi.fn(async () => []),
getExportImportTemplates: vi.fn(async () => []),
}; };
}); });

View file

@ -6,6 +6,8 @@ import {
getExportSuppliers, getExportSuppliers,
getExportKeywords, getExportKeywords,
getExportTransactions, getExportTransactions,
getExportImportSources,
getExportImportTemplates,
serializeToJson, serializeToJson,
parseImportedJson, parseImportedJson,
type ExportEnvelope, type ExportEnvelope,
@ -254,17 +256,36 @@ export async function createPreMigrationBackup(
} }
// 1. Gather data — same mode as "transactions_with_categories" export. // 1. Gather data — same mode as "transactions_with_categories" export.
// Import sources and templates are part of that mode since #331: this
// backup is a safety net, and one that came back without a single import
// configuration would be a poor one.
const appVersion = await getVersion(); const appVersion = await getVersion();
const [categories, suppliers, keywords, transactions] = await Promise.all([ const [
categories,
suppliers,
keywords,
transactions,
import_sources,
import_config_templates,
] = await Promise.all([
getExportCategories(), getExportCategories(),
getExportSuppliers(), getExportSuppliers(),
getExportKeywords(), getExportKeywords(),
getExportTransactions(), getExportTransactions(),
getExportImportSources(),
getExportImportTemplates(),
]); ]);
const content = serializeToJson( const content = serializeToJson(
"transactions_with_categories", "transactions_with_categories",
{ categories, suppliers, keywords, transactions }, {
categories,
suppliers,
keywords,
transactions,
import_sources,
import_config_templates,
},
appVersion, appVersion,
); );

View file

@ -303,6 +303,119 @@ describe("applyMigration — preserved custom categories", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// Merged custom categories (#259)
// ---------------------------------------------------------------------------
describe("applyMigration — merged custom categories (#259)", () => {
it("reassigns transactions/budgets/keywords/suppliers of a merged custom to the chosen leaf", async () => {
// Custom category 9001 merged into leaf 1444.
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444)]),
FAKE_BACKUP,
);
const merged = (re: RegExp) =>
fake.calls.filter(
(c) => re.test(c.sql) && (c.params?.[1] as number) === 9001,
);
// Each reassignment must target the chosen leaf (1444), keyed by the custom
// id (9001) — not merely be emitted.
expect(merged(/UPDATE transactions SET category_id/i)[0]?.params).toEqual([
1444, 9001,
]);
expect(merged(/UPDATE budget_entries SET category_id/i)[0]?.params).toEqual([
1444, 9001,
]);
expect(merged(/UPDATE keywords SET category_id/i)[0]?.params).toEqual([
1444, 9001,
]);
expect(merged(/UPDATE suppliers SET category_id/i)[0]?.params).toEqual([
1444, 9001,
]);
});
it("soft-deletes a merged custom (is_active=0) and does NOT re-parent it", async () => {
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444)]),
FAKE_BACKUP,
);
const deactivate = fake.calls.filter(
(c) =>
/UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
(c.params?.[0] as number) === 9001,
);
const reparent = fake.calls.filter(
(c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
(c.params?.[1] as number) === 9001,
);
expect(deactivate.length).toBe(1);
expect(reparent.length).toBe(0);
});
it("does NOT create the custom parent when every preserved custom is merged", async () => {
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, 1555)]),
FAKE_BACKUP,
);
const parentInserts = fake.calls.filter(
(c) =>
/INSERT OR IGNORE INTO categories/i.test(c.sql) &&
(c.params?.[0] as number) === 2000,
);
expect(parentInserts.length).toBe(0);
});
it("creates the custom parent when at least one preserved custom is left unmerged", async () => {
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, null)]),
FAKE_BACKUP,
);
const parentInserts = fake.calls.filter(
(c) =>
/INSERT OR IGNORE INTO categories/i.test(c.sql) &&
(c.params?.[0] as number) === 2000,
);
expect(parentInserts.length).toBe(1);
// Only the unmerged custom (9002) is re-parented; the merged one (9001) is not.
const reparent = fake.calls.filter((c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql),
);
expect(reparent.map((c) => c.params?.[1])).toEqual([9002]);
});
it("leaves no orphan when a custom PARENT is merged but its custom CHILD is not (regression)", async () => {
// plan.preserved is a flat list: a merged parent (9001 → 1444) and its
// unresolved child (9002). The child must land under the bucket (2000) and
// the parent be deactivated — the child never dangles under a dead parent.
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, null)]),
FAKE_BACKUP,
);
// Child re-parented under 2000.
const childReparent = fake.calls.filter(
(c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
(c.params?.[1] as number) === 9002,
);
expect(childReparent.length).toBe(1);
expect(childReparent[0].params?.[0]).toBe(2000);
// Parent merged → deactivated, not re-parented.
const parentDeactivate = fake.calls.filter(
(c) =>
/UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
(c.params?.[0] as number) === 9001,
);
const parentReparent = fake.calls.filter(
(c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
(c.params?.[1] as number) === 9001,
);
expect(parentDeactivate.length).toBe(1);
expect(parentReparent.length).toBe(0);
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Rollback on SQL failure // Rollback on SQL failure
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -142,12 +142,17 @@ function validateBackup(backup: BackupResult): void {
} }
} }
/** Build the v2Id → v1Id map from plan.rows (only resolved targets are kept). */ /** True when a mapping row carries a concrete v1 target (resolved / merged). */
function isResolvedTarget(row: MappingRow): boolean {
return row.v1TargetId !== null && row.v1TargetId !== undefined;
}
/** Build the v2Id → v1Id map from mapping rows (only resolved targets kept). */
function buildMappingFromRows(rows: MappingRow[]): Map<number, number> { function buildMappingFromRows(rows: MappingRow[]): Map<number, number> {
const map = new Map<number, number>(); const map = new Map<number, number>();
for (const row of rows) { for (const row of rows) {
if (row.v1TargetId !== null && row.v1TargetId !== undefined) { if (isResolvedTarget(row)) {
map.set(row.v2CategoryId, row.v1TargetId); map.set(row.v2CategoryId, row.v1TargetId as number);
} }
} }
return map; return map;
@ -190,13 +195,21 @@ async function applyMigrationInTransaction(
backup: BackupResult, backup: BackupResult,
outcome: MigrationOutcome, outcome: MigrationOutcome,
): Promise<MigrationOutcome> { ): Promise<MigrationOutcome> {
const mapping = buildMappingFromRows(plan.rows); // Seeded rows AND merged custom categories (preserved rows the user gave a
// target) share the same rewrite: their transactions, budgets, keywords and
// suppliers are reassigned to the chosen v1 leaf. Unresolved preserved rows
// have a null target and are filtered out by buildMappingFromRows.
const allMappableRows = [...plan.rows, ...plan.preserved];
const mapping = buildMappingFromRows(allMappableRows);
await db.execute("BEGIN"); await db.execute("BEGIN");
try { try {
// 1. Optionally create the "custom categories (migration)" parent. // 1. Optionally create the "custom categories (migration)" parent — only
// when at least one custom category is left UNMERGED. If every custom
// was merged into a standard leaf, the bucket would be empty, so skip it.
let customParentId: number | null = null; let customParentId: number | null = null;
if (plan.preserved.length > 0) { const hasUnmergedPreserved = plan.preserved.some((p) => !isResolvedTarget(p));
if (hasUnmergedPreserved) {
// Use INSERT OR IGNORE so a re-run never throws on the PK. // Use INSERT OR IGNORE so a re-run never throws on the PK.
await db.execute( await db.execute(
`INSERT OR IGNORE INTO categories `INSERT OR IGNORE INTO categories
@ -322,6 +335,9 @@ async function applyMigrationInTransaction(
// at a v2 structural parent in the 1..6 range): children follow naturally. // at a v2 structural parent in the 1..6 range): children follow naturally.
if (customParentId !== null) { if (customParentId !== null) {
for (const preservedRow of plan.preserved) { for (const preservedRow of plan.preserved) {
// Merged customs are deactivated in the soft-delete step below, not
// re-parented — only the ones left unmerged move under the bucket.
if (isResolvedTarget(preservedRow)) continue;
const r = await db.execute( const r = await db.execute(
`UPDATE categories SET parent_id = $1 WHERE id = $2`, `UPDATE categories SET parent_id = $1 WHERE id = $2`,
[customParentId, preservedRow.v2CategoryId], [customParentId, preservedRow.v2CategoryId],
@ -330,18 +346,16 @@ async function applyMigrationInTransaction(
} }
} }
// 9. Soft-delete v2 seeded categories that are now unreferenced. // 9. Soft-delete every category whose data we just rewrote: mapped v2 seed
// We deactivate instead of hard-deleting so that any historical // rows AND merged custom categories. We deactivate (is_active=0) instead
// reference we might have missed stays intact (is_active=0 hides them // of hard-deleting so any historical reference we might have missed stays
// from the UI lists). We explicitly only target the v2 seed id range // intact and hidden from the UI lists.
// (< 1000) AND ids that map in our plan — this avoids touching user for (const row of allMappableRows) {
// custom categories that may also have parent_id < 1000 structural. // Skip rows with no target. Seed rows can't reach here (consent is
for (const row of plan.rows) { // blocked until they're all resolved), but a custom left unmerged
// Only deactivate rows that were part of the v2 seed AND we successfully // legitimately does — it must stay alive under the bucket, not be
// mapped to a v1 target. Rows with no v1 target (unresolved review) are // deactivated.
// left alone — in the UX, the consent step is blocked until all rows if (!isResolvedTarget(row)) continue;
// are resolved, so this should be dead code, but it is a safety net.
if (row.v1TargetId === null) continue;
const r = await db.execute( const r = await db.execute(
`UPDATE categories SET is_active = 0 WHERE id = $1`, `UPDATE categories SET is_active = 0 WHERE id = $1`,
[row.v2CategoryId], [row.v2CategoryId],

View file

@ -0,0 +1,730 @@
/**
* The data export/restore cycle preserves import configurations (#331).
*
* Three properties are under test here, and each one is a way the format used
* to be lost:
* 1. What goes OUT the file carries `import_sources` and
* `import_config_templates`, which it never did.
* 2. What comes back IN templates before sources, `template_id` remapped
* through resolved ids, and the synthetic "Data Import" source demoted to
* what it always should have been: a host for transactions that describe
* no folder of their own.
* 3. What happens when it goes wrong a restore that fails at row N leaves
* the profile untouched. That one is the reason this file exists at all:
* before #331 the service deleted six tables and then re-inserted them
* with no transaction around any of it.
*
* Real `tauri-plugin-sql` cannot run outside the Tauri WebView, so the service
* runs against an in-memory FakeDb that interprets the statements it issues
* the same approach as `import-format-roundtrip.test.ts`. This one additionally
* models `UNIQUE(name)` and BEGIN/ROLLBACK, because those are the failures the
* transaction exists to survive.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("./db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: unknown) => unknown) => fn(await getDb())),
};
});
import { getDb } from "./db";
import {
getExportImportSources,
getExportImportTemplates,
importCategoriesOnly,
importTransactionsOnly,
importTransactionsWithCategories,
parseImportedJson,
serializeToJson,
validateImportedFormatRows,
LEGACY_SREF_FORMAT_VERSION,
SREF_FORMAT_VERSION,
SrefValidationError,
type ExportEnvelope,
type ExportImportSource,
type ExportImportTemplate,
} from "./dataExportService";
import { formatFromRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat";
import type { Category } from "../shared/types";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
// ---------------------------------------------------------------------------
// FakeDb — enough SQLite to make the failure modes real.
// ---------------------------------------------------------------------------
type Row = Record<string, unknown>;
const UNIQUE_NAME_TABLES = ["import_sources", "import_config_templates"];
function makeFakeDb(shouldFail?: (sql: string, params: unknown[]) => boolean) {
const tables: Record<string, Row[]> = {
categories: [],
suppliers: [],
keywords: [],
transactions: [],
imported_files: [],
import_sources: [],
import_config_templates: [],
};
const log: string[] = [];
let snapshot: Record<string, Row[]> | null = null;
let nextId = 1;
const clone = (source: Record<string, Row[]>) =>
Object.fromEntries(
Object.entries(source).map(([name, rows]) => [
name,
rows.map((row) => ({ ...row })),
])
);
const insert = (sql: string, params: unknown[]) => {
const [, table, cols] = /INSERT INTO (\w+) \(([^)]+)\)/.exec(sql) ?? [];
const columns = cols.split(",").map((c) => c.trim());
const row: Row = { id: nextId++ };
columns.forEach((c, i) => (row[c] = params[i]));
const clash = tables[table].find((r) => r.name === row.name);
if (/ON CONFLICT\(name\) DO UPDATE/.test(sql)) {
if (clash) {
columns.forEach((c, i) => (clash[c] = params[i]));
nextId--;
return { lastInsertId: 0, rowsAffected: 1 };
}
} else if (clash && UNIQUE_NAME_TABLES.includes(table)) {
throw new Error(`UNIQUE constraint failed: ${table}.name`);
}
tables[table].push(row);
return { lastInsertId: row.id as number, rowsAffected: 1 };
};
return {
tables,
log,
seed(table: string, rows: Row[]) {
rows.forEach((row) => tables[table].push({ id: nextId++, ...row }));
},
execute: vi.fn(async (sql: string, params: unknown[] = []) => {
const head = sql.trimStart().split(/\s+/).slice(0, 3).join(" ");
log.push(head);
if (shouldFail?.(sql, params)) throw new Error("boom");
const trimmed = sql.trimStart();
if (trimmed === "BEGIN") {
snapshot = clone(tables);
return { rowsAffected: 0 };
}
if (trimmed === "COMMIT") {
snapshot = null;
return { rowsAffected: 0 };
}
if (trimmed === "ROLLBACK") {
if (snapshot) {
for (const [name, rows] of Object.entries(snapshot)) {
tables[name] = rows;
}
snapshot = null;
}
return { rowsAffected: 0 };
}
if (trimmed.startsWith("DELETE FROM")) {
const [, table] = /DELETE FROM (\w+)/.exec(trimmed) ?? [];
tables[table] = [];
return { rowsAffected: 0 };
}
if (trimmed.startsWith("UPDATE transactions SET")) {
return { rowsAffected: 0 };
}
if (trimmed.startsWith("INSERT")) return insert(sql, params);
throw new Error(`FakeDb: unsupported statement ${sql.slice(0, 40)}`);
}),
select: vi.fn(async (sql: string, params: unknown[] = []) => {
const [, table] = /FROM (\w+)/.exec(sql) ?? [];
const rows = tables[table] ?? [];
if (/WHERE (?:\w+\.)?name = \$1/.test(sql))
return rows.filter((r) => r.name === params[0]);
// `getExportImportSources` resolves the template through a LEFT JOIN.
if (table === "import_sources" && /LEFT JOIN/.test(sql)) {
return rows.map((r) => ({
...r,
template_name:
tables.import_config_templates.find((t) => t.id === r.template_id)
?.name ?? null,
}));
}
return [...rows];
}),
};
}
let db: ReturnType<typeof makeFakeDb>;
function wire(instance: ReturnType<typeof makeFakeDb>) {
db = instance;
vi.mocked(getDb).mockResolvedValue(instance as never);
}
beforeEach(() => {
vi.mocked(getDb).mockReset();
wire(makeFakeDb());
});
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const TEMPLATE: ExportImportTemplate = {
name: "Desjardins EOP",
delimiter: ";",
encoding: "windows-1252",
date_format: "YYYY-MM-DD",
skip_lines: 1,
has_header: 1,
column_mapping: JSON.stringify({ date: 0, description: 2, amount: 3 }),
amount_mode: "single",
sign_convention: "positive_expense",
};
const SOURCE: ExportImportSource = {
name: "Visa Desjardins",
description: "Credit card",
header_signature: "date|description|amount",
template_name: TEMPLATE.name,
delimiter: ",",
encoding: "utf-8",
date_format: "DD/MM/YYYY",
skip_lines: 2,
has_header: 0,
column_mapping: JSON.stringify({ date: 1, description: 3, debitAmount: 4, creditAmount: 5 }),
amount_mode: "debit_credit",
sign_convention: "negative_expense",
};
function envelopeData(
overrides: Partial<ExportEnvelope["data"]> = {}
): ExportEnvelope["data"] {
return {
categories: [],
suppliers: [],
keywords: [],
transactions: [],
import_sources: [SOURCE],
import_config_templates: [TEMPLATE],
...overrides,
};
}
function transactionFixture(id: number) {
return {
id,
date: "2026-03-10",
description: `tx ${id}`,
amount: -10,
category_id: null,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 0,
is_split: 0,
parent_transaction_id: null,
};
}
// ---------------------------------------------------------------------------
// 1. What goes out
// ---------------------------------------------------------------------------
describe("export carries the import configurations (#331)", () => {
it("projects every format field of a source, plus its own columns", async () => {
db.seed("import_config_templates", [{ name: TEMPLATE.name }]);
const templateId = db.tables.import_config_templates[0].id;
db.seed("import_sources", [
{
name: SOURCE.name,
description: SOURCE.description,
header_signature: SOURCE.header_signature,
template_id: templateId,
delimiter: SOURCE.delimiter,
encoding: SOURCE.encoding,
date_format: SOURCE.date_format,
skip_lines: SOURCE.skip_lines,
has_header: SOURCE.has_header,
column_mapping: SOURCE.column_mapping,
amount_mode: SOURCE.amount_mode,
sign_convention: SOURCE.sign_convention,
},
]);
const [exported] = await getExportImportSources();
for (const column of Object.values(FORMAT_FIELD_PAIRS)) {
expect(exported[column], `column ${column}`).toEqual(SOURCE[column]);
}
expect(exported.name).toBe(SOURCE.name);
expect(exported.description).toBe(SOURCE.description);
// Drift metadata rides along beside the format, never through the codec.
expect(exported.header_signature).toBe(SOURCE.header_signature);
// The foreign key travels as a NAME — ids mean nothing in another profile.
expect(exported.template_name).toBe(TEMPLATE.name);
expect("id" in exported).toBe(false);
expect("template_id" in exported).toBe(false);
});
it("carries a source an older build left unreadable rather than aborting", async () => {
// The `'{}'` mapping the restore itself used to write: `formatFromRow`
// refuses it, so decoding on the way out would make the whole profile
// impossible to back up.
db.seed("import_sources", [
{
name: "Data Import",
column_mapping: "{}",
delimiter: ",",
encoding: "utf-8",
date_format: "%Y-%m-%d",
skip_lines: 0,
has_header: 1,
amount_mode: "single",
sign_convention: "negative_expense",
},
]);
const [exported] = await getExportImportSources();
expect(exported.column_mapping).toBe("{}");
});
it("exports templates by name with their eight fields", async () => {
db.seed("import_config_templates", [{ ...TEMPLATE }]);
const [exported] = await getExportImportTemplates();
expect(exported.name).toBe(TEMPLATE.name);
for (const column of Object.values(FORMAT_FIELD_PAIRS)) {
expect(exported[column], `column ${column}`).toEqual(TEMPLATE[column]);
}
});
it("stamps the envelope with an explicit format version", () => {
const json = serializeToJson("transactions_with_categories", envelopeData(), "0.15.0");
expect(JSON.parse(json).format_version).toBe(SREF_FORMAT_VERSION);
});
it("round-trips the two arrays through serialize -> parse", () => {
const json = serializeToJson("transactions_with_categories", envelopeData(), "0.15.0");
const { envelope, summary } = parseImportedJson(json);
expect(envelope.data.import_sources).toEqual([SOURCE]);
expect(envelope.data.import_config_templates).toEqual([TEMPLATE]);
expect(summary.importSourcesCount).toBe(1);
expect(summary.importTemplatesCount).toBe(1);
expect(summary.formatVersion).toBe(SREF_FORMAT_VERSION);
});
});
// ---------------------------------------------------------------------------
// 2. Backward compatibility — a backup written before this change
// ---------------------------------------------------------------------------
const LEGACY_BACKUP = JSON.stringify({
export_type: "transactions_with_categories",
app_version: "0.14.0",
exported_at: "2026-07-01T00:00:00Z",
data: {
categories: [],
suppliers: [],
keywords: [],
transactions: [transactionFixture(1)],
},
});
describe("a backup written before #331 still imports", () => {
it("reads as format version 1 with both counts at zero", () => {
const { envelope, summary } = parseImportedJson(LEGACY_BACKUP);
expect(summary.formatVersion).toBe(LEGACY_SREF_FORMAT_VERSION);
expect(summary.importSourcesCount).toBe(0);
expect(summary.importTemplatesCount).toBe(0);
expect(envelope.data.import_sources).toBeUndefined();
});
it("restores exactly as it did before — wipe, then one host source", async () => {
db.seed("import_sources", [{ name: "Old source", column_mapping: "{}" }]);
const { envelope } = parseImportedJson(LEGACY_BACKUP);
await importTransactionsWithCategories(envelope.data, "backup.json");
expect(db.tables.import_sources).toHaveLength(1);
expect(db.tables.import_sources[0].name).toBe("Data Import");
expect(db.tables.transactions).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// 3. The whitelist at the import boundary
// ---------------------------------------------------------------------------
describe("the import boundary refuses a format it cannot store", () => {
it("accepts a file that carries no configuration at all", () => {
expect(() => validateImportedFormatRows(undefined, undefined)).not.toThrow();
});
it("rejects an amount mode outside the whitelist, naming the source", () => {
// `absolute_indicator` passes the v17 CHECK but no code path can read it.
const bad = { ...SOURCE, amount_mode: "absolute_indicator" as never };
try {
validateImportedFormatRows([bad], undefined);
expect.unreachable("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(SrefValidationError);
const err = e as SrefValidationError;
expect(err.i18nKey).toBe(
"settings.dataManagement.import.errors.unsupportedAmountMode"
);
expect(err.params).toEqual({
name: SOURCE.name,
value: "absolute_indicator",
});
}
});
it("rejects an unknown sign convention", () => {
const bad = { ...SOURCE, sign_convention: "inverted" as never };
expect(() => validateImportedFormatRows([bad], undefined)).toThrow(
SrefValidationError
);
});
it("rejects a row with no column mapping instead of letting NOT NULL fire", () => {
const bad = { ...SOURCE, column_mapping: undefined as never };
try {
validateImportedFormatRows([bad], undefined);
expect.unreachable("should have thrown");
} catch (e) {
expect((e as SrefValidationError).i18nKey).toBe(
"settings.dataManagement.import.errors.invalidFormatRow"
);
}
});
it("checks templates too", () => {
const bad = { ...TEMPLATE, amount_mode: "absolute_indicator" as never };
expect(() => validateImportedFormatRows(undefined, [bad])).toThrow(
SrefValidationError
);
});
it("refuses at PARSE time, so the confirmation never opens", () => {
const json = serializeToJson(
"transactions_with_categories",
envelopeData({
import_sources: [{ ...SOURCE, amount_mode: "absolute_indicator" as never }],
}),
"0.15.0"
);
expect(() => parseImportedJson(json)).toThrow(SrefValidationError);
});
it("refuses again at the restore, before a single DELETE", async () => {
db.seed("categories", [{ name: "Épicerie" }]);
await expect(
importTransactionsWithCategories(
envelopeData({
import_sources: [{ ...SOURCE, sign_convention: "inverted" as never }],
}),
"backup.json"
)
).rejects.toBeInstanceOf(SrefValidationError);
expect(db.tables.categories).toHaveLength(1);
expect(db.log).not.toContain("BEGIN");
});
});
// ---------------------------------------------------------------------------
// 4. The restore itself
// ---------------------------------------------------------------------------
describe("restoring brings the configurations back (#331)", () => {
it("restores a source field by field, template resolved to the new id", async () => {
await importTransactionsWithCategories(envelopeData(), "backup.json");
expect(db.tables.import_config_templates).toHaveLength(1);
const template = db.tables.import_config_templates[0];
const [source] = db.tables.import_sources;
for (const column of Object.values(FORMAT_FIELD_PAIRS)) {
expect(source[column], `column ${column}`).toEqual(SOURCE[column]);
}
expect(source.description).toBe(SOURCE.description);
expect(source.header_signature).toBe(SOURCE.header_signature);
expect(source.template_id).toBe(template.id);
});
it("writes the templates BEFORE the sources — template_id is a foreign key", async () => {
await importTransactionsWithCategories(envelopeData(), "backup.json");
const inserts = db.log.filter((l) => l.startsWith("INSERT INTO"));
expect(inserts.indexOf("INSERT INTO import_config_templates")).toBeLessThan(
inserts.indexOf("INSERT INTO import_sources")
);
});
it("upserts a template that already exists by name and remaps to its id", async () => {
// Restoring into a profile that already holds templates is the NORMAL path;
// a plain INSERT would hit UNIQUE(name) and roll the whole restore back.
db.seed("import_config_templates", [
{ ...TEMPLATE, delimiter: "\t", skip_lines: 99 },
]);
const existingId = db.tables.import_config_templates[0].id;
await importTransactionsWithCategories(envelopeData(), "backup.json");
expect(db.tables.import_config_templates).toHaveLength(1);
expect(db.tables.import_config_templates[0].id).toBe(existingId);
expect(db.tables.import_config_templates[0].delimiter).toBe(TEMPLATE.delimiter);
expect(db.tables.import_sources[0].template_id).toBe(existingId);
});
it("leaves template_id null when the name resolves to nothing", async () => {
await importTransactionsWithCategories(
envelopeData({
import_sources: [{ ...SOURCE, template_name: "Gone" }],
import_config_templates: [],
}),
"backup.json"
);
expect(db.tables.import_sources[0].template_id).toBeNull();
});
it("creates NO host source when the file carries no transactions", async () => {
await importTransactionsWithCategories(envelopeData(), "backup.json");
expect(db.tables.import_sources.map((s) => s.name)).toEqual([SOURCE.name]);
expect(db.tables.imported_files).toHaveLength(0);
});
it("creates the host source only to carry transactions, with a readable mapping", async () => {
await importTransactionsWithCategories(
envelopeData({ transactions: [transactionFixture(1), transactionFixture(2)] }),
"backup.json"
);
const host = db.tables.import_sources.find((s) => s.name === "Data Import")!;
expect(host).toBeDefined();
// The mapping used to be `'{}'`, which the codec cannot decode: a source
// the app wrote and then refused to read.
const decoded = formatFromRow(host as never);
expect(decoded.columnMapping.date).toBe(0);
expect(decoded.columnMapping.description).toBe(1);
expect(decoded.columnMapping.amount).toBe(2);
expect(db.tables.transactions).toHaveLength(2);
expect(db.tables.transactions.every((t) => t.source_id === host.id)).toBe(true);
});
it("reuses a restored source that already bears the host name", async () => {
// A profile restored once already holds a "Data Import" source, which the
// backup then carries — inserting a second one would break UNIQUE(name)
// and roll back everything.
await importTransactionsWithCategories(
envelopeData({
import_sources: [{ ...SOURCE, name: "Data Import" }],
transactions: [transactionFixture(1)],
}),
"backup.json"
);
expect(db.tables.import_sources).toHaveLength(1);
// The restored configuration wins; it is not overwritten by the host stub.
expect(db.tables.import_sources[0].column_mapping).toBe(SOURCE.column_mapping);
expect(db.tables.transactions[0].source_id).toBe(db.tables.import_sources[0].id);
});
it("restores sources in transactions_only mode too", async () => {
await importTransactionsOnly(envelopeData(), "backup.json");
expect(db.tables.import_sources.map((s) => s.name)).toEqual([SOURCE.name]);
expect(db.tables.import_config_templates).toHaveLength(1);
});
it("leaves import_sources alone in categories_only mode", async () => {
db.seed("import_sources", [{ name: "Kept", column_mapping: "{}" }]);
await importCategoriesOnly({ categories: [], suppliers: [], keywords: [] });
expect(db.tables.import_sources.map((s) => s.name)).toEqual(["Kept"]);
});
});
// ---------------------------------------------------------------------------
// 5. All or nothing — the property the whole restore rests on
// ---------------------------------------------------------------------------
const CATEGORY_FIXTURES: Category[] = [1, 2, 3].map(
(id) =>
({
id,
name: `Cat ${id}`,
parent_id: null,
color: null,
icon: null,
type: "expense",
is_active: 1,
is_inputable: 1,
sort_order: id,
}) as unknown as Category
);
describe("a restore that fails at row N leaves the profile untouched", () => {
it("rolls the categories, sources and transactions back", async () => {
let categoryInserts = 0;
wire(
makeFakeDb((sql) => {
if (!/INSERT INTO categories/.test(sql)) return false;
return ++categoryInserts === 3;
})
);
db.seed("categories", [{ id: 900, name: "Existing" }]);
db.seed("import_sources", [{ name: "Existing source", column_mapping: "{}" }]);
db.seed("transactions", [{ description: "existing tx" }]);
await expect(
importTransactionsWithCategories(
envelopeData({
categories: CATEGORY_FIXTURES,
transactions: [transactionFixture(1)],
}),
"backup.json"
)
).rejects.toThrow("boom");
expect(db.log).toContain("ROLLBACK");
expect(db.log).not.toContain("COMMIT");
expect(db.tables.categories.map((c) => c.name)).toEqual(["Existing"]);
expect(db.tables.import_sources.map((s) => s.name)).toEqual(["Existing source"]);
expect(db.tables.transactions).toHaveLength(1);
});
it("rolls back a UNIQUE(name) collision between two restored sources", async () => {
db.seed("categories", [{ id: 900, name: "Existing" }]);
await expect(
importTransactionsWithCategories(
envelopeData({ import_sources: [SOURCE, { ...SOURCE }] }),
"backup.json"
)
).rejects.toThrow(/UNIQUE constraint failed/);
expect(db.tables.categories.map((c) => c.name)).toEqual(["Existing"]);
expect(db.tables.import_sources).toHaveLength(0);
});
it("wraps categories_only as well", async () => {
let keywordInserts = 0;
wire(
makeFakeDb((sql) => {
if (!/INSERT INTO keywords/.test(sql)) return false;
return ++keywordInserts === 1;
})
);
db.seed("categories", [{ id: 900, name: "Existing" }]);
await expect(
importCategoriesOnly({
categories: CATEGORY_FIXTURES,
suppliers: [],
keywords: [
{ id: 1, keyword: "epicerie", category_id: 1, priority: 1, is_active: 1 } as never,
],
})
).rejects.toThrow("boom");
expect(db.log).toContain("ROLLBACK");
expect(db.tables.categories.map((c) => c.name)).toEqual(["Existing"]);
});
it("wraps transactions_only as well", async () => {
let txInserts = 0;
wire(
makeFakeDb((sql) => {
if (!/INSERT INTO transactions/.test(sql)) return false;
return ++txInserts === 2;
})
);
db.seed("transactions", [{ description: "existing tx" }]);
await expect(
importTransactionsOnly(
envelopeData({
transactions: [transactionFixture(1), transactionFixture(2)],
}),
"backup.json"
)
).rejects.toThrow("boom");
expect(db.log).toContain("ROLLBACK");
expect(db.tables.transactions.map((t) => t.description)).toEqual(["existing tx"]);
expect(db.tables.import_sources).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 6. Every string the refusal can show exists in both languages
// ---------------------------------------------------------------------------
describe("the refusal messages are translated (#331)", () => {
const errorKeys = [
"unsupportedAmountMode",
"unsupportedSignConvention",
"invalidFormatRow",
] as const;
it("carries every refusal key in FR and EN", () => {
for (const key of errorKeys) {
expect(
fr.settings.dataManagement.import.errors[key].length,
`fr.${key}`
).toBeGreaterThan(0);
expect(
en.settings.dataManagement.import.errors[key].length,
`en.${key}`
).toBeGreaterThan(0);
}
});
it("names the offending source in both languages", () => {
for (const key of errorKeys) {
expect(fr.settings.dataManagement.import.errors[key], `fr.${key}`).toContain(
"{{name}}"
);
expect(en.settings.dataManagement.import.errors[key], `en.${key}`).toContain(
"{{name}}"
);
}
});
it("interpolates the two configuration counts", () => {
for (const key of ["countImportSources", "countImportTemplates"] as const) {
expect(fr.settings.dataManagement.import[key], `fr.${key}`).toContain(
"{{count}}"
);
expect(en.settings.dataManagement.import[key], `en.${key}`).toContain(
"{{count}}"
);
}
});
it("keys every SrefValidationError to a string that exists", () => {
const cases: Array<[ExportImportSource, string]> = [
[{ ...SOURCE, amount_mode: "absolute_indicator" as never }, "unsupportedAmountMode"],
[{ ...SOURCE, sign_convention: "inverted" as never }, "unsupportedSignConvention"],
[{ ...SOURCE, column_mapping: undefined as never }, "invalidFormatRow"],
];
for (const [row, suffix] of cases) {
try {
validateImportedFormatRows([row], undefined);
expect.unreachable(`should have thrown for ${suffix}`);
} catch (e) {
const key = (e as SrefValidationError).i18nKey;
expect(key).toBe(`settings.dataManagement.import.errors.${suffix}`);
// The key must resolve in the bundle, not merely look plausible.
const resolved = key
.split(".")
.reduce<unknown>(
(node, part) => (node as Record<string, unknown>)[part],
en as unknown
);
expect(typeof resolved).toBe("string");
}
}
});
});

View file

@ -1,6 +1,19 @@
import { getDb } from "./db"; import type Database from "@tauri-apps/plugin-sql";
import { getDb, withTransaction } from "./db";
import Papa from "papaparse"; import Papa from "papaparse";
import type { Category, Supplier, Keyword } from "../shared/types"; import type {
Category,
ImportConfigTemplate,
ImportFormatRow,
ImportSource,
Keyword,
Supplier,
} from "../shared/types";
import {
AMOUNT_MODES,
SIGN_CONVENTIONS,
pickFormatRow,
} from "../utils/importFormat";
// --- Export types --- // --- Export types ---
@ -11,15 +24,34 @@ export type ExportMode =
export type ExportFormat = "json" | "csv"; export type ExportFormat = "json" | "csv";
/**
* Version of the SREF envelope this build writes.
*
* Version 2 is the first to carry `import_sources` and
* `import_config_templates`. A file with no `format_version` at all is a
* version 1 file: it predates #331, so its missing arrays mean "this backup
* never held any configuration", not "this profile had none". The restore then
* behaves exactly as it did before wipe the sources and hang the transactions
* off a synthetic one which is the only reading that cannot invent data.
*/
export const SREF_FORMAT_VERSION = 2;
/** What an envelope without an explicit `format_version` is. */
export const LEGACY_SREF_FORMAT_VERSION = 1;
export interface ExportEnvelope { export interface ExportEnvelope {
export_type: ExportMode; export_type: ExportMode;
app_version: string; app_version: string;
/** Absent on files written before #331 — see `SREF_FORMAT_VERSION`. */
format_version?: number;
exported_at: string; exported_at: string;
data: { data: {
categories?: Category[]; categories?: Category[];
suppliers?: Supplier[]; suppliers?: Supplier[];
keywords?: Keyword[]; keywords?: Keyword[];
transactions?: ExportTransaction[]; transactions?: ExportTransaction[];
import_sources?: ExportImportSource[];
import_config_templates?: ExportImportTemplate[];
}; };
} }
@ -37,6 +69,35 @@ export interface ExportTransaction {
parent_transaction_id: number | null; parent_transaction_id: number | null;
} }
/**
* An import source as it travels in the file: the eight format fields in their
* persisted shape, plus the source's own columns.
*
* No `id`: nothing in the envelope points at a source by id (`ExportTransaction`
* carries no `source_id`), so ids would only be noise that collides on restore.
* The link to a template travels as `template_name` for the same reason the
* name is the table's natural key (`UNIQUE`), it survives renumbering, and it is
* what the restore resolves the foreign key through.
*/
export interface ExportImportSource extends ImportFormatRow {
name: string;
description: string | null;
/**
* Drift metadata (#330), NOT one of the eight format fields it travels
* beside them, never through the codec. Round-tripping it keeps drift
* detection armed across a restore; a source that comes back without one just
* has detection off until its next successful import records it.
*/
header_signature: string | null;
/** Name of the template this source was configured from, or null. */
template_name: string | null;
}
/** A template as it travels in the file: a named format, nothing more. */
export interface ExportImportTemplate extends ImportFormatRow {
name: string;
}
// --- Import types --- // --- Import types ---
export interface ImportSummary { export interface ImportSummary {
@ -45,6 +106,37 @@ export interface ImportSummary {
suppliersCount: number; suppliersCount: number;
keywordsCount: number; keywordsCount: number;
transactionsCount: number; transactionsCount: number;
importSourcesCount: number;
importTemplatesCount: number;
/** `LEGACY_SREF_FORMAT_VERSION` when the file declares none. */
formatVersion: number;
}
/** i18n keys for the ways an imported configuration can be refused. */
export type SrefValidationErrorKey =
| "settings.dataManagement.import.errors.unsupportedAmountMode"
| "settings.dataManagement.import.errors.unsupportedSignConvention"
| "settings.dataManagement.import.errors.invalidFormatRow";
/**
* Thrown when a backup carries a configuration this build cannot store.
*
* The `CHECK` added by migration v17 already refuses these values, but a SQLite
* constraint error is not something a user can act on and it would surface
* only once the restore had already started deleting. This is raised at the
* boundary, before anything is touched, and carries an i18n key plus the
* offending source name so the message can name what to fix.
*/
export class SrefValidationError extends Error {
readonly i18nKey: SrefValidationErrorKey;
readonly params: Record<string, string>;
constructor(i18nKey: SrefValidationErrorKey, params: Record<string, string>) {
super(`${i18nKey}: ${JSON.stringify(params)}`);
this.name = "SrefValidationError";
this.i18nKey = i18nKey;
this.params = params;
}
} }
// --- Data gathering --- // --- Data gathering ---
@ -76,6 +168,45 @@ export async function getExportTransactions(): Promise<ExportTransaction[]> {
); );
} }
/**
* Every configured import source, with its template resolved to a name.
*
* This is what the export was missing (#331): the file carried transactions but
* not a single line of how they had been read, so restoring a backup meant
* reconfiguring every source by hand.
*/
export async function getExportImportSources(): Promise<ExportImportSource[]> {
const db = await getDb();
const rows = await db.select<
Array<ImportSource & { template_name: string | null }>
>(
`SELECT s.*, t.name AS template_name
FROM import_sources s
LEFT JOIN import_config_templates t ON s.template_id = t.id
ORDER BY s.id`
);
return rows.map((row) => ({
name: row.name,
description: row.description ?? null,
header_signature: row.header_signature ?? null,
template_name: row.template_name ?? null,
...pickFormatRow(row as unknown as Record<string, unknown>),
}));
}
export async function getExportImportTemplates(): Promise<
ExportImportTemplate[]
> {
const db = await getDb();
const rows = await db.select<ImportConfigTemplate[]>(
"SELECT * FROM import_config_templates ORDER BY id"
);
return rows.map((row) => ({
name: row.name,
...pickFormatRow(row as unknown as Record<string, unknown>),
}));
}
// --- Serialization --- // --- Serialization ---
export function serializeToJson( export function serializeToJson(
@ -86,6 +217,7 @@ export function serializeToJson(
const envelope: ExportEnvelope = { const envelope: ExportEnvelope = {
export_type: exportType, export_type: exportType,
app_version: appVersion, app_version: appVersion,
format_version: SREF_FORMAT_VERSION,
exported_at: new Date().toISOString(), exported_at: new Date().toISOString(),
data, data,
}; };
@ -113,6 +245,49 @@ export function serializeTransactionsToCsv(
// --- Import parsing --- // --- Import parsing ---
/**
* Refuse a configuration this build cannot store, BEFORE anything is deleted.
*
* Only the two enumerated fields are checked. `column_mapping` deliberately is
* not decoded: a profile that already went through an old restore holds sources
* with `'{}'` there, and refusing to restore a backup because of a row the app
* itself wrote would strand the user with no way back in. It is carried through
* as the opaque string the column stores, exactly as it was found the wizard
* validates it when it is actually about to read a file.
*
* PURE: no I/O, so it can be called from the parse step and again at the top of
* each restore, which is where the guarantee has to hold.
*/
export function validateImportedFormatRows(
sources: ExportImportSource[] | undefined,
templates: ExportImportTemplate[] | undefined
): void {
const check = (row: ImportFormatRow, name: unknown) => {
const label = typeof name === "string" && name.length > 0 ? name : "?";
if (typeof row?.column_mapping !== "string") {
throw new SrefValidationError(
"settings.dataManagement.import.errors.invalidFormatRow",
{ name: label }
);
}
if (!AMOUNT_MODES.includes(row.amount_mode)) {
throw new SrefValidationError(
"settings.dataManagement.import.errors.unsupportedAmountMode",
{ name: label, value: String(row.amount_mode) }
);
}
if (!SIGN_CONVENTIONS.includes(row.sign_convention)) {
throw new SrefValidationError(
"settings.dataManagement.import.errors.unsupportedSignConvention",
{ name: label, value: String(row.sign_convention) }
);
}
};
for (const template of templates ?? []) check(template, template?.name);
for (const source of sources ?? []) check(source, source?.name);
}
export function parseImportedJson(content: string): { export function parseImportedJson(content: string): {
envelope: ExportEnvelope; envelope: ExportEnvelope;
summary: ImportSummary; summary: ImportSummary;
@ -141,6 +316,13 @@ export function parseImportedJson(content: string): {
throw new Error(`Unknown export type: ${envelope.export_type}`); throw new Error(`Unknown export type: ${envelope.export_type}`);
} }
// Refuse an unreadable configuration while the file is only being LOOKED at:
// the confirmation dialog never opens, so nothing is deleted.
validateImportedFormatRows(
envelope.data.import_sources,
envelope.data.import_config_templates
);
return { return {
envelope, envelope,
summary: { summary: {
@ -149,6 +331,9 @@ export function parseImportedJson(content: string): {
suppliersCount: envelope.data.suppliers?.length ?? 0, suppliersCount: envelope.data.suppliers?.length ?? 0,
keywordsCount: envelope.data.keywords?.length ?? 0, keywordsCount: envelope.data.keywords?.length ?? 0,
transactionsCount: envelope.data.transactions?.length ?? 0, transactionsCount: envelope.data.transactions?.length ?? 0,
importSourcesCount: envelope.data.import_sources?.length ?? 0,
importTemplatesCount: envelope.data.import_config_templates?.length ?? 0,
formatVersion: envelope.format_version ?? LEGACY_SREF_FORMAT_VERSION,
}, },
}; };
} }
@ -190,15 +375,283 @@ export function parseImportedCsv(content: string): {
suppliersCount: 0, suppliersCount: 0,
keywordsCount: 0, keywordsCount: 0,
transactionsCount: transactions.length, transactionsCount: transactions.length,
// A flat CSV carries no configuration at all.
importSourcesCount: 0,
importTemplatesCount: 0,
formatVersion: LEGACY_SREF_FORMAT_VERSION,
}, },
}; };
} }
// --- Import execution --- // --- Import execution ---
export async function importCategoriesOnly(data: ExportEnvelope["data"]): Promise<void> { /**
const db = await getDb(); * The source that hosts transactions restored from a file, which describe no
* import folder of their own.
*
* Its mapping used to be `'{}'`, which `formatFromRow` cannot decode a source
* the app itself wrote and then refused to read. It describes the CSV this very
* service exports instead: date, description, amount, comma-separated, ISO
* dates, expenses negative. That is true of the file the rows came from, so the
* wizard finds a usable format if a folder ever bears this name.
*/
const HOST_SOURCE_NAME = "Data Import";
const HOST_SOURCE_MAPPING = JSON.stringify({
date: 0,
description: 1,
amount: 2,
});
/**
* Run `body` as one all-or-nothing restore.
*
* Every one of these functions starts by DELETING the profile's financial
* history and then re-inserts it row by row, against tables carrying
* `UNIQUE(name)` and a foreign key. Without a transaction, the first constraint
* violation leaves the profile emptied at whatever row it reached, with nothing
* to go back to. `withTransaction` holds the single DB lock for the whole
* BEGIN..COMMIT so the statements cannot be split across pooled connections
* (see `db.ts`); the BEGIN/COMMIT/ROLLBACK themselves are ours to issue.
*/
async function runRestore(
body: (db: Database) => Promise<void>
): Promise<void> {
return withTransaction(async (db) => {
await db.execute("BEGIN");
let open = true;
try {
await body(db);
await db.execute("COMMIT");
open = false;
} catch (e) {
if (open) {
try {
await db.execute("ROLLBACK");
} catch {
// Preserve the original error — it is the one that explains the failure.
}
}
throw e;
}
});
}
async function restoreCategories(
db: Database,
categories: Category[] | undefined
): Promise<void> {
for (const cat of categories ?? []) {
await db.execute(
`INSERT INTO categories (id, name, parent_id, color, icon, type, is_active, is_inputable, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
cat.id,
cat.name,
cat.parent_id ?? null,
cat.color ?? null,
cat.icon ?? null,
cat.type,
cat.is_active ? 1 : 0,
cat.is_inputable ? 1 : 0,
cat.sort_order,
]
);
}
}
async function restoreSuppliers(
db: Database,
suppliers: Supplier[] | undefined
): Promise<void> {
for (const sup of suppliers ?? []) {
await db.execute(
`INSERT INTO suppliers (id, name, normalized_name, category_id, is_active)
VALUES ($1, $2, $3, $4, $5)`,
[sup.id, sup.name, sup.normalized_name, sup.category_id ?? null, sup.is_active ? 1 : 0]
);
}
}
async function restoreKeywords(
db: Database,
keywords: Keyword[] | undefined
): Promise<void> {
for (const kw of keywords ?? []) {
await db.execute(
`INSERT INTO keywords (id, keyword, category_id, supplier_id, priority, is_active)
VALUES ($1, $2, $3, $4, $5, $6)`,
[kw.id, kw.keyword, kw.category_id, kw.supplier_id ?? null, kw.priority, kw.is_active ? 1 : 0]
);
}
}
/**
* Restore the templates and return their resolved ids, keyed by name.
*
* `import_config_templates` is in NEITHER wipe list, and restoring into a
* profile that already has templates is the normal path, not an edge case a
* plain INSERT would hit `UNIQUE constraint failed: import_config_templates.name`
* and (now) roll the whole restore back. Wiping the table instead was the other
* option and was rejected: it would destroy templates the user never asked to
* replace and that no confirmation dialog mentions. So: upsert by name, and give
* the caller the map it needs to remap `import_sources.template_id`, since the
* ids in the file mean nothing here.
*
* Templates go in BEFORE sources `template_id` is a foreign key to this table.
*/
async function restoreImportTemplates(
db: Database,
templates: ExportImportTemplate[] | undefined
): Promise<Map<string, number>> {
const idsByName = new Map<string, number>();
for (const template of templates ?? []) {
await db.execute(
`INSERT INTO import_config_templates (name, delimiter, encoding, date_format, skip_lines, has_header, column_mapping, amount_mode, sign_convention)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT(name) DO UPDATE SET
delimiter = excluded.delimiter,
encoding = excluded.encoding,
date_format = excluded.date_format,
skip_lines = excluded.skip_lines,
has_header = excluded.has_header,
column_mapping = excluded.column_mapping,
amount_mode = excluded.amount_mode,
sign_convention = excluded.sign_convention`,
[
template.name,
template.delimiter,
template.encoding,
template.date_format,
template.skip_lines,
template.has_header,
template.column_mapping,
template.amount_mode,
template.sign_convention,
]
);
// `lastInsertId` is 0 on the conflict branch, so the id is read back by
// name — the same reason `importSourceService.createSource` does it.
const rows = await db.select<Array<{ id: number }>>(
"SELECT id FROM import_config_templates WHERE name = $1",
[template.name]
);
if (rows.length > 0) idsByName.set(template.name, rows[0].id);
}
return idsByName;
}
/**
* Restore the sources, resolving each `template_name` to the id the templates
* pass just settled. A name with no match becomes NULL: `template_id` is a
* provenance tag, never re-read as format, so losing it costs no configuration.
*
* Ids are not carried over nothing in the envelope refers to a source by id.
*/
async function restoreImportSources(
db: Database,
sources: ExportImportSource[] | undefined,
templateIds: Map<string, number>
): Promise<void> {
for (const source of sources ?? []) {
await db.execute(
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines, has_header, amount_mode, sign_convention, header_signature, template_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
source.name,
source.description ?? null,
source.date_format,
source.delimiter,
source.encoding,
source.column_mapping,
source.skip_lines,
source.has_header,
source.amount_mode,
source.sign_convention,
source.header_signature ?? null,
source.template_name !== null
? templateIds.get(source.template_name) ?? null
: null,
]
);
}
}
/**
* Attach the restored transactions to a source and one `imported_files` row.
*
* The host source is created ONLY here, and only when there are transactions to
* hang off it it exists to satisfy `transactions.source_id`, not to stand in
* for the configurations the file now carries on its own. A restored source may
* already bear the name (an earlier restore of this same profile wrote one), in
* which case it is reused rather than re-inserted: `import_sources.name` is
* UNIQUE, and a collision here would roll back the entire restore.
*/
async function attachTransactions(
db: Database,
transactions: ExportTransaction[] | undefined,
filename: string
): Promise<void> {
if (!transactions || transactions.length === 0) return;
const existing = await db.select<Array<{ id: number }>>(
"SELECT id FROM import_sources WHERE name = $1",
[HOST_SOURCE_NAME]
);
let sourceId: number;
if (existing.length > 0) {
sourceId = existing[0].id;
} else {
const sourceResult = await db.execute(
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines, has_header, amount_mode, sign_convention)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
HOST_SOURCE_NAME,
"Imported from settings",
"%Y-%m-%d",
",",
"utf-8",
HOST_SOURCE_MAPPING,
0,
1,
"single",
"negative_expense",
]
);
sourceId = sourceResult.lastInsertId as number;
}
const fileResult = await db.execute(
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status)
VALUES ($1, $2, $3, $4, $5)`,
[sourceId, filename, `data-import-${Date.now()}`, transactions.length, "completed"]
);
const fileId = fileResult.lastInsertId;
for (const tx of transactions) {
await db.execute(
`INSERT INTO transactions (date, description, amount, category_id, original_description, notes, is_manually_categorized, is_split, parent_transaction_id, source_id, file_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
tx.date,
tx.description,
tx.amount,
tx.category_id,
tx.original_description,
tx.notes,
tx.is_manually_categorized,
tx.is_split,
tx.parent_transaction_id,
sourceId,
fileId,
]
);
}
}
export async function importCategoriesOnly(
data: ExportEnvelope["data"]
): Promise<void> {
return runRestore(async (db) => {
// Wipe keywords, suppliers, categories // Wipe keywords, suppliers, categories
await db.execute("DELETE FROM keywords"); await db.execute("DELETE FROM keywords");
await db.execute("DELETE FROM suppliers"); await db.execute("DELETE FROM suppliers");
@ -209,56 +662,19 @@ export async function importCategoriesOnly(data: ExportEnvelope["data"]): Promis
"UPDATE transactions SET category_id = NULL, supplier_id = NULL, is_manually_categorized = 0" "UPDATE transactions SET category_id = NULL, supplier_id = NULL, is_manually_categorized = 0"
); );
// Re-insert categories await restoreCategories(db, data.categories);
if (data.categories) { await restoreSuppliers(db, data.suppliers);
for (const cat of data.categories) { await restoreKeywords(db, data.keywords);
await db.execute( });
`INSERT INTO categories (id, name, parent_id, color, icon, type, is_active, is_inputable, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
cat.id,
cat.name,
cat.parent_id ?? null,
cat.color ?? null,
cat.icon ?? null,
cat.type,
cat.is_active ? 1 : 0,
cat.is_inputable ? 1 : 0,
cat.sort_order,
]
);
}
}
// Re-insert suppliers
if (data.suppliers) {
for (const sup of data.suppliers) {
await db.execute(
`INSERT INTO suppliers (id, name, normalized_name, category_id, is_active)
VALUES ($1, $2, $3, $4, $5)`,
[sup.id, sup.name, sup.normalized_name, sup.category_id ?? null, sup.is_active ? 1 : 0]
);
}
}
// Re-insert keywords
if (data.keywords) {
for (const kw of data.keywords) {
await db.execute(
`INSERT INTO keywords (id, keyword, category_id, supplier_id, priority, is_active)
VALUES ($1, $2, $3, $4, $5, $6)`,
[kw.id, kw.keyword, kw.category_id, kw.supplier_id ?? null, kw.priority, kw.is_active ? 1 : 0]
);
}
}
} }
export async function importTransactionsWithCategories( export async function importTransactionsWithCategories(
data: ExportEnvelope["data"], data: ExportEnvelope["data"],
filename: string filename: string
): Promise<void> { ): Promise<void> {
const db = await getDb(); validateImportedFormatRows(data.import_sources, data.import_config_templates);
return runRestore(async (db) => {
// Wipe everything // Wipe everything
await db.execute("DELETE FROM transactions"); await db.execute("DELETE FROM transactions");
await db.execute("DELETE FROM imported_files"); await db.execute("DELETE FROM imported_files");
@ -267,136 +683,39 @@ export async function importTransactionsWithCategories(
await db.execute("DELETE FROM suppliers"); await db.execute("DELETE FROM suppliers");
await db.execute("DELETE FROM categories"); await db.execute("DELETE FROM categories");
// Re-insert categories await restoreCategories(db, data.categories);
if (data.categories) { await restoreSuppliers(db, data.suppliers);
for (const cat of data.categories) { await restoreKeywords(db, data.keywords);
await db.execute(
`INSERT INTO categories (id, name, parent_id, color, icon, type, is_active, is_inputable, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
cat.id,
cat.name,
cat.parent_id ?? null,
cat.color ?? null,
cat.icon ?? null,
cat.type,
cat.is_active ? 1 : 0,
cat.is_inputable ? 1 : 0,
cat.sort_order,
]
);
}
}
// Re-insert suppliers // Templates first: `import_sources.template_id` points at them.
if (data.suppliers) { const templateIds = await restoreImportTemplates(
for (const sup of data.suppliers) { db,
await db.execute( data.import_config_templates
`INSERT INTO suppliers (id, name, normalized_name, category_id, is_active)
VALUES ($1, $2, $3, $4, $5)`,
[sup.id, sup.name, sup.normalized_name, sup.category_id ?? null, sup.is_active ? 1 : 0]
); );
} await restoreImportSources(db, data.import_sources, templateIds);
}
// Re-insert keywords await attachTransactions(db, data.transactions, filename);
if (data.keywords) { });
for (const kw of data.keywords) {
await db.execute(
`INSERT INTO keywords (id, keyword, category_id, supplier_id, priority, is_active)
VALUES ($1, $2, $3, $4, $5, $6)`,
[kw.id, kw.keyword, kw.category_id, kw.supplier_id ?? null, kw.priority, kw.is_active ? 1 : 0]
);
}
}
// Create tracking records for import history
const sourceResult = await db.execute(
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
["Data Import", "Imported from settings", "%Y-%m-%d", ",", "utf-8", "{}", 0]
);
const sourceId = sourceResult.lastInsertId;
const txCount = data.transactions?.length ?? 0;
const fileResult = await db.execute(
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status)
VALUES ($1, $2, $3, $4, $5)`,
[sourceId, filename, `data-import-${Date.now()}`, txCount, "completed"]
);
const fileId = fileResult.lastInsertId;
// Re-insert transactions linked to the import
if (data.transactions) {
for (const tx of data.transactions) {
await db.execute(
`INSERT INTO transactions (date, description, amount, category_id, original_description, notes, is_manually_categorized, is_split, parent_transaction_id, source_id, file_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
tx.date,
tx.description,
tx.amount,
tx.category_id,
tx.original_description,
tx.notes,
tx.is_manually_categorized,
tx.is_split,
tx.parent_transaction_id,
sourceId,
fileId,
]
);
}
}
} }
export async function importTransactionsOnly( export async function importTransactionsOnly(
data: ExportEnvelope["data"], data: ExportEnvelope["data"],
filename: string filename: string
): Promise<void> { ): Promise<void> {
const db = await getDb(); validateImportedFormatRows(data.import_sources, data.import_config_templates);
return runRestore(async (db) => {
// Wipe transactions and import history // Wipe transactions and import history
await db.execute("DELETE FROM transactions"); await db.execute("DELETE FROM transactions");
await db.execute("DELETE FROM imported_files"); await db.execute("DELETE FROM imported_files");
await db.execute("DELETE FROM import_sources"); await db.execute("DELETE FROM import_sources");
// Create tracking records for import history const templateIds = await restoreImportTemplates(
const sourceResult = await db.execute( db,
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines) data.import_config_templates
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
["Data Import", "Imported from settings", "%Y-%m-%d", ",", "utf-8", "{}", 0]
); );
const sourceId = sourceResult.lastInsertId; await restoreImportSources(db, data.import_sources, templateIds);
const txCount = data.transactions?.length ?? 0; await attachTransactions(db, data.transactions, filename);
const fileResult = await db.execute( });
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status)
VALUES ($1, $2, $3, $4, $5)`,
[sourceId, filename, `data-import-${Date.now()}`, txCount, "completed"]
);
const fileId = fileResult.lastInsertId;
// Re-insert transactions linked to the import
if (data.transactions) {
for (const tx of data.transactions) {
await db.execute(
`INSERT INTO transactions (date, description, amount, category_id, original_description, notes, is_manually_categorized, is_split, parent_transaction_id, source_id, file_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
tx.date,
tx.description,
tx.amount,
tx.category_id,
tx.original_description,
tx.notes,
tx.is_manually_categorized,
tx.is_split,
tx.parent_transaction_id,
sourceId,
fileId,
]
);
}
}
} }

View file

@ -0,0 +1,157 @@
import { describe, it, expect } from "vitest";
// Subject lives in src/shared/entitlements.ts; test co-located here per the
// issue's file plan (services/).
import {
isEntitled,
requiredTierFor,
ENTITLEMENTS,
type FeatureKey,
} from "../shared/entitlements";
import type { Edition } from "./licenseService";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
const EDITIONS: Edition[] = ["free", "base", "premium"];
const FEATURES = Object.keys(ENTITLEMENTS) as FeatureKey[];
describe("isEntitled — matrix", () => {
it("denies every feature in Free (fail-closed)", () => {
FEATURES.forEach((f) => {
expect(isEntitled(f, "free", [])).toBe(false);
});
});
it("Base unlocks budget/adjustments/reports-advanced/multi-profile, not balance", () => {
expect(isEntitled("budget", "base", [])).toBe(true);
expect(isEntitled("adjustments", "base", [])).toBe(true);
expect(isEntitled("reports-advanced", "base", [])).toBe(true);
expect(isEntitled("multi-profile", "base", [])).toBe(true);
expect(isEntitled("balance", "base", [])).toBe(false);
});
it("Premium unlocks everything including balance", () => {
FEATURES.forEach((f) => {
expect(isEntitled(f, "premium", [])).toBe(true);
});
});
it("matches the ENTITLEMENTS table for every feature × edition", () => {
FEATURES.forEach((f) => {
EDITIONS.forEach((ed) => {
const expected = ed !== "free" && ENTITLEMENTS[f].includes(ed);
expect(isEntitled(f, ed, [])).toBe(expected);
});
});
});
});
describe("isEntitled — features[] override", () => {
it("grants a higher-tier feature when the signed override lists it", () => {
// balance is Premium-only; a Base license carrying balance in features[] gets it.
expect(isEntitled("balance", "base", ["balance"])).toBe(true);
});
it("IGNORES the override when edition is free (CWE-863)", () => {
// A copied key downgrades to free (machine-binding) but still carries its
// signed features[]. Those must not be re-granted.
expect(isEntitled("balance", "free", ["balance"])).toBe(false);
expect(isEntitled("budget", "free", ["budget"])).toBe(false);
});
it("does not grant unrelated features via the override", () => {
expect(isEntitled("balance", "base", ["budget"])).toBe(false);
});
});
describe("isEntitled — unknown feature", () => {
it("denies an unknown feature key (deny-all)", () => {
expect(isEntitled("web-sync" as FeatureKey, "premium", [])).toBe(false);
expect(isEntitled("web-sync" as FeatureKey, "base", [])).toBe(false);
});
it("still honours a signed override for an unknown key when non-free", () => {
expect(isEntitled("web-sync" as FeatureKey, "premium", ["web-sync"])).toBe(true);
});
it("keeps an unknown feature denied in Free even with an override", () => {
expect(isEntitled("web-sync" as FeatureKey, "free", ["web-sync"])).toBe(false);
});
});
describe("requiredTierFor — upsell tier derivation", () => {
it("maps every Base-tier feature to base, and balance to premium", () => {
expect(requiredTierFor("budget")).toBe("base");
expect(requiredTierFor("adjustments")).toBe("base");
expect(requiredTierFor("reports-advanced")).toBe("base");
expect(requiredTierFor("multi-profile")).toBe("base");
expect(requiredTierFor("balance")).toBe("premium");
});
it("returns the MINIMUM tier satisfying isEntitled, never free", () => {
FEATURES.forEach((f) => {
const tier = requiredTierFor(f);
expect(tier).not.toBe("free");
// The returned tier does unlock the feature…
expect(isEntitled(f, tier, [])).toBe(true);
// …and is minimal: premium only when base does not unlock it.
if (tier === "premium") {
expect(isEntitled(f, "base", [])).toBe(false);
}
});
});
});
describe("upsell i18n coverage (fr + en)", () => {
// UpsellGate renders t(`upsell.features.${feature}`) — every FeatureKey must
// resolve in BOTH locales or a locked screen shows a raw key to the user.
// Structural sub-type only: full typeof-identity between the two JSON files
// is not this test's concern.
interface UpsellMessages {
nav: { locked: string };
license: { editions: { base: string; premium: string } };
upsell: {
title: string;
features: Record<string, string>;
ctaGet: string;
ctaGetSoon: string;
ctaHaveKey: string;
};
}
const locales: Record<string, UpsellMessages> = { fr, en };
it("has a non-empty upsell description for every FeatureKey in both locales", () => {
Object.entries(locales).forEach(([lng, messages]) => {
FEATURES.forEach((f) => {
expect(
messages.upsell.features[f],
`missing upsell.features.${f} in ${lng}`,
).toBeTruthy();
});
});
});
it("has no stale upsell.features key outside the ENTITLEMENTS matrix", () => {
Object.entries(locales).forEach(([lng, messages]) => {
Object.keys(messages.upsell.features).forEach((k) => {
expect(FEATURES, `stale upsell.features.${k} in ${lng}`).toContain(k);
});
});
});
it("exposes the title, CTA keys and nav.locked in both locales", () => {
Object.values(locales).forEach((messages) => {
expect(messages.upsell.title).toBeTruthy();
expect(messages.upsell.ctaGet).toBeTruthy();
expect(messages.upsell.ctaGetSoon).toBeTruthy();
expect(messages.upsell.ctaHaveKey).toBeTruthy();
expect(messages.nav.locked).toBeTruthy();
});
});
it("keeps the license.editions.<tier> label keys required by the CTA", () => {
Object.values(locales).forEach((messages) => {
expect(messages.license.editions.base).toBeTruthy();
expect(messages.license.editions.premium).toBeTruthy();
});
});
});

View file

@ -1,5 +1,15 @@
import { getDb } from "./db"; import { getDb } from "./db";
import type { ImportConfigTemplate } from "../shared/types"; import type { ImportConfigTemplate, ImportFormatRow } from "../shared/types";
/**
* A template is a named format, nothing more. Taking `ImportFormatRow` here
* the same shape `importSourceService` takes is what keeps both writers on
* the codec: a field added to the format cannot reach one table and miss the
* other.
*/
export interface ImportTemplateInput extends ImportFormatRow {
name: string;
}
export async function getAllTemplates(): Promise<ImportConfigTemplate[]> { export async function getAllTemplates(): Promise<ImportConfigTemplate[]> {
const db = await getDb(); const db = await getDb();
@ -9,7 +19,7 @@ export async function getAllTemplates(): Promise<ImportConfigTemplate[]> {
} }
export async function createTemplate( export async function createTemplate(
template: Omit<ImportConfigTemplate, "id" | "created_at"> template: ImportTemplateInput
): Promise<number> { ): Promise<number> {
const db = await getDb(); const db = await getDb();
const result = await db.execute( const result = await db.execute(
@ -30,9 +40,14 @@ export async function createTemplate(
return result.lastInsertId as number; return result.lastInsertId as number;
} }
/**
* Editing a template touches `import_config_templates` and nothing else. A
* source that was configured from this template keeps its own eight columns
* `import_sources.template_id` is a provenance tag, never re-read as format.
*/
export async function updateTemplate( export async function updateTemplate(
id: number, id: number,
template: Omit<ImportConfigTemplate, "id" | "created_at"> template: ImportTemplateInput
): Promise<void> { ): Promise<void> {
const db = await getDb(); const db = await getDb();
await db.execute( await db.execute(

View file

@ -1,5 +1,24 @@
import { getDb } from "./db"; import { getDb } from "./db";
import type { ImportSource } from "../shared/types"; import type { ImportFormatRow, ImportSource } from "../shared/types";
/**
* What a writer hands to this service: the eight format fields in their
* persisted shape (produced by `formatToRow` never assembled by hand) plus
* the source's own columns.
*
* Note it is NOT `Omit<ImportSource, "id" | ...>`: `ImportSource.has_header`
* is declared boolean while the codec emits the 0/1 SQLite actually stores.
* Taking `ImportFormatRow` here is what makes the codec the only place that
* normalization happens.
*/
export interface ImportSourceInput extends ImportFormatRow {
name: string;
description?: string | null;
/** Drift-detection metadata, written by #330. */
header_signature?: string | null;
/** Provenance tag — recorded and displayed, never re-read as format. */
template_id?: number | null;
}
export async function getAllSources(): Promise<ImportSource[]> { export async function getAllSources(): Promise<ImportSource[]> {
const db = await getDb(); const db = await getDb();
@ -29,12 +48,12 @@ export async function getSourceById(
} }
export async function createSource( export async function createSource(
source: Omit<ImportSource, "id" | "created_at" | "updated_at"> source: ImportSourceInput
): Promise<number> { ): Promise<number> {
const db = await getDb(); const db = await getDb();
const result = await db.execute( const result = await db.execute(
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines, has_header) `INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines, has_header, amount_mode, sign_convention, header_signature, template_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
description = excluded.description, description = excluded.description,
date_format = excluded.date_format, date_format = excluded.date_format,
@ -43,16 +62,27 @@ export async function createSource(
column_mapping = excluded.column_mapping, column_mapping = excluded.column_mapping,
skip_lines = excluded.skip_lines, skip_lines = excluded.skip_lines,
has_header = excluded.has_header, has_header = excluded.has_header,
amount_mode = excluded.amount_mode,
sign_convention = excluded.sign_convention,
header_signature = excluded.header_signature,
template_id = excluded.template_id,
updated_at = CURRENT_TIMESTAMP`, updated_at = CURRENT_TIMESTAMP`,
[ [
source.name, source.name,
source.description || null, source.description ?? null,
source.date_format, source.date_format,
source.delimiter, source.delimiter,
source.encoding, source.encoding,
source.column_mapping, source.column_mapping,
source.skip_lines, source.skip_lines,
source.has_header ? 1 : 0, source.has_header,
source.amount_mode,
source.sign_convention,
// Explicitly null for a headerless file: drift detection is inoperative
// there and a stale signature from an earlier shape would be worse than
// none (#330).
source.header_signature ?? null,
source.template_id ?? null,
] ]
); );
// On conflict, lastInsertId may be 0 — look up the existing row // On conflict, lastInsertId may be 0 — look up the existing row
@ -63,45 +93,30 @@ export async function createSource(
export async function updateSource( export async function updateSource(
id: number, id: number,
source: Partial<Omit<ImportSource, "id" | "created_at" | "updated_at">> source: Partial<ImportSourceInput>
): Promise<void> { ): Promise<void> {
const db = await getDb(); const db = await getDb();
const fields: string[] = []; const fields: string[] = [];
const values: unknown[] = []; const values: unknown[] = [];
let paramIndex = 1; let paramIndex = 1;
if (source.name !== undefined) { const setColumn = (column: string, value: unknown) => {
fields.push(`name = $${paramIndex++}`); fields.push(`${column} = $${paramIndex++}`);
values.push(source.name); values.push(value);
} };
if (source.description !== undefined) {
fields.push(`description = $${paramIndex++}`); if (source.name !== undefined) setColumn("name", source.name);
values.push(source.description); if (source.description !== undefined) setColumn("description", source.description);
} if (source.date_format !== undefined) setColumn("date_format", source.date_format);
if (source.date_format !== undefined) { if (source.delimiter !== undefined) setColumn("delimiter", source.delimiter);
fields.push(`date_format = $${paramIndex++}`); if (source.encoding !== undefined) setColumn("encoding", source.encoding);
values.push(source.date_format); if (source.column_mapping !== undefined) setColumn("column_mapping", source.column_mapping);
} if (source.skip_lines !== undefined) setColumn("skip_lines", source.skip_lines);
if (source.delimiter !== undefined) { if (source.has_header !== undefined) setColumn("has_header", source.has_header);
fields.push(`delimiter = $${paramIndex++}`); if (source.amount_mode !== undefined) setColumn("amount_mode", source.amount_mode);
values.push(source.delimiter); if (source.sign_convention !== undefined) setColumn("sign_convention", source.sign_convention);
} if (source.header_signature !== undefined) setColumn("header_signature", source.header_signature);
if (source.encoding !== undefined) { if (source.template_id !== undefined) setColumn("template_id", source.template_id);
fields.push(`encoding = $${paramIndex++}`);
values.push(source.encoding);
}
if (source.column_mapping !== undefined) {
fields.push(`column_mapping = $${paramIndex++}`);
values.push(source.column_mapping);
}
if (source.skip_lines !== undefined) {
fields.push(`skip_lines = $${paramIndex++}`);
values.push(source.skip_lines);
}
if (source.has_header !== undefined) {
fields.push(`has_header = $${paramIndex++}`);
values.push(source.has_header ? 1 : 0);
}
if (fields.length === 0) return; if (fields.length === 0) return;

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { NAV_ITEMS } from "./index";
/**
* Nav gating contract (#299). Locks in the /review-spec CRITICAL caveat:
* the `reports` nav item points to the FREE hub (/reports, not route-gated)
* and must NEVER carry a `feature` gating it would show a lock over a
* fully functional free page.
*/
describe("NAV_ITEMS feature gating", () => {
const byKey = Object.fromEntries(NAV_ITEMS.map((item) => [item.key, item]));
it("gates budget, adjustments and balance with their matching feature key", () => {
expect(byKey.budget.feature).toBe("budget");
expect(byKey.adjustments.feature).toBe("adjustments");
expect(byKey.balance.feature).toBe("balance");
});
it("never gates the reports hub nav item nor any Free module", () => {
expect(byKey.reports.feature).toBeUndefined();
expect(byKey.dashboard.feature).toBeUndefined();
expect(byKey.import.feature).toBeUndefined();
expect(byKey.transactions.feature).toBeUndefined();
expect(byKey.categories.feature).toBeUndefined();
expect(byKey.settings.feature).toBeUndefined();
});
it("gates exactly 3 of the 9 nav items", () => {
const gated = NAV_ITEMS.filter((item) => item.feature !== undefined);
expect(NAV_ITEMS).toHaveLength(9);
expect(gated.map((item) => item.key).sort()).toEqual([
"adjustments",
"balance",
"budget",
]);
});
});

View file

@ -33,12 +33,14 @@ export const NAV_ITEMS: NavItem[] = [
path: "/adjustments", path: "/adjustments",
icon: "SlidersHorizontal", icon: "SlidersHorizontal",
labelKey: "nav.adjustments", labelKey: "nav.adjustments",
feature: "adjustments",
}, },
{ {
key: "budget", key: "budget",
path: "/budget", path: "/budget",
icon: "PiggyBank", icon: "PiggyBank",
labelKey: "nav.budget", labelKey: "nav.budget",
feature: "budget",
}, },
{ {
key: "reports", key: "reports",
@ -51,6 +53,7 @@ export const NAV_ITEMS: NavItem[] = [
path: "/balance", path: "/balance",
icon: "Wallet", icon: "Wallet",
labelKey: "nav.balance", labelKey: "nav.balance",
feature: "balance",
}, },
{ {
key: "settings", key: "settings",

View file

@ -0,0 +1,59 @@
import type { Edition } from "../services/licenseService";
/**
* UI entitlements matrix single front-end source of truth for feature gating.
*
* Keys are kebab-case because the JWT `features[]` array is a namespace shared
* between the Rust override and this TS layer (cf. `auto-update` string on the
* Rust side). Enforcement is UI-only (soft-paywall, GPL assumed) the edition
* itself is still resolved by the machine-bound Rust `current_edition()`.
*
* Modules that stay Free (dashboard, import, transactions, categories,
* reports/trends, export, changelog, docs) have NO key here never gated.
*/
export type FeatureKey =
| "budget"
| "adjustments"
| "reports-advanced"
| "multi-profile"
| "balance";
export const ENTITLEMENTS: Record<FeatureKey, Edition[]> = {
budget: ["base", "premium"],
adjustments: ["base", "premium"],
"reports-advanced": ["base", "premium"],
"multi-profile": ["base", "premium"],
balance: ["premium"],
};
/**
* Pure entitlement check.
*
* Fail-closed in Free (CWE-863): a `license.key` copied onto another machine
* downgrades `edition` "free" (machine-binding), but still carries its signed
* `features[]`. We must NOT re-grant those features to a downgraded license, so
* the Free short-circuit runs BEFORE the `features[]` override.
*
* `licenseFeatures` is the JWT override namespace. An unknown feature (not in
* the matrix the namespace is shared with Rust and may drift) is deny-all
* unless explicitly present in the signed override.
*/
export function isEntitled(
f: FeatureKey,
edition: Edition,
licenseFeatures: string[],
): boolean {
if (edition === "free") return false;
const tiers = ENTITLEMENTS[f];
if (!tiers) return licenseFeatures.includes(f);
return tiers.includes(edition) || licenseFeatures.includes(f);
}
/**
* Minimum paid tier that unlocks a feature drives the upsell copy
* ("Obtenir Base" vs "Obtenir Premium"). Derived from matrix MEMBERSHIP, not
* array order, so reordering an ENTITLEMENTS row can never change the answer.
*/
export function requiredTierFor(f: FeatureKey): Edition {
return ENTITLEMENTS[f].includes("base") ? "base" : "premium";
}

View file

@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import {
isProfileSwitchLocked,
isProfileCreationLocked,
type EntitlementGate,
} from "./profileGate";
const LOCKED_GATE: EntitlementGate = { allowed: false, ready: true };
const ALLOWED_GATE: EntitlementGate = { allowed: true, ready: true };
const BOOTING_GATE: EntitlementGate = { allowed: false, ready: false };
describe("isProfileSwitchLocked", () => {
it("locks profiles beyond the active one when gate is active (Free, ready)", () => {
expect(isProfileSwitchLocked("p2", "p1", LOCKED_GATE)).toBe(true);
expect(isProfileSwitchLocked("p3", "p1", LOCKED_GATE)).toBe(true);
});
it("keeps the active profile accessible for a Free user", () => {
expect(isProfileSwitchLocked("p1", "p1", LOCKED_GATE)).toBe(false);
});
it("locks nothing while the license is not ready (anti-flash at boot)", () => {
expect(isProfileSwitchLocked("p2", "p1", BOOTING_GATE)).toBe(false);
expect(isProfileSwitchLocked("p1", "p1", BOOTING_GATE)).toBe(false);
});
it("locks nothing when multi-profile is allowed (Base/Premium)", () => {
expect(isProfileSwitchLocked("p2", "p1", ALLOWED_GATE)).toBe(false);
});
it("locks nothing when no active profile resolves (never lock out of ALL profiles)", () => {
expect(isProfileSwitchLocked("p1", null, LOCKED_GATE)).toBe(false);
expect(isProfileSwitchLocked("p2", null, LOCKED_GATE)).toBe(false);
});
it("stays unlocked when allowed even if not ready (allowed short-circuits)", () => {
expect(isProfileSwitchLocked("p2", "p1", { allowed: true, ready: false })).toBe(false);
});
});
describe("isProfileCreationLocked", () => {
it("locks creation for a Free user who already has at least one profile", () => {
expect(isProfileCreationLocked(1, LOCKED_GATE)).toBe(true);
expect(isProfileCreationLocked(5, LOCKED_GATE)).toBe(true);
});
it("never blocks creating the FIRST profile (empty config)", () => {
expect(isProfileCreationLocked(0, LOCKED_GATE)).toBe(false);
});
it("locks nothing while the license is not ready (anti-flash at boot)", () => {
expect(isProfileCreationLocked(3, BOOTING_GATE)).toBe(false);
});
it("locks nothing when multi-profile is allowed (Base/Premium)", () => {
expect(isProfileCreationLocked(3, ALLOWED_GATE)).toBe(false);
expect(isProfileCreationLocked(0, ALLOWED_GATE)).toBe(false);
});
});

55
src/shared/profileGate.ts Normal file
View file

@ -0,0 +1,55 @@
/**
* Pure predicates for the multi-profile gate (Base+), issue #300.
*
* NON-destructive by construction: these functions only decide whether a UI
* surface is locked nothing is ever removed from profiles.json. An upgrade
* (edition becomes base/premium) flips `allowed` and every profile reappears
* untouched, without any migration.
*
* Anti-flash rule: the gate is active only when the license is `ready`
* (`status === "ready"` in LicenseContext). While loading or errored, nothing
* is locked a paying user must never see a "locked" flash at boot (the same
* rule RequireFeature/Sidebar follow).
*/
/** Shape returned by `useEntitlement("multi-profile")`. */
export interface EntitlementGate {
allowed: boolean;
ready: boolean;
}
/**
* Is switching to `profileId` locked?
*
* Locked iff the gate is active (ready && !allowed) AND the profile is beyond
* the active one. A Free user keeps full access to their active profile.
*
* `activeProfileId === null` (degenerate state: no active profile resolves)
* locks NOTHING we never lock a user out of all of their profiles; the only
* surface without an active profile is ProfileSelectionPage, where selection
* stays free by design (see decisions log #300).
*/
export function isProfileSwitchLocked(
profileId: string,
activeProfileId: string | null,
gate: EntitlementGate,
): boolean {
if (!gate.ready || gate.allowed) return false;
if (activeProfileId === null) return false;
return profileId !== activeProfileId;
}
/**
* Is creating a NEW profile locked?
*
* Locked iff the gate is active AND at least one profile already exists a
* Free user is entitled to one profile, so creation from an empty config
* (fresh/repaired install) is never blocked.
*/
export function isProfileCreationLocked(
profileCount: number,
gate: EntitlementGate,
): boolean {
if (!gate.ready || gate.allowed) return false;
return profileCount >= 1;
}

View file

@ -1,3 +1,5 @@
import type { FeatureKey } from "../entitlements";
export interface ImportSource { export interface ImportSource {
id: number; id: number;
name: string; name: string;
@ -8,6 +10,28 @@ export interface ImportSource {
column_mapping: string; column_mapping: string;
skip_lines: number; skip_lines: number;
has_header: boolean; has_header: boolean;
/**
* The two fields that decide how an amount is READ, added by migration v17.
* Before v17 they lived only on `import_config_templates`, so restoring a
* source re-inferred the mode and hardcoded the convention (#324).
*
* They are never read directly: `formatFromRow` is the single conversion
* point and validates them, because the column `CHECK` admits
* `absolute_indicator` a value the app cannot map today.
*/
amount_mode: AmountMode;
sign_convention: SignConvention;
/**
* Normalized header labels seen at the last successful import, for drift
* detection. NOT a format field: written by #330, not by the codec.
*/
header_signature?: string | null;
/**
* Provenance tag only records which template this source was configured
* from, and is NEVER re-read as format. The eight format fields above are
* authoritative, so editing a template alters no linked source.
*/
template_id?: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -152,17 +176,14 @@ export interface BudgetYearRow {
previousYearTotal: number; // actual (transactions) total from the previous year previousYearTotal: number; // actual (transactions) total from the previous year
} }
export interface ImportConfigTemplate { /**
* A named, reusable format. It carries exactly the eight persisted format
* fields (via `ImportFormatRow`) plus its identity the same eight an
* `import_sources` row carries since v17.
*/
export interface ImportConfigTemplate extends ImportFormatRow {
id: number; id: number;
name: string; name: string;
delimiter: string;
encoding: string;
date_format: string;
skip_lines: number;
has_header: number;
column_mapping: string;
amount_mode: AmountMode;
sign_convention: SignConvention;
created_at: string; created_at: string;
} }
@ -177,6 +198,12 @@ export interface NavItem {
path: string; path: string;
icon: string; icon: string;
labelKey: string; labelKey: string;
/**
* Gated feature backing this nav entry. When set, the Sidebar shows a lock
* icon if the license is not entitled (and ready) the item stays clickable
* (the gated route renders the upsell). Absent = never gated (Free module).
*/
feature?: FeatureKey;
} }
// --- Import Wizard Types --- // --- Import Wizard Types ---
@ -205,16 +232,57 @@ export interface ColumnMapping {
export type AmountMode = "single" | "debit_credit"; export type AmountMode = "single" | "debit_credit";
export type SignConvention = "negative_expense" | "positive_expense"; export type SignConvention = "negative_expense" | "positive_expense";
export interface SourceConfig { /**
name: string; * --- The import format ------------------------------------------------------
*
* The eight fields that fully decide how a CSV file is read. They exist in two
* shapes that CANNOT be a single composed type: the persisted rows are
* snake_case with the mapping serialized to JSON and no boolean type in SQLite,
* while the wizard works in camelCase on a parsed mapping.
*
* The guarantee that no field is ever half-persisted therefore does not come
* from the type structure but from `src/utils/importFormat.ts`, the SINGLE
* conversion point between the two shapes, and from its completeness test.
*/
/** Persisted shape — snake_case, mapping as JSON, `has_header` normalized to 0/1. */
export interface ImportFormatRow {
delimiter: string;
encoding: string;
date_format: string;
skip_lines: number;
/** SQLite has no boolean: written as 0/1. Reads tolerate both, see `ImportFormatRowInput`. */
has_header: number;
column_mapping: string;
amount_mode: AmountMode;
sign_convention: SignConvention;
}
/**
* What `formatFromRow` accepts. `import_sources` rows declare `has_header` as a
* boolean and `import_config_templates` rows as a number; both are the same 0/1
* integer at runtime, and the codec normalizes it. Widening the field here is
* what lets a row from either table be decoded without a cast.
*/
export type ImportFormatRowInput = Omit<ImportFormatRow, "has_header"> & {
has_header: number | boolean;
};
/** Domain shape — camelCase, mapping parsed. What the wizard manipulates. */
export interface ImportFormat {
delimiter: string; delimiter: string;
encoding: string; encoding: string;
dateFormat: string; dateFormat: string;
skipLines: number; skipLines: number;
hasHeader: boolean;
columnMapping: ColumnMapping; columnMapping: ColumnMapping;
amountMode: AmountMode; amountMode: AmountMode;
signConvention: SignConvention; signConvention: SignConvention;
hasHeader: boolean; }
/** A format plus the source it belongs to. */
export interface SourceConfig extends ImportFormat {
name: string;
} }
export interface ParsedRow { export interface ParsedRow {

View file

@ -0,0 +1,253 @@
// amountParser — characterization tests (#326), hardened (#325).
//
// `parseFrenchAmount` had no test at all, on a codebase of 871. It is called
// from 11 sites (8 in `csvAutoDetect.ts`, 3 in `useSnapshotEditor.ts`) and sits
// under every imported amount, so #326 pinned what it did before #325 touched
// it. The `KNOWN DEFECT` blocks #326 left here have since flipped: the
// expectations were updated in place and the markers dropped, per the standing
// rule (update, never delete).
//
// The defect that motivated the whole chantier: `parseFrenchAmount` ended on
// `parseFloat`, which stops at the first invalid character instead of rejecting
// the string. A trailing unit or sign therefore yielded a magnitude off by a
// factor of 100 — and it passed `isNaN`, so it counted as a VALID row
// everywhere downstream. `"100,00 CAD"` did not fail; it imported as 10 000.
// Validation is anchored now and such a cell is NaN, i.e. a visible row error.
import { describe, it, expect } from "vitest";
import { detectDecimalSeparator, parseFrenchAmount } from "./amountParser";
import { autoDetectConfig } from "./csvAutoDetect";
import {
buildDetailedLines,
holdingsFromCsvRows,
} from "../hooks/useSnapshotEditor";
import { BalanceServiceError } from "../services/balance.service";
import { readCsvFixture } from "../__fixtures__/csv";
describe("parseFrenchAmount — separator contract (#326)", () => {
it("reads the French decimal comma", () => {
expect(parseFrenchAmount("1234,56")).toBe(1234.56);
expect(parseFrenchAmount("12,5")).toBe(12.5);
expect(parseFrenchAmount("0,00")).toBe(0);
});
it("reads French thousand separators (space, non-breaking space, dot)", () => {
expect(parseFrenchAmount("1 234,56")).toBe(1234.56);
expect(parseFrenchAmount("1\u00A0234,56")).toBe(1234.56); // non-breaking space
expect(parseFrenchAmount("1.234,56")).toBe(1234.56);
});
it("reads English notation", () => {
expect(parseFrenchAmount("1234.56")).toBe(1234.56);
expect(parseFrenchAmount("1,234.56")).toBe(1234.56);
});
it("keeps the sign of a leading minus", () => {
expect(parseFrenchAmount("-84,32")).toBe(-84.32);
expect(parseFrenchAmount(" -84,32 ")).toBe(-84.32);
});
it("strips currency symbols on either side", () => {
expect(parseFrenchAmount("1 250,00 $")).toBe(1250);
expect(parseFrenchAmount("$1,250.00")).toBe(1250);
expect(parseFrenchAmount("€84,32")).toBe(84.32);
expect(parseFrenchAmount("£84,32")).toBe(84.32);
});
it("rejects blank and non-numeric input", () => {
expect(parseFrenchAmount("")).toBeNaN();
expect(parseFrenchAmount(" ")).toBeNaN();
expect(parseFrenchAmount("abc")).toBeNaN();
expect(parseFrenchAmount("-")).toBeNaN();
expect(parseFrenchAmount("--5")).toBeNaN();
// Letter-leading labels are rejected — this is what keeps `detectHeader`
// working on a header such as "Solde 2024" (see the twin defect below).
expect(parseFrenchAmount("Solde 2024")).toBeNaN();
// Non-string input is guarded before any parsing.
expect(parseFrenchAmount(undefined as unknown as string)).toBeNaN();
expect(parseFrenchAmount(42 as unknown as string)).toBeNaN();
});
});
describe("parseFrenchAmount — anchored validation (#325, was a KNOWN DEFECT of #326)", () => {
// FIXED. `parseFloat` used to return the longest valid PREFIX instead of
// rejecting the string; validation is anchored over the whole normalized
// value now, so any residual character yields NaN.
//
// Note on the two currency/indicator cases: link 1 wrote "should be 1234.56"
// and "should be 100" in the titles, while the block header it wrote just
// above said "all of these then become NaN, except the accounting-parenthesis
// and trailing-sign forms". The header is what #325 implements, for two
// reasons the titles missed. `CR`/`DB` carry a DIRECTION, so returning a
// magnitude for both would replace a loud failure with a silent SIGN error —
// and the D/C-indicator shape is refused upstream by design (#328). And
// "100,00 CAD" is structurally identical to "2025 Montant": rescuing the
// first by whitelisting a trailing word re-blinds `detectHeader` on the
// second, which is the very defect measured below.
it("reads a trailing sign as a negative amount", () => {
// "50,00-" is the trailing-minus convention of several bank exports.
expect(parseFrenchAmount("50,00-")).toBe(-50);
expect(parseFrenchAmount("1 234,56-")).toBe(-1234.56);
expect(parseFrenchAmount("50,00+")).toBe(50);
});
it("rejects a trailing direction indicator instead of guessing a sign", () => {
expect(parseFrenchAmount("1 234,56 CR")).toBeNaN();
expect(parseFrenchAmount("1 234,56 DB")).toBeNaN();
});
it("rejects a trailing currency code", () => {
expect(parseFrenchAmount("100,00 CAD")).toBeNaN();
expect(parseFrenchAmount("84,32 USD")).toBeNaN();
});
it("rejects the numeric prefix of a text label", () => {
// This is what used to blind `detectHeader`: a header cell STARTING with
// digits read as a number, so the header row was taken for data.
expect(parseFrenchAmount("2024 Montant")).toBeNaN();
expect(parseFrenchAmount("5%")).toBeNaN();
});
it("rejects JavaScript number literals a bank never emits", () => {
expect(parseFrenchAmount("1e3")).toBeNaN();
expect(parseFrenchAmount("Infinity")).toBeNaN();
expect(parseFrenchAmount("0x1F")).toBeNaN();
});
it("rejects a malformed number instead of keeping its first two groups", () => {
expect(parseFrenchAmount("1,2,3")).toBeNaN();
expect(parseFrenchAmount("1.2.3")).toBeNaN();
expect(parseFrenchAmount("1,23,456")).toBeNaN(); // groups must be 3 digits
});
it("rejects two signs, wherever they sit", () => {
expect(parseFrenchAmount("-50,00-")).toBeNaN();
expect(parseFrenchAmount("(-50,00)")).toBeNaN();
});
});
describe("parseFrenchAmount — accounting forms (#325, was a KNOWN DEFECT of #326)", () => {
it("reads accounting parentheses as a negative amount", () => {
expect(parseFrenchAmount("(50,00)")).toBe(-50);
expect(parseFrenchAmount("(1 234,56)")).toBe(-1234.56);
expect(parseFrenchAmount("(1,234.56)")).toBe(-1234.56);
});
it("still rejects an unbalanced parenthesis", () => {
expect(parseFrenchAmount("(50,00")).toBeNaN();
expect(parseFrenchAmount("50,00)")).toBeNaN();
});
});
describe("parseFrenchAmount — column-level arbitration (#325, was a KNOWN DEFECT of #326)", () => {
// The French-vs-English decision used to be taken on each cell in isolation
// and NOTHING else, so two cells of the same column could be read under two
// different conventions. The isolated readings below are unchanged — they
// are the best a lone cell allows — but a caller that knows the column now
// passes its verdict and settles the ambiguity.
it("keeps the documented reading when no column context is given", () => {
// "12,345" is 12.345 in a column of decimals, 12345 in a column of
// thousands. Nothing in the cell ALONE can tell.
expect(parseFrenchAmount("12,345")).toBe(12345);
expect(parseFrenchAmount("1.234")).toBe(1.234);
expect(parseFrenchAmount("1.234,56")).toBe(1234.56); // ...unless a comma follows
});
it("obeys the column verdict when there is one", () => {
expect(parseFrenchAmount("12,345", { decimalSeparator: "," })).toBe(12.345);
expect(parseFrenchAmount("12,345", { decimalSeparator: "." })).toBe(12345);
expect(parseFrenchAmount("1.234", { decimalSeparator: "," })).toBe(1234);
expect(parseFrenchAmount("1.234", { decimalSeparator: "." })).toBe(1.234);
});
it("still rejects a value that contradicts the column verdict", () => {
// A grouping separator groups by three, always.
expect(parseFrenchAmount("1.23", { decimalSeparator: "," })).toBeNaN();
expect(parseFrenchAmount("1,23", { decimalSeparator: "." })).toBeNaN();
});
it("arbitrates a column from a decisive sibling cell", () => {
// "1.234" alone is ambiguous; "84,32" in the same column is not.
expect(detectDecimalSeparator(["1.234", "84,32", "-6,95"])).toBe(",");
expect(detectDecimalSeparator(["1,234", "84.32", "-6.95"])).toBe(".");
// Mixed notation settles on the last separator of each decisive cell.
expect(detectDecimalSeparator(["1.234,56"])).toBe(",");
expect(detectDecimalSeparator(["1,234.56"])).toBe(".");
});
it("returns no verdict when the column gives no evidence", () => {
expect(detectDecimalSeparator([])).toBeUndefined();
expect(detectDecimalSeparator(["1.234", "5.678"])).toBeUndefined();
expect(detectDecimalSeparator(["", " ", "N/A"])).toBeUndefined();
// One vote each way is a tie, not a majority.
expect(detectDecimalSeparator(["84,32", "84.32"])).toBeUndefined();
});
});
describe("parseFrenchAmount — call-site fallout (#326, hardened by #325)", () => {
// The /review-spec revision of #326 asks the corpus to reach the holdings
// call sites too, because #325 hardens the parser GLOBALLY. These pin what
// the shared parser does to its two most exposed consumers.
it("no longer blinds detectHeader when a header cell starts with digits", () => {
// `detectHeader` (csvAutoDetect.ts:224) treats "parses as a number" as
// proof of a data row. "2025 Montant" used to parse as 2025, so the header
// was taken for data; anchoring makes it NaN and the header is recognised.
const cfg = autoDetectConfig(readCsvFixture("header-numeric-label"))!;
expect(cfg.hasHeader).toBe(true);
});
it("no longer leaks a x100 magnitude into the holdings CSV import (#245)", () => {
// `holdingsFromCsvRows` (useSnapshotEditor.ts:191-202) shares the parser.
// A price column carrying its currency code used to multiply every
// position by 100 — a $150.25 share stored at $15,025. It is refused now,
// and an empty price is what `buildDetailedLines` rejects on save.
const drafts = holdingsFromCsvRows(
[
["AAPL", "10", "150,25 CAD", "1 200,00"],
["MSFT", "5", "300,50", "1 400,00 CAD"],
],
{ symbol: 0, quantity: 1, unit_price: 2, book_cost: 3 }
);
expect(drafts[0].unit_price).toBe(""); // refused, not 15025
expect(drafts[0].book_cost).toBe("1200"); // clean cell
expect(drafts[1].unit_price).toBe("300.5"); // clean cell
expect(drafts[1].book_cost).toBe(""); // refused, not 140000
});
it("reads an accounting-parenthesis price instead of dropping it", () => {
const drafts = holdingsFromCsvRows(
[["GOOG", "2", "(140,10)", "280,20"]],
{ symbol: 0, quantity: 1, unit_price: 2, book_cost: 3 }
);
expect(drafts[0].unit_price).toBe("-140.1");
expect(drafts[0].quantity).toBe("2");
});
it("keeps an unreadable quantity verbatim so the save refuses it", () => {
// It used to be coerced to 0, which SAVED a zero-value position in
// silence. `buildDetailedLines` throws on the raw text instead.
const drafts = holdingsFromCsvRows(
[["GOOG", "2 parts", "140,10", "280,20"]],
{ symbol: 0, quantity: 1, unit_price: 2, book_cost: 3 }
);
expect(drafts[0].quantity).toBe("2 parts");
expect(() =>
buildDetailedLines({ 7: drafts }, new Set([7]))
).toThrowError(BalanceServiceError);
});
it("taints the merged quantity when one lot of a symbol is unreadable", () => {
const drafts = holdingsFromCsvRows(
[
["AAPL", "6", "150,00", "700"],
["AAPL", "quatre", "151,00", "500"],
],
{ symbol: 0, quantity: 1, unit_price: 2, book_cost: 3 }
);
expect(drafts).toHaveLength(1);
expect(drafts[0].quantity).toBe("quatre"); // NOT "6"
});
});

View file

@ -1,26 +1,192 @@
/** /**
* Parse a French-formatted amount string to a number. * Amount parsing for imported files (#325).
* Handles formats like: 1.234,56 / 1234,56 / -1 234.56 / 1 234,56 *
* The function used to end on `parseFloat`, which returns the longest valid
* PREFIX of a string instead of rejecting it. `"100,00 CAD"` therefore came out
* as 10 000 and `"1 234,56 CR"` as 123 456 a factor-100 error that passes
* `isNaN`, so the row counted as VALID everywhere downstream. Validation is
* ANCHORED now: after normalisation the whole remaining string must match a
* numeric grammar, and any residual character yields `NaN`.
*
* `NaN` is the only safe answer for a cell we cannot read with certainty. A
* trailing `CR` / `DB` carries a DIRECTION, not noise, so guessing a magnitude
* for it 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, by design, upstream (#328).
*
* Two accounting forms ARE legitimate and supported: parentheses `(50,00)` and
* a trailing sign `50,00-`, both meaning a negative amount.
*
* Separator arbitration: `1.234` means 1234 in a French column and 1.234 in an
* English one, and nothing INSIDE the cell can tell. Callers that know the
* column pass `decimalSeparator` (see `detectDecimalSeparator`); callers that
* do not get the documented per-cell default.
*/ */
export function parseFrenchAmount(raw: string): number {
/** Which character separates the decimals in a given column. */
export type DecimalSeparator = "," | ".";
export interface AmountParseOptions {
/**
* Decimal separator arbitrated at COLUMN level. When set, the other character
* is read as a grouping separator, whatever a single cell looks like.
*/
decimalSeparator?: DecimalSeparator;
}
/** Currency symbols and every flavour of space are noise, never data. */
const NOISE = /[€$£\s\u00A0]/g;
/** Digits, optionally grouped in threes by `sep`. Anchored. */
function groupedRe(sep: "," | "."): RegExp {
const s = sep === "." ? "\\." : ",";
return new RegExp(`^\\d{1,3}(?:${s}\\d{3})+$`);
}
/** Digits, one `sep`, then decimals. Anchored. `limit` caps the decimal count. */
function decimalRe(sep: "," | ".", limit?: number): RegExp {
const s = sep === "." ? "\\." : ",";
const tail = limit === undefined ? "+" : `{1,${limit}}`;
return new RegExp(`^\\d+${s}\\d${tail}$`);
}
/** Grouped digits then decimals, e.g. `1.234,56`. Anchored. */
function groupedDecimalRe(group: "," | ".", dec: "," | "."): RegExp {
const g = group === "." ? "\\." : ",";
const d = dec === "." ? "\\." : ",";
return new RegExp(`^\\d{1,3}(?:${g}\\d{3})+${d}\\d+$`);
}
/**
* Read a fully normalised body (digits plus `.` and `,` only, no sign, no
* spaces) under a known decimal separator. Returns `NaN` when the body does not
* match the grammar exactly.
*/
function readWithSeparator(body: string, decimal: DecimalSeparator): number {
const group: DecimalSeparator = decimal === "," ? "." : ",";
const hasDecimal = body.includes(decimal);
const hasGroup = body.includes(group);
let normalized: string | null = null;
if (hasDecimal && hasGroup) {
if (groupedDecimalRe(group, decimal).test(body)) normalized = body;
} else if (hasDecimal) {
if (decimalRe(decimal).test(body)) normalized = body;
} else if (hasGroup) {
if (groupedRe(group).test(body)) normalized = body;
} else if (/^\d+$/.test(body)) {
normalized = body;
}
if (normalized === null) return NaN;
const stripped = normalized.split(group).join("");
return Number(decimal === "," ? stripped.replace(",", ".") : stripped);
}
/**
* Read a body with no column context. The rules reproduce the documented
* per-cell behaviour, now anchored:
* - both separators present -> the LAST one is the decimal;
* - a single `,` followed by one or two digits -> French decimal;
* - a single `.` -> English decimal (`1.234` reads as 1.234 alone);
* - otherwise the separator must group digits in perfect threes.
*/
function readPerCell(body: string): number {
const lastComma = body.lastIndexOf(",");
const lastDot = body.lastIndexOf(".");
if (lastComma >= 0 && lastDot >= 0) {
return readWithSeparator(body, lastComma > lastDot ? "," : ".");
}
if (lastComma >= 0) {
if (decimalRe(",", 2).test(body)) return readWithSeparator(body, ",");
return groupedRe(",").test(body) ? readWithSeparator(body, ".") : NaN;
}
if (lastDot >= 0) {
if (decimalRe(".").test(body)) return readWithSeparator(body, ".");
return groupedRe(".").test(body) ? readWithSeparator(body, ",") : NaN;
}
return /^\d+$/.test(body) ? Number(body) : NaN;
}
/**
* Parse an amount cell to a number, or `NaN` when it cannot be read with
* certainty. Handles `1.234,56`, `1 234,56`, `1,234.56`, `-84,32`, `(50,00)`
* and `50,00-`.
*/
export function parseFrenchAmount(
raw: string,
options: AmountParseOptions = {}
): number {
if (!raw || typeof raw !== "string") return NaN; if (!raw || typeof raw !== "string") return NaN;
let cleaned = raw.trim(); let cleaned = raw.trim();
let negative = false;
// Remove currency symbols and whitespace // Accounting parentheses. Only a balanced, outermost pair counts; `(50,00`
cleaned = cleaned.replace(/[€$£\s\u00A0]/g, ""); // falls through and fails the anchored grammar below.
const parens = /^\((.*)\)$/.exec(cleaned);
// Detect if comma is decimal separator (French style) if (parens) {
// Pattern: digits followed by comma followed by exactly 1-2 digits at end negative = true;
const frenchPattern = /,\d{1,2}$/; cleaned = parens[1].trim();
if (frenchPattern.test(cleaned)) {
// French format: remove dots (thousand sep), replace comma with dot (decimal)
cleaned = cleaned.replace(/\./g, "").replace(",", ".");
} else {
// English format or no decimal: remove commas (thousand sep)
cleaned = cleaned.replace(/,/g, "");
} }
const result = parseFloat(cleaned); cleaned = cleaned.replace(NOISE, "");
return isNaN(result) ? NaN : result; if (!cleaned) return NaN;
// One sign, leading or trailing, never both, never inside parentheses.
const leading = /^[+-]/.test(cleaned);
const trailing = /[+-]$/.test(cleaned);
if (leading && trailing) return NaN;
if (leading || trailing) {
if (negative) return NaN; // `(-50,00)` is not a form any bank emits
negative = cleaned[leading ? 0 : cleaned.length - 1] === "-";
cleaned = leading ? cleaned.slice(1) : cleaned.slice(0, -1);
}
// Anchored gate: nothing but digits and separators may remain. This is what
// rejects `2024Montant`, `1e3`, `Infinity`, `5%` and `100,00CAD`.
if (!/^[\d.,]+$/.test(cleaned) || !/\d/.test(cleaned)) return NaN;
const value = options.decimalSeparator
? readWithSeparator(cleaned, options.decimalSeparator)
: readPerCell(cleaned);
if (!Number.isFinite(value)) return NaN;
return negative ? -value : value;
}
/**
* Arbitrate the decimal separator of a COLUMN from all of its cells.
*
* A cell is decisive when it carries both separators (the last one is the
* decimal) or exactly one separator followed by one or two digits a grouping
* separator is always followed by three. Ambiguous cells such as `1.234` vote
* for nothing. Returns `undefined` when the column gives no verdict, in which
* case callers keep the per-cell default.
*/
export function detectDecimalSeparator(
cells: Iterable<string>
): DecimalSeparator | undefined {
let comma = 0;
let dot = 0;
for (const cell of cells) {
if (!cell || typeof cell !== "string") continue;
const body = cell.trim().replace(NOISE, "").replace(/^[+-]|[+-]$/g, "");
if (!body) continue;
const lastComma = body.lastIndexOf(",");
const lastDot = body.lastIndexOf(".");
if (lastComma >= 0 && lastDot >= 0) {
if (lastComma > lastDot) comma++;
else dot++;
continue;
}
if (lastComma >= 0 && decimalRe(",", 2).test(body)) comma++;
else if (lastDot >= 0 && decimalRe(".", 2).test(body)) dot++;
}
if (comma === dot) return undefined;
return comma > dot ? "," : ".";
} }

View file

@ -0,0 +1,764 @@
// Bank signatures and format drift (#330).
//
// Two contracts are frozen here.
//
// THE SIGNATURES. Each of the four banks has a fixture carrying its documented
// header layout. The test that matters is not "the signature matches" — it is
// the pair: the same file WITH its bank header and WITHOUT it. Three of the
// four layouts are read wrong by the generic dictionary, and the counterfactual
// is what proves the signature earns its place instead of merely agreeing with
// the heuristic. The other half of that pair is the fall-back: every fixture of
// the #326 corpus must still be detected with no bank at all.
//
// THE DRIFT. `header_signature` records the labels of the last successful
// import; a later file whose header normalizes differently is drift. The
// interesting cases are the ones that must NOT fire: no stored signature, a
// headerless file, a stored value that will not parse, and a cosmetic rename
// (`Montant` -> `MONTANT ($)`) that normalizes to the same label.
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import {
BANK_SIGNATURES,
MIN_SIGNATURE_LABELS,
bankSignatureById,
buildHeaderSignature,
detectHeaderDrift,
matchBankSignature,
parseHeaderSignature,
type BankSignatureId,
} from "./bankSignatures";
import { normalizeHeaderCell } from "./headerDictionary";
import { detectImportFormat } from "./csvAutoDetect";
import { CSV_FIXTURE_NAMES, readCsvFixture } from "../__fixtures__/csv";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
/** The bank a file is recognised as, or null. Throws on a file detection refuses. */
function bankOf(content: string): BankSignatureId | null {
const outcome = detectImportFormat(content);
if (outcome.status !== "ok") {
throw new Error(`detection returned ${outcome.status}`);
}
return outcome.bank;
}
/** The configuration detection settles on. Throws on a file it refuses. */
function configOf(content: string) {
const outcome = detectImportFormat(content);
if (outcome.status !== "ok") {
throw new Error(`detection returned ${outcome.status}`);
}
return outcome.config;
}
/** The same file with its header row replaced — every data row untouched. */
function withHeader(raw: string, header: string): string {
const lines = raw.split("\n");
lines[0] = header;
return lines.join("\n");
}
describe("the signature table is well formed (#330)", () => {
it("gives every bank a distinct id", () => {
const ids = BANK_SIGNATURES.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("declares every label already normalized", () => {
// A label written `Montant` would never match: the header cell is
// normalized before the comparison and the table's side is not.
for (const signature of BANK_SIGNATURES) {
for (const variant of signature.variants) {
for (const label of variant.labels) {
expect(normalizeHeaderCell(label), `${signature.id}: ${label}`).toBe(
label
);
}
}
}
});
it("keeps every role's label inside the fingerprint it projects", () => {
// A role naming a label absent from `labels` would resolve to a column the
// match never checked for.
for (const signature of BANK_SIGNATURES) {
for (const variant of signature.variants) {
for (const [role, label] of Object.entries(variant.roles)) {
expect(variant.labels, `${signature.id}.${role}`).toContain(label);
}
}
}
});
it("refuses a fingerprint too small to identify anyone", () => {
// `Date;Description;Montant` is the shape of half the corpus and of any
// hand-made export. A three-label variant would put a bank's name over
// files nobody can attribute to it.
for (const signature of BANK_SIGNATURES) {
for (const variant of signature.variants) {
expect(
variant.labels.length,
`${signature.id}: ${variant.labels.join(",")}`
).toBeGreaterThanOrEqual(MIN_SIGNATURE_LABELS);
}
}
});
it("resolves a bank from its id, and nothing from an unknown one", () => {
expect(bankSignatureById("desjardins")?.label).toBe("Desjardins");
expect(bankSignatureById(null)).toBeNull();
expect(bankSignatureById(undefined)).toBeNull();
});
});
describe("each bank is recognised on its own fixture (#330)", () => {
it("reads the Desjardins layout and names it", () => {
expect(bankOf(readCsvFixture("bank-desjardins"))).toBe("desjardins");
expect(configOf(readCsvFixture("bank-desjardins"))).toEqual({
delimiter: ";",
hasHeader: true,
skipLines: 0,
dateFormat: "DD/MM/YYYY",
// Solde (column 3) is the balance the signature names, and it stays out
// of the amount candidates.
columnMapping: { date: 0, description: 1, amount: 2 },
amountMode: "single",
signConvention: "negative_expense",
});
});
it("reads the English Desjardins header too", () => {
const content =
"Date;Description;Amount;Balance\n" +
"05/01/2025;EPICERIE METRO;-84,32;915,68\n" +
"15/01/2025;DEPOT PAIE;1250,00;2165,68\n" +
"18/01/2025;HYDRO QUEBEC;-142,18;2023,50\n" +
"22/01/2025;RESTAURANT;-56,75;1966,75\n";
expect(bankOf(content)).toBe("desjardins");
});
it("reads a whole-line-quoted Desjardins export", () => {
// `preprocessQuotedCSV` unwraps it into a comma-separated file; the
// signature declares the quirk and the comma, so the match survives it.
const content =
'"Date,""Description"",Montant,Solde"\n' +
'"05/01/2025,""EPICERIE METRO"",-84.32,915.68"\n' +
'"15/01/2025,""DEPOT PAIE"",1250.00,2165.68"\n' +
'"18/01/2025,""HYDRO QUEBEC"",-142.18,2023.50"\n' +
'"22/01/2025,""RESTAURANT"",-56.75,1966.75"\n';
expect(bankOf(content)).toBe("desjardins");
});
it("reads the RBC layout and maps CAD$ as the amount", () => {
expect(bankOf(readCsvFixture("bank-rbc"))).toBe("rbc");
expect(configOf(readCsvFixture("bank-rbc"))).toEqual({
delimiter: ",",
hasHeader: true,
skipLines: 0,
dateFormat: "DD/MM/YYYY",
columnMapping: { date: 2, description: 4, amount: 6 },
amountMode: "single",
signConvention: "negative_expense",
});
});
it("reads the Banque Nationale layout as a debit/credit pair", () => {
expect(bankOf(readCsvFixture("bank-bnc"))).toBe("bnc");
expect(configOf(readCsvFixture("bank-bnc"))).toEqual({
delimiter: ";",
hasHeader: true,
skipLines: 0,
dateFormat: "DD/MM/YYYY",
columnMapping: {
date: 0,
description: 1,
debitAmount: 3,
creditAmount: 4,
},
amountMode: "debit_credit",
signConvention: "negative_expense",
});
});
it("reads the Tangerine layout and maps Name as the description", () => {
expect(bankOf(readCsvFixture("bank-tangerine"))).toBe("tangerine");
expect(configOf(readCsvFixture("bank-tangerine"))).toEqual({
delimiter: ",",
hasHeader: true,
skipLines: 0,
dateFormat: "DD/MM/YYYY",
columnMapping: { date: 0, description: 2, amount: 4 },
amountMode: "single",
signConvention: "negative_expense",
});
});
});
describe("what the signatures actually buy (#330)", () => {
it("keeps RBC's cheque number out of the amounts", () => {
// The SAME rows, under a header no signature knows. `CAD$` and
// `Cheque Number` are sparse-complementary — one cheque number in six rows
// — so the shape scan pairs them as debit/credit and the row carrying a
// cheque number imports as -247.95 instead of -6.95. Nothing in the file
// can tell that apart from a genuine debit/credit pair; a documented layout
// can.
const anonymised = withHeader(
readCsvFixture("bank-rbc"),
"Type,Numero,Date,Cheque,Libelle,Note,Montant,Devise"
);
expect(bankOf(anonymised)).toBeNull();
expect(configOf(anonymised).amountMode).toBe("debit_credit");
expect(configOf(anonymised).columnMapping).toEqual({
date: 2,
description: 4,
debitAmount: 3,
creditAmount: 6,
});
// With the header the bank actually prints, the same rows read as one
// signed column.
expect(configOf(readCsvFixture("bank-rbc")).amountMode).toBe("single");
});
it("keeps Tangerine's direction column out of the description, signature or not", () => {
// `Transaction` is a description keyword of the generic dictionary, and
// Tangerine's `Transaction` column holds DEBIT / CREDIT. Read as the
// description, every transaction of the file is labelled `DEBIT`.
//
// This used to be rescued by the Tangerine signature alone, leaving any
// unrecognised file with a `Transaction` column broken. The cardinality
// veto in `detectDescriptionColumn` fixes the cause instead: a labelled
// column that behaves like an enum is not the description, whether or not a
// bank was identified.
const anonymised = withHeader(
readCsvFixture("bank-tangerine"),
"Date,Transaction,Nom,Note,Montant"
);
expect(bankOf(anonymised)).toBeNull();
expect(configOf(anonymised).columnMapping.description).toBe(2);
expect(configOf(readCsvFixture("bank-tangerine")).columnMapping.description).toBe(
2
);
});
it("does not let a generic variant claim a richer header", () => {
// Review finding on #340: Desjardins' fingerprint is four labels any
// Canadian bank could emit, so matching them as a SUBSET announced
// "Format Desjardins reconnu" over files that have nothing to do with it.
// A variant made only of generic labels now has to describe the header
// exactly.
const richer =
"Date;Description;Débit;Crédit;Montant;Solde\n" +
"05/01/2025;EPICERIE;84,32;;-84,32;1000,00\n" +
"15/01/2025;DEPOT PAIE;;1250,00;1250,00;2250,00\n" +
"20/01/2025;LOYER;900,00;;-900,00;1350,00\n" +
"25/01/2025;REMBOURSEMENT;;45,00;45,00;1395,00\n";
expect(bankOf(richer)).toBeNull();
// The exact header still is Desjardins.
const exact =
"Date;Description;Montant;Solde\n" +
"05/01/2025;EPICERIE;-84,32;1000,00\n" +
"15/01/2025;DEPOT PAIE;1250,00;2250,00\n";
expect(bankOf(exact)).toBe("desjardins");
});
it("does not let a signature's amount column displace a pair it is not in", () => {
// The measured regression: with the false Desjardins match above, the
// signature's `Montant` short-circuited the sparse-complementary scan, so a
// file that reads correctly as debit/credit became one unsigned column and
// every deposit imported as an expense. The scan now runs first and the
// signature only wins when the pair contains its column — which is what
// RBC's genuine `Cheque Number` / `CAD$` case needs.
const richer =
"Date;Description;Débit;Crédit;Montant;Solde\n" +
"05/01/2025;EPICERIE;84,32;;-84,32;1000,00\n" +
"15/01/2025;DEPOT PAIE;;1250,00;1250,00;2250,00\n" +
"20/01/2025;LOYER;900,00;;-900,00;1350,00\n" +
"25/01/2025;REMBOURSEMENT;;45,00;45,00;1395,00\n";
const config = configOf(richer);
expect(config.amountMode).toBe("debit_credit");
expect(config.columnMapping.debitAmount).toBe(2);
expect(config.columnMapping.creditAmount).toBe(3);
// RBC keeps its override: there the declared amount column IS in the pair.
expect(configOf(readCsvFixture("bank-rbc")).amountMode).toBe("single");
});
it("holds even for a legitimately recognised bank whose file grew columns", () => {
// The guard above is only reachable through a signature that really matches,
// so it needs a discriminating one. Tangerine is identified by `memo`, so it
// still matches as a subset when the export gains Débit/Crédit columns — and
// then its declared `Amount` must not displace that genuine pair either.
const grown =
"Date,Transaction,Name,Memo,Amount,Débit,Crédit\n" +
"05/01/2025,DEBIT,EPICERIE METRO,,-84.32,84.32,\n" +
"15/01/2025,CREDIT,DEPOT PAIE,Paie,1250.00,,1250.00\n" +
"20/01/2025,DEBIT,LOYER,,-900.00,900.00,\n" +
"25/01/2025,CREDIT,REMBOURSEMENT,,45.00,,45.00\n";
expect(bankOf(grown)).toBe("tangerine");
const config = configOf(grown);
expect(config.amountMode).toBe("debit_credit");
expect(config.columnMapping.debitAmount).toBe(5);
expect(config.columnMapping.creditAmount).toBe(6);
});
});
describe("an unknown file falls back to the generic dictionary (#330)", () => {
it("names no bank on any of the shape fixtures", () => {
// The corpus frozen by #326 is synthetic and belongs to no bank. A single
// false positive here is a banner claiming a bank the file is not from.
for (const name of CSV_FIXTURE_NAMES) {
if (name.startsWith("bank-")) continue;
if (name === "absolute-indicator") continue; // refused before any bank
expect(bankOf(readCsvFixture(name)), name).toBeNull();
}
});
it("leaves the pre-#330 configurations untouched", () => {
// The signatures must not have moved a single mapping of the corpus.
expect(configOf(readCsvFixture("signed-amount"))).toEqual({
delimiter: ";",
hasHeader: true,
skipLines: 0,
dateFormat: "DD/MM/YYYY",
columnMapping: { date: 0, description: 1, amount: 2 },
amountMode: "single",
signConvention: "negative_expense",
});
});
});
describe("the preconditions of a match (#330)", () => {
const rows =
"05/01/2025;EPICERIE METRO;-84,32;915,68\n" +
"15/01/2025;DEPOT PAIE;1250,00;2165,68\n" +
"18/01/2025;HYDRO QUEBEC;-142,18;2023,50\n" +
"22/01/2025;RESTAURANT;-56,75;1966,75\n";
it("refuses a delimiter the bank does not use", () => {
const tabbed = ("Date;Description;Montant;Solde\n" + rows).replace(
/;/g,
"\t"
);
expect(bankOf(tabbed)).toBeNull();
});
it("refuses an export carrying more preamble than the bank prints", () => {
const content =
"RELEVE DE COMPTE\nCompte 12345\nDate;Description;Montant;Solde\n" + rows;
expect(bankOf(content)).toBeNull();
});
it("refuses a whole-line-quoted file for a bank that does not quote", () => {
// Tangerine's labels, wrapped the way Desjardins wraps its exports. The
// quirk is what says this is not a Tangerine file.
const content =
'"Date,""Transaction"",Name,Memo,Amount"\n' +
'"05/01/2025,""DEBIT"",EPICERIE,,-84.32"\n' +
'"15/01/2025,""CREDIT"",PAIE,,1250.00"\n' +
'"18/01/2025,""DEBIT"",HYDRO,,-142.18"\n' +
'"22/01/2025,""DEBIT"",RESTO,,-56.75"\n';
expect(bankOf(content)).toBeNull();
});
it("names no bank for a headerless file, whatever its shape", () => {
// There is no label to read. This is the same boundary that leaves
// `header_signature` null on those sources.
expect(bankOf(readCsvFixture("no-header"))).toBeNull();
});
it("matches on the whole label, never on a substring", () => {
// `matchHeaderColumn` matches substrings — that is the generic dictionary's
// rule and the reason it mis-reads these layouts. A fingerprint compared
// loosely would inherit the same problem.
expect(
matchBankSignature({
headerRow: ["Date", "Description", "Montant net", "Solde"],
delimiter: ";",
skipLines: 0,
wholeLineQuoted: false,
})
).toBeNull();
});
it("resolves the roles to the columns of THIS file, not to the declared order", () => {
const match = matchBankSignature({
headerRow: ["Solde", "Montant", "Description", "Date"],
delimiter: ";",
skipLines: 0,
wholeLineQuoted: false,
});
expect(match?.signature.id).toBe("desjardins");
expect(match?.roles).toEqual({
date: 3,
description: 2,
amount: 1,
debit: null,
credit: null,
balance: 0,
roleCount: 4,
});
});
});
describe("the stored signature (#330)", () => {
it("stores the normalized labels, in order", () => {
expect(buildHeaderSignature(["Date", "Description", "Montant", "Solde"])).toBe(
'["date","description","montant","solde"]'
);
});
it("stores nothing for a file with no header row", () => {
// The whole point of the boundary: `Col 0`, `Col 1` … is not a signature,
// and inventing one from the data would fire on every change of content.
expect(buildHeaderSignature(null)).toBeNull();
expect(buildHeaderSignature([])).toBeNull();
});
it("reads back what it wrote", () => {
const stored = buildHeaderSignature(["Date", "Montant"]);
expect(parseHeaderSignature(stored)).toEqual(["date", "montant"]);
});
it("returns null rather than throwing on anything it did not write", () => {
// A hash left by an older build, a truncated row, hand-edited SQL: drift
// detection switches off, an import never blows up.
expect(parseHeaderSignature(null)).toBeNull();
expect(parseHeaderSignature("")).toBeNull();
expect(parseHeaderSignature("d41d8cd98f00b204")).toBeNull();
expect(parseHeaderSignature("{}")).toBeNull();
expect(parseHeaderSignature("[1,2,3]")).toBeNull();
});
});
describe("drift detection stays silent when it should (#330)", () => {
const stored = buildHeaderSignature(["Date", "Description", "Montant"]);
it("says nothing about a source that never recorded a signature", () => {
expect(detectHeaderDrift(null, ["Date", "Solde"])).toBeNull();
expect(detectHeaderDrift(undefined, ["Date", "Solde"])).toBeNull();
});
it("says nothing about a headerless file", () => {
expect(detectHeaderDrift(stored, null)).toBeNull();
expect(detectHeaderDrift(stored, [])).toBeNull();
});
it("says nothing about an identical header", () => {
expect(
detectHeaderDrift(stored, ["Date", "Description", "Montant"])
).toBeNull();
});
it("says nothing about a cosmetic rename", () => {
// Accents, case, punctuation and spacing are stripped before the
// comparison: none of them changes which column holds what.
expect(
detectHeaderDrift(stored, ["DATE", "Description ", "MONTANT ($)"])
).toBeNull();
});
it("says nothing when the stored value cannot be read", () => {
expect(detectHeaderDrift("not json", ["Date"])).toBeNull();
});
});
describe("drift detection names the columns that moved (#330)", () => {
it("reports a column that changed position", () => {
const stored = buildHeaderSignature([
"Date",
"Description",
"Montant",
"Solde",
]);
expect(
detectHeaderDrift(stored, ["Date", "Description", "Solde", "Montant"])
).toEqual([
{
kind: "moved",
normalizedLabel: "solde",
label: "Solde",
previousIndex: 3,
currentIndex: 2,
},
{
kind: "moved",
normalizedLabel: "montant",
label: "Montant",
previousIndex: 2,
currentIndex: 3,
},
]);
});
it("reports a new column and the shift it causes", () => {
const stored = buildHeaderSignature(["Date", "Description", "Montant"]);
expect(
detectHeaderDrift(stored, ["Date", "Devise", "Description", "Montant"])
).toEqual([
{
kind: "added",
normalizedLabel: "devise",
label: "Devise",
previousIndex: null,
currentIndex: 1,
},
{
kind: "moved",
normalizedLabel: "description",
label: "Description",
previousIndex: 1,
currentIndex: 2,
},
{
kind: "moved",
normalizedLabel: "montant",
label: "Montant",
previousIndex: 2,
currentIndex: 3,
},
]);
});
it("reports a column that disappeared, under its stored label", () => {
// The raw spelling of a removed column is recorded nowhere — only the
// normalized label survives, and that is what is shown.
const stored = buildHeaderSignature(["Date", "Description", "Solde"]);
expect(detectHeaderDrift(stored, ["Date", "Description"])).toEqual([
{
kind: "removed",
normalizedLabel: "solde",
label: "solde",
previousIndex: 2,
currentIndex: null,
},
]);
});
it("survives a bank swapping its amount column for a debit/credit pair", () => {
const stored = buildHeaderSignature([
"Date",
"Description",
"Montant",
"Solde",
]);
const drift = detectHeaderDrift(stored, [
"Date",
"Description",
"Débit",
"Crédit",
"Solde",
])!;
expect(drift.map((e) => [e.kind, e.normalizedLabel])).toEqual([
["added", "debit"],
["added", "credit"],
["moved", "solde"],
["removed", "montant"],
]);
});
});
// ---------------------------------------------------------------------------
// Static guards. The wiring lives in a React hook and two components, and the
// repository has no jsdom — same technique as the guards of #324 to #329.
// ---------------------------------------------------------------------------
const WIZARD_SRC = readFileSync(
resolve(import.meta.dirname, "..", "hooks", "useImportWizard.ts"),
"utf-8"
);
const PAGE_SRC = readFileSync(
resolve(import.meta.dirname, "..", "pages", "ImportPage.tsx"),
"utf-8"
);
const componentSrc = (name: string) =>
readFileSync(
resolve(import.meta.dirname, "..", "components", "import", name),
"utf-8"
);
describe("the signature is written at every successful import (#330)", () => {
it("builds it from the header row the import actually read", () => {
expect(WIZARD_SRC).toContain("buildHeaderSignature(");
expect(WIZARD_SRC).toContain(
"config.hasHeader ? state.previewHeaders : null"
);
});
it("writes it on both arms of the single write point", () => {
const body = WIZARD_SRC.slice(
WIZARD_SRC.indexOf("const executeImport ="),
WIZARD_SRC.indexOf("const goToStep =")
);
expect(body.match(/header_signature: headerSignature/g)).toHaveLength(2);
});
it("keeps it out of the format codec", () => {
// It is drift metadata, not one of the eight fields that decide how a row
// is read. `formatToRow` naming it would put it back in the format.
const CODEC = readFileSync(
resolve(import.meta.dirname, "importFormat.ts"),
"utf-8"
);
expect(CODEC).not.toContain("header_signature");
expect(CODEC).not.toContain("headerSignature");
});
});
describe("drift is computed on the way to the preview (#330)", () => {
const body = () =>
WIZARD_SRC.slice(
WIZARD_SRC.indexOf("const parseAndPreview ="),
WIZARD_SRC.indexOf("const checkDuplicatesInternal =")
);
it("compares the stored signature to the headers just parsed", () => {
expect(body()).toContain("detectHeaderDrift(");
expect(body()).toContain("state.existingSource?.header_signature");
});
it("does not compare anything on a headerless file", () => {
expect(body()).toContain("state.sourceConfig.hasHeader");
});
it("still reaches the preview step", () => {
expect(body()).toContain(
'dispatch({ type: "SET_STEP", payload: "file-preview" })'
);
});
it("drops the score the re-detection produced, which describes another format", () => {
// The re-detection measures the format the PANEL offers, not the one in
// use. Keeping its score would show "Format reconnu — 6 of 6 rows read"
// beside the stored mapping as soon as the user steps back.
expect(body()).toContain(
'dispatch({ type: "SET_DETECTION_SCORE", payload: null })'
);
});
});
describe("the drift panel offers two outcomes and writes neither (#330)", () => {
const PANEL = componentSrc("FormatDriftPanel.tsx");
it("is rendered on the preview step, above the table", () => {
expect(PAGE_SRC).toContain("{state.formatDrift && (");
expect(PAGE_SRC).toContain("<FormatDriftPanel");
expect(PAGE_SRC).toContain("onAdopt={adoptDriftFormat}");
expect(PAGE_SRC).toContain("onKeep={keepCurrentFormat}");
expect(PAGE_SRC.indexOf("<FormatDriftPanel")).toBeLessThan(
PAGE_SRC.indexOf("<FilePreviewTable")
);
});
it("re-parses under the adopted format instead of re-reading state", () => {
// `state` has not re-rendered when the parse starts, exactly as for the
// sign flip: reading it back would redisplay the table just replaced.
const body = WIZARD_SRC.slice(
WIZARD_SRC.indexOf("const adoptDriftFormat ="),
WIZARD_SRC.indexOf("const keepCurrentFormat =")
);
expect(body).toContain("parseFilesInternal(adopted)");
expect(body).not.toContain("createSource(");
expect(body).not.toContain("updateSource(");
});
it("renders the three kinds of change through i18n", () => {
for (const key of ["columnMoved", "columnAdded", "columnRemoved"]) {
expect(PANEL, key).toContain(`import.drift.${key}`);
}
});
});
describe("the repair path is stated in the interface (#330)", () => {
const NOTICE = componentSrc("RepairPathNotice.tsx");
it("names the history deletion rather than a vague warning", () => {
expect(NOTICE).toContain("import.repairPath.title");
expect(NOTICE).toContain("import.repairPath.body");
for (const locale of [fr, en]) {
expect(locale.import.repairPath.body.length).toBeGreaterThan(0);
}
// The two facts a user needs: duplicates are matched on the amount, and the
// faulty import has to go first.
expect(fr.import.repairPath.body).toContain("historique");
expect(en.import.repairPath.body).toContain("history");
});
it("appears in the preview and in the drift panel, not in one of the two", () => {
expect(componentSrc("FilePreviewTable.tsx")).toContain("<RepairPathNotice />");
expect(componentSrc("FormatDriftPanel.tsx")).toContain("<RepairPathNotice />");
});
});
describe("the recognised-bank banner (#330)", () => {
const PANEL = componentSrc("SourceConfigPanel.tsx");
it("names the bank only when the rows actually read", () => {
// A bank label over a file two thirds of whose rows fail is a claim the app
// cannot back; the uncertain wording is the one that helps there.
expect(PANEL).toContain("detectionScore?.confident");
expect(PANEL).toContain("import.config.detectionBank");
});
it("interpolates the bank name instead of translating it", () => {
// A bank is a proper noun. `Desjardins` is `Desjardins` in both languages.
for (const locale of [fr, en]) {
expect(locale.import.config.detectionBank).toContain("{{bank}}");
expect(locale.import.config.detectionBank).toContain("{{read}}");
expect(locale.import.config.detectionBank).toContain("{{total}}");
}
});
it("clears the bank with the score, in one place", () => {
// Three sites drop the score (a hand edit, a template, a sign flip). The
// reducer clearing the bank alongside it is what keeps a fourth from
// forgetting.
const reducerCase = WIZARD_SRC.slice(
WIZARD_SRC.indexOf('case "SET_DETECTION_SCORE":'),
WIZARD_SRC.indexOf('case "SET_DETECTED_BANK":')
);
expect(reducerCase).toContain("detectedBank: action.payload === null");
});
});
describe("every new string exists in both languages (#330)", () => {
it("carries the drift panel", () => {
for (const key of [
"title",
"intro",
"columnMoved",
"columnAdded",
"columnRemoved",
"adopt",
"adoptHint",
"adoptUnavailable",
"keep",
"keepHint",
] as const) {
expect(fr.import.drift[key].length, `fr.${key}`).toBeGreaterThan(0);
expect(en.import.drift[key].length, `en.${key}`).toBeGreaterThan(0);
}
});
it("interpolates the column positions in both languages", () => {
for (const locale of [fr, en]) {
expect(locale.import.drift.columnMoved).toContain("{{label}}");
expect(locale.import.drift.columnMoved).toContain("{{from}}");
expect(locale.import.drift.columnMoved).toContain("{{to}}");
expect(locale.import.drift.columnAdded).toContain("{{to}}");
expect(locale.import.drift.columnRemoved).toContain("{{from}}");
}
});
it("carries the repair path", () => {
for (const key of ["title", "body"] as const) {
expect(fr.import.repairPath[key].length, `fr.${key}`).toBeGreaterThan(0);
expect(en.import.repairPath[key].length, `en.${key}`).toBeGreaterThan(0);
}
});
});

Some files were not shown because too many files have changed in this diff Show more