On the page of the step in progress, a card takes the place of the
banner. `StepCard` is driven by props: an h2 title that takes the focus
("Step n of N", then the step), the page's sentence, a status region
always rendered that receives "Done.", "Skip this step" until the step is
done, then "Next step" or the end of the path. `OnboardingStepCard` is
the only piece that reads the path: it renders on the latched card page
only, so the card survives its own success, focuses its title once per
arrival by "Next step" and consumes the navigation state.
The control to use carries `data-onboarding-highlight`, drawn in
styles.css as a 3px accent outline with a pulse bounded to three cycles
and none under prefers-reduced-motion; a test reads the stylesheet and
checks 3:1 against the background and the cards in both themes. The
target is passed by props to EmptyState's primary action, the first
SourceList card and WizardNavigation's next button, never queried.
`importCardModel` reads the wizard and the checklist in order: importing
before the counters, a report that added nothing as a failure, and the
"steps 3 and 4 checked" row at rest, where it can apply. CARD_PAGES gets
/import; the Import page builds the input, renders the card first and
highlights the model's target only while the card shows, and its empty
state waits for the folder to be read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The getting-started path now follows the user: AppShell mounts
OnboardingBanner at the top of <main>, in the page flow, and the
provider's mode keeps it off a hidden path, the dashboard of a profile
without transactions, a page that carries a step card and the first
load. The banner shows "Getting started n/N" once the Budget
entitlement is known, the next step linked with its navigation state,
"Show the list", which unfolds the detailed list in place, and "Hide";
at the end, the closing message, "Open the guide" and "Close". Each
route mounts its own banner slot, so a page always starts folded.
The dashboard's compact bar and the compact mode of
OnboardingChecklist are gone (D7). The detailed list reads each state
out to screen readers, shows a skipped step with its badge and
"Resume", and gives every link to a step the same navigation state
(stepNavState), with the guided entry for the manual category.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR 0021 records the catch-up rules and amends ADR 0003 and 0005, whose
claims about secondary profiles were false. architecture.md, CLAUDE.md
and the migration rule describe the two migration paths; the guide tells
where the pre-update copy lives and how to remove a database a deletion
on Linux left behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The English in-app tip now says, as the French one does, that the
revoked license is that of a refunded key left next to the current key,
not the key itself. The header of the price tests says that the tests of
the stored token call fetch_price_in with a temporary data directory,
instead of claiming that every test bypasses the token file.
Resolves#367
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A data directory written by v0.15.0 or earlier can hold a valid key next
to the activation token of another license, typically a refunded hence
revoked one. The License card read "Activated", market prices were
refused, and "Activate" was never offered: the folder could not recover
without a manual file removal.
- Prices: a 403 license_revoked drops the refused token, and only while
the file still holds it, so an activation that lands while the refused
request is in flight keeps its new token (CWE-367). The key's revoked
state is never written from this route. The token is read through
ACTIVATION_FILE by the directory-based read_stored_activation_token_in,
in fetch_price_in, testable without AppHandle.
- Machine list: list_activated_machines returns { machines,
token_dropped }. A complete list that names this machine under neither
identifier drops a token that activates this machine, when the OS id is
readable and the token is the one on disk when the list was asked for.
An error, a revoked key or a rate limit drops nothing.
machines_from_verify counts the rows it skips, which the list still
tolerates. This also gives "Activate" back to a machine deactivated from
another computer.
- Front: the activation status is read again after every list load; a
fixed log line (ACTIVATION_TOKEN_LOG) is written on token_dropped and on
a price refused as revoked; the revoked-license price message invites to
activate the machine again.
- Docs: architecture, ADR 0017 (#365 amendment), ADR 0020 (rule 4), user
guide and in-app tips, changelogs.
Resolves#367
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Remove the key" on the License card removes license.key, then the
activation token, and keeps license.check, so a revoked key removed then
entered again stays Free. Removing the key stops the revocation check,
which until now only storing another key could stop.
When this machine is activated with a valid key, its slot is freed on
the license server first (remove_license with release_slot). A release
that fails removes nothing; on network, server, rate_limit or
invalid_response the card asks whether to remove the key anyway, the
slot then staying occupied. no_active_activation, license_not_found and
license_revoked (recorded for the key sent) mean the slot is free.
- Rust: remove_license / remove_license_in, release_this_machine_slot,
this_machine_token_id, and the shared remove_activation_token_in
helper, now also used by store_license_in and the deactivation.
- Front: removeLicense service; performRemoveLicense in
useLicenseMachines (confirmations by state, error table, in-app log
lines); removing and removeError in the machines reducer, which lock
key entry, Activate and Deactivate; the button for any stored key,
valid or revoked.
- Docs: user guide, in-app tips, architecture, ADR 0017 amendment,
changelogs.
Resolves#370
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A license key entered during a session was only checked against the
license server at the next start: a revoked key kept its edition until
the app restarted, or until a machine activation was refused (#365
checked at start only).
Front: performSubmitKey launches the revocation check after
VALIDATE_DONE, without waiting for it, and never after a refused key.
SubmitKeyDeps gets a single launcher, startRevocationCheck(wasRevoked),
built once by the provider with revocationCheckLauncher and shared with
the start check, whose starter is unchanged.
Rust: the command runs the checks one at a time. A static
tauri::async_runtime::Mutex (REVOCATION_CHECK_TURN) is held by
check_revocation_in_turn over the whole check, request and verdict
included, and the time is read once the turn has come. A key entered
before the first ready state, checked by the entry and by the start,
sends one request; the check of a key entered while another one is in
flight waits for its verdict instead of overwriting license.check.
check_revocation_in is unchanged, and so are Cargo.toml and Cargo.lock.
Docs: ADR 0017 (amendment #365, point 5), user guide, in-app docs
(docs.settings.tips, docs.editions.tips), architecture, and both
changelogs under [Unreleased] (Fixed / Corrigé).
Resolves#373
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Guide: drop the internal provenance (author, date, issue number) from the
BMO status line in the three copies (fr.json, en.json, guide-utilisateur.md).
The status now only says the procedure is reconstructed and unconfirmed.
- Dashboard: gate `showCompactChecklist` on `budgetReady` so a Base+ profile
with the five other items done no longer sees the compact list vanish (5/5
under the fail-closed entitlement) and reappear (5/6) once the licence
resolves.
- Test: pin TZ=America/Toronto around the `formatLocalDate` tests and assert
that `toISOString()` disagrees, so a UTC regression is caught on the UTC CI
runner too (measured: the mutation passed 7/7 under TZ=UTC before, fails now).
Follow-ups raised by the independent reviews of PRs #381, #378 and #382.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closes phase 1 of the onboarding wave (#351-#356) on the documentation side.
- CHANGELOG.md / CHANGELOG.fr.md: Added (guided empty states, getting-started
checklist, sample folder, bank CSV export guide) and Changed (re-show button
in Settings > Users, Import "?" popover linking to the guide) under
[Unreleased] / [Non publié], same entries in both languages.
- docs/architecture.md: create_sample_import_folders (fs_commands 6 -> 7,
total 36 -> 37) with its contract, onboardingService, useOnboardingChecklist,
the onboarding_checklist_hidden preference, the samples/ corpus next to csv/,
and an "Onboarding" section (four empty states and their signal, source of
truth of the six checklist rows, sample-folder flow). Tree counts measured
for the directories the wave touched.
- docs/guide-utilisateur.md chapters 3 and 11 + docs.dashboard.* and
docs.settings.* keys (FR/EN). Hand-off from #355: docs.gettingStarted.steps
aligned with the six-step quick start of the markdown, docs.import.steps
mentions the sample folder, so the three guide copies agree.
- docs/qa-onboarding.html: interactive manual-test checklist, French only,
static, no network dependency, state in localStorage, plain-text report to
the clipboard with a visible fallback, reset button.
Resolves#357
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The import page without a source no longer shows a bare "no source found"
card. `ImportEmptyState` (built on the shared `EmptyState`) takes the place
of the empty `SourceList` in both situations: without an import folder it
offers "Choose a folder" as the single primary action; with a folder and no
bank sub-folder it offers "Create a sample folder" (primary, disabled while
in flight) and "Refresh" (secondary). Both variants, and the "?" help
popover through the new optional `linkTo` prop of `PageHelp`, link to the
bank CSV export guide at `/settings/users#import-export`. `SourceList`
stays an untouched pure list.
`useImportWizard` gains `createSampleFolders`: it invokes the Rust command
`create_sample_import_folders` with the folder and the webview's LOCAL date
(built from the local getters, not `toISOString()`), rescans the folder so
the two cards appear without another click, then raises a closable success
banner from the new `sampleCreated` state, which `reset` and a folder change
clear. A refusal from Rust (a sample folder already exists) lands in the
existing error banner. The log line carries the folder NAMES only, never
the absolute path, since the logs travel with a feedback report.
"Browse" on the folder card drops to the secondary style while no folder is
configured, so a single primary button is on screen. The reducer and its
initial state are exported as `wizardReducer` / `initialWizardState` and
unit-tested; `ImportEmptyState` is tested by direct call.
Resolves#356
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add an optional `subsections` array to a user-guide section, rendered by
the new pure `DocsSubsections` component between the "How to" block and
the tips. Each entry has an id, a title, an optional italic status line,
an ordered list of steps and an optional trailing note. The ids are
registered in `sectionRefs` of `DocsContent`, so the existing hash
navigation (`/settings/users#import-export`) reaches the sub-sections
too. Only the Import section declares the key; the eleven other
sections render exactly as before.
The Import section gets six blocks in French and English: a generic
"export a CSV from your bank" block, then Desjardins, Tangerine, RBC,
National Bank and BMO with their verification status. The National Bank
and BMO blocks, which ask the user to send a header line through the
feedback form, carry the warning never to paste an account number, a
balance or a real transaction, in the JSON files and in the markdown
guide alike.
`docs/guide-utilisateur.md` receives the same six blocks in chapter 4,
a mention of the "Create a sample folder" button in its "How to", and a
"Quick start" in chapter 1 rewritten as the six steps of the
getting-started checklist, same order and wording as
`onboarding.checklist.items.*`.
`DocsSubsections.test.tsx` calls the component directly (no jsdom):
null for a section without sub-sections, one block per entry with its
id and ref registration, status line and note rendered only when
present, malformed entries dropped.
Resolves#355
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A profile without any transaction now gets an "import first" card with a
button to the import page on both pages, in place of the filter bar, the
table and the pagination (Transactions) and of the filter panel and the
grid (Budget). Title, page help and template actions stay.
The signal is a profile-wide `totalInDb` count loaded once at mount via
`getTransactionCount()`, not `rows.length`: the default Transactions
filter is "since January 1st", so an empty page cannot tell an empty
profile from a year without transactions. `null` (not loaded yet) keeps
the regular layout, and so does a loading Transactions page.
`transactionsReducer` / `initialTransactionsState` and `budgetReducer` /
`initialBudgetState` are exported with a pure `showsEmptyState` selector
per hook, so the reducer tests pin that the count never touches filters,
sort or page and that 0 then N > 0 brings the table back. The existing
"no transactions found" / "no categories found" rows are unchanged.
Resolves#353
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A profile without any transaction now opens a dashboard that says what to
do: a welcome card with the expanded getting-started checklist and one
primary action towards the import page, instead of widgets showing 0 $.
With transactions, the compact checklist sits between the title and the
filter panel until it is complete or dismissed.
- `EmptyState` (pure, dashed card from the import source list; a `to`
action renders a react-router `<Link>`, an `onClick` action a button)
- `OnboardingChecklist` (pure, expanded and compact modes; undone rows
link to their route, the locked Budget row shows a lock, the "Base and
up" label and links to the license card; neutral budget row and hidden
counter until the entitlement is known)
- `DashboardEmptyState` composes both
- `DashboardPage` branches on the hook `status`: loading renders the title,
help and banner only; error falls back to the v0.15.0 page without the
list and logs a warning; ready with zero transactions renders the empty
state outside the `isLoading` veil
- `useOnboardingChecklist` exposes `counters` and `error` for the page
- Users settings: "Show the getting-started checklist" button in the Guide
section, visible only when the preference is set, with an inline
confirmation after the click
- FR + EN keys `dashboard.empty.*`, `onboarding.checklist.*`,
`settings.users.onboarding.*`
Tests: `EmptyState.test.tsx` and `OnboardingChecklist.test.tsx` by direct
call (FilterPanel pattern). No DB migration. CHANGELOG left to #357.
Resolves#352
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- A state file that cannot be written now has a test on both of its paths:
the attempt (nothing is sent, or a read-only data directory would send a
request at every start) and the verdict (the key is not reported revoked
when nothing on disk says so). The first path was correct but unguarded:
the review's mutation left all 185 tests green.
- The storage outcome no longer claims that nothing was sent: it also covers
a verdict that could not be written. Reworded in licenseService.ts, the
in-app log line and docs/architecture.md.
- The Updates card names a revoked key in its Free branch, between the
message and the CTAs: a refunded Base buyer first meets the loss of
automatic updates there (Max, 2026-10-03). The error page stays as it is.
- A revoked key keeps being checked once per 24 h while it stays stored, so
that a correction made on the license server is picked up (Max,
2026-10-03). Declared in the user guide, the in-app docs, the changelogs
and the ADR 0017 amendment.
Refs #365
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refunded license was revoked on the server but kept working in the app:
the edition is resolved offline from the key alone, and the flag added by
#347 lasted the session only and never touched the edition.
Rust
- license.check, next to the key, holds what the license server last said
about it: {key_sha256, attempted_at, revoked}. Never the key itself. A
missing, unreadable or invalid file, or a state about another key, reads
as "no state": not revoked, check due.
- entitlements_from_dir resolves a revoked key to ("free", []), which also
locks auto-update. read_license and store_license report the state.
- check_license_revocation: POST /v1/licenses/verify with the product and
the key only. Nothing is sent without a stored key that validates, nor
within 24 h of the last attempt, which is written before the request.
Fail-open: only "valid" lifts the state, only "revoked" sets it.
- Activation, machine list and deactivation record a license_revoked answer
under the key they sent.
- fetch_price reads error.code of a 403 and names license_revoked instead of
premium_required. It records nothing: the price route judges the license
of the activation token, which the client cannot tie to the stored key,
and 0.15.0 left the token of a previous key next to a new one (#367).
Front
- LicenseContext takes the revoked state from Rust (info.revoked); the
session flag and markRevoked are gone. The check starts once per app
start, writes its outcome to the in-app log, and reloads the context only
when the state changed. A revoked key entered again stays Free.
- UpsellGate and the License card name the revocation.
Docs: ADR 0017 amendment (D13 replaced, the first unsolicited request and
its bounds), architecture, user guide, price contract annex, changelogs.
Refs #365
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Activation sent `machine_uid::get()` as is: /etc/machine-id on Linux,
MachineGuid on Windows. machine-id(5) calls that id confidential and asks
applications to send a keyed hash under a fixed, application-specific key
instead. The license server stores what it receives next to the license
email and the host name, lists it back and signs it into the activation
token.
- derive_machine_id: HMAC-SHA256 under a frozen key, 64 lowercase hex
characters, pinned by two reference vectors. An empty OS id is refused,
never hashed.
- Activation sends the derived identifier and requires the issued token to
carry it. Nothing else originates a machine identifier.
- A machine activated before this change stays activated: a valid token
whose claim is the raw id of this machine still reads "Activated" (D1).
- The machine list flags `is_this_machine` in Rust, for a row under either
identifier (D3). `ActivationStatus` no longer carries an identifier and
the unused `get_machine_id` command is removed: no command hands the OS
id to the webview any more.
- After a deactivation the local token is dropped when it stood for the
freed slot: any row of this machine takes it, except the old raw row
while the token stands for the derived one.
- ADR 0020 records the derivation, the transition and the condition for
removing the raw-id compatibility.
Behaviour changes beyond the issue text: the machine list and a
deactivation no longer fail on an unreadable OS id (best effort), the
activation status then reads "not activated" instead of failing, and the
unused `store_activation_token` command refuses a token issued for the
raw id.
No new dependency (hmac and sha2 were already declared); Cargo.lock is
untouched. Rust 151 -> 168 tests, vitest 1311 -> 1314.
Refs #350
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- store_license_in drops activation.token when the stored key changes.
The token names the previous license to the price server (its sub is
the license id, the key carries the email), so the card showed the
machine as activated while prices were refused for the old license.
The same key entered again keeps its token; a refused key changes
nothing.
- get_activation_status goes through activation_status_in(dir,
machine_id, key), tested in a temporary directory: a missing,
expired, foreign-machine, foreign-key or unreadable token reads as
not activated. It alone decides "Not activated" and "Activate".
- LicenseCard is keyed by licenseIdentity(info): another key remounts
the body, which re-reads the activation and drops the previous key's
machine list.
- invalidSignature asks to check the copy first. jsonwebtoken checks
the signature before decoding the payload, so a damaged copy often
reads as a signature failure (characterization test). User guide and
changelogs aligned.
- Render tests: the other errors stay visible under Machines, and the
edition's green check gives way to an alert for a revoked key.
Refs #347
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rust (license_commands.rs)
- LicenseError gains InvalidPrefix, InvalidSignature, Malformed and
UnknownEdition. validate_with_key returns it (ExpiredSignature maps
to LicenseExpired), and store_license, read_license and
validate_license_key reject with the same JSON as the network
commands. store_license_in and read_license_in take a directory, so
the Storage mapping is tested at the source.
- D12: the edition comes from the license key alone.
resolve_license_entitlements no longer takes the activation token,
and current_entitlements reads only license.key
(entitlements_from_dir). A token that had expired (2-year server
lifetime) or came from another machine (restored data) used to drop
a valid key to free, while a key copied without its token kept its
edition. get_activation_status and persist_activation_token keep the
strict token validation, and the key keeps its strict validation
(CWE-613).
Front
- LicenseCard names the load error, and LicenseKeyForm (extracted so a
static render reaches it) names a refused key, through
parseLicenseError: no raw backend string reaches the card.
- D13: a license_revoked answer is recorded in LicenseContext for the
whole session, because the card unmounts when the user leaves
Settings > Users. Only a different key clears it. Card-level
message, alert icon, Activate disabled through selectCanActivate.
- Machine counter n/limit, "Not activated", activating and
deactivating labels, dates in fr-CA/en-CA, and "No expiry date"
beyond 50 years (licenseExpiry.ts, for the 100-year keys of
maximus-api#63).
- license.removeLicense and license.machines.activateError removed;
the price authFailed message points to activating the machine.
Tests: 15 new Rust tests (resolution from a data directory, named
variants, the 100-year key of #363, a key without exp) replace 3
machine-binding tests; vitest covers the card and form renders, the
context revocation, the hook wiring and the locales.
test: reproduces #347 (an expired token of this machine resolved a
valid Base key to free on 5d549d6).
Docs: CHANGELOG FR/EN, guide section 12 and in-app docs,
architecture.md, ADR 0017 amendment for #347, qa-update-cycle.md,
api-contract-prices.md.
Fixes#347Fixes#360
Refs #363
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Render tests for the upsell CTAs (UpsellCtas.render.test.tsx): the
reviewer's probe, which needs no jsdom (react-dom/server in the node
environment, plus the FilterPanel.test.tsx pattern of calling a
component as a function). It pins the Base/Premium CTA on UpsellGate,
the router guard of HaveKeyButton, the localized product URL, the
Premium note and the onNavigate forwarding, none of which the suite
guarded before.
- A failure to open the browser (openUrl) is now logged through
logError in PurchaseButton, DownloadLatestButton and LicenseCard,
instead of vanishing as an unhandled rejection that never reaches the
in-app logs. Tested for both shared buttons.
- upsellCtaFor: comment the trap in making Premium purchasable, since
PurchaseButton sells Base only.
- ADR 0017 amendment: the Account card text holds in practice only;
current_entitlements still grants Premium to an active subscription,
a dormant path until a subscription is sold.
- User guide and in-app docs: the purchase step now comes before the
key entry, and the Balance button is named "Get Premium".
- CHANGELOG: "Maximus Account" in English, "every screen that requires
it" in both files (the Balance screen sells nothing), and the French
elision before a guillemet.
Refs #346
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- upsellCtaFor(tier) decides what a locked screen offers: Base is
purchasable, Premium stays disabled with "Premium isn't sold online
yet". Pure and tested on the whole entitlements matrix.
- Shared CTAs, one component per file: PurchaseButton ("Get Base") and
DownloadLatestButton open the localized product page (purchaseUrl) and
need no router; HaveKeyButton renders only inside BrowserRouter.
- UpsellGate uses them; the purchase link never calls onNavigate, so
modal hosts stay open. ProfileFormModal reuses UpsellGate instead of
its compact panel, keeps its Cancel button, and closes after "I
already have a key" (onNavigate={onClose}).
- In Free, the Updates card offers Get Base, I already have a key and
Download the latest version; the error page (outside the router)
offers Get Base and the download link. No update check, no network
call.
- i18n FR/EN: Account card text, Premium note, Updates notEntitled text,
download label, editions guide step and tip; the "coming soon" note
key is gone. Tests pin the upsell key set and the decided texts.
- Docs: ADR 0017 amendment, architecture (UpsellGate row, shared CTAs,
upsellCtaFor, multi-profile row, counts), user guide section 12;
CHANGELOG FR/EN.
Proven red: Premium made purchasable 2, balance sold as Base in the
matrix 4, the old note key back in a locale 1, Account text altered 1,
download label altered 1.
Fixes#346
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A 404 without a known code (missing route, proxy error page) is now
pinned as a server error by two table rows, so "key not found" can
never be shown for it.
- The message-key test spells out the expected key of every code
instead of comparing the map with itself, and checks the keys are
distinct: swapping two entries now fails.
- Idempotent deactivation moves into deactivate_idempotent, without
AppHandle, tested against a mock server (404 no_active_activation ->
done, 403 revoked -> error).
- Deactivate buttons stay disabled while the list reloads
(selectCanDeactivate), so a machine deactivated during a reload
cannot reappear.
- The rate-limit message uses i18next plural forms (rateLimit_one /
rateLimit_other with count): no more "1 seconds".
- CHANGELOG: drop the bullet about the edition updating after an
activation. The edition does not change with or without an
activation token, so nothing is observable; refresh() stays.
Proven red on the fixed tree: 404 without a known code read as
LicenseNotFound 1, license_expired mapped to the revoked key 2,
idempotence removed 1, buttons live during a reload 1, {seconds}
instead of {count} 2.
Refs #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Activation, deactivation and verify POST to /v1/licenses/* with
"product":"simpl-resultat" instead of the legacy /licenses/* paths,
which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
read from error.code of the /v1 envelope. A 200 {valid:false} from
verify is now a coded refusal instead of an empty machine list, and an
HTML error page on a non-2xx maps to server instead of
invalid_response. Deactivation is idempotent: no_active_activation
counts as done.
- The three calls live in *_inner functions without AppHandle, tested
against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
survives the list load it triggers (machine_limit_reached opens the
list), a deactivation removes the machine locally without re-running
verify (4 requests on the machine-limit path, under the server's
5/min/IP limit), and refresh() follows every successful activation or
deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
/{fr|en}/produits/simpl-resultat.
test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.
Fixes#270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
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>
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
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
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
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
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
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
"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>
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>
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>
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>
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>
DashboardPage now reuses the Cartes report's presentational widgets
(KpiCard with MoM/YoY deltas, top movers, budget adherence) sourced from
getCartesSnapshot against a Dashboard-owned reference month (defaults to
the last complete month). The expense-only pie chart is replaced by a
ranked bar chart of top expense categories (resurrecting the previously
unused CategoryBarChart), and the account (import-source) filter
introduced on Trends/Compare/Budget now also applies to the Dashboard's
own transactional widgets via a Dashboard-local accountIds state (kept
separate from useReportsPeriod, since the Dashboard owns its own two
temporal axes).
A new net-worth tile surfaces the Balance sheet's latest total
(getSnapshotTotalsByDate) — a distinct metric from every transactional
card here, so it stays hidden (never a misleading "$0") until at least
one balance account has a recorded snapshot, reusing deriveLandingState
rather than re-inferring emptiness from nulls. It is not scoped by the
account filter (balance_accounts is a disjoint concept from
import_sources) and is fetched independently on mount.
The category-over-time trend chart now passes typeFilter "expense",
fixing a latent mismatch where a revenue category could silently show
up in a chart titled "expenses over time".
Resolves#279
Adds src/components/reports/FilterPanel.tsx, the shared filter bar for the
report pages. It renders the page's own temporal control (PeriodSelector /
CompareReferenceMonthPicker / YearNavigator / …) as-is via a `temporalControl`
ReactNode prop, plus a checkbox multi-select over `accounts: ImportSource[]`.
No `temporalMode` enum — that design was rejected to avoid mixing state
sources; the panel owns no temporal state at all.
Empty `accountIds` means no filter (all sources). Selection toggling is a
pure, exported `toggleAccountId` helper. Copy says "sources"/"import
sources" everywhere, never "comptes"/"accounts", so the filter reads as
distinct from the Bilan module's own "Compte" vocabulary.
This issue creates the standalone component only; wiring it into report
pages is left to follow-up issues (#275/#276).
Resolves#274
The two hierarchical comparable reports — real-vs-real Compare and
real-vs-budget — now let the user collapse or expand each top-level
category's sub-categories. Groups start collapsed by default (#260): only
the parent's subtotal row shows until the user expands it, and each expanded
group is remembered per report.
- New pure module utils/collapsibleRows.ts (visibility filter, group-key
extraction, expanded-set (de)serialization) + useCollapsibleGroups hook
wrapping the localStorage-persisted expanded set. Persisting the *expanded*
set (not the collapsed one) makes "all collapsed" the zero/default state.
- A chevron toggles each top-level parent; an "Expand all / Collapse all"
button toggles them together. Section subtotals and result lines keep
summing every leaf, so collapsing never changes any total.
- Accessors mirror each table's own depth/parent logic so hidden rows are
exactly a group's indented descendants.
- i18n keys reports.collapse.{expandAll,collapseAll} (FR/EN); CHANGELOG.
CategoryOverTimeTable (Trends -> by category) is intentionally left out: its
rows are a flat top-N category list with no parent/child hierarchy to fold.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses /pr-review REQUEST_CHANGES on #255.
- useCompare: the "skip first sync" boolean was not StrictMode-safe — the dev
double-invoke of effects flipped the flag on setup #1, so setup #2 re-synced
the reference month to the civil-year December, re-introducing the very bug
Changement 2 fixes (dev only; prod has no double-invoke). Replace it with a
value-change guard: a ref seeded with the initial `to` plus a pure
syncReferenceOnPeriodChange() that only dispatches when `to` actually changes.
Idempotent across the double-invoke, and now unit-tested (5 cases) since the
decision is a pure function (the project has no renderHook harness).
- Remove the now-orphaned reports.compare.totalRow i18n key (both locales) — the
flat grand total it labelled was replaced by the result lines.
- ComparePeriodTable: gate the "before transfers" line on results.hasTransfers
(previously computed/tested but unused).
- ComparePeriodChart: show the no-data empty state when the expense filter
leaves nothing (a pure income/transfer period) instead of bare axes.
Build + 690 vitest green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "actual vs actual" compare was expenses-only (WHERE amount < 0), so revenue
categories were invisible — you could not tell a surplus from a deficit. Make it
an income statement (the product's namesake "résultat"):
- COMPARE_DELTA_SQL: broaden the WHERE and per-type CASE to include `income` as
a signed SUM (like `transfer`), so revenue credits survive the outflow filter.
Expenses stay ABS-of-outflows, byte-identical. Transfer netting (#243) intact.
- Section order is now income -> expense -> transfer (COMPARE_TYPE_ORDER).
- ComparePeriodTable: replace the now-meaningless flat grand total with two
result lines — "result before transfers" (revenues - expenses) and the net
"result" (after transfers) — extracted into a pure, tested compareResults
module. Delta colours are direction-aware (income/result up = green, spending
up = red); result amounts are coloured by sign (surplus/deficit).
- ComparePeriodChart stays a spending view: filter to expense leaves so revenue
bars don't mix into the same axis.
- useCompare: skip the initial period-sync so the compare opens on the previous
(last complete) month instead of the civil-year December that useReportsPeriod
yields by default.
Also anchor the `reports/` gitignore to `/reports/` — the unanchored rule was
silently ignoring new files under src/components/reports/.
Tests: new compareResults.test.ts (result roll-up, unbalanced transfer, deficit,
subtotal exclusion); reportService.test.ts updated for the broadened SQL, the
income bucket rule, and income-first section order. Build + 685 vitest green.
Resolves#253
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the v1 target editable on EVERY row of the categories-migration
preview (StepSimulate), not only the "needs review" ones. Each row now
renders a reused CategoryCombobox (accent-insensitive type-ahead,
keyboard, hierarchical) in place of the read-only text / flat <select>.
A pure adapter (taxonomyToComboboxCategories) flattens the full v1
taxonomy into Category-shaped rows once in StepSimulate and passes it as
a prop to the rows. The full tree (not just leaves) is fed so mid-tree
default targets such as "Divertissement" (id 1710, the default for
"Jeux, Films & Livres") both display and can be re-picked. The target
cell stops click/keydown propagation so picker interaction never toggles
the row preview. Reducer/guard behavior unchanged: resolving a "none"
row still bumps confidence to medium, and the unresolved-count guard
stays functional.
Resolves#246
A detailed (by-security) account can now import its positions from a CSV
instead of adding each security one by one. An "Import CSV" button next to
"Add a title" opens a native picker; the delimiter, encoding and columns
(symbol, quantity, optional price + book_cost) are auto-detected via new
csvAutoDetect helpers and adjustable in a small mapping editor. Rows sharing
a symbol are merged (SUM qty + book_cost, first price) and the batch is merged
into the basket by normalized symbol, so no UNIQUE(snapshot_line_id,
security_id) violation can occur at save. A CSV without a price column imports
quantities with an empty unit_price (fetch/type later) — the existing atomic
save path is unchanged (no SQL/Rust change).
- csvAutoDetect: autoDetectHoldingColumns + analyzeHoldingsCsv (+ tests)
- useSnapshotEditor: holdingsFromCsvRows + IMPORT_HOLDINGS action + importHoldings (+ tests)
- HoldingsCsvImportModal: file pick -> parse -> mapping editor + preview
- i18n FR/EN under balance.snapshot.detailed.importCsv.*
- CHANGELOG (Added / Ajouté)
Resolves#245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decouple the /balance empty-state guard into three explicit states via a
new pure helper `deriveLandingState` (empty / accounts-no-snapshot /
populated). Having accounts but no snapshot no longer strands the user on a
snapshot-only onboarding card: each state now renders navigation tiles
(HubReportNavCard, modeled on the Reports hub), and "Manage accounts"
(/balance/accounts) is reachable in every state, including the populated
dashboard.
- Remove BalanceOnboardingCard (component + test) and the dead
balance.onboarding.* i18n keys; add balanceLanding.ts + test.
- Add balance.hub.* and balance.landing.* keys in FR and EN.
- CHANGELOG (EN + FR) under [Unreleased].
Resolves#244
Render the real-vs-real comparable report as a parent/child category tree
with subtotal rows, mirroring the budget report — rows grouped into
expense/income/transfer sections with per-section and grand totals and a
subtotals-on-top/bottom toggle. The compare service now builds the tree on
top of the flat per-category deltas, so leaf-category figures are unchanged
and the #243 transfer netting is preserved (a balanced transfer group
subtotals to ~0, i.e. the group's net).
- reportService: buildCompareTree() synthesizes subtotal rows from the flat
leaves + category metadata; getCompareMonthOverMonth/YoY return the tree
(COMPARE_DELTA_SQL and rowsToDeltas untouched).
- CategoryDelta gains optional parent_id/is_parent/depth/category_type.
- ComparePeriodTable: sections, depth indentation, reorderRows toggle,
section/grand net totals.
- Cartes top-movers and ComparePeriodChart filter to leaf rows only.
- reorderRows constraint loosened to the two fields it reads.
- i18n (FR+EN) section labels; CHANGELOG entries.
Resolves#247
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a light confirmation modal (DetailAccountWizard) that flips a simple
balance account to detailed entry mode: sets kind='detailed' and
detailed_since = today (local civil day, YYYY-MM-DD) via updateBalanceAccount.
Toggle-only — no title capture; per-security holdings are entered at the next
normal snapshot, where validateDetailedSnapshot requires them from the pivot on.
Entry point: a 'Détailler en titres' action in the per-row actions menu of
BalanceAccountsTable, shown only for kind==='simple' rows (replaces the disabled
'Détail / coming soon' placeholder). Past aggregated history stays frozen
read-only. The flip is one-way: the #212 service backstop rejects
detailed -> simple once holdings exist, and the UI exposes no inverse action.
Exports buildDetailToggleInput() as a pure helper for a focused unit test
(project has no jsdom harness). FR/EN i18n under balance.detailWizard.*; removed
the now-dead balance.overview.detailAction / detailComingSoon keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface unrealized (latent) gain on the existing balance surfaces, no new
visualization (decision 2026-06-04).
Service:
- AccountLatestSnapshot gains account `kind` + `latest_snapshot_line_id`, so
the table knows which rows are detailed and where their holdings live.
- getAccountLatentGainByLine(lineId) folds a line's holdings through the
existing computeUnrealizedGain (shares the book_cost=0 / NULL -> N/A guard).
- rollupLatentGain(accounts): pure aggregation by asset class, by envelope
(vehicle_type, 'none' bucket), and grand total; per-bucket % denominator is
the known book_cost only (null when none), mirroring the aggregate guard.
Hook (useBalanceOverview):
- Prefetches per-detailed-account latent gain in parallel (failures isolated),
exposes latentGainByAccount + latentGainRollup. Simple accounts skipped.
UI:
- BalanceAccountsTable: expandable detailed rows -> per-security value + latent
gain %; a latent-gain column (shown only when a detailed account has one);
a summary block aggregating latent gain by asset class / envelope.
- BalanceOverviewCard: total latent gain line (hidden without detailed accounts).
- N/A rendered (never divide-by-zero) when book_cost is NULL or 0; partial-%
flag when some positions lack a cost basis.
Native non-CAD currency display de-scoped this round (untestable while all
securities are CAD). Modified Dietz return columns (#204) unchanged.
i18n: balance.latentGain.* in FR + EN. Focused unit tests for
getAccountLatentGainByLine and rollupLatentGain (grouping, 'none' envelope,
null-% / unknown book_cost, empty).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>