Commit graph

558 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
le king fu
9c18e10281 chore: release v0.14.0
All checks were successful
Release / build-and-release (push) Successful in 24m21s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:56:17 -04:00
le king fu
5a3d87b31f docs(collapse): ADR 0016 per-profile UI state + guide, architecture, i18n
Document the final multi-level category collapse behavior (shipped in
#288/#289/#290) and the structural decision to persist per-profile UI
state in user_preferences rather than localStorage.

- ADR 0016 (accepted): profile-specific UI state (category collapse) lives
  in the profile's own SQLite user_preferences table, not localStorage.
  deleteProfile drops the .db but purges no localStorage, so a per-profile
  localStorage key would be a residue surviving profile deletion, leaking
  which categories a (possibly PIN-protected) profile explored. States the
  boundary: profile-specific -> user_preferences; machine-global (theme,
  subtotals position, Cartes period mode) -> localStorage.
- guide-utilisateur.md sections 8 (Budget) + 9 (Reports): multi-level
  collapse, collapsed-by-default, "Expand all / Collapse all" button,
  memory per profile.
- docs.* i18n keys (fr + en): mirror the guide additions in the in-app help
  page (docs.budget + docs.reports features/tips).
- architecture.md: user_preferences line now names the 4 collapse keys,
  useCollapsibleGroups cross-cutting hook note, ADR index row.

No DB migration, no behavior change (docs only). Build clean, 828 vitest.

Resolves #291

Generated autonomously by /autopilot run of 2026-07-15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:43:24 -04:00
le king fu
9f628aa9f4 refactor(categories): unify both category trees' collapse state onto useCollapsibleGroups
Replace the two hand-rolled Set-of-ids collapse state machines in the category
trees with the shared useCollapsibleGroups hook (a strict superset after #288),
keeping each tree's distinct recursive render and CategoryTree's drag-and-drop
untouched.

- CategoryTree (Categories page): storageKey null + defaultExpanded true, so
  every parent opens with no seeding; drops the local Set + collectExpandable.
- CategoryTaxonomyTree + guide page: storageKey null + defaultExpanded false
  (collapsed by default); exports a shared TAXONOMY_COLLAPSE_ACCESSORS.
- Fix the guide's button bug: allExpanded = expanded.size > 0 flipped to
  "Collapse all" after opening a single node; now uses the hook's correct
  allExpanded (every group must be open).
- Also migrate StepDiscover (4th consumer of CategoryTaxonomyTree, same button
  bug) onto the hook for a green build and consistency.

Both trees pass a flattened node list to the bulk ops; behaviour preserved:
Categories opens expanded, the guide/wizard open collapsed.

Resolves #290

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:30:17 -04:00
le king fu
9c325e274b feat(budget): adopt multi-level collapse on the Budget grid
Reverse #278's deliberate no-collapse decision for the budget grid: it now
folds/unfolds at every category level like the hierarchical reports, opening
fully collapsed with a one-click "Expand all".

- BudgetTable: wire useCollapsibleGroups (defaultExpanded: false), inline
  BUDGET_COLLAPSE_ACCESSORS + BUDGET_EXPANDED_KEY (mirrors ComparePeriodTable /
  BudgetVsActualTable — no budgetTableModel.ts). Chevron + aria-expanded +
  aria-level on every parent row; groups.visible(group) before reorderRows.
- BudgetTable: section subtotal now uses the tested sumLeavesForType on the RAW
  group (drop-in for the hand-rolled loop) so folding stays purely visual.
- BudgetTable: rename STORAGE_KEY to "budget-subtotals-position", decoupling the
  subtotals-position preference from BudgetVsActualTable (they collided).
- useBudget: extract the pure buildBudgetYearRows(); the grid's rows are
  level-order (BFS), not DFS — document the invariant and pin it in a test, since
  the #288 ancestor-walk collapse is order-independent (the v1 plan assumed DFS
  and would have broken here).
- Tests: useBudget.test.ts locks the level-order emission, the DFS-killer, the
  end-to-end multi-level collapse on real builder output, and that subtotals sum
  raw rows regardless of collapse. 828 vitest pass.

Resolves #289

Generated autonomously by /autopilot run of 2026-07-15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:16:01 -04:00
le king fu
48adb3db77 feat(reports): collapse category hierarchy at every level (socle + 3 reports)
All checks were successful
PR Check / rust (pull_request) Successful in 23m0s
PR Check / frontend (pull_request) Successful in 2m30s
Generalize the report category collapse from level-1-only to every hierarchy
level, on the three hierarchical report tables (real-vs-real Compare,
real-vs-budget Compare, Trends by category). Visibility is now decided by an
ancestor walk, not by row adjacency, so it is independent of row order (the
level-ordered budget grid emits a non-DFS order).

- collapsibleRows: rewrite visibleRows as an ancestor walk (a row is hidden iff
  any ancestor is collapsed); add parentKeyOf + injective `p:` keys;
  collapsibleKeys returns all parents (any depth); extract the pure, tested
  isCollapsedFor polarity helper; MAX_TREE_DEPTH cycle guard.
- useCollapsibleGroups: persist in user_preferences (per-profile, destroyed with
  the profile) instead of localStorage; storageKey nullable (no persistence);
  options.defaultExpanded; async hydration (no flash); collapseAll(rows).
- 3 tables: fix BOTH gates (collapsed flag + button) isTopParent -> isParent, add
  parentKeyOf accessors, aria-level on parent rows.
- Delete dead CategoryTable.tsx (0 imports).
- Tests: rewrite collapsibleRows.test.ts (BFS==DFS masking, cycle guard,
  cross-section ancestor, "(direct)" leaf, polarity); extend overTimeTableModel
  fixture to 3 levels with cascade assertions.

Collapse stays purely visual: subtotals and result lines are computed from raw
rows, never from visible rows.

Resolves #288

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:58:18 -04:00
le king fu
524fe162ea chore: harden release skill (pre-flight and post-CI checks)
Lessons from the v0.13.0 release (session fdda84cb):
- Step 0: revalidate the tip locally before tagging — check.yml never
  runs on main, and ensure .claude/worktrees/ is empty (vitest recurses)
- Step 9: verify the published release — 7 expected artifacts and
  latest.json content (drives auto-update); status=success is not enough
- Rule: tagging publishes externally via the updater JSON — confirm
  with Max before tagging

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:32:58 -04:00